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 ( '