feat(hub): background chart poll with SSE for positions and market watch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 12:39:26 +08:00
parent 9d12323ce6
commit 6f8f0968c8
9 changed files with 591 additions and 10 deletions
+122
View File
@@ -52,6 +52,12 @@ from url_public import browser_url, default_review_url, public_origin
from urllib.parse import urlencode
from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store
from hub_chart_cache import (
HUB_CHART_POLL_INTERVAL,
HUB_CHART_WATCH_TTL_SEC,
chart_poll_store,
parse_series_key,
)
try:
from exchange_orders import symbols_match as _symbols_match
@@ -135,6 +141,65 @@ def _find_exchange(ex_id: str) -> dict | None:
return None
async def _run_chart_poll() -> dict:
keys = chart_poll_store.active_series_keys()
if not keys:
return {"ok": True, "series_count": 0, "polled": 0}
polled = 0
errors: list[str] = []
for key in keys:
parsed = parse_series_key(key)
if not parsed:
continue
ex_k, sym, tf = parsed
ex = _find_exchange_by_key(ex_k)
if not ex or not ex.get("enabled"):
continue
ex_ref = ex
sym_ref = sym
tf_ref = tf
def remote_fetch(**kwargs) -> dict:
tf_use = kwargs.get("timeframe") or tf_ref
return _fetch_instance_ohlcv_sync(
ex_ref,
symbol=kwargs.get("symbol") or sym_ref,
timeframe=tf_use,
since_ms=kwargs.get("since_ms"),
limit=int(kwargs.get("limit") or bar_limit_for_timeframe(tf_use)),
)
try:
result = await asyncio.to_thread(
resolve_chart_bars,
ex_k,
sym,
tf,
remote_fetch,
force_refresh=False,
tail_refresh=True,
)
polled += 1
chart_poll_store.note_series_result(
ex_k,
sym,
tf,
ok=bool(result.get("ok")),
fetched=int(result.get("fetched") or 0),
error=None if result.get("ok") else str(result.get("msg") or "poll_failed"),
)
if not result.get("ok"):
errors.append(f"{key}:{result.get('msg')}")
except Exception as e:
chart_poll_store.note_series_result(ex_k, sym, tf, ok=False, error=str(e))
errors.append(f"{key}:{e}")
out: dict = {"ok": True, "series_count": len(keys), "polled": polled}
if errors:
out["errors"] = errors[:8]
return out
async def _run_board_aggregate() -> dict:
try:
body = await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT)
@@ -159,9 +224,11 @@ def _schedule_board_refresh() -> None:
@asynccontextmanager
async def _hub_lifespan(_app: FastAPI):
await board_store.start(_run_board_aggregate)
await chart_poll_store.start(_run_chart_poll)
try:
yield
finally:
await chart_poll_store.stop()
await board_store.stop()
@@ -442,9 +509,64 @@ def api_chart_ohlcv(
else None,
tick,
)
result["chart_version"] = chart_poll_store.version
result["series_version"] = chart_poll_store.series_version(ex_key, sym, timeframe)
result["chart_poll_interval_sec"] = HUB_CHART_POLL_INTERVAL
return result
class ChartWatchBody(BaseModel):
exchange_key: str = ""
symbol: str = ""
timeframe: str = "5m"
@app.post("/api/chart/watch")
async def api_chart_watch(body: ChartWatchBody = Body(...)):
ex_k = (body.exchange_key or "").strip().lower()
sym = (body.symbol or "").strip().upper()
tf = (body.timeframe or "5m").strip()
if not ex_k or not sym:
raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol")
if tf not in CHART_TIMEFRAMES:
raise HTTPException(status_code=400, detail="不支持的周期")
key = chart_poll_store.touch_watch(ex_k, sym, tf)
chart_poll_store.request_refresh()
return {
"ok": True,
"series_key": key,
"series_version": chart_poll_store.series_version(ex_k, sym, tf),
"chart_version": chart_poll_store.version,
"watch_ttl_sec": HUB_CHART_WATCH_TTL_SEC,
}
@app.post("/api/chart/unwatch")
async def api_chart_unwatch(body: ChartWatchBody = Body(...)):
chart_poll_store.clear_watch(body.exchange_key, body.symbol, body.timeframe)
return {"ok": True}
@app.get("/api/chart/stream")
async def api_chart_stream():
from fastapi.responses import StreamingResponse
return StreamingResponse(
chart_poll_store.iter_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.get("/api/chart/poll/meta")
async def api_chart_poll_meta():
return chart_poll_store.event_dict()
@app.get("/api/settings/meta")
def api_settings_meta():
po = public_origin()