条件单列表改为中控后台并行缓存并以 SSE 推浏览器,下发仍走中控到实例。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-07 14:50:52 +08:00
parent 1c68493aa0
commit 5ffcee3f56
6 changed files with 444 additions and 55 deletions
+2
View File
@@ -11,6 +11,8 @@ HUB_PULL_TIMEOUT_SEC=5
HUB_SSE_HEARTBEAT_SEC=25
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
HUB_STATS_POLL_INTERVAL_SEC=15
# 条件单列表后台并行拉取间隔(秒)
HUB_COND_POLL_INTERVAL_SEC=10
# 中控企业微信(离线/故障告警,与客户端通知分开)
HUB_WECHAT_ENABLED=false
+74 -40
View File
@@ -43,13 +43,13 @@ from lib.bridge_client_lib import (
fetch_backup_file,
fetch_backup_list,
fetch_client_version,
fetch_cond_orders,
post_client_update,
post_close_full,
post_cond_cancel,
post_cond_exit,
pull_all_enabled,
)
from lib.cond_orders_cache_lib import cond_orders_store
from lib.cash_sample_lib import record_cash_samples_from_overview
from lib.db_lib import db_path as hub_db_path, init_db
from lib.env_lib import apply_env_updates, env_get
@@ -560,8 +560,13 @@ def start_stats_cache() -> None:
stats_store.start()
def start_cond_orders_cache() -> None:
cond_orders_store.start()
start_overview_cache()
start_stats_cache()
start_cond_orders_cache()
try:
start_hub_backup_scheduler(
@@ -690,49 +695,66 @@ def cond_orders_page():
@app.route("/api/cond_orders/candidates")
@login_required
def api_cond_order_candidates():
"""可选客户端:在线且无持仓、未被条件单锁定."""
rows = pull_all_enabled()
out = []
for row in rows:
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
brief = snap.get("positions_brief") or snap.get("positions") or []
has_pos = isinstance(brief, list) and len(brief) > 0
locked = bool(snap.get("cond_order_locked"))
selectable = bool(row.get("online")) and not has_pos and not locked
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
mode = str(inst.get("mode") or "").strip().lower()
if mode not in ("sim", "live"):
mode = ""
out.append(
"""可选客户端:读热缓存(总览快照推导),不现场拉实例."""
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
cond_orders_store.request_refresh()
overview_store.request_refresh()
snap = cond_orders_store.snapshot_dict()
return jsonify(
{
"id": row.get("id"),
"name": row.get("name"),
"online": bool(row.get("online")),
"has_position": has_pos,
"cond_order_locked": locked,
"selectable": selectable,
"mode": mode,
"error": row.get("error") or "",
"ok": True,
"instances": snap.get("candidates") or [],
"cond_version": snap.get("cond_version"),
"updated_at": snap.get("updated_at"),
"refreshing": snap.get("refreshing"),
"from_cache": True,
}
)
return jsonify({"ok": True, "instances": out})
@app.route("/api/cond_orders", methods=["GET"])
@login_required
def api_cond_orders_list():
instances = list_instances(enabled_only=True)
all_orders = []
for inst in instances:
data = fetch_cond_orders(inst)
for o in data.get("orders") or []:
if not isinstance(o, dict):
continue
item = dict(o)
item["instance_id"] = inst.get("id")
item["instance_name"] = inst.get("name")
all_orders.append(item)
return jsonify({"ok": True, "orders": all_orders})
"""条件单列表只读热缓存;后台常驻并行从实例拉取."""
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
cond_orders_store.request_refresh()
snap = cond_orders_store.snapshot_dict()
return jsonify(
{
"ok": True,
"orders": snap.get("orders") or [],
"cond_version": snap.get("cond_version"),
"updated_at": snap.get("updated_at"),
"refreshing": snap.get("refreshing"),
"from_cache": True,
"poll_interval_sec": snap.get("poll_interval_sec"),
"error": snap.get("error"),
}
)
@app.route("/api/cond_orders/refresh", methods=["POST"])
@login_required
def api_cond_orders_refresh():
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify({"ok": True, "msg": "已触发条件单后台刷新"})
@app.route("/api/cond_orders/stream")
@login_required
def api_cond_orders_stream():
return Response(
stream_with_context(cond_orders_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/cond_orders", methods=["POST"])
@@ -749,8 +771,9 @@ def api_cond_orders_create():
if not instance_ids:
return jsonify({"ok": False, "msg": "请选择客户端"})
# 再次按快照过滤有仓/锁定
rows = {r.get("id"): r for r in pull_all_enabled()}
# 再次按热缓存快照过滤有仓/锁定(下发本身仍 POST 到实例)
ov = overview_store.snapshot_dict()
rows = {r.get("id"): r for r in (ov.get("instances") or [])}
allowed = []
rejected = []
for iid in instance_ids:
@@ -792,6 +815,9 @@ def api_cond_orders_create():
}
results = dispatch_cond_order_to_ids(allowed, payload)
ok_n = sum(1 for r in results if r.get("ok"))
if ok_n > 0:
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(
{
"ok": ok_n > 0,
@@ -813,7 +839,11 @@ def api_cond_orders_cancel(hub_order_id: str):
inst = get_instance(int(iid))
if not inst:
return jsonify({"ok": False, "msg": "实例不存在"}), 404
return jsonify(post_cond_cancel(inst, hub_order_id))
result = post_cond_cancel(inst, hub_order_id)
if isinstance(result, dict) and result.get("ok"):
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(result)
@app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
@@ -831,7 +861,11 @@ def api_cond_orders_exit(hub_order_id: str):
"profit_exit_enabled": data.get("profit_exit_enabled"),
"profit_exit_mult": data.get("profit_exit_mult"),
}
return jsonify(post_cond_exit(inst, hub_order_id, payload))
result = post_cond_exit(inst, hub_order_id, payload)
if isinstance(result, dict) and result.get("ok"):
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(result)
@app.route("/manifest.webmanifest")
+34
View File
@@ -215,6 +215,40 @@ def fetch_cond_orders(inst: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")}
def pull_all_cond_orders() -> list[dict[str, Any]]:
"""并行拉取各机条件单并展平为带 instance 字段的订单列表."""
instances = list_instances(enabled_only=True)
if not instances:
return []
by_id: dict[Any, dict[str, Any]] = {}
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
futs = {pool.submit(fetch_cond_orders, inst): inst for inst in instances}
for fut in as_completed(futs):
inst = futs[fut]
try:
data = fut.result()
except Exception as e:
data = {
"ok": False,
"msg": str(e) or "拉取失败",
"orders": [],
"instance_id": inst.get("id"),
"instance_name": inst.get("name"),
}
by_id[inst.get("id")] = data if isinstance(data, dict) else {}
all_orders: list[dict[str, Any]] = []
for inst in instances:
data = by_id.get(inst["id"]) or {}
for o in data.get("orders") or []:
if not isinstance(o, dict):
continue
item = dict(o)
item["instance_id"] = inst.get("id")
item["instance_name"] = inst.get("name")
all_orders.append(item)
return all_orders
def pull_all_enabled() -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True)
if not instances:
+232
View File
@@ -0,0 +1,232 @@
"""条件单热缓存:后台常驻并行从实例拉取;浏览器只读缓存 + SSE.
下发/撤单/改出场仍由中控 POST 到实例,成功后触发刷新.
"""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Iterator
from typing import Any
def _poll_interval_sec() -> float:
try:
return max(
3.0,
float(
os.getenv("HUB_COND_POLL_INTERVAL_SEC")
or os.getenv("HUB_PULL_INTERVAL_SEC")
or 10
),
)
except (TypeError, ValueError):
return 10.0
def _sse_heartbeat_sec() -> float:
try:
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
except (TypeError, ValueError):
return 25.0
def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in rows or []:
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
brief = snap.get("positions_brief") or snap.get("positions") or []
has_pos = isinstance(brief, list) and len(brief) > 0
locked = bool(snap.get("cond_order_locked"))
selectable = bool(row.get("online")) and not has_pos and not locked
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
mode = str(inst.get("mode") or "").strip().lower()
if mode not in ("sim", "live"):
mode = ""
out.append(
{
"id": row.get("id"),
"name": row.get("name"),
"online": bool(row.get("online")),
"has_position": has_pos,
"cond_order_locked": locked,
"selectable": selectable,
"mode": mode,
"error": row.get("error") or "",
}
)
return out
class CondOrdersStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.orders: list[dict[str, Any]] = []
self.candidates: list[dict[str, Any]] = []
self.refreshing = True
self.last_error: str | None = None
self.updated_at: str = ""
self._ready = False
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self.refreshing = True
self._ready = False
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="hub-cond-orders-pull",
)
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:
return {
"ok": True,
"orders": list(self.orders),
"candidates": list(self.candidates),
"cond_version": self.version,
"refreshing": self.refreshing,
"updated_at": self.updated_at,
"error": self.last_error,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
"ready": self._ready,
}
def event_dict(self) -> dict[str, Any]:
with self._lock:
return {
"cond_version": self.version,
"updated_at": self.updated_at,
"refreshing": self.refreshing,
"ok": True,
"error": self.last_error,
"poll_interval_sec": _poll_interval_sec(),
}
def _overview_rows(self) -> list[dict[str, Any]]:
try:
from lib.overview_cache_lib import overview_store
snap = overview_store.snapshot_dict()
rows = snap.get("instances") if isinstance(snap, dict) else None
if isinstance(rows, list) and rows:
return rows
except Exception:
pass
try:
from lib.bridge_client_lib import pull_all_enabled
return pull_all_enabled()
except Exception:
return []
def _loop(self) -> None:
while not self._stop.is_set():
self._refresh_once()
if self._stop.is_set():
break
self._refresh.clear()
self._refresh.wait(timeout=_poll_interval_sec())
def _refresh_once(self) -> None:
with self._lock:
self.refreshing = True
self._broadcast()
try:
from lib.bridge_client_lib import pull_all_cond_orders
rows = self._overview_rows()
candidates = _candidates_from_snaps(rows)
orders = pull_all_cond_orders()
with self._lock:
self.version += 1
self.orders = orders if isinstance(orders, list) else []
self.candidates = candidates
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
self.last_error = None
self.refreshing = False
self._ready = True
except Exception as e:
with self._lock:
self.version += 1
self.last_error = str(e) or "cond_refresh_failed"
self.refreshing = False
self._ready = True
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=_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: cond\ndata: {body}\n\n"
cond_orders_store = CondOrdersStore()
+96 -10
View File
@@ -117,7 +117,7 @@
});
const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已取消" : "失败"));
refresh();
requestCondRefresh();
});
});
box.querySelectorAll("[data-exit]").forEach(function (btn) {
@@ -148,7 +148,7 @@
});
const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已更新" : "失败"));
refresh();
requestCondRefresh();
});
});
}
@@ -414,13 +414,25 @@
fillOrderBox(document.getElementById("cond-list-done"), lastDone, "暂无已结束的条件单", "done");
}
async function loadOrders() {
let condVersion = 0;
let condLoading = false;
let condEventSource = null;
let condReconnectTimer = null;
function setCondStatus(msg) {
const el = document.getElementById("cond-status");
if (el) el.textContent = msg || "";
}
async function loadOrders(opts) {
opts = opts || {};
const activeBox = document.getElementById("cond-list-active");
const doneBox = document.getElementById("cond-list-done");
if (!activeBox && !doneBox) return;
const r = await fetch("/api/cond_orders");
const r = await fetch("/api/cond_orders", { credentials: "same-origin" });
const d = await r.json();
const list = d.orders || [];
condVersion = Number(d.cond_version) || condVersion;
lastActive = [];
lastDone = [];
list.forEach(function (o) {
@@ -428,6 +440,19 @@
else lastDone.push(o);
});
paintCachedOrders();
if (!opts.silent) {
const live = condEventSource && condEventSource.readyState === 1;
const sec = Number(d.poll_interval_sec) || 10;
setCondStatus(
(live ? "SSE 已连接 · " : "") +
"进行中 " +
lastActive.length +
" · 已结束 " +
lastDone.length +
(d.updated_at ? " · " + d.updated_at : "") +
(d.refreshing ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉")
);
}
}
async function createOrder() {
@@ -467,16 +492,72 @@
const tog = document.getElementById("cond-add-toggle");
if (tog) tog.checked = false;
}
refresh();
requestCondRefresh();
}
async function refresh() {
await Promise.all([loadCandidates(), loadOrders()]);
async function refresh(opts) {
opts = opts || {};
if (condLoading) return;
condLoading = true;
if (!opts.silent) setCondStatus("读取缓存…");
try {
await Promise.all([loadCandidates(), loadOrders({ silent: !!opts.silent })]);
} finally {
condLoading = false;
}
}
async function requestCondRefresh() {
try {
await fetch("/api/cond_orders/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
refresh({ silent: true });
}
function closeCondStream() {
if (condEventSource) {
condEventSource.close();
condEventSource = null;
}
if (condReconnectTimer) {
clearTimeout(condReconnectTimer);
condReconnectTimer = null;
}
}
function connectCondStream() {
if (typeof EventSource === "undefined") return;
closeCondStream();
condEventSource = new EventSource("/api/cond_orders/stream");
condEventSource.addEventListener("cond", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.cond_version) || 0;
if (ver && ver !== condVersion) {
refresh({ silent: true });
} else if (st.refreshing) {
setCondStatus("后台并行拉取条件单…");
}
} catch (_) {}
});
condEventSource.onerror = function () {
closeCondStream();
setCondStatus("SSE 断开,8s 后重连…");
condReconnectTimer = setTimeout(function () {
connectCondStream();
refresh({ silent: true });
}, 8000);
};
}
function boot() {
const btn = document.getElementById("btn-cond-refresh");
if (btn) btn.addEventListener("click", refresh);
if (btn) btn.addEventListener("click", function () {
requestCondRefresh();
});
const create = document.getElementById("btn-cond-create");
if (create) create.addEventListener("click", createOrder);
const typeEl = document.getElementById("cf-opt-type");
@@ -484,9 +565,14 @@
if (typeEl) typeEl.addEventListener("change", syncTriggerHint);
if (pxEl) pxEl.addEventListener("input", syncTriggerHint);
syncTriggerHint();
// 读缓存秒开 + SSE;不再前端定时现场拉实例
refresh();
setInterval(refresh, 10000);
connectCondStream();
}
global.HubCondOrders = { boot: boot, refresh: refresh };
global.HubCondOrders = {
boot: boot,
refresh: refresh,
requestRefresh: requestCondRefresh,
};
})(window);
+3 -2
View File
@@ -4,7 +4,8 @@
<section class="page-head cond-head">
<div>
<h1>条件单</h1>
<p class="muted cond-lead">中控只下发;盯盘、买币、开平仓都在客户端。有仓或已占用的机器不可选。</p>
<p class="muted cond-lead">中控下发到实例;列表由中控后台缓存并推送到浏览器。盯盘与开平仓在客户端。有仓或已占用的机器不可选。</p>
<p class="muted" id="cond-status">读取缓存…</p>
</div>
<div class="cond-head-actions">
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
@@ -93,6 +94,6 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=7"></script>
<script>HubCondOrders.boot();</script>
{% endblock %}