diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js
index 1f38644..85301fd 100644
--- a/lib/common/static/instance_embed.js
+++ b/lib/common/static/instance_embed.js
@@ -60,45 +60,52 @@
else history.pushState({ embedTab: tab }, "", url);
}
- function runPageInit(tab) {
+ function runPageInit(tab, opts) {
+ const options = opts || {};
+ const revisit = !!options.revisit;
document.body.setAttribute("data-page", tab);
if (typeof global.attachListWindowToExports === "function") {
global.attachListWindowToExports();
}
if (tab === "trade") {
- if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
- if (typeof global.initOrderEntryModelSelect === "function") {
+ if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
+ if (!revisit && typeof global.initOrderEntryModelSelect === "function") {
const root = pageRoot() || document;
global.initOrderEntryModelSelect(root);
}
- if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
+ if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
global.ManualOrderRrPreview.wire();
}
}
- if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
+ if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
global.KeyMonitorForm.init();
}
- if (tab === "strategy" && typeof global.initStrategyRollForm === "function") {
+ if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
global.initStrategyRollForm();
}
- if (tab === "records") {
+ if (!revisit && tab === "records") {
if (typeof global.loadJournals === "function") global.loadJournals();
if (typeof global.loadReviews === "function") global.loadReviews();
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
}
- if (tab === "stats") {
+ if (!revisit && tab === "stats") {
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
}
- if (typeof global.refreshPriceSnapshotConditional === "function") {
- global.refreshPriceSnapshotConditional();
+ if (revisit && tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
+ global.OptionsPanelLive.refreshSoft();
}
- if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
- const root = pageRoot() || document;
- global.SymbolLivePrice.init(root);
- }
- if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
- const root = pageRoot() || document;
- global.JournalUploadSlots.init(root);
+ if (!revisit) {
+ if (typeof global.refreshPriceSnapshotConditional === "function") {
+ global.refreshPriceSnapshotConditional();
+ }
+ if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
+ const root = pageRoot() || document;
+ global.SymbolLivePrice.init(root);
+ }
+ if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
+ const root = pageRoot() || document;
+ global.JournalUploadSlots.init(root);
+ }
}
}
@@ -192,7 +199,7 @@
showPane(tab);
setNavActive(tab);
if (!options.skipUrl) syncUrl(tab, !!options.replace);
- runPageInit(tab);
+ runPageInit(tab, { revisit: !!options.revisit });
}
async function loadTab(tab, opts) {
@@ -200,7 +207,7 @@
if (!tab) return;
if (tabPanes.has(tab) && !options.force) {
- activateTab(tab, options);
+ activateTab(tab, Object.assign({}, options, { revisit: true }));
return;
}
diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js
new file mode 100644
index 0000000..5be5f86
--- /dev/null
+++ b/lib/common/static/instance_live.js
@@ -0,0 +1,94 @@
+/**
+ * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML。
+ */
+(function (global) {
+ let liveEventSource = null;
+ let liveReconnectTimer = null;
+ let localLiveVersion = -1;
+ let sseConnected = false;
+
+ function isEmbedShell() {
+ return document.body && document.body.getAttribute("data-embed-shell") === "1";
+ }
+
+ function currentTab() {
+ if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
+ return global.InstanceEmbed.getTab();
+ }
+ return document.body.getAttribute("data-page") || "trade";
+ }
+
+ function refreshTabData(tab, opts) {
+ const options = opts || {};
+ if (typeof global.refreshAccountSnapshot === "function") {
+ global.refreshAccountSnapshot();
+ }
+ if (typeof global.refreshPriceSnapshotConditional === "function") {
+ global.refreshPriceSnapshotConditional();
+ }
+ if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
+ global.OptionsPanelLive.refreshSoft(options);
+ }
+ }
+
+ function onLiveEvent(data) {
+ const ver = Number(data && data.live_version) || 0;
+ if (!ver || ver === localLiveVersion) return;
+ localLiveVersion = ver;
+ refreshTabData(currentTab(), { silent: true });
+ }
+
+ function closeLiveStream() {
+ if (liveEventSource) {
+ liveEventSource.close();
+ liveEventSource = null;
+ }
+ if (liveReconnectTimer) {
+ clearTimeout(liveReconnectTimer);
+ liveReconnectTimer = null;
+ }
+ sseConnected = false;
+ }
+
+ function connectLiveStream() {
+ if (!isEmbedShell()) return;
+ closeLiveStream();
+ liveEventSource = new EventSource("/api/instance/live/stream");
+ liveEventSource.addEventListener("live", function (ev) {
+ try {
+ onLiveEvent(JSON.parse(ev.data || "{}"));
+ } catch (_) {}
+ });
+ liveEventSource.onopen = function () {
+ sseConnected = true;
+ };
+ liveEventSource.onerror = function () {
+ sseConnected = false;
+ closeLiveStream();
+ liveReconnectTimer = setTimeout(function () {
+ connectLiveStream();
+ refreshTabData(currentTab(), { silent: true });
+ }, 8000);
+ };
+ }
+
+ function startLive() {
+ if (!isEmbedShell()) return;
+ refreshTabData(currentTab());
+ connectLiveStream();
+ }
+
+ global.InstanceLive = {
+ start: startLive,
+ refreshTabData: refreshTabData,
+ isConnected: function () {
+ return sseConnected;
+ },
+ };
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", startLive);
+ } else {
+ startLive();
+ }
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index f99644a..0eb1995 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -473,9 +473,6 @@
renderExpiries();
renderStrikes();
refreshAllPositions();
- requestAnimationFrame(function () {
- loadChain();
- });
return;
}
requestAnimationFrame(function () {
@@ -523,4 +520,11 @@
});
bootOptionsPanel();
+
+ window.OptionsPanelLive = {
+ refreshSoft: function () {
+ refreshAllPositions();
+ },
+ refreshChain: loadChain,
+ };
})();
diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py
index 0091529..6baa68f 100644
--- a/lib/hub/hub_bridge.py
+++ b/lib/hub/hub_bridge.py
@@ -68,6 +68,7 @@ def install_instance_theme_static(app) -> None:
"strategy_roll.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; charset=utf-8",
+ "instance_live.js": "application/javascript; charset=utf-8",
"order_entry_model.js": "application/javascript; charset=utf-8",
"focus_chart_page.js": "application/javascript; charset=utf-8",
"focus_chart_page.css": "text/css; charset=utf-8",
diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py
index 58d7042..f3e1c19 100644
--- a/lib/instance/instance_embed_lib.py
+++ b/lib/instance/instance_embed_lib.py
@@ -144,7 +144,10 @@ def register_embed_routes(
login_required: Callable,
render_main_page_fn: Callable,
) -> None:
+ from lib.instance.instance_live_push_lib import register_instance_live_routes
+
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
+ register_instance_live_routes(app, login_required)
@login_required
@app.route("/embed")
diff --git a/lib/instance/instance_live_push_lib.py b/lib/instance/instance_live_push_lib.py
new file mode 100644
index 0000000..0da8a9b
--- /dev/null
+++ b/lib/instance/instance_live_push_lib.py
@@ -0,0 +1,117 @@
+"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)。"""
+from __future__ import annotations
+
+import json
+import os
+import queue
+import threading
+from collections.abc import Iterator
+from typing import Any, Callable
+
+from flask import Flask, Response, stream_with_context
+
+INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5"))
+INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25"))
+
+
+class InstanceLivePush:
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self.version = 0
+ self._subscribers: list[queue.Queue[str | None]] = []
+ self._stop = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ def start(self) -> None:
+ if self._thread and self._thread.is_alive():
+ return
+ self._stop.clear()
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push")
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+ self._broadcast(close=True)
+
+ def tick(self, reason: str = "poll") -> int:
+ with self._lock:
+ self.version += 1
+ ver = self.version
+ payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False)
+ self._broadcast(payload)
+ return ver
+
+ def event_dict(self) -> dict[str, Any]:
+ return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC}
+
+ def _loop(self) -> None:
+ while not self._stop.is_set():
+ self.tick("poll")
+ if self._stop.wait(INSTANCE_LIVE_TICK_SEC):
+ break
+
+ def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
+ with self._lock:
+ subs = list(self._subscribers)
+ dead: list[queue.Queue[str | None]] = []
+ for q in subs:
+ try:
+ q.put_nowait(None if close else event)
+ except Exception:
+ dead.append(q)
+ if dead:
+ with self._lock:
+ for q in dead:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+ def _subscribe(self) -> queue.Queue[str | None]:
+ q: queue.Queue[str | None] = queue.Queue(maxsize=16)
+ with self._lock:
+ self._subscribers.append(q)
+ return q
+
+ def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
+ with self._lock:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+ def iter_sse(self) -> Iterator[str]:
+ q = self._subscribe()
+ try:
+ yield self._format_event(self.event_dict() | {"reason": "connect"})
+ while True:
+ try:
+ raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC)
+ except queue.Empty:
+ yield ": heartbeat\n\n"
+ continue
+ if raw is None:
+ break
+ yield f"event: live\ndata: {raw}\n\n"
+ finally:
+ self._unsubscribe(q)
+
+ @staticmethod
+ def _format_event(data: dict[str, Any]) -> str:
+ return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
+
+
+instance_live_push = InstanceLivePush()
+
+
+def register_instance_live_routes(app: Flask, login_required: Callable) -> None:
+ instance_live_push.start()
+
+ @login_required
+ @app.route("/api/instance/live/stream")
+ def api_instance_live_stream():
+ return Response(
+ stream_with_context(instance_live_push.iter_sse()),
+ mimetype="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html
index 21dd3aa..a340676 100644
--- a/lib/instance/templates/embed_boot_scripts.html
+++ b/lib/instance/templates/embed_boot_scripts.html
@@ -1268,7 +1268,6 @@ if(addOrderForm){
refreshOrderDefaults();
if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect();
refreshPriceSnapshotConditional();
-setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));
function refreshPriceSnapshotConditional(){
const page = document.body.getAttribute("data-page") || "";
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
@@ -1348,5 +1347,22 @@ function tickOrderHoldDurations(){
}
setInterval(tickOrderHoldDurations, 1000);
tickOrderHoldDurations();
-setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));
+(function startInstanceDataRefresh(){
+ const embedShell = document.body && document.body.getAttribute("data-embed-shell") === "1";
+ const balanceMs = Number(document.body.dataset.balanceRefreshMs || 30000);
+ const priceMs = Number(document.body.dataset.priceRefreshMs || 5000);
+ if(embedShell){
+ setInterval(function(){
+ if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return;
+ refreshAccountSnapshot();
+ }, balanceMs);
+ setInterval(function(){
+ if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return;
+ refreshPriceSnapshotConditional();
+ }, priceMs);
+ return;
+ }
+ setInterval(refreshAccountSnapshot, balanceMs);
+ setInterval(refreshPriceSnapshotConditional, priceMs);
+})();
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index 114e0d8..932b6d9 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -90,6 +90,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
{% include 'embed_boot_scripts.html' %}
-
+
+