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 = '
委托以 CTP 柜台为准;未成交可撤单,超时自动撤开仓单。
-持仓以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓。
-委托、持仓均以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓。
+