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
+9 -2
View File
@@ -286,10 +286,11 @@ def resolve_chart_bars(
*,
db_path: Path | None = None,
force_refresh: bool = False,
tail_refresh: bool = False,
) -> dict[str, Any]:
"""
按需:先读库,不足则 remote_fetch(symbol, timeframe, since_ms, limit) 补齐并写库。
无后台定时任务;每次调用时顺带 purge 15 天前数据
tail_refresh=True 时即使库内「够新」也增量拉取尾部 K 线(未收盘 K 的 OHLC 更新)
"""
init_db(db_path)
purged = purge_retention(db_path)
@@ -317,6 +318,10 @@ def resolve_chart_bars(
period_ms = TIMEFRAME_MS[tf]
newest_ok = newest_db is not None and int(newest_db) >= int(last_closed) - period_ms
need_fetch = force_refresh or len(db_rows) < need or not newest_ok
tail_only = False
if tail_refresh and db_rows and not force_refresh and not need_fetch:
need_fetch = True
tail_only = True
fetched = 0
price_tick: Optional[float] = None
@@ -324,8 +329,10 @@ def resolve_chart_bars(
if need_fetch:
since = fetch_start_ms
if tail_only and newest_db is not None:
since = max(0, int(newest_db) - period_ms * 3)
# 仅当库内根数已够且缺口在尾部时做增量拉取;否则全量回看,避免 Gate from>to
if (
elif (
db_rows
and not force_refresh
and newest_ok
+5
View File
@@ -68,6 +68,11 @@ HUB_TRUST_LAN=true
# ---------- 行情区 K 线库(data/hub_kline.db,默认保留 15 天)----------
# HUB_KLINE_RETENTION_DAYS=15
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
# 行情区后台轮询 + SSE(对齐监控区 board)
# HUB_CHART_POLL_INTERVAL=5
# HUB_CHART_POSITION_TIMEFRAME=5m
# HUB_CHART_WATCH_TTL_SEC=45
# HUB_CHART_MAX_SERIES_PER_TICK=24
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT---
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
+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()
+259
View File
@@ -0,0 +1,259 @@
"""行情区 K 线:后台轮询订阅 + SSE 版本通知(对齐监控区 board)。"""
from __future__ import annotations
import asyncio
import json
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from hub_board_cache import board_store
HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5"))
HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25"))
HUB_CHART_WATCH_TTL_SEC = float(os.getenv("HUB_CHART_WATCH_TTL_SEC", "45"))
HUB_CHART_POSITION_TIMEFRAME = (os.getenv("HUB_CHART_POSITION_TIMEFRAME", "5m") or "5m").strip()
HUB_CHART_MAX_SERIES_PER_TICK = max(1, int(os.getenv("HUB_CHART_MAX_SERIES_PER_TICK", "24")))
PollFn = Callable[[], Awaitable[dict[str, Any]]]
def series_key(exchange_key: str, symbol: str, timeframe: str) -> str:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = (timeframe or "").strip()
return f"{ex_k}|{sym}|{tf}"
def parse_series_key(key: str) -> tuple[str, str, str] | None:
parts = (key or "").split("|")
if len(parts) != 3:
return None
ex_k, sym, tf = parts[0].strip().lower(), parts[1].strip().upper(), parts[2].strip()
if not ex_k or not sym or not tf:
return None
return ex_k, sym, tf
@dataclass
class SeriesState:
version: int = 0
updated_at: str | None = None
fetched: int = 0
error: str | None = None
class ChartPollStore:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self.version = 0
self.updated_at: str | None = None
self.polling = False
self.last_error: str | None = None
self._watch_until: dict[str, float] = {}
self._position_keys: set[str] = set()
self._series: dict[str, SeriesState] = {}
self._subscribers: list[asyncio.Queue[str | None]] = []
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self._refresh = asyncio.Event()
self._poll_fn: PollFn | None = None
async def start(self, poll_fn: PollFn) -> None:
if self._task and not self._task.done():
return
self._poll_fn = poll_fn
self._stop.clear()
self._task = asyncio.create_task(self._loop(), name="hub-chart-poll")
async def stop(self) -> None:
self._stop.set()
self._refresh.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def touch_watch(self, exchange_key: str, symbol: str, timeframe: str) -> str:
key = series_key(exchange_key, symbol, timeframe)
self._watch_until[key] = time.monotonic() + HUB_CHART_WATCH_TTL_SEC
return key
def clear_watch(self, exchange_key: str, symbol: str, timeframe: str) -> None:
key = series_key(exchange_key, symbol, timeframe)
self._watch_until.pop(key, None)
def sync_positions_from_rows(self, rows: list[Any]) -> None:
keys: set[str] = set()
tf = HUB_CHART_POSITION_TIMEFRAME
for row in rows or []:
if not isinstance(row, dict):
continue
ex_key = str(row.get("key") or row.get("exchange_key") or "").strip().lower()
if not ex_key:
ex_id = str(row.get("id") or "").strip()
if ex_id:
ex_key = ex_id.lower()
if not ex_key:
continue
ag = row.get("agent") if isinstance(row.get("agent"), dict) else {}
if ag.get("ok") is False:
continue
for pos in ag.get("positions") or []:
if not isinstance(pos, dict):
continue
sym = str(pos.get("symbol") or "").strip().upper()
if sym:
keys.add(series_key(ex_key, sym, tf))
self._position_keys = keys
def active_series_keys(self) -> list[str]:
now = time.monotonic()
watch = {k for k, until in self._watch_until.items() if until > now}
merged = self._position_keys | watch
return sorted(merged)[:HUB_CHART_MAX_SERIES_PER_TICK]
def series_event_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, st in self._series.items():
out[key] = {
"series_version": st.version,
"updated_at": st.updated_at,
"fetched": st.fetched,
"error": st.error,
}
return out
def event_dict(self) -> dict[str, Any]:
return {
"chart_version": self.version,
"updated_at": self.updated_at,
"polling": self.polling,
"ok": self.last_error is None,
"error": self.last_error,
"series": self.series_event_dict(),
"poll_interval_sec": HUB_CHART_POLL_INTERVAL,
"position_timeframe": HUB_CHART_POSITION_TIMEFRAME,
}
def series_version(self, exchange_key: str, symbol: str, timeframe: str) -> int:
key = series_key(exchange_key, symbol, timeframe)
st = self._series.get(key)
return st.version if st else 0
async def _loop(self) -> None:
assert self._poll_fn is not None
while not self._stop.is_set():
await self._poll_once(self._poll_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
async def _poll_once(self, poll_fn: PollFn) -> None:
async with self._lock:
self.polling = True
self._broadcast()
try:
snap = board_store.snapshot_dict()
rows = snap.get("rows") if isinstance(snap, dict) else []
if isinstance(rows, list):
self.sync_positions_from_rows(rows)
result = await poll_fn()
if not isinstance(result, dict):
result = {"ok": False, "msg": "chart poll 返回无效"}
except Exception as e:
result = {"ok": False, "msg": str(e), "error": "chart_poll_failed"}
async with self._lock:
self.version += 1
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self.last_error = None if result.get("ok") is not False else (
str(result.get("msg") or result.get("error") or "chart_poll_failed")
)
self.polling = False
self._broadcast()
def note_series_result(
self,
exchange_key: str,
symbol: str,
timeframe: str,
*,
ok: bool,
fetched: int = 0,
error: str | None = None,
) -> None:
key = series_key(exchange_key, symbol, timeframe)
st = self._series.setdefault(key, SeriesState())
st.version += 1
st.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
st.fetched = int(fetched or 0)
st.error = error if not ok else None
def _broadcast(self, *, close: bool = False) -> None:
dead: list[asyncio.Queue[str | None]] = []
payload = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
for q in self._subscribers:
try:
q.put_nowait(payload)
except asyncio.QueueFull:
try:
q.get_nowait()
except asyncio.QueueEmpty:
pass
try:
q.put_nowait(payload)
except asyncio.QueueFull:
dead.append(q)
except Exception:
dead.append(q)
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
async def iter_sse(self) -> AsyncIterator[str]:
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = await asyncio.wait_for(q.get(), timeout=HUB_CHART_SSE_HEARTBEAT_SEC)
except asyncio.TimeoutError:
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:
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: chart\ndata: {body}\n\n"
chart_poll_store = ChartPollStore()
+4 -1
View File
@@ -533,7 +533,10 @@
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
} else if (window.hubMarketChart) {
if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh();
if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive();
else {
if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh();
}
if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer();
}
}
+123 -3
View File
@@ -1,8 +1,10 @@
/**
* 中控行情区K 线 + 成交量默认最新 OHLCV5s 自动刷新价格轴自动
* 中控行情区K 线 + 成交量Hub 后台轮询 + SSE 推送价格轴自动
*/
(function () {
const AUTO_REFRESH_MS = 5000;
const CHART_WATCH_HEARTBEAT_MS = 25000;
const CHART_SSE_FALLBACK_MS = 30000;
const DEFAULT_VISIBLE_BARS = 200;
const RIGHT_OFFSET_BARS = 10;
const CANDLE_SCALE_BOTTOM = 0.26;
@@ -128,6 +130,11 @@
let loadToken = 0;
let marketInited = false;
let refreshTimer = null;
let chartWatchTimer = null;
let chartEventSource = null;
let chartSseReconnectTimer = null;
let localChartVersion = 0;
let localSeriesVersion = 0;
let lastViewKey = "";
let currentTf = "1d";
let priceTagTimer = null;
@@ -1529,18 +1536,117 @@
if (elTf && !elTf.value) elTf.value = "1d";
}
function currentViewSeriesKey() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return "";
return exKey + "|" + sym + "|" + tf;
}
function postChartWatch() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return Promise.resolve();
return fetch("/api/chart/watch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
}).catch(function () {});
}
function postChartUnwatch() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return Promise.resolve();
return fetch("/api/chart/unwatch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
}).catch(function () {});
}
function closeChartStream() {
if (chartEventSource) {
chartEventSource.close();
chartEventSource = null;
}
}
function connectChartStream() {
closeChartStream();
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
chartEventSource = new EventSource("/api/chart/stream");
chartEventSource.addEventListener("chart", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.chart_version) || 0;
const series = st.series || {};
const vKey = currentViewSeriesKey();
const sVer = vKey && series[vKey] ? Number(series[vKey].series_version) || 0 : 0;
const seriesChanged = vKey && sVer > 0 && sVer !== localSeriesVersion;
if (seriesChanged) {
localSeriesVersion = sVer;
localChartVersion = ver;
loadChart(false, { autoTick: true });
} else if (ver !== localChartVersion) {
localChartVersion = ver;
}
} catch (_) {}
});
chartEventSource.onerror = function () {
closeChartStream();
if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer);
chartSseReconnectTimer = setTimeout(function () {
const p = document.getElementById("page-market");
if (p && !p.classList.contains("hidden")) connectChartStream();
}, 8000);
};
}
function startChartWatchHeartbeat() {
stopChartWatchHeartbeat();
void postChartWatch();
chartWatchTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
void postChartWatch();
}, CHART_WATCH_HEARTBEAT_MS);
}
function stopChartWatchHeartbeat() {
if (chartWatchTimer) clearInterval(chartWatchTimer);
chartWatchTimer = null;
}
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
loadChart(false, { autoTick: true });
}, AUTO_REFRESH_MS);
}, CHART_SSE_FALLBACK_MS);
}
function stopAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (chartSseReconnectTimer) {
clearTimeout(chartSseReconnectTimer);
chartSseReconnectTimer = null;
}
}
function stopChartLive() {
stopAutoRefresh();
stopChartWatchHeartbeat();
closeChartStream();
void postChartUnwatch();
}
async function loadMeta() {
@@ -1563,6 +1669,10 @@
async function loadChart(force, options) {
options = options || {};
const autoTick = !!options.autoTick;
if (!autoTick) {
localSeriesVersion = 0;
void postChartWatch();
}
if (!ensureChart()) return;
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
@@ -1645,7 +1755,9 @@
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
")· 每 5s 刷新";
")· 后台 " +
(data.chart_poll_interval_sec || 5) +
"s 轮询 · SSE";
if (data.stale && data.stale_message) {
hint += " · 缓存:" + data.stale_message;
}
@@ -1654,6 +1766,8 @@
elStatus.textContent = hint;
}
if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--");
if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion;
if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion;
tickLiveClock();
} catch (e) {
if (myToken !== loadToken) return;
@@ -1778,6 +1892,8 @@
readQuery();
}
focusMarketChartArea();
connectChartStream();
startChartWatchHeartbeat();
startAutoRefresh();
await loadChart(false);
startPriceTagTimer();
@@ -1790,7 +1906,10 @@
if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase();
if (tf && elTf) elTf.value = tf;
lastViewKey = "";
localSeriesVersion = 0;
updateExchangeDisplay();
connectChartStream();
startChartWatchHeartbeat();
startAutoRefresh();
await loadChart(false);
startPriceTagTimer();
@@ -1800,6 +1919,7 @@
},
startAutoRefresh: startAutoRefresh,
stopAutoRefresh: stopAutoRefresh,
stopChartLive: stopChartLive,
stopPriceTagTimer: stopPriceTagTimer,
};
+2 -2
View File
@@ -248,7 +248,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-hub-theme4"></script>
<script src="/assets/app.js?v=20260604-hub-theme4"></script>
<script src="/assets/chart.js?v=20260604-hub-chart-sse"></script>
<script src="/assets/app.js?v=20260604-hub-chart-sse"></script>
</body>
</html>
+9 -2
View File
@@ -42,11 +42,11 @@
| 项 | 说明 |
|------|------|
| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;无后台定时全量同步 |
| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 |
| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git |
| **保留** | 默认 **15 天**`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 |
| **根数** | 日内周期约 **1000** 根;`1d` / `1w`**500** 根 |
| **刷新** | 约 **5 秒** 自动刷新最新一根;**加载** 用缓存**强制刷新** 忽略缓存重拉 |
| **刷新** | Hub**5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期)。浏览器经 **SSE**`chart_version` 后拉 `/api/chart/ohlcv`**加载** 读库**强制刷新** 全量重拉 |
| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 |
| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 |
@@ -55,6 +55,9 @@
```bash
# HUB_KLINE_RETENTION_DAYS=15
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
# HUB_CHART_POLL_INTERVAL=5
# HUB_CHART_POSITION_TIMEFRAME=5m
# HUB_CHART_WATCH_TTL_SEC=45
```
---
@@ -77,6 +80,10 @@
|------|------|------|
| GET | `/api/chart/meta` | 已启用交易所列表、周期列表、各周期 limit、保留天数 |
| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key``symbol``timeframe`、可选 `refresh=1` 强制刷新 |
| POST | `/api/chart/watch` | 行情页订阅(JSON`exchange_key``symbol``timeframe`),45s 内需续期 |
| POST | `/api/chart/unwatch` | 离开行情页取消订阅 |
| GET | `/api/chart/stream` | SSE`event: chart`,含 `chart_version` 与各 `series` 版本 |
| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 |
实例侧(中控转发):
+58
View File
@@ -0,0 +1,58 @@
"""行情区 chart 后台轮询订阅。"""
from __future__ import annotations
import asyncio
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
from hub_chart_cache import ChartPollStore, series_key # noqa: E402
class TestHubChartCache(unittest.TestCase):
def test_series_key(self) -> None:
self.assertEqual(series_key("Gate_X", "hype/usdt", "5m"), "gate_x|HYPE/USDT|5m")
def test_position_and_watch_keys(self) -> None:
store = ChartPollStore()
store.sync_positions_from_rows(
[
{
"key": "okx_auto",
"agent": {
"ok": True,
"positions": [{"symbol": "BTC/USDT"}, {"symbol": "ETH/USDT"}],
},
}
]
)
store.touch_watch("gate_trend", "HYPE/USDT", "5m")
keys = store.active_series_keys()
self.assertIn(series_key("okx_auto", "BTC/USDT", "5m"), keys)
self.assertIn(series_key("gate_trend", "HYPE/USDT", "5m"), keys)
def test_poll_increments_version(self) -> None:
async def run() -> None:
store = ChartPollStore()
n = 0
async def poll():
nonlocal n
n += 1
store.touch_watch("binance", "BTC/USDT", "1d")
return {"ok": True, "n": n}
await store.start(poll)
await asyncio.sleep(0.05)
self.assertGreaterEqual(store.version, 1)
await store.stop()
asyncio.run(run())
if __name__ == "__main__":
unittest.main()