diff --git a/ctp_trading_state.py b/ctp_trading_state.py index c311ff3..854cf92 100644 --- a/ctp_trading_state.py +++ b/ctp_trading_state.py @@ -226,6 +226,7 @@ class CtpTradingState: *, trades: Optional[list[dict[str, Any]]] = None, ths_for_vnpy_sym: Optional[Callable[[str, str], str]] = None, + preserve_positions_if_margin: float = 0.0, ) -> None: """全量校准:以 vnpy 内存为准重建订单/持仓簿。""" self.begin_sync() @@ -256,6 +257,9 @@ class CtpTradingState: new_positions[pk] = reconcile_position_avg( old, row, tick, trades=trades, ths_sym=ths, ) + if not new_positions and self._positions and preserve_positions_if_margin > 0: + with self._lock: + new_positions = {k: dict(v) for k, v in self._positions.items()} with self._lock: self._orders = new_orders self._positions = new_positions diff --git a/docs/风控说明.md b/docs/风控说明.md index 47d3b90..c43fa07 100644 --- a/docs/风控说明.md +++ b/docs/风控说明.md @@ -35,8 +35,7 @@ |------|------| | 正常 · 可新开仓 | 未触发冻结,可新开仓 | | 仓位上限冻结 · 已达仓位上限 1/1 | 同时 active 持仓数已达上限,禁止新开仓,**滚仓/加仓仍允许** | -| 1h / 4h 冻结 | 手动平仓触发冷静期 | -| 日冻结 | 复盘勾选情绪问题或当日规则触发,禁止新开仓 | +| 日冻结 | 复盘勾选情绪问题、当日手动平仓超限或日限额触发,禁止新开仓 | - **绿色**:当前可交易(`can_trade=true`) - **红色**:当前禁止新开仓(`can_trade=false`) @@ -47,14 +46,11 @@ | 指标 | 说明 | 配置来源 | |------|------|----------| -| **风控开关** | 是否启用账户冷静期等风控 | `.env` → `RISK_CONTROL_ENABLED` | +| **风控开关** | 是否启用账户风控(持仓/日限额等) | `.env` → `RISK_CONTROL_ENABLED` | | **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env` → `MAX_ACTIVE_POSITIONS` | | **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.env` → `RISK_DAILY_POSITION_LIMIT`(默认 5) | | **日交易风险** | 当日累计止损风险占权益 / 上限 | `.env` → `RISK_DAILY_TRADING_RISK_PCT`(默认 2%) | -| **手动平仓(冷静期触发)** | 当日手动平仓次数 / 上限 | `.env` → `RISK_MANUAL_CLOSE_DAILY_LIMIT` | -| **冷静期(默认)** | 超限后默认冻结时长 | `.env` → `RISK_COOLING_HOURS_MANUAL`(默认 4h) | -| **复盘后冷静** | 填写复盘情绪日记后缩短的冷静期 | `.env` → `RISK_COOLING_HOURS_MANUAL_JOURNAL`(默认 1h) | -| **冷静剩余** | 当前冷静期剩余时间 | 运行时计算 | +| **手动平仓次数** | 当日手动平仓次数 / 上限(超限日冻结) | `.env` → `RISK_MANUAL_CLOSE_DAILY_LIMIT` | | **综合保证金占比** | 占用保证金占权益 / **综合上限(50%)** | 实时计算 + 系统设置 `roll_max_margin_pct` | | **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30%) | | **滚仓/多仓保证金上限** | 单仓=滚仓上限;多仓=合计上限 | 系统设置 `roll_max_margin_pct`(默认 50%) | @@ -110,7 +106,8 @@ ## 与全局风控的关系 - 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。 -- **日持仓限制**、**日交易风险** 与「同时持仓上限」「冷静期」并列生效,任一超限即禁止新开仓。 +- **日持仓限制**、**日交易风险** 与「同时持仓上限」并列生效,任一超限即禁止新开仓。 +- **期货不使用本系统「手动平仓冷静期」**(交易所自有规则);手动平仓仅计入当日次数,超限触发日冻结。 - **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`。 --- diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 221c08e..b270af3 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,21 +1,34 @@ +const path = require("path"); +const fs = require("fs"); + +const ROOT = __dirname; +const venvCandidates = [ + path.join(ROOT, "venv", "bin", "python"), + path.join(ROOT, "venv", "Scripts", "python.exe"), +]; +const interpreter = venvCandidates.find((p) => fs.existsSync(p)) || "python3"; + module.exports = { apps: [ { name: "qihuo", script: "app.py", - cwd: "/opt/qihuo", - interpreter: "/opt/qihuo/venv/bin/python", + cwd: ROOT, + interpreter, instances: 1, autorestart: true, watch: false, + max_memory_restart: "8192M", env: { NODE_ENV: "production", LANG: "zh_CN.UTF-8", LC_ALL: "zh_CN.UTF-8", LC_CTYPE: "zh_CN.UTF-8", + QIHUO_STARTUP_WORKERS: "8", + QIHUO_MEMORY_MB: "8192", }, - error_file: "/opt/qihuo/logs/pm2-error.log", - out_file: "/opt/qihuo/logs/pm2-out.log", + error_file: path.join(ROOT, "logs", "pm2-error.log"), + out_file: path.join(ROOT, "logs", "pm2-out.log"), time: true, }, ], diff --git a/install_trading.py b/install_trading.py index c367022..486e3ed 100644 --- a/install_trading.py +++ b/install_trading.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import logging +import os import threading import time from datetime import datetime @@ -67,11 +68,11 @@ from sl_tp_guard import ( ) from risk.account_risk_lib import ( assert_can_open, + count_active_trade_monitors, get_risk_status, on_mood_journal_freeze, on_user_initiated_close, parse_mood_issues, - reduce_cooloff_after_journal, trading_day_label, ) from strategy.strategy_db import init_strategy_tables @@ -701,12 +702,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se return reconcile_monitors_without_position(conn, mode) def _effective_active_position_count(conn, mode: str) -> int: - if ctp_status(mode).get("connected"): - return len(_ctp_position_keys(mode)) - row = conn.execute( - "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" - ).fetchone() - return int(row["n"] or 0) + """风控持仓数以本地 active 监控为准,不随 CTP 内存空窗抖动。""" + del mode + return count_active_trade_monitors(conn) def _build_pending_orders(conn, mode: str) -> list[dict]: pending: list[dict] = [] @@ -1672,15 +1670,6 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ctp_list = _ctp_positions(mode, refresh_if_empty=False, refresh_margin=False) if not ctp_list: ctp_list = trading_state.get_positions() - if not ctp_list: - try: - with _ctp_td_lock: - get_bridge().calibrate_trading_state() - except Exception as exc: - logger.debug("live calibrate: %s", exc) - ctp_list = trading_state.get_positions() or _ctp_positions( - mode, refresh_if_empty=False, refresh_margin=False, - ) rows: list[dict] = [] for p in ctp_list: @@ -1742,6 +1731,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if not deduped and ctp_status(mode).get("connected") and monitor_by_pk: margin_used = float(ctp_account_margin_used(mode) or 0) + has_active_mon = any( + int(m.get("lots") or 0) > 0 for m in monitor_by_pk.values() + ) since_connect = 9999.0 try: since_connect = time.time() - float( @@ -1749,7 +1741,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ) except Exception: pass - if margin_used > 100 or since_connect < 300: + if margin_used > 0 or has_active_mon or since_connect < 300: for mon in monitor_by_pk.values(): lots = int(mon.get("lots") or 0) if lots <= 0: @@ -1849,6 +1841,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se init_strategy_tables(conn) payload = _build_trading_live_payload(conn, fast=fast) commit_retry(conn) + prev = position_hub.get_snapshot() + if ( + prev + and ctp_status(mode).get("connected") + and not (payload.get("rows") or []) + and (prev.get("rows") or []) + ): + margin_used = float(ctp_account_margin_used(mode) or 0) + if margin_used > 0 or trading_state.sync_state == "syncing": + payload = dict(payload) + payload["rows"] = prev["rows"] + payload["sync_state"] = "syncing" + payload["sync_label"] = "同步中…" return payload finally: conn.close() @@ -1956,8 +1961,22 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se finally: conn.close() + def _prime_position_snapshot() -> None: + """进程启动同步预热:优先写入持仓/权益快照,页面打开即可读。""" + try: + payload = _refresh_trading_live_snapshot(fast=True) + position_hub.set_snapshot(payload) + n = len(payload.get("rows") or []) + logger.info( + "持仓快照已预热 capital=%s rows=%d", + payload.get("capital"), + n, + ) + except Exception as exc: + logger.warning("prime position snapshot: %s", exc) + def _bootstrap_trading_runtime() -> None: - """进程启动:读 CTP 快照推送,事件驱动增量 + 定期全量校准。""" + """进程启动:并发预热持仓快照 + CTP 连接,不阻塞 HTTP 监听。""" set_position_refresh_callback( lambda: _push_position_snapshot_async(fast=True) ) @@ -1969,23 +1988,31 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se try: mode = get_trading_mode(get_setting) if ctp_status(mode).get("connected"): - get_bridge().calibrate_trading_state() + with _ctp_td_lock: + get_bridge().calibrate_trading_state() payload = _refresh_trading_live_snapshot(fast=False) position_hub.set_snapshot(payload) position_hub.broadcast("positions", payload) except Exception as exc: logger.warning("bootstrap position snapshot: %s", exc) - threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start() - try: - from ctp_premarket_connect import should_auto_connect_now - from vnpy_bridge import ctp_start_connect + def _start_ctp() -> None: + try: + from ctp_premarket_connect import should_auto_connect_now + from vnpy_bridge import ctp_start_connect - if should_auto_connect_now(): - mode = get_trading_mode(get_setting) - ctp_start_connect(mode, force=False, scheduled=True) - except Exception as exc: - logger.debug("bootstrap ctp connect: %s", exc) + if should_auto_connect_now(): + mode = get_trading_mode(get_setting) + ctp_start_connect(mode, force=False, scheduled=True) + except Exception as exc: + logger.debug("bootstrap ctp connect: %s", exc) + + from concurrent.futures import ThreadPoolExecutor + + workers = max(2, int(os.getenv("QIHUO_STARTUP_WORKERS", "8") or 8)) + with ThreadPoolExecutor(max_workers=min(workers, 4), thread_name_prefix="boot") as pool: + pool.submit(_warm) + pool.submit(_start_ctp) def _on_ctp_connected(mode: str) -> None: if mode != get_trading_mode(get_setting): @@ -2050,6 +2077,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se _schedule_recommend_refresh() ctp_connected = is_ctp_connected(get_setting) margin_rec = small_account_margin_recommendations() + bootstrap_live: dict = {} + try: + bootstrap_live = _build_trading_live_payload(conn, fast=True) + position_hub.set_snapshot(bootstrap_live) + except Exception as exc: + logger.debug("positions page bootstrap: %s", exc) return render_template( "trade.html", trading_mode=mode, @@ -2083,6 +2116,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se session_clock=trading_session_clock(), roll_max_margin_pct=get_roll_max_margin_pct(get_setting), product_categories=PRODUCT_CATEGORIES, + bootstrap_live=bootstrap_live, ) finally: conn.close() @@ -3836,8 +3870,6 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): if parse_mood_issues(behavior_tags): on_mood_journal_freeze(conn, trading_day=trading_day_label()) - if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip(): - reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) app._risk_review_hook = hook_review_mood @@ -3846,33 +3878,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se def _init_tables(conn): init_strategy_tables(conn) - start_recommend_worker( - db_path=DB_PATH, - get_capital_fn=_recommend_capital, - quote_fn=_main_quote, - init_tables_fn=_init_tables, - get_mode_fn=lambda: get_trading_mode(get_setting), - get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), - get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), - get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), - ) - start_ctp_reconnect_worker( - get_mode_fn=lambda: get_trading_mode(get_setting), - get_setting_fn=get_setting, - ) - start_ctp_premarket_connect_worker( - get_mode_fn=lambda: get_trading_mode(get_setting), - get_setting_fn=get_setting, - ) - start_sl_tp_guard_worker( - db_path=DB_PATH, - get_mode_fn=lambda: get_trading_mode(get_setting), - init_tables_fn=_init_tables, - get_capital_fn=_capital, - get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), - notify_fn=send_wechat_msg, - interval=1, - ) + _prime_position_snapshot() + _pos_refresh_tick = {"n": 0} _last_full_calibrate = {"ts": 0.0} @@ -3904,18 +3911,22 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se idle_interval=3, ) _bootstrap_trading_runtime() - start_ctp_fee_worker( + start_ctp_reconnect_worker( get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, - set_setting_fn=set_setting, ) - from ai_worker import start_ai_worker - - start_ai_worker( - db_path=DB_PATH, + start_ctp_premarket_connect_worker( + get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, - set_setting_fn=set_setting, - send_wechat_fn=send_wechat_msg, + ) + start_sl_tp_guard_worker( + db_path=DB_PATH, + get_mode_fn=lambda: get_trading_mode(get_setting), + init_tables_fn=_init_tables, + get_capital_fn=_capital, + get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), + notify_fn=send_wechat_msg, + interval=1, ) start_pending_order_worker( db_path=DB_PATH, @@ -3925,3 +3936,35 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se reconcile_fn=_reconcile_pending, on_changed_fn=lambda: _push_position_snapshot_async(fast=False), ) + + def _start_deferred_workers() -> None: + time.sleep(2) + start_recommend_worker( + db_path=DB_PATH, + get_capital_fn=_recommend_capital, + quote_fn=_main_quote, + init_tables_fn=_init_tables, + get_mode_fn=lambda: get_trading_mode(get_setting), + get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), + get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), + get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), + ) + start_ctp_fee_worker( + get_mode_fn=lambda: get_trading_mode(get_setting), + get_setting_fn=get_setting, + set_setting_fn=set_setting, + ) + from ai_worker import start_ai_worker + + start_ai_worker( + db_path=DB_PATH, + get_setting_fn=get_setting, + set_setting_fn=set_setting, + send_wechat_fn=send_wechat_msg, + ) + + threading.Thread( + target=_start_deferred_workers, + daemon=True, + name="deferred-workers", + ).start() diff --git a/risk/account_risk_lib.py b/risk/account_risk_lib.py index 215042b..02d31f7 100644 --- a/risk/account_risk_lib.py +++ b/risk/account_risk_lib.py @@ -51,17 +51,12 @@ def risk_control_enabled() -> bool: def cooling_hours_manual() -> float: - try: - return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4"))) - except (TypeError, ValueError): - return 4.0 + """期货版不使用应用层冷静期(交易所自有规则),恒为 0。""" + return 0.0 def cooling_hours_manual_journal() -> float: - try: - return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1"))) - except (TypeError, ValueError): - return 1.0 + return 0.0 def manual_close_daily_limit() -> int: @@ -286,15 +281,16 @@ def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = if count >= manual_close_daily_limit(): conn.execute( """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, - daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""", + daily_frozen=1, cooloff_until_ms=NULL, cooloff_hours=NULL, + last_close_at_ms=?, updated_at=? WHERE id=1""", (td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) return - until = close_ms + int(cooling_hours_manual() * 3600 * 1000) conn.execute( """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, - daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""", - (td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + daily_frozen=0, cooloff_until_ms=NULL, cooloff_hours=NULL, + last_close_at_ms=?, updated_at=? WHERE id=1""", + (td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) @@ -310,26 +306,9 @@ def on_mood_journal_freeze(conn, *, trading_day: str) -> None: def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: - """复盘手动平仓说明后,4h 冷静期降为 1h。""" - if not risk_control_enabled(): - return - ensure_account_risk_schema(conn) - row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() - if int(_row_get(row, "daily_frozen") or 0): - return - until = _row_get(row, "cooloff_until_ms") - if not until: - return - now_ms = _now_ms(now) - if int(until) <= now_ms: - return - last = int(_row_get(row, "last_close_at_ms") or now_ms) - journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000) - new_until = max(now_ms, last + journal_ms) - conn.execute( - """UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""", - (new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) + """期货版无应用层冷静期,保留空实现兼容旧复盘钩子。""" + del conn, trading_day, now + return def get_risk_status( @@ -355,6 +334,11 @@ def get_risk_status( now_ms = _now_ms(now) daily = int(_row_get(row, "daily_frozen") or 0) == 1 until = _row_get(row, "cooloff_until_ms") + if until: + conn.execute( + "UPDATE account_risk_state SET cooloff_until_ms=NULL, cooloff_hours=NULL WHERE id=1" + ) + conn.commit() active = count_active_trade_monitors(conn) if active_count is None else int(active_count) mx = max_active_positions() pos_limit = active >= mx @@ -387,19 +371,6 @@ def get_risk_status( "can_roll": False, "reason": "当日日冻结,禁止新开仓", } - if until and int(until) > now_ms: - rem = int((int(until) - now_ms) / 1000) - hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) - st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H - return { - **base, - "status": st, - "status_label": STATUS_LABELS[st], - "can_trade": False, - "can_roll": pos_limit, - "reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m", - "freeze_remaining_sec": rem, - } if daily_risk_limit_hit: return { **base, diff --git a/static/js/dashboard.js b/static/js/dashboard.js index d8ae924..65e8538 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -422,10 +422,7 @@ { label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') }, { label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') }, { label: '日交易风险', value: dailyRiskText }, - { label: '手动平仓(冷静期触发)', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, - { label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) }, - { label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) }, - { label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) }, + { label: '手动平仓次数', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, { label: '综合保证金占比', valueHtml: riskMarginPctHtml(marginPct, rollMaxPct), @@ -862,6 +859,9 @@ equityEl.textContent = fmtMoney(data.capital); } var rows = positionRows(data); + if (!rows.length && data.sync_state === 'syncing' && lastPosRows.length) { + rows = lastPosRows; + } var sig = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); return key + '|' + (isBreakevenLocked(r) ? '1' : '0') + '|' + slText(r) + '|' + tpText(r) + '|' + String(r.lots); diff --git a/static/js/trade.js b/static/js/trade.js index 45004d8..1fa521e 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -34,6 +34,7 @@ var ctpConnecting = false; var ctpAutoConnectEnabled = true; var positionsRendered = false; + var lastPosRowCount = 0; var selectedMaxLots = null; var recommendMaxByProduct = {}; var recommendMaxByCode = {}; @@ -63,6 +64,13 @@ window.TRADE_FIXED_AMOUNT = cfg.fixed_amount; window.__RECOMMEND_ROWS__ = cfg.recommend_rows || []; if (cfg.session_clock) applySessionClock(cfg.session_clock); + if (cfg.capital != null) { + var capEl = document.getElementById('cap-display'); + if (capEl) capEl.textContent = Number(cfg.capital).toFixed(2); + } + if (cfg.bootstrap_live && (cfg.bootstrap_live.rows || cfg.bootstrap_live.capital != null)) { + window.__BOOTSTRAP_LIVE__ = cfg.bootstrap_live; + } } catch (e) { /* ignore */ } } @@ -149,15 +157,15 @@ function savePosCache(data) { try { if (!data) return; - var connected = data.ctp_status && data.ctp_status.connected; - if (!connected) { - sessionStorage.removeItem(POS_CACHE_KEY); + var hasRows = data.rows && data.rows.length; + var hasOrders = data.active_orders && data.active_orders.length; + if (!hasRows && !hasOrders && data.capital == null) { return; } - if (!data.rows || !data.rows.length) { - if (!data.active_orders || !data.active_orders.length) { - sessionStorage.removeItem(POS_CACHE_KEY); - return; + if (!hasRows && lastPosRowCount > 0) { + var prev = loadPosCache(); + if (prev && prev.rows && prev.rows.length) { + data = Object.assign({}, data, { rows: prev.rows }); } } sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); @@ -321,10 +329,22 @@ if (ctpAutoConnectEnabled) tryAutoCtpReconnect(); return; } + var syncing = data.sync_state === 'syncing'; + var hadPos = lastPosRowCount > 0 || !!list.querySelector('.pos-card'); + if (syncing || hadPos) { + if (syncBadge) { + syncBadge.hidden = false; + syncBadge.textContent = data.sync_label || '持仓同步中…'; + syncBadge.className = 'sync-badge text-accent'; + } + return; + } list.innerHTML = '
暂无持仓。
'; + lastPosRowCount = 0; syncPositionListScroll(0); return; } + lastPosRowCount = rows.length; if (!connected && ctpAutoConnectEnabled) { tryAutoCtpReconnect(); } @@ -1926,12 +1946,16 @@ lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); if (lotsInput) lotsInput.value = lotsCalc.value; } - var cached = loadPosCache(); - if (cached) { - if (cached.ctp_status) { - cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); - } - if (cached.ctp_status && cached.ctp_status.connected) { + var bootData = window.__BOOTSTRAP_LIVE__; + if (bootData) { + applyPositionsData(bootData); + savePosCache(bootData); + } else { + var cached = loadPosCache(); + if (cached && ((cached.rows && cached.rows.length) || cached.capital != null)) { + if (cached.ctp_status) { + cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); + } applyPositionsData(cached); } } @@ -1939,20 +1963,22 @@ connectPositionStream(); bindSlTpModal(); initCtpOnLoad(); - connectRecommendStream(); - initRecommendSortControls(); - if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { - recRowsRaw = window.__RECOMMEND_ROWS__.slice(); - renderRecommendTable(); - } - fetch('/api/recommend/list') - .then(function (r) { return r.json(); }) - .then(function (data) { if (data.ok) renderRecommendations(data); }) - .catch(function () {}); updateSessionUi(); updateRRDisplay(); - scheduleQuote(); - scheduleAutoCalc(); + setTimeout(function () { + connectRecommendStream(); + initRecommendSortControls(); + if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { + recRowsRaw = window.__RECOMMEND_ROWS__.slice(); + renderRecommendTable(); + } + fetch('/api/recommend/list') + .then(function (r) { return r.json(); }) + .then(function (data) { if (data.ok) renderRecommendations(data); }) + .catch(function () {}); + scheduleQuote(); + scheduleAutoCalc(); + }, 400); } document.addEventListener('visibilitychange', function () { diff --git a/templates/trade.html b/templates/trade.html index bfcf52c..08877f4 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -74,7 +74,7 @@

可开仓品种表