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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 19:24:24 +08:00
parent 631aa2c0ab
commit ddfe2a52aa
5 changed files with 223 additions and 41 deletions
+3 -2
View File
@@ -1281,11 +1281,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
capital: float, capital: float,
now_iso: str, now_iso: str,
) -> list[dict]: ) -> list[dict]:
"""当前委托:CTP 柜台为准,本地 pending 开仓单合并展示""" """当前委托:CTP 已连接时读柜台;未连接时不展示本地 pending。"""
orders: list[dict] = [] orders: list[dict] = []
seen_keys: set[str] = set() 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() ctp_orders = trading_state.get_active_orders()
if not ctp_orders: if not ctp_orders:
ctp_orders = _ctp_active_orders(mode) ctp_orders = _ctp_active_orders(mode)
+152
View File
@@ -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()
+10 -1
View File
@@ -2,8 +2,17 @@
/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */ /* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */
.trade-page{width:100%} .trade-page{width:100%}
.trade-split{margin-bottom:1.25rem} .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} .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} .sync-badge{font-size:.72rem;font-weight:400;margin-left:.35rem}
.trade-top-bar{ .trade-top-bar{
display:flex;flex-wrap:wrap;gap:.65rem 1rem; display:flex;flex-wrap:wrap;gap:.65rem 1rem;
+23 -6
View File
@@ -47,7 +47,7 @@
var REC_COLSPAN = 18; var REC_COLSPAN = 18;
var marketNavEnabled = !!window.MARKET_NAV_ENABLED; var marketNavEnabled = !!window.MARKET_NAV_ENABLED;
var productCategories = window.PRODUCT_CATEGORIES || []; var productCategories = window.PRODUCT_CATEGORIES || [];
var POS_CACHE_KEY = 'qihuo_trading_live_v4'; var POS_CACHE_KEY = 'qihuo_trading_live_v5';
function runWhenReady(fn) { function runWhenReady(fn) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -133,9 +133,17 @@
function savePosCache(data) { function savePosCache(data) {
try { try {
if (!data || !data.rows || !data.rows.length) { if (!data) return;
var prev = loadPosCache(); var connected = data.ctp_status && data.ctp_status.connected;
if (prev && prev.rows && prev.rows.length) return; 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)); sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ } } catch (e) { /* quota */ }
@@ -159,10 +167,17 @@
return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0)); 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; if (!orderList) return;
orders = orders || []; orders = orders || [];
if (!orders.length) { 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 = '<div class="empty-hint text-muted">' + hint + '</div>';
return;
}
orderList.innerHTML = '<div class="empty-hint">暂无委托。</div>'; orderList.innerHTML = '<div class="empty-hint">暂无委托。</div>';
return; return;
} }
@@ -213,7 +228,7 @@
riskBadge.textContent = data.risk_status.status_label || ''; riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
} }
applyActiveOrders(data.active_orders || []); applyActiveOrders(data.active_orders || [], data);
if (!list) return; if (!list) return;
var rows = (data.rows || []).filter(function (row) { var rows = (data.rows || []).filter(function (row) {
return row.order_state !== 'pending'; return row.order_state !== 'pending';
@@ -1592,8 +1607,10 @@
if (cached.ctp_status) { if (cached.ctp_status) {
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
} }
if (cached.ctp_status && cached.ctp_status.connected) {
applyPositionsData(cached); applyPositionsData(cached);
} }
}
pollPositions(); pollPositions();
connectPositionStream(); connectPositionStream();
initCtpOnLoad(); initCtpOnLoad();
+13 -10
View File
@@ -108,20 +108,23 @@
</div> </div>
</div> </div>
<div class="card trade-card" id="active-orders"> <div class="card trade-card" id="trading-live">
<h2>当前委托 <span class="sync-badge text-muted" id="sync-badge" hidden></span></h2> <h2>委托与持仓 <span class="sync-badge text-muted" id="sync-badge" hidden></span></h2>
<p class="hint pos-hint">委托以 CTP 柜台为准;未成交可撤单,超时自动撤开仓单</p> <p class="hint pos-hint">委托、持仓均以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓</p>
<div class="card-body card-scroll" id="order-live-list"> <div class="card-body card-scroll trading-live-body">
<section class="trading-live-section" id="active-orders">
<h3 class="trading-live-subtitle">当前委托</h3>
<div id="order-live-list">
<div class="empty-hint" id="order-placeholder">加载委托…</div> <div class="empty-hint" id="order-placeholder">加载委托…</div>
</div> </div>
</div> </section>
<section class="trading-live-section trading-live-positions" id="positions">
<div class="card trade-card" id="positions"> <h3 class="trading-live-subtitle">当前持仓</h3>
<h2>当前持仓</h2> <div id="position-live-list">
<p class="hint pos-hint">持仓以 CTP 柜台为准;止盈止损为程序本地监控,触发后市价平仓。</p>
<div class="card-body card-scroll" id="position-live-list">
<div class="empty-hint" id="position-placeholder">加载持仓…</div> <div class="empty-hint" id="position-placeholder">加载持仓…</div>
</div> </div>
</section>
</div>
</div> </div>
</div> </div>