Align instance dashboard with snapshot+SSE and options net PnL.
Only dashboard path changes: background snapshot, SSE refresh, smaller header type, options net PnL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9541,12 +9541,19 @@ register_trade_records_api(
|
|||||||
app_tz=APP_TZ,
|
app_tz=APP_TZ,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _dashboard_enrich_orders(items):
|
||||||
|
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||||
|
|
||||||
|
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||||
|
|
||||||
|
|
||||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||||
|
|
||||||
register_instance_dashboard_routes(
|
register_instance_dashboard_routes(
|
||||||
app,
|
app,
|
||||||
login_required=login_required,
|
login_required=login_required,
|
||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
|
enrich_orders=_dashboard_enrich_orders,
|
||||||
hedge_enabled=False,
|
hedge_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9375,12 +9375,19 @@ register_trade_records_api(
|
|||||||
app_tz=APP_TZ,
|
app_tz=APP_TZ,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _dashboard_enrich_orders(items):
|
||||||
|
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||||
|
|
||||||
|
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||||
|
|
||||||
|
|
||||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||||
|
|
||||||
register_instance_dashboard_routes(
|
register_instance_dashboard_routes(
|
||||||
app,
|
app,
|
||||||
login_required=login_required,
|
login_required=login_required,
|
||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
|
enrich_orders=_dashboard_enrich_orders,
|
||||||
hedge_enabled=False,
|
hedge_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9050,14 +9050,17 @@ def _dashboard_fetch_options_positions():
|
|||||||
if not isinstance(cfg, dict) or not cfg.get("enabled"):
|
if not isinstance(cfg, dict) or not cfg.get("enabled"):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from lib.options.options_hub_lib import build_options_hub_snapshot
|
from lib.options.options_dashboard_lib import fetch_light_option_positions_for_dashboard
|
||||||
|
|
||||||
snap = build_options_hub_snapshot(cfg)
|
return fetch_light_option_positions_for_dashboard(cfg)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
if not snap.get("ok"):
|
|
||||||
return []
|
|
||||||
return list(snap.get("positions") or [])
|
def _dashboard_enrich_orders(items):
|
||||||
|
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||||
|
|
||||||
|
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||||
|
|
||||||
|
|
||||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||||
@@ -9067,6 +9070,7 @@ register_instance_dashboard_routes(
|
|||||||
login_required=login_required,
|
login_required=login_required,
|
||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
fetch_options_positions=_dashboard_fetch_options_positions,
|
fetch_options_positions=_dashboard_fetch_options_positions,
|
||||||
|
enrich_orders=_dashboard_enrich_orders,
|
||||||
hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
|
hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
|
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let timer = null;
|
let localDashVersion = 0;
|
||||||
|
let dashEventSource = null;
|
||||||
|
let dashReconnectTimer = null;
|
||||||
|
let booted = false;
|
||||||
|
|
||||||
function root() {
|
function root() {
|
||||||
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
|
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
|
||||||
@@ -121,40 +124,6 @@
|
|||||||
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
|
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeOrderLive(items, orderPrices) {
|
|
||||||
const map = {};
|
|
||||||
(orderPrices || []).forEach(function (p) {
|
|
||||||
if (p && p.id != null) map[String(p.id)] = p;
|
|
||||||
});
|
|
||||||
return (items || []).map(function (it) {
|
|
||||||
const live = map[String(it.id)] || {};
|
|
||||||
const mark =
|
|
||||||
live.exchange_mark_price != null
|
|
||||||
? live.exchange_mark_price
|
|
||||||
: live.price != null
|
|
||||||
? live.price
|
|
||||||
: it.mark_price;
|
|
||||||
const contracts =
|
|
||||||
live.contracts != null
|
|
||||||
? live.contracts
|
|
||||||
: live.order_amount != null
|
|
||||||
? live.order_amount
|
|
||||||
: it.contracts;
|
|
||||||
const entry =
|
|
||||||
live.avg_entry_price != null
|
|
||||||
? live.avg_entry_price
|
|
||||||
: it.entry;
|
|
||||||
return Object.assign({}, it, {
|
|
||||||
entry: entry,
|
|
||||||
mark_price: mark,
|
|
||||||
mark_display: live.price_display || null,
|
|
||||||
contracts: contracts,
|
|
||||||
tp_profit: live.reward_at_tp_usdt != null ? live.reward_at_tp_usdt : it.tp_profit,
|
|
||||||
float_pnl: live.float_pnl != null ? live.float_pnl : it.float_pnl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOrdersTable(items) {
|
function renderOrdersTable(items) {
|
||||||
const rows = items
|
const rows = items
|
||||||
.map(function (it) {
|
.map(function (it) {
|
||||||
@@ -294,7 +263,7 @@
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "盈亏"], rows);
|
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "净盈亏"], rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHedgeTable(items) {
|
function renderHedgeTable(items) {
|
||||||
@@ -391,31 +360,32 @@
|
|||||||
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
||||||
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
|
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
|
||||||
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
|
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
|
||||||
if (loading) return;
|
const options = opts || {};
|
||||||
|
if (loading && !options.force) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
if (status && !(opts && opts.silent)) status.textContent = "加载中…";
|
if (status && !options.silent) status.textContent = "同步中…";
|
||||||
try {
|
try {
|
||||||
const [dashRes, priceRes] = await Promise.all([
|
const dashRes = await fetch("/api/instance/dashboard", { credentials: "same-origin" });
|
||||||
fetch("/api/instance/dashboard", { credentials: "same-origin" }),
|
if (dashRes.status === 401) {
|
||||||
fetch("/api/price_snapshot", { credentials: "same-origin" }).catch(function () {
|
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
||||||
return null;
|
return;
|
||||||
}),
|
}
|
||||||
]);
|
|
||||||
const data = await dashRes.json().catch(function () {
|
const data = await dashRes.json().catch(function () {
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
if (!dashRes.ok || !data.ok) {
|
if (!dashRes.ok || !data.ok) {
|
||||||
throw new Error(data.msg || dashRes.statusText || "加载失败");
|
if (
|
||||||
}
|
data.aggregating ||
|
||||||
let orderPrices = [];
|
(data.msg && String(data.msg).indexOf("尚未就绪") >= 0)
|
||||||
if (priceRes && priceRes.ok) {
|
) {
|
||||||
try {
|
if (status) status.textContent = "后台聚合中…";
|
||||||
const snap = await priceRes.json();
|
return;
|
||||||
orderPrices = snap.order_prices || [];
|
}
|
||||||
} catch (_) {}
|
throw new Error(data.msg || data.error || dashRes.statusText || "加载失败");
|
||||||
}
|
}
|
||||||
|
const ver = Number(data.dashboard_version) || 0;
|
||||||
|
if (ver) localDashVersion = ver;
|
||||||
if (data.orders && Array.isArray(data.orders.items)) {
|
if (data.orders && Array.isArray(data.orders.items)) {
|
||||||
data.orders.items = mergeOrderLive(data.orders.items, orderPrices);
|
|
||||||
data.orders.count = data.orders.items.length;
|
data.orders.count = data.orders.items.length;
|
||||||
}
|
}
|
||||||
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
|
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
|
||||||
@@ -435,7 +405,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (status) status.textContent = "";
|
const sec = Number(data.poll_interval_sec) || 5;
|
||||||
|
if (status) {
|
||||||
|
status.textContent = options.silent
|
||||||
|
? "SSE 已连接 · 后台每 " + sec + "s 聚合"
|
||||||
|
: "已更新 · 后台每 " + sec + "s 聚合";
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (status) status.textContent = e.message || "加载失败";
|
if (status) status.textContent = e.message || "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -443,42 +418,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAuto() {
|
function closeDashboardStream() {
|
||||||
if (timer) {
|
if (dashEventSource) {
|
||||||
clearInterval(timer);
|
dashEventSource.close();
|
||||||
timer = null;
|
dashEventSource = null;
|
||||||
|
}
|
||||||
|
if (dashReconnectTimer) {
|
||||||
|
clearTimeout(dashReconnectTimer);
|
||||||
|
dashReconnectTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAuto() {
|
function connectDashboardStream() {
|
||||||
stopAuto();
|
const el = root();
|
||||||
timer = setInterval(function () {
|
if (!el) return;
|
||||||
const el = root();
|
closeDashboardStream();
|
||||||
if (!el) return;
|
dashEventSource = new EventSource("/api/instance/dashboard/stream");
|
||||||
const pane = el.closest(".embed-tab-pane");
|
dashEventSource.addEventListener("dashboard", function (ev) {
|
||||||
if (pane && !pane.classList.contains("is-active-pane")) return;
|
try {
|
||||||
load({ silent: true });
|
const st = JSON.parse(ev.data || "{}");
|
||||||
}, 15000);
|
const ver = Number(st.dashboard_version) || 0;
|
||||||
|
if (ver && ver !== localDashVersion) {
|
||||||
|
load({ silent: true });
|
||||||
|
} else if (st.aggregating) {
|
||||||
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
||||||
|
if (status) status.textContent = "后台聚合中…";
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
dashEventSource.onerror = function () {
|
||||||
|
closeDashboardStream();
|
||||||
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
||||||
|
if (status) status.textContent = "SSE 断开,8s 后重连…";
|
||||||
|
dashReconnectTimer = setTimeout(function () {
|
||||||
|
if (booted) {
|
||||||
|
connectDashboardStream();
|
||||||
|
load({ silent: true });
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDashboardRefresh() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/instance/dashboard/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
load({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAuto() {
|
||||||
|
closeDashboardStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(force) {
|
function init(force) {
|
||||||
const el = root();
|
const el = root();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!force && el.getAttribute("data-dash-booted") === "1") {
|
if (!force && el.getAttribute("data-dash-booted") === "1") {
|
||||||
|
booted = true;
|
||||||
load({ silent: true });
|
load({ silent: true });
|
||||||
startAuto();
|
connectDashboardStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.setAttribute("data-dash-booted", "1");
|
el.setAttribute("data-dash-booted", "1");
|
||||||
|
booted = true;
|
||||||
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
|
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
|
||||||
if (btn && !btn.getAttribute("data-bound")) {
|
if (btn && !btn.getAttribute("data-bound")) {
|
||||||
btn.setAttribute("data-bound", "1");
|
btn.setAttribute("data-bound", "1");
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
load({});
|
requestDashboardRefresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
load({});
|
load({});
|
||||||
startAuto();
|
connectDashboardStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSoft(opts) {
|
function refreshSoft(opts) {
|
||||||
|
|||||||
@@ -30,9 +30,7 @@
|
|||||||
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
|
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
|
||||||
global.OptionsPanelLive.refreshSoft(options);
|
global.OptionsPanelLive.refreshSoft(options);
|
||||||
}
|
}
|
||||||
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.refreshSoft === "function") {
|
// 数据看板自有 SSE + 快照,不跟 embed live tick 重拉.
|
||||||
global.InstanceDashboard.refreshSoft(options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRefresh(opts) {
|
function scheduleRefresh(opts) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* å¤�盘表å�•:长下拉文案需å�¯æ”¶ç¼?å�¦åˆ™ä¼šæ’‘ç ´å››åˆ—ç½‘æ ?*/
|
||||||
.journal-card .form-grid{gap:10px}
|
.journal-card .form-grid{gap:10px}
|
||||||
.journal-card .form-grid > input,
|
.journal-card .form-grid > input,
|
||||||
.journal-card .form-grid > select{
|
.journal-card .form-grid > select{
|
||||||
@@ -270,10 +270,12 @@
|
|||||||
#embed-page-root{min-height:120px;position:relative}
|
#embed-page-root{min-height:120px;position:relative}
|
||||||
.embed-tab-pane[hidden]{display:none!important}
|
.embed-tab-pane[hidden]{display:none!important}
|
||||||
.inst-dash-card{grid-column:1/-1}
|
.inst-dash-card{grid-column:1/-1}
|
||||||
.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:8px}
|
.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:6px}
|
||||||
.inst-dash-desc{margin:0;font-size:.82rem}
|
.inst-dash-card > .inst-dash-head h2{font-size:.88rem;margin:0 0 2px;font-weight:600}
|
||||||
.inst-dash-head-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
.inst-dash-desc{margin:0;font-size:.72rem;line-height:1.35}
|
||||||
.inst-dash-status{min-height:1.2em;margin:0 0 10px}
|
.inst-dash-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.inst-dash-updated{font-size:.72rem}
|
||||||
|
.inst-dash-status{min-height:1.1em;margin:0 0 8px;font-size:.72rem}
|
||||||
.inst-dash-sections{display:flex;flex-direction:column;gap:14px}
|
.inst-dash-sections{display:flex;flex-direction:column;gap:14px}
|
||||||
.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
|
.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
|
||||||
.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""实例数据看板:后台定时聚合,内存快照,SSE 版本通知(对齐中控 dashboard_store)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
INSTANCE_DASHBOARD_POLL_SEC = float(os.getenv("INSTANCE_DASHBOARD_POLL_SEC", "5"))
|
||||||
|
INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
|
||||||
|
|
||||||
|
BuildFn = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceDashboardStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.version = 0
|
||||||
|
self.payload: dict[str, Any] | None = None
|
||||||
|
self.aggregating = False
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self._subscribers: list[queue.Queue[str | None]] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._refresh = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._build_fn: BuildFn | None = None
|
||||||
|
|
||||||
|
def start(self, build_fn: BuildFn) -> None:
|
||||||
|
self._build_fn = build_fn
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._loop,
|
||||||
|
daemon=True,
|
||||||
|
name="instance-dashboard-poll",
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._refresh.set()
|
||||||
|
self._broadcast(close=True)
|
||||||
|
|
||||||
|
def request_refresh(self) -> None:
|
||||||
|
self._refresh.set()
|
||||||
|
|
||||||
|
def snapshot_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
p = dict(self.payload or {})
|
||||||
|
ver = self.version
|
||||||
|
aggregating = self.aggregating
|
||||||
|
err = self.last_error
|
||||||
|
if not p:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"dashboard_version": ver,
|
||||||
|
"aggregating": aggregating,
|
||||||
|
"error": err,
|
||||||
|
"msg": err or "看板快照尚未就绪",
|
||||||
|
"poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**p,
|
||||||
|
"dashboard_version": ver,
|
||||||
|
"aggregating": aggregating,
|
||||||
|
"error": err or p.get("error"),
|
||||||
|
"poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC,
|
||||||
|
}
|
||||||
|
|
||||||
|
def event_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
p = self.payload or {}
|
||||||
|
return {
|
||||||
|
"dashboard_version": self.version,
|
||||||
|
"updated_at": p.get("updated_at"),
|
||||||
|
"aggregating": self.aggregating,
|
||||||
|
"ok": p.get("ok", True) if self.payload else False,
|
||||||
|
"error": self.last_error or p.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
assert self._build_fn is not None
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._aggregate_once(self._build_fn)
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
self._refresh.clear()
|
||||||
|
# 周期等待,可被 request_refresh 提前唤醒
|
||||||
|
self._refresh.wait(timeout=INSTANCE_DASHBOARD_POLL_SEC)
|
||||||
|
|
||||||
|
def _aggregate_once(self, build_fn: BuildFn) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.aggregating = True
|
||||||
|
self._broadcast()
|
||||||
|
try:
|
||||||
|
result = build_fn()
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {"ok": False, "msg": "聚合返回无效"}
|
||||||
|
except Exception as e:
|
||||||
|
result = {"ok": False, "msg": str(e), "error": "aggregate_failed"}
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
prev = self.payload if isinstance(self.payload, dict) else None
|
||||||
|
if result.get("ok") is False and prev and prev.get("ok"):
|
||||||
|
self.payload = prev
|
||||||
|
self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed")
|
||||||
|
else:
|
||||||
|
self.payload = result
|
||||||
|
self.last_error = (
|
||||||
|
None
|
||||||
|
if result.get("ok") is not False
|
||||||
|
else str(result.get("msg") or result.get("error") or "aggregate_failed")
|
||||||
|
)
|
||||||
|
self.aggregating = False
|
||||||
|
self._broadcast()
|
||||||
|
|
||||||
|
def _broadcast(self, *, close: bool = False) -> None:
|
||||||
|
with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||||
|
dead: list[queue.Queue[str | None]] = []
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(None if close else event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
dead.append(q)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
if dead:
|
||||||
|
with self._lock:
|
||||||
|
for q in dead:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def iter_sse(self) -> Iterator[str]:
|
||||||
|
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.append(q)
|
||||||
|
try:
|
||||||
|
yield _sse_frame(self.event_dict())
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = q.get(timeout=INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC)
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
continue
|
||||||
|
if raw is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
data = self.event_dict()
|
||||||
|
yield _sse_frame(data)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_frame(data: dict[str, Any]) -> str:
|
||||||
|
body = json.dumps(data, ensure_ascii=False)
|
||||||
|
return f"event: dashboard\ndata: {body}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
instance_dashboard_store = InstanceDashboardStore()
|
||||||
@@ -64,6 +64,7 @@ def _format_order_item(od: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"title": title,
|
"title": title,
|
||||||
"subtitle": subtitle,
|
"subtitle": subtitle,
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
|
"price_symbol": od.get("symbol") or sym,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"direction_label": _dir_label(direction),
|
"direction_label": _dir_label(direction),
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
@@ -135,15 +136,14 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
|||||||
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
||||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
|
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
|
||||||
upl = _safe_float(p.get("upl"))
|
# 看板期权列固定用净盈亏(买一回收−权利金);残档买一则空.
|
||||||
net = None
|
pnl = None
|
||||||
try:
|
try:
|
||||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||||
|
|
||||||
net = net_pnl_from_display_row(p)
|
pnl = net_pnl_from_display_row(p)
|
||||||
except Exception:
|
except Exception:
|
||||||
net = None
|
pnl = None
|
||||||
pnl = net if net is not None else upl
|
|
||||||
pos = _safe_float(p.get("pos"))
|
pos = _safe_float(p.get("pos"))
|
||||||
exp_ms = p.get("exp_time_ms")
|
exp_ms = p.get("exp_time_ms")
|
||||||
if exp_ms is None:
|
if exp_ms is None:
|
||||||
@@ -350,6 +350,38 @@ def collect_options_items(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_order_items_with_marks(
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
get_price: Optional[Callable[[str], Any]] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""后台聚合时补标记价(不打全量 fetch_positions;浮盈仍由实盘页口径负责)."""
|
||||||
|
if not items or not callable(get_price):
|
||||||
|
return items
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for it in items:
|
||||||
|
row = dict(it)
|
||||||
|
sym = str(row.get("price_symbol") or row.get("symbol") or "").strip()
|
||||||
|
if not sym:
|
||||||
|
out.append(row)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
px = get_price(sym)
|
||||||
|
except Exception:
|
||||||
|
px = None
|
||||||
|
mark = _safe_float(px)
|
||||||
|
if mark is None and ":" in sym:
|
||||||
|
try:
|
||||||
|
px = get_price(sym.split(":", 1)[0])
|
||||||
|
except Exception:
|
||||||
|
px = None
|
||||||
|
mark = _safe_float(px)
|
||||||
|
if mark is not None:
|
||||||
|
row["mark_price"] = mark
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_instance_dashboard_payload(
|
def build_instance_dashboard_payload(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""注册 GET /api/instance/dashboard(三所共用)."""
|
"""注册实例数据看板:快照 GET + SSE + 手动刷新(对齐中控)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, Response, jsonify, stream_with_context
|
||||||
|
|
||||||
|
|
||||||
def register_instance_dashboard_routes(
|
def register_instance_dashboard_routes(
|
||||||
@@ -13,12 +13,12 @@ def register_instance_dashboard_routes(
|
|||||||
get_db: Callable,
|
get_db: Callable,
|
||||||
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
||||||
hedge_enabled: bool = False,
|
hedge_enabled: bool = False,
|
||||||
|
enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
from lib.instance.instance_dashboard_cache import instance_dashboard_store
|
||||||
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
|
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
|
||||||
|
|
||||||
@app.route("/api/instance/dashboard")
|
def _build() -> dict[str, Any]:
|
||||||
@login_required
|
|
||||||
def api_instance_dashboard():
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
payload = build_instance_dashboard_payload(
|
payload = build_instance_dashboard_payload(
|
||||||
@@ -26,6 +26,45 @@ def register_instance_dashboard_routes(
|
|||||||
fetch_options_positions=fetch_options_positions,
|
fetch_options_positions=fetch_options_positions,
|
||||||
hedge_enabled=bool(hedge_enabled),
|
hedge_enabled=bool(hedge_enabled),
|
||||||
)
|
)
|
||||||
return jsonify(payload)
|
if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict):
|
||||||
|
items = list(payload["orders"].get("items") or [])
|
||||||
|
try:
|
||||||
|
enriched = enrich_orders(items) or items
|
||||||
|
except Exception:
|
||||||
|
enriched = items
|
||||||
|
payload["orders"]["items"] = enriched
|
||||||
|
payload["orders"]["count"] = len(enriched)
|
||||||
|
return payload
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
instance_dashboard_store.start(_build)
|
||||||
|
|
||||||
|
@app.route("/api/instance/dashboard")
|
||||||
|
@login_required
|
||||||
|
def api_instance_dashboard():
|
||||||
|
return jsonify(instance_dashboard_store.snapshot_dict())
|
||||||
|
|
||||||
|
@app.route("/api/instance/dashboard/stream")
|
||||||
|
@login_required
|
||||||
|
def api_instance_dashboard_stream():
|
||||||
|
return Response(
|
||||||
|
stream_with_context(instance_dashboard_store.iter_sse()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/instance/dashboard/refresh", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_instance_dashboard_refresh():
|
||||||
|
instance_dashboard_store.request_refresh()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"dashboard_version": instance_dashboard_store.version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="inst-dash-head">
|
<div class="inst-dash-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin-bottom:4px">数据看板</h2>
|
<h2 style="margin-bottom:4px">数据看板</h2>
|
||||||
<p class="muted inst-dash-desc">本户活跃监控总览 · 只读 · 无数据的区块不显示 · 有数据按表格展示</p>
|
<p class="muted inst-dash-desc">本户活跃监控总览 · 只读 · 后台快照 + SSE · 无数据的区块不显示</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="inst-dash-head-actions">
|
<div class="inst-dash-head-actions">
|
||||||
<span class="muted inst-dash-updated" id="inst-dash-updated">—</span>
|
<span class="muted inst-dash-updated" id="inst-dash-updated">—</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<script src="/static/instance_theme.js?v=50"></script>
|
<script src="/static/instance_theme.js?v=50"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=9">
|
<link rel="stylesheet" href="/static/instance_page.css?v=10">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
|
||||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
@@ -111,7 +111,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
|||||||
{% include 'embed_boot_scripts.html' %}
|
{% include 'embed_boot_scripts.html' %}
|
||||||
<script src="/static/records_review_page.js?v=2"></script>
|
<script src="/static/records_review_page.js?v=2"></script>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/instance_dashboard.js?v=3"></script>
|
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||||
<script>
|
<script>
|
||||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ pwa_app_name }}</title>
|
<title>{{ pwa_app_name }}</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=9">
|
<link rel="stylesheet" href="/static/instance_page.css?v=10">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@@ -2011,7 +2011,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
|
|||||||
</script>
|
</script>
|
||||||
<script src="/static/records_review_page.js?v=2"></script>
|
<script src="/static/records_review_page.js?v=2"></script>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/instance_dashboard.js?v=3"></script>
|
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||||
<script>
|
<script>
|
||||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
{% if page == 'dashboard' %}
|
{% if page == 'dashboard' %}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
|
||||||
|
不走 build_options_hub_snapshot(避免余额/历史).
|
||||||
|
"""
|
||||||
|
if not cfg.get("enabled"):
|
||||||
|
return []
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
ready_fn = cfg.get("options_api_ready")
|
||||||
|
if not callable(ready_fn):
|
||||||
|
return []
|
||||||
|
ok, _reason = ready_fn(ex)
|
||||||
|
if not ok:
|
||||||
|
return []
|
||||||
|
fetch_fn = cfg.get("fetch_option_positions")
|
||||||
|
if not callable(fetch_fn):
|
||||||
|
return []
|
||||||
|
raw = fetch_fn(ex)
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||||
|
from lib.options.options_history_lib import enrich_position_row_display
|
||||||
|
from lib.options.options_positions_lib import attach_close_preview
|
||||||
|
|
||||||
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
get_db = cfg.get("get_db")
|
||||||
|
if not callable(get_db):
|
||||||
|
for p in raw:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
|
||||||
|
attach_close_preview(cfg, ex, row)
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
try:
|
||||||
|
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||||
|
from lib.options.options_target_lib import targets_by_inst
|
||||||
|
|
||||||
|
tgt_map = targets_by_inst(conn)
|
||||||
|
hedge_target_map = active_options_targets_by_inst(conn)
|
||||||
|
except Exception:
|
||||||
|
tgt_map = {}
|
||||||
|
hedge_target_map = {}
|
||||||
|
|
||||||
|
for p in raw:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
inst = str(p.get("instId") or "").strip()
|
||||||
|
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||||
|
row = enrich_position_row_display(
|
||||||
|
cfg,
|
||||||
|
ex,
|
||||||
|
p,
|
||||||
|
meta_cache=meta_cache,
|
||||||
|
premium_override=premium_override,
|
||||||
|
)
|
||||||
|
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
|
||||||
|
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||||
|
if mon:
|
||||||
|
row["target_index"] = mon.get("target_index")
|
||||||
|
row["target_monitor_id"] = mon.get("id")
|
||||||
|
row["target_monitor"] = mon
|
||||||
|
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
||||||
|
if hedge_target:
|
||||||
|
row["hedge_plan_target"] = hedge_target
|
||||||
|
if not mon:
|
||||||
|
row["target_index"] = hedge_target.get("target_index")
|
||||||
|
rows.append(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""instance_dashboard_cache 单元测试."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from lib.instance.instance_dashboard_cache import InstanceDashboardStore
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstanceDashboardStore(unittest.TestCase):
|
||||||
|
def test_snapshot_before_ready(self):
|
||||||
|
store = InstanceDashboardStore()
|
||||||
|
snap = store.snapshot_dict()
|
||||||
|
self.assertFalse(snap["ok"])
|
||||||
|
self.assertIn("尚未就绪", snap["msg"])
|
||||||
|
|
||||||
|
def test_aggregate_and_snapshot(self):
|
||||||
|
store = InstanceDashboardStore()
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def build():
|
||||||
|
calls["n"] += 1
|
||||||
|
return {"ok": True, "updated_at": "t1", "orders": {"count": 0, "items": []}}
|
||||||
|
|
||||||
|
store.start(build)
|
||||||
|
deadline = time.time() + 3
|
||||||
|
while time.time() < deadline:
|
||||||
|
snap = store.snapshot_dict()
|
||||||
|
if snap.get("ok") and snap.get("dashboard_version", 0) >= 1:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
snap = store.snapshot_dict()
|
||||||
|
self.assertTrue(snap["ok"])
|
||||||
|
self.assertGreaterEqual(snap["dashboard_version"], 1)
|
||||||
|
self.assertGreaterEqual(calls["n"], 1)
|
||||||
|
store.request_refresh()
|
||||||
|
ver0 = snap["dashboard_version"]
|
||||||
|
deadline = time.time() + 3
|
||||||
|
while time.time() < deadline:
|
||||||
|
if store.snapshot_dict().get("dashboard_version", 0) > ver0:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertGreater(store.snapshot_dict()["dashboard_version"], ver0)
|
||||||
|
store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user