diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index fb1da1f..8fe87cc 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -10042,14 +10042,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - funding_capital, trading_capital = get_exchange_capitals(force=True) + # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance + funding_capital, trading_capital = get_exchange_capitals(force=False) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None - available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "available_trading_usdt": trading_usdt, "trading_day": get_trading_day(app_now()), } diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 6c611ed..a37848c 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -467,6 +467,8 @@ from lib.exchange.gate_ccxt_lib import gate_ccxt_class # Gate.io USDT 永续(swap) exchange = gate_ccxt_class()({ "enableRateLimit": True, + # 避免关键位监控/账户拉取无限挂起拖垮中控 + "timeout": int(os.getenv("GATE_CCXT_TIMEOUT_MS", "8000")), "options": { "defaultType": "swap", "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, @@ -4611,14 +4613,25 @@ def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) +_RS_BAR_CACHE: dict[str, dict] = {} +_RS_BAR_CACHE_TTL_SEC = float(os.getenv("GATE_RS_BAR_CACHE_SEC", "45")) + + def _fetch_last_closed_bar(symbol): - """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.""" + """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.短缓存减轻关键位监控打爆 ccxt.""" ex_sym = normalize_exchange_symbol(symbol) + now = time.time() + cached = _RS_BAR_CACHE.get(ex_sym) + if cached and now - float(cached.get("updated_at") or 0) < _RS_BAR_CACHE_TTL_SEC: + return cached.get("bar") bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] if len(bars) < 2: + _RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": None} return None closed = bars[:-1] - return closed[-1] if closed else None + bar = closed[-1] if closed else None + _RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": bar} + return bar def _key_rs_gate_preview(symbol, upper, lower): @@ -9893,14 +9906,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - funding_capital, trading_capital = get_exchange_capitals(force=True) + # 中控看板高频拉取:仅走余额缓存;不再额外 fetch_balance(会与关键位监控争用 ccxt) + funding_capital, trading_capital = get_exchange_capitals(force=False) funding_usdt = round(funding_capital, 2) if funding_capital is not None else None trading_usdt = round(trading_capital, 2) if trading_capital is not None else None - available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": round(available, 2) if available is not None else None, + "available_trading_usdt": trading_usdt, "trading_day": get_trading_day(app_now()), } diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 7a609be..c7be6b0 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -9665,14 +9665,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - funding_capital, trading_capital = get_exchange_capitals(force=True) + # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance + funding_capital, trading_capital = get_exchange_capitals(force=False) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None - available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "available_trading_usdt": trading_usdt, "trading_day": get_trading_day(app_now()), } diff --git a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md new file mode 100644 index 0000000..bfa2a1e --- /dev/null +++ b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md @@ -0,0 +1,53 @@ +# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 1 轮) + +## 背景 + +期权链接入 OKX WS 推送后,生产中控出现「期权数据不可用 / 子代理不可用」。按要求**先回滚 WS 链路**,再全量审计并修复。 + +## 回滚 + +| 提交 | 说明 | +|------|------| +| `a488e2f` | Revert fast-path(依赖 WS 热缓存) | +| `d592632` | Revert OKX WS + SSE 推送整栈 | + +恢复为 **REST 拉链 + 前端约 15s soft-poll**(commit `24bb853` 行为),删除: + +- `lib/exchange/okx_public_ws_lib.py` +- `lib/options/options_quote_live_lib.py` +- `tests/test_options_quote_live_lib.py` + +## 根因结论(非仅 WS) + +| 级别 | 问题 | 证据 | +|------|------|------| +| Critical | Gate 关键位 RS 监控在后台线程高频 `fetch_ohlcv`,与 `/api/hub/account` 争用同一 ccxt 客户端,账户/子代理超时 | 日志 `[key_rs_level_alert] BTC/USDT id=13`;本机 `5000/api/hub/account` 25s 超时 | +| Critical | 中控期权快照对每仓拉 books 深度,易超 `HUB_FLASK_TIMEOUT=10` | `build_display_option_positions` → `attach_close_preview` → `fetch_option_book_depth` | +| High | Soft 拉链 3 次重试 + 无单飞,易与 SSE tick 叠打 OKX | `options_panel.js` loadChain | +| High | 中控账户接口每轮 `force=True` 绕过余额缓存 | Gate/OKX/Binance `_hub_account_bundle` | +| Medium | Flask 超时错误只有 `error` 无 `msg`,前端易落默认文案 | `hub.py` `_fetch_flask_json` | +| Medium | `options` 为 null 时前端当成「0 仓」而非不可用 | `app.js` renderOptionsSectionBody | + +WS 部署触发的**全进程重启**放大了 Gate 争用与快照超时,表现为「全不可用」;OKX 快照在轻负载下仍可 `ok:true`。 + +## 本轮修复 + +1. **Hub 期权快照**关闭逐仓 `close_preview`/books(`with_close_preview=False`) +2. **Hub 账户**三所改为 `get_exchange_capitals(force=False)` +3. **Gate ccxt** 增加 `timeout=8000ms`;RS K 线 **45s 缓存** +4. **期权 tickers** 恢复 **10s** 短缓存(无 WS) +5. **前端 soft 拉链**:单飞 + soft 仅 1 次尝试;已有链不先清空表格 +6. **Hub**:超时补 `msg`;期权快照与 account/monitor **并行 gather** +7. **中控 UI**:capabilities 含 options 且 snapshot 缺失时显式「期权数据不可用」 + +## 测试建议 + +- 强刷中控监控区:OKX 期权资金/持仓应恢复;Gate 子代理 status 应在数秒内恢复 +- 期权页「刷新链」不应长时间白屏;指数行显示约 15s 静默刷新 +- Gate 关键位监控日志不应再每秒刷屏 `fetch_ohlcv` 失败 + +## 残留风险(交第 2 轮) + +- Gate 仍与监控共用单一 ccxt 客户端(未加全局锁) +- 中控 board 仍可能被最慢交易所拉长整轮等待 +- Soft-poll 仍是 REST,非真·实时 diff --git a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md new file mode 100644 index 0000000..da1f87f --- /dev/null +++ b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md @@ -0,0 +1,41 @@ +# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 2 轮) + +## 范围 + +复查第 1 轮修复是否引入回归,并扫清仍会导致「中控不可用」的残留高优先级问题。 + +## 复查结论 + +| 项 | 结论 | +|----|------| +| Hub 并行 options 索引进位 | 正确(day / options 组合无错位) | +| Hub 关闭 close_preview | UI 降级为 upl/`—`,不崩 | +| Gate RS 缓存 / timeout | timeout 已为 int;缓存可接受 | +| board row capabilities | `_fetch_agent_status` 始终带上 | +| `with_close_preview` 默认 | 实例路径仍为 True | + +## 本轮新发现问题与修复 + +| 级别 | 问题 | 修复 | +|------|------|------| +| High | Hub 账户在 `force=False` 后仍调用 `get_available_trading_usdt()` 再打一枪 `fetch_balance`,Gate 争用依旧 | 三所 `_hub_account_bundle` 改为用缓存的 `trading_usdt` 作为 `available_trading_usdt` | +| Medium | `loadChain` soft 门禁在 `seq++` 之后,叠刷可导致 `chainLoadInFlight` 永不清理 | soft 门禁移到 `seq++` 之前 | + +## 与第 1 轮一并交付的状态 + +- WS 推送链路已回滚(REST + 15s soft-poll) +- 中控期权快照轻量化 + 并行拉取 +- Gate RS K 线短缓存 + ccxt timeout +- 期权 tickers 10s 缓存;soft 单飞/单次尝试 + +## 已知残留(不阻塞本次部署) + +- Gate 监控与账户仍共用单一 ccxt 客户端(无全局锁) +- 中控 board 仍可能被最慢交易所拉长整轮 +- Soft-poll 非真·实时报价 + +## 部署后验收 + +1. 中控强刷:OKX 期权区有资金数字,不再长期「期权数据不可用」 +2. Gate 卡:子代理恢复绿色/有资金;不再长时间「子代理不可用」 +3. 期权页刷新链不白屏;约 15s 静默更新时间戳 diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index b8504d3..7085b12 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -40,6 +40,7 @@ let chainSoftTimer = null; let lastChainSoftAt = 0; let chainQuotedAt = 0; + let chainLoadInFlight = false; let pendingTtlSeconds = 600; const POSITIONS_STALE_MS = 45000; const PENDING_POLL_MS = 8000; @@ -669,6 +670,7 @@ function softRefreshChainThrottled(force) { if (document.hidden) return; if (!document.getElementById("options-root")) return; + if (chainLoadInFlight) return; const now = Date.now(); if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return; lastChainSoftAt = now; @@ -1239,11 +1241,16 @@ async function loadChain(opts) { const soft = !!(opts && opts.soft); + // soft 门禁必须在 seq++ 之前,否则叠刷会抬高 seq 导致 inFlight 永不清理 + if (chainLoadInFlight && soft) return; const uly = state.underlying; const seq = ++chainLoadSeq; const btn = document.getElementById("opt-load-chain"); + const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly; + chainLoadInFlight = true; if (btn && !soft) btn.disabled = true; - if (!soft) { + // 已有链时不先清空,避免刷新白屏 + if (!soft && !hadChain) { setExpirySelectStatus("加载到期日中…"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { @@ -1254,7 +1261,9 @@ try { let d = null; let lastMsg = ""; - for (let attempt = 0; attempt < 3; attempt++) { + // soft 只试 1 次,避免与 15s 轮询叠加重试打爆 OKX + const maxAttempts = soft ? 1 : 3; + for (let attempt = 0; attempt < maxAttempts; attempt++) { if (seq !== chainLoadSeq) return; d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); if (seq !== chainLoadSeq) return; @@ -1264,8 +1273,8 @@ !!(d && d.rate_limited) || /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")); d = null; - if (attempt < 2) { - if (!soft) { + if (attempt < maxAttempts - 1) { + if (!soft && !hadChain) { setExpirySelectStatus( rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…" ); @@ -1339,7 +1348,10 @@ ""; } } finally { - if (seq === chainLoadSeq && btn) btn.disabled = false; + if (seq === chainLoadSeq) { + chainLoadInFlight = false; + if (btn) btn.disabled = false; + } } } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 4fb4e15..e961f1b 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -30,6 +30,9 @@ _INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {} _INSTRUMENTS_CACHE_LOCK = threading.Lock() _INSTRUMENTS_CACHE_TTL_SEC = 90.0 _INSTRUMENTS_STALE_SEC = 600.0 +_TICKERS_CACHE: dict[str, dict[str, Any]] = {} +_TICKERS_CACHE_LOCK = threading.Lock() +_TICKERS_CACHE_TTL_SEC = float(os.getenv("OKX_OPTIONS_TICKERS_CACHE_SEC", "10") or "10") def invalidate_options_balance_cache() -> None: @@ -712,17 +715,40 @@ def fetch_option_instruments( return [] -def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: +def fetch_option_tickers( + ex: ccxt.okx, + inst_family: str, + *, + force: bool = False, +) -> dict[str, dict[str, Any]]: + family = (inst_family or "").strip() + if not family: + return {} + now = time.time() + with _TICKERS_CACHE_LOCK: + cached = _TICKERS_CACHE.get(family) + if ( + not force + and cached + and now - float(cached.get("updated_at") or 0) < max(1.0, _TICKERS_CACHE_TTL_SEC) + and isinstance(cached.get("rows"), dict) + and cached["rows"] + ): + return dict(cached["rows"]) + out: dict[str, dict[str, Any]] = {} last_err: BaseException | None = None for attempt in range(3): try: rows = ex.public_get_market_tickers( - {"instType": "OPTION", "instFamily": inst_family} + {"instType": "OPTION", "instFamily": family} ).get("data") or [] for r in rows: if isinstance(r, dict) and r.get("instId"): out[str(r["instId"])] = r + if out: + with _TICKERS_CACHE_LOCK: + _TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)} return out except Exception as e: last_err = e @@ -730,6 +756,8 @@ def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, time.sleep(0.6 * (attempt + 1)) continue break + if cached and isinstance(cached.get("rows"), dict) and cached["rows"]: + return dict(cached["rows"]) if last_err is not None and _is_okx_rate_limit(last_err): return out return out diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 604a185..4053c8a 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -22,7 +22,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: raw = cfg["fetch_option_positions"](ex) if raw is None: return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} - positions = build_display_option_positions(cfg, ex, raw) + # 中控看板不拉逐仓 books(易超 HUB_FLASK_TIMEOUT);实例页仍走完整 preview + positions = build_display_option_positions(cfg, ex, raw, with_close_preview=False) target_monitors: list[dict[str, Any]] = [] try: conn = cfg["get_db"]() diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index a917f2c..2367021 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -145,8 +145,10 @@ def build_display_option_positions( cfg: dict[str, Any], ex: Any, raw_positions: list[dict[str, Any]], + *, + with_close_preview: bool = True, ) -> list[dict[str, Any]]: - """与实例 /api/options/positions 相同 enrichment + close_preview.""" + """与实例 /api/options/positions 相同 enrichment;中控可关 close_preview 避免逐仓拉盘口超时.""" meta_cache: dict[str, dict[str, Any] | None] = {} rows: list[dict[str, Any]] = [] conn = cfg["get_db"]() @@ -162,7 +164,8 @@ def build_display_option_positions( meta_cache=meta_cache, premium_override=premium_override, ) - attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + if with_close_preview: + attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) rows.append(row) finally: conn.close() diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 7ba1c7b..9fe468f 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -322,4 +322,4 @@ - + diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 46cbe6f..25bc494 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -2028,7 +2028,8 @@ async def _fetch_flask_json( return parsed return _parse_http_json_body(r) except Exception as e: - return {"ok": False, "error": str(e)} + err = str(e) + return {"ok": False, "error": err, "msg": err} async def _notify_instance_user_close( @@ -2567,8 +2568,8 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict async def _fetch_exchange_flask_bundle( client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None -) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]: - """单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取.""" +) -> tuple: + """单所 Flask:monitor / meta / price_snapshot / account / trades/today / options 并行拉取.""" caps = ex.get("capabilities") or [] tasks = [ _fetch_flask_json(client, ex, "/api/hub/monitor"), @@ -2576,6 +2577,7 @@ async def _fetch_exchange_flask_bundle( ] has_flask = bool((ex.get("flask_url") or "").strip()) day = (trading_day or "").strip() + want_options = has_flask and "options" in caps if has_flask: tasks.extend( [ @@ -2592,15 +2594,26 @@ async def _fetch_exchange_flask_bundle( params={"trading_day": day}, ) ) + if want_options: + tasks.append(_fetch_flask_json(client, ex, "/api/hub/options/snapshot")) results = await asyncio.gather(*tasks) hub_mon = results[0] meta = results[1] - snap = results[2] if has_flask and len(results) > 2 else None - account = results[3] if has_flask and len(results) > 3 else None - trades_today = results[4] if has_flask and day and len(results) > 4 else None + idx = 2 + snap = None + account = None + trades_today = None options_snap = None - if has_flask and "options" in caps: - options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot") + if has_flask: + snap = results[idx] + idx += 1 + account = results[idx] + idx += 1 + if day: + trades_today = results[idx] + idx += 1 + if want_options: + options_snap = results[idx] key_prices = None want_prices = HUB_BOARD_KEY_PRICES and "key" in caps if want_prices and isinstance(snap, dict): diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 652ca17..0a892e5 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -4022,20 +4022,26 @@ function renderOptionsSectionBody(row, opts) { const options = opts || {}; const layout = options.layout || "table"; - const opt = row.options || {}; + const caps = Array.isArray(row.capabilities) ? row.capabilities : []; + const wantsOptions = caps.indexOf("options") >= 0; + const opt = row.options; let html = ""; - if (opt.enabled === false) { - html += renderOptionsAccountStatRow(opt); + if (wantsOptions && (opt == null || typeof opt !== "object")) { + html += '
'; + html += `