feat(hub): background chart poll with SSE for positions and market watch
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user