From 23d0f1d6faae82ee81d03b9be6dfa68135859ad0 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 14:09:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=E5=B9=BD=E7=81=B5?= =?UTF-8?q?=E6=AD=A2=E7=9B=88=E6=AD=A2=E6=8D=9F=E7=9B=91=E6=8E=A7=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=BB=93=E4=BD=8D=E4=B8=8A=E9=99=90=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E8=AF=AF=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- install_trading.py | 141 ++++++++++++++++++++++++++++++++------- risk/account_risk_lib.py | 8 +-- static/css/trade.css | 3 + static/js/trade.js | 54 ++++++++++++++- 4 files changed, 176 insertions(+), 30 deletions(-) diff --git a/install_trading.py b/install_trading.py index 240c33a..ea5bf59 100644 --- a/install_trading.py +++ b/install_trading.py @@ -125,6 +125,49 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se except Exception: return "" + def _ctp_position_keys(mode: str) -> set[tuple[str, str]]: + keys: set[tuple[str, str]] = set() + for p in _ctp_positions(mode): + lots = int(p.get("lots") or 0) + if lots <= 0: + continue + sym = (p.get("symbol") or "").lower() + direction = p.get("direction") or "long" + keys.add((sym, direction)) + return keys + + def _monitor_matches_ctp_position(mon: dict, position_keys: set[tuple[str, str]]) -> bool: + ms = mon.get("symbol") or "" + md = mon.get("direction") or "long" + for ps, pd in position_keys: + if pd != md: + continue + if _match_ctp_symbol(ps, ms): + return True + return False + + def _sync_trade_monitors_with_ctp(conn, mode: str) -> int: + """关闭无对应 CTP 持仓的 active 监控(委托被拒或未成交的幽灵记录)。""" + if not ctp_status(mode).get("connected"): + return 0 + position_keys = _ctp_position_keys(mode) + closed = 0 + for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall(): + mon = dict(r) + if _monitor_matches_ctp_position(mon, position_keys): + continue + conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) + closed += 1 + return closed + + 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) + def _build_pending_orders(conn, mode: str) -> list[dict]: pending: list[dict] = [] for r in conn.execute( @@ -141,6 +184,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "source": "monitor", + "monitor_id": mon.get("id"), } sl = mon.get("stop_loss") tp = mon.get("take_profit") @@ -235,6 +279,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "price": sl, "lots": lots, "source": "monitor", + "monitor_id": mon["id"] if mon else None, }) if tp is not None: pending_for_row.append({ @@ -243,6 +288,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "price": tp, "lots": lots, "source": "monitor", + "monitor_id": mon["id"] if mon else None, }) rows.append({ "key": f"ctp:{sym.lower()}:{direction}", @@ -280,8 +326,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) + _sync_trade_monitors_with_ctp(conn, mode) capital = _capital(conn) - risk = get_risk_status(conn) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} active_trend = conn.execute( "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" @@ -328,10 +375,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) + _sync_trade_monitors_with_ctp(conn, mode) rows = _build_trading_live_rows(conn) pending_orders = _build_pending_orders(conn, mode) capital = _capital(conn) - risk = get_risk_status(conn) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) conn.commit() return jsonify({ "rows": rows, @@ -344,6 +392,34 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se finally: conn.close() + @app.route("/api/trading/monitor/dismiss", methods=["POST"]) + @login_required + def api_trading_monitor_dismiss(): + d = request.get_json(silent=True) or {} + try: + monitor_id = int(d.get("monitor_id") or 0) + except (TypeError, ValueError): + monitor_id = 0 + if monitor_id <= 0: + return jsonify({"ok": False, "error": "无效的监控记录"}), 400 + conn = get_db() + try: + init_strategy_tables(conn) + row = conn.execute( + "SELECT id FROM trade_order_monitors WHERE id=? AND status='active'", + (monitor_id,), + ).fetchone() + if not row: + return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404 + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (monitor_id,), + ) + conn.commit() + return jsonify({"ok": True, "message": "已取消本地止盈止损监控"}) + finally: + conn.close() + @app.route("/api/trading/close", methods=["POST"]) @login_required def api_trading_close(): @@ -523,12 +599,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se return jsonify({"ok": False, "error": "品种或价格无效"}), 400 conn = get_db() init_strategy_tables(conn) + mode = get_trading_mode(get_setting) if offset.startswith("open"): - err = assert_can_open(conn) + _sync_trade_monitors_with_ctp(conn, mode) + err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode)) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 - mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) if not ctp_st.get("connected"): conn.close() @@ -569,25 +646,40 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if offset.startswith("open"): sl = d.get("stop_loss") tp = d.get("take_profit") - codes = ths_to_codes(sym) - conn.execute( - """INSERT INTO trade_order_monitors ( - symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", - ( - sym, - codes.get("name", sym) if codes else sym, - codes.get("market_code", "") if codes else "", - direction, - lots, - price, - float(sl) if sl else None, - float(tp) if tp else None, - datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "manual", - ), - ) + if sl or tp: + import time + time.sleep(2.0) + actual_lots = lots + has_pos = False + for p in _ctp_positions(mode): + if int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if _match_ctp_symbol(p.get("symbol") or "", sym): + has_pos = True + actual_lots = int(p.get("lots") or lots) + break + if has_pos: + codes = ths_to_codes(sym) + conn.execute( + """INSERT INTO trade_order_monitors ( + symbol, symbol_name, market_code, direction, lots, entry_price, + stop_loss, take_profit, open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + ( + sym, + codes.get("name", sym) if codes else sym, + codes.get("market_code", "") if codes else "", + direction, + actual_lots, + price, + float(sl) if sl else None, + float(tp) if tp else None, + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "manual", + ), + ) conn.commit() send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") conn.close() @@ -643,8 +735,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) + _sync_trade_monitors_with_ctp(conn, mode) capital = _capital(conn) - risk = get_risk_status(conn) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) conn.commit() ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} positions = _ctp_positions(mode) if ctp_st.get("connected") else [] diff --git a/risk/account_risk_lib.py b/risk/account_risk_lib.py index a3fc913..26e0b73 100644 --- a/risk/account_risk_lib.py +++ b/risk/account_risk_lib.py @@ -227,7 +227,7 @@ def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[dateti ) -def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict: +def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict: def _load() -> dict: ensure_account_risk_schema(conn) row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() @@ -244,7 +244,7 @@ def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict: now_ms = _now_ms(now) daily = int(_row_get(row, "daily_frozen") or 0) == 1 until = _row_get(row, "cooloff_until_ms") - active = count_active_trade_monitors(conn) + active = count_active_trade_monitors(conn) if active_count is None else int(active_count) mx = max_active_positions() pos_limit = active >= mx @@ -295,8 +295,8 @@ def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict: return _db_retry(_load) -def assert_can_open(conn) -> Optional[str]: - rs = get_risk_status(conn) +def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]: + rs = get_risk_status(conn, active_count=active_count) if not rs.get("can_trade"): return rs.get("reason") or "当前不可开仓" return None diff --git a/static/css/trade.css b/static/css/trade.css index 46448ef..abcfef8 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -43,6 +43,9 @@ .pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} .pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} .pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)} +.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0} +.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3} +.pos-dismiss-btn:disabled{opacity:.55;cursor:wait} .pos-pending-item.sl{border-left:3px solid var(--loss)} .pos-pending-item.tp{border-left:3px solid var(--profit)} .pos-pending-item.ctp{border-left:3px solid var(--accent)} diff --git a/static/js/trade.js b/static/js/trade.js index d12ea7a..ad96681 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -318,16 +318,56 @@ if (!items || !items.length) return ''; var rows = items.map(function (p) { var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp'); + var dismissBtn = p.monitor_id ? + '' : ''; return ( '
' + '' + (p.label || '挂单') + '' + - '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' + + '' + + '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' + + dismissBtn + + '' + '
' ); }).join(''); return '
止盈止损挂单
' + rows + '
'; } + function dismissMonitor(monitorId, btn) { + if (!monitorId) return; + if (!confirm('取消该本地止盈止损监控?(不影响柜台委托)')) return; + if (btn) { + btn.disabled = true; + btn.textContent = '取消中…'; + } + fetch('/api/trading/monitor/dismiss', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ monitor_id: monitorId }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.ok) throw new Error(d.error || '取消失败'); + pollPositions(); + }) + .catch(function (e) { + alert(e.message || '取消失败'); + if (btn) { + btn.disabled = false; + btn.textContent = '取消'; + } + }); + } + + function bindPendingDismiss(root) { + if (!root) return; + root.querySelectorAll('[data-monitor-id]').forEach(function (btn) { + btn.addEventListener('click', function () { + dismissMonitor(parseInt(btn.getAttribute('data-monitor-id'), 10), btn); + }); + }); + } + function buildPosCard(row) { var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--'; @@ -405,6 +445,11 @@ var connected = data.ctp_status && data.ctp_status.connected; var connecting = data.ctp_status && data.ctp_status.connecting; updateCtpBadge(!!connected, !!connecting); + var riskBadge = document.getElementById('risk-badge'); + if (riskBadge && data.risk_status) { + riskBadge.textContent = data.risk_status.status_label || ''; + riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); + } var rows = data.rows || []; if (!connected) { if (connecting) { @@ -420,19 +465,24 @@ if (pendingOnly.length) { list.innerHTML = '
柜台暂无持仓
' + pendingOnly.map(function (p) { + var dismissBtn = p.monitor_id ? + '' : ''; return ( '
' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '' + - '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手
' + '' + fmtNum(p.price) + ' · ' + + (p.lots || 1) + ' 手' + dismissBtn + '' ); }).join(''); + bindPendingDismiss(list); } else { list.innerHTML = '
柜台暂无持仓。
'; } return; } list.innerHTML = rows.map(buildPosCard).join(''); + bindPendingDismiss(list); list.querySelectorAll('[data-close]').forEach(function (btn) { btn.addEventListener('click', function () { closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);