中控行情区与 K 线本地库(15 天滚动、按需拉取)

新增行情区单图与周期切换,K 线优先读 hub_kline.db,不足时经各实例 /api/hub/ohlcv 补齐;无后台定时更新。含回滚标签说明与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 10:58:59 +08:00
parent ef99fb6c2e
commit ba681c7a58
16 changed files with 1298 additions and 3 deletions
+119 -1
View File
@@ -13,6 +13,9 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from hub_kline_store import format_ohlcv_detail, resolve_chart_bars, retention_days
from hub_ohlcv_lib import CHART_TIMEFRAMES, bar_limit_for_timeframe
from env_load import load_hub_dotenv
load_hub_dotenv()
@@ -66,7 +69,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260526-hub-key3col"
HUB_BUILD = "20260528-hub-market"
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower()
@@ -258,6 +261,7 @@ def root_redirect():
@app.get("/monitor")
@app.get("/market")
@app.get("/settings")
def shell_pages():
return _shell_page()
@@ -294,6 +298,120 @@ def api_save_settings(body: SettingsBody):
return {"ok": True, "settings": load_settings()}
def _find_exchange_by_key(exchange_key: str) -> dict | None:
key = (exchange_key or "").strip().lower()
if not key:
return None
for ex in load_settings().get("exchanges") or []:
if str(ex.get("key") or "").strip().lower() == key:
return ex
if str(ex.get("id") or "").strip() == exchange_key.strip():
return ex
return None
def _fetch_instance_ohlcv_sync(
ex: dict,
*,
symbol: str,
timeframe: str,
since_ms: int | None,
limit: int,
) -> dict:
base = (ex.get("flask_url") or "").rstrip("/")
if not base:
return {"ok": False, "msg": "未配置 flask_url"}
params = {"symbol": symbol, "timeframe": timeframe, "limit": str(int(limit))}
if since_ms is not None and int(since_ms) > 0:
params["since_ms"] = str(int(since_ms))
url = f"{base}/api/hub/ohlcv?{urlencode(params)}"
try:
with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client:
r = client.get(url, headers=_hub_headers())
if r.status_code >= 400:
parsed = _parse_http_json_body(r)
parsed.setdefault("ok", False)
return parsed
data = r.json() if r.content else {}
return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"}
except Exception as e:
return {"ok": False, "msg": str(e)}
@app.get("/api/chart/meta")
def api_chart_meta():
tfs = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"]
exchanges = []
for ex in enabled_exchanges(load_settings()):
exchanges.append(
{
"id": ex.get("id"),
"key": ex.get("key"),
"name": ex.get("name"),
}
)
return {
"ok": True,
"timeframes": [tf for tf in tfs if tf in CHART_TIMEFRAMES],
"retention_days": retention_days(),
"limits": {tf: bar_limit_for_timeframe(tf) for tf in tfs if tf in CHART_TIMEFRAMES},
"exchanges": exchanges,
}
@app.get("/api/chart/ohlcv")
def api_chart_ohlcv(
exchange_key: str = "",
symbol: str = "",
timeframe: str = "5m",
refresh: str = "",
):
ex = _find_exchange_by_key(exchange_key)
if not ex:
raise HTTPException(status_code=400, detail="交易所不存在")
if not ex.get("enabled"):
raise HTTPException(status_code=400, detail="该交易所未启用")
sym = (symbol or "").strip().upper()
if not sym:
raise HTTPException(status_code=400, detail="请输入币种")
ex_key = str(ex.get("key") or "").strip().lower()
force = (refresh or "").strip().lower() in ("1", "true", "yes", "on")
def remote_fetch(**kwargs):
return _fetch_instance_ohlcv_sync(
ex,
symbol=kwargs.get("symbol") or sym,
timeframe=kwargs.get("timeframe") or timeframe,
since_ms=kwargs.get("since_ms"),
limit=int(kwargs.get("limit") or bar_limit_for_timeframe(timeframe)),
)
result = resolve_chart_bars(
ex_key,
sym,
timeframe,
remote_fetch,
force_refresh=force,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "K线加载失败")
tick = result.get("price_tick")
last = result["candles"][-1] if result.get("candles") else None
result["ohlcv"] = format_ohlcv_detail(
{
"open": last.get("open") if last else None,
"high": last.get("high") if last else None,
"low": last.get("low") if last else None,
"close": last.get("close") if last else None,
"volume": last.get("volume") if last else None,
}
if last
else None,
tick,
)
return result
@app.get("/api/settings/meta")
def api_settings_meta():
po = public_origin()