From ddfe2a52aa3c4a75a2548987f685629006ccd89e Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 26 Jun 2026 19:24:24 +0800 Subject: [PATCH] Merge orders and positions into one card and hide stale pending when CTP is off. Stop showing DB pending orders while disconnected, invalidate session cache when CTP is down, and add a local DB clear script without embedded credentials. Co-authored-by: Cursor --- install_trading.py | 41 +++++----- scripts/clear_local_db.py | 152 ++++++++++++++++++++++++++++++++++++++ static/css/trade.css | 11 ++- static/js/trade.js | 31 ++++++-- templates/trade.html | 29 ++++---- 5 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 scripts/clear_local_db.py diff --git a/install_trading.py b/install_trading.py index caab656..7abf243 100644 --- a/install_trading.py +++ b/install_trading.py @@ -1281,11 +1281,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se capital: float, now_iso: str, ) -> list[dict]: - """当前委托:以 CTP 柜台为准,本地 pending 开仓单合并展示。""" + """当前委托:CTP 已连接时读柜台;未连接时不展示本地 pending。""" orders: list[dict] = [] seen_keys: set[str] = set() + connected = ctp_status(mode).get("connected") - if ctp_status(mode).get("connected"): + if connected: ctp_orders = trading_state.get_active_orders() if not ctp_orders: ctp_orders = _ctp_active_orders(mode) @@ -1304,25 +1305,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se except Exception as exc: logger.warning("compose ctp order row failed: %s", exc) - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" - ).fetchall(): - mon = dict(r) - try: - prow = _compose_pending_row( - mon, mode=mode, capital=capital, now_iso=now_iso, - ) - if prow and prow.get("key") not in seen_keys: - pk = f"{prow.get('symbol_code') or ''}:{prow.get('direction') or ''}" - dup = any( - (x.get("symbol_code") or "") + ":" + (x.get("direction") or "") == pk - and x.get("order_state") == "pending" - for x in orders + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall(): + mon = dict(r) + try: + prow = _compose_pending_row( + mon, mode=mode, capital=capital, now_iso=now_iso, ) - if not dup: - orders.append(prow) - except Exception as exc: - logger.warning("compose pending order row failed: %s", exc) + if prow and prow.get("key") not in seen_keys: + pk = f"{prow.get('symbol_code') or ''}:{prow.get('direction') or ''}" + dup = any( + (x.get("symbol_code") or "") + ":" + (x.get("direction") or "") == pk + and x.get("order_state") == "pending" + for x in orders + ) + if not dup: + orders.append(prow) + except Exception as exc: + logger.warning("compose pending order row failed: %s", exc) return orders def _compose_ctp_order_row_any( diff --git a/scripts/clear_local_db.py b/scripts/clear_local_db.py new file mode 100644 index 0000000..c87e97b --- /dev/null +++ b/scripts/clear_local_db.py @@ -0,0 +1,152 @@ +"""清空本地 futures.db 交易/监控数据,保留系统设置与 CTP 配置。""" +from __future__ import annotations + +import os +import shutil +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from db_conn import DB_PATH + +# 保留:settings、fee_rates(柜台费率)、stats_cache 等配置/缓存 +KEEP_TABLES = frozenset({ + "settings", + "fee_rates", + "stats_cache", + "sqlite_sequence", +}) + +# 清空后重置 sim 账户默认值 +RESET_SIM_ACCOUNT = True + + +def _backup_db(db_path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + dest = db_path.with_suffix(f".bak.{ts}.db") + shutil.copy2(db_path, dest) + return dest + + +def clear_trading_data(db_path: Path | None = None, *, backup: bool = True) -> dict: + path = Path(db_path or DB_PATH) + if not path.is_file(): + raise FileNotFoundError(f"数据库不存在: {path}") + + if backup: + bak = _backup_db(path) + print(f"已备份 -> {bak}") + + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + try: + tables = [ + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + ] + cleared: dict[str, int] = {} + for name in tables: + if name in KEEP_TABLES: + continue + before = conn.execute(f"SELECT COUNT(*) AS n FROM [{name}]").fetchone()[0] + conn.execute(f"DELETE FROM [{name}]") + cleared[name] = int(before) + + if RESET_SIM_ACCOUNT and "ctp_sim_account" in tables: + conn.execute( + "INSERT OR REPLACE INTO ctp_sim_account (id, balance, available, updated_at) " + "VALUES (1, 100000, 100000, datetime('now'))" + ) + + conn.commit() + conn.execute("VACUUM") + conn.commit() + return cleared + finally: + conn.close() + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="清空 futures.db 交易数据,保留 settings") + parser.add_argument( + "--remote", + action="store_true", + help="清空服务器 /opt/qihuo/futures.db(需 scripts 内 SSH 配置)", + ) + parser.add_argument("--no-backup", action="store_true", help="不备份原库") + args = parser.parse_args() + + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + if args.remote: + import paramiko + import time + + remote_path = "/opt/qihuo/futures.db" + script_body = Path(__file__).read_text(encoding="utf-8") + remote_py = ( + "import sqlite3, shutil, sys\n" + "from datetime import datetime\n" + f"KEEP={sorted(KEEP_TABLES)!r}\n" + f"RESET_SIM={RESET_SIM_ACCOUNT!r}\n" + f"path='{remote_path}'\n" + "bak=path.replace('.db', f'.bak.{datetime.now():%Y%m%d_%H%M%S}.db')\n" + "shutil.copy2(path, bak); print('backup', bak)\n" + "c=sqlite3.connect(path)\n" + "tables=[r[0] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table'\")]\n" + "cleared={}\n" + "for name in tables:\n" + " if name in KEEP: continue\n" + " n=c.execute(f'SELECT COUNT(*) FROM [{name}]').fetchone()[0]\n" + " c.execute(f'DELETE FROM [{name}]'); cleared[name]=n\n" + "if RESET_SIM and 'ctp_sim_account' in tables:\n" + " c.execute(\"INSERT OR REPLACE INTO ctp_sim_account (id,balance,available,updated_at) VALUES (1,100000,100000,datetime('now'))\")\n" + "c.commit(); c.execute('VACUUM'); c.commit(); c.close()\n" + "print('cleared', cleared)\n" + ) + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + host = os.getenv("QIHUO_SERVER_HOST", "192.168.8.21") + user = os.getenv("QIHUO_SERVER_USER", "root") + password = os.getenv("QIHUO_SERVER_PASSWORD", "") + if not password: + raise SystemExit("远程清库需设置环境变量 QIHUO_SERVER_PASSWORD") + c.connect(host, username=user, password=password, timeout=15) + sftp = c.open_sftp() + with sftp.file("/tmp/clear_qihuo_db.py", "w") as f: + f.write(remote_py) + sftp.close() + _, o, e = c.exec_command( + "cd /opt/qihuo && python3 /tmp/clear_qihuo_db.py && pm2 restart qihuo", + timeout=120, + ) + print(o.read().decode("utf-8", "replace")) + err = e.read().decode("utf-8", "replace") + if err.strip(): + print(err) + time.sleep(2) + c.close() + return + + print(f"目标库: {DB_PATH}") + cleared = clear_trading_data(backup=not args.no_backup) + if not cleared: + print("无需要清空的表") + return + print("已清空:") + for name, n in sorted(cleared.items()): + print(f" {name}: {n} 行") + print("保留表:", ", ".join(sorted(KEEP_TABLES - {"sqlite_sequence"}))) + + +if __name__ == "__main__": + main() diff --git a/static/css/trade.css b/static/css/trade.css index 5a7334e..b61fc00 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -2,8 +2,17 @@ /* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */ .trade-page{width:100%} .trade-split{margin-bottom:1.25rem} -.trade-split .card{min-height:320px} +.trade-split .card{min-height:360px} .trade-split .trade-card#order{margin-bottom:.75rem} +.trading-live-body{gap:0} +.trading-live-section{padding-bottom:.5rem} +.trading-live-section.trading-live-positions{ + margin-top:.65rem;padding-top:.75rem;border-top:1px solid var(--card-border); +} +.trading-live-subtitle{ + font-size:.82rem;font-weight:600;color:var(--text-muted); + margin:0 0 .45rem .15rem;letter-spacing:.02em; +} .sync-badge{font-size:.72rem;font-weight:400;margin-left:.35rem} .trade-top-bar{ display:flex;flex-wrap:wrap;gap:.65rem 1rem; diff --git a/static/js/trade.js b/static/js/trade.js index 3f4dc1c..e861b95 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -47,7 +47,7 @@ var REC_COLSPAN = 18; var marketNavEnabled = !!window.MARKET_NAV_ENABLED; var productCategories = window.PRODUCT_CATEGORIES || []; - var POS_CACHE_KEY = 'qihuo_trading_live_v4'; + var POS_CACHE_KEY = 'qihuo_trading_live_v5'; function runWhenReady(fn) { if (document.readyState === 'loading') { @@ -133,9 +133,17 @@ function savePosCache(data) { try { - if (!data || !data.rows || !data.rows.length) { - var prev = loadPosCache(); - if (prev && prev.rows && prev.rows.length) return; + if (!data) return; + var connected = data.ctp_status && data.ctp_status.connected; + if (!connected) { + sessionStorage.removeItem(POS_CACHE_KEY); + return; + } + if (!data.rows || !data.rows.length) { + if (!data.active_orders || !data.active_orders.length) { + sessionStorage.removeItem(POS_CACHE_KEY); + return; + } } sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); } catch (e) { /* quota */ } @@ -159,10 +167,17 @@ return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0)); } - function applyActiveOrders(orders) { + function applyActiveOrders(orders, data) { if (!orderList) return; orders = orders || []; if (!orders.length) { + var connected = data && data.ctp_status && data.ctp_status.connected; + if (!connected) { + var hint = (data && data.ctp_status && data.ctp_status.disabled_hint) || + 'CTP 未连接,委托以柜台为准'; + orderList.innerHTML = '
' + hint + '
'; + return; + } orderList.innerHTML = '
暂无委托。
'; return; } @@ -213,7 +228,7 @@ riskBadge.textContent = data.risk_status.status_label || ''; riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); } - applyActiveOrders(data.active_orders || []); + applyActiveOrders(data.active_orders || [], data); if (!list) return; var rows = (data.rows || []).filter(function (row) { return row.order_state !== 'pending'; @@ -1592,7 +1607,9 @@ if (cached.ctp_status) { cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); } - applyPositionsData(cached); + if (cached.ctp_status && cached.ctp_status.connected) { + applyPositionsData(cached); + } } pollPositions(); connectPositionStream(); diff --git a/templates/trade.html b/templates/trade.html index 2cb23e4..177f44d 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -108,19 +108,22 @@ -
-

当前委托

-

委托以 CTP 柜台为准;未成交可撤单,超时自动撤开仓单。

-
-
加载委托…
-
-
- -
-

当前持仓

-

持仓以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓。

-
-
加载持仓…
+
+

委托与持仓

+

委托、持仓均以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓。

+
+
+

当前委托

+
+
加载委托…
+
+
+
+

当前持仓

+
+
加载持仓…
+
+