# Copyright (c) 2025-2026 马建军. All rights reserved. # 专有软件 — 未经授权禁止复制、传播、转售。 # 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 # 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md """期货下单、可开仓品种、策略交易路由注册。""" from __future__ import annotations import json import logging import os import threading import time from datetime import datetime from typing import Any, Callable, Optional from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context from contract_specs import calc_position_metrics, get_contract_spec from fee_specs import calc_fee_breakdown from kline_stream import sse_format from market_sessions import is_night_trading_session, is_trading_session, trading_session_clock from position_sizing import ( MODE_AMOUNT, MODE_FIXED, DEFAULT_MAX_ORDER_LOTS, calc_lots_by_amount, calc_lots_by_risk, calc_margin_usage_pct, cap_lots_for_margin_budget, calc_order_tick_metrics, normalize_sizing_mode, ) from product_recommend import ( assert_product_allowed_for_capital, should_apply_small_account_scope, small_account_margin_recommendations, small_account_scope_hint, SMALL_ACCOUNT_SCOPE_LABEL, ) from recommend_store import ( recommend_payload, refresh_recommend_cache, ) from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker from position_stream import position_hub, start_position_worker from ctp_settings import is_ctp_auto_connect_enabled from ctp_reconnect import start_ctp_reconnect_worker from ctp_premarket_connect import start_ctp_premarket_connect_worker from ctp_fee_worker import start_ctp_fee_worker from pending_order_worker import start_pending_order_worker from order_pending import ( cancel_pending_monitor, pending_auto_cancel_remaining, pending_monitor_has_live_order, reconcile_pending_orders, ) from db_conn import commit_retry, execute_retry from sl_tp_guard import ( cancel_monitor_exit_orders, ensure_monitor_order_columns, monitor_order_status, monitor_source_label, place_monitor_exit_orders, reconcile_monitors_without_position, start_sl_tp_guard_worker, write_manual_close_trade_log, ) from risk.account_risk_lib import ( assert_can_open, count_active_trade_monitors, get_risk_status, on_mood_journal_freeze, on_user_initiated_close, parse_mood_issues, trading_day_label, ) from strategy.strategy_db import init_strategy_tables from strategy.strategy_roll_lib import ( ADD_MODE_BREAKOUT, ADD_MODE_MARKET, FIB_MODES, LEG_STATUS_CANCELLED, LEG_STATUS_FILLED, LEG_STATUS_PENDING, PENDING_MODES, add_mode_label, avg_entry_after_add, preview_roll, roll_eligibility_error, ) from strategy.strategy_roll_monitor_lib import ( cancel_roll_leg, check_roll_monitors, roll_sync_after_external_close, ) from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot from strategy.strategy_trend_lib import ( compute_trend_plan_futures, enrich_trend_plan_preview, normalize_trend_period, trend_dca_level_reached, trend_period_label, trend_strategy_periods, ) from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES, position_symbol_meta from trading_context import ( TRADING_MODE_LIVE, TRADING_MODE_SIM, get_account_capital, get_fixed_amount, get_fixed_lots, get_max_margin_pct, get_pending_order_timeout_min, get_pending_order_timeout_sec, get_recommend_capital, get_roll_max_margin_pct, get_risk_percent, get_sizing_mode, get_trailing_be_tick_buffer, get_trading_mode, is_ctp_connected, trading_mode_label, ) from ctp_entry_price import round_to_tick from ctp_symbol import ths_to_vnpy_symbol from ctp_trading_state import position_key, trading_state from vnpy_bridge import ( _ctp_td_lock, ctp_cancel_order, ctp_connect, ctp_account_margin_used, ctp_estimate_margin_one_lot, ctp_get_account, ctp_get_tick_price, ctp_list_active_orders, ctp_list_positions, ctp_list_trades, ctp_status, execute_order, get_bridge, set_position_refresh_callback, set_tick_sl_tp_callback, set_tick_quote_callback, set_ctp_connected_callback, ) logger = logging.getLogger(__name__) def install_trading(app, *, login_required, require_nav, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): """注册交易相关路由。""" _nav = require_nav _live_refresh_lock = threading.Lock() def _sizing_mode_label(mode: str) -> str: m = normalize_sizing_mode(mode) if m == MODE_AMOUNT: return "固定金额" return "固定手数" def _symbol_display_fields(sym: str) -> dict: meta = position_symbol_meta(sym) name = meta.get("name") or sym return { "symbol": name, "symbol_name": name, "symbol_exchange": meta.get("exchange") or "", "symbol_is_main": bool(meta.get("is_main")), } def _breakeven_locked( *, entry: Optional[float], stop_loss: Optional[float], direction: str, tick_size: Optional[float] = None, trailing_r_locked: int = 0, ) -> bool: if int(trailing_r_locked or 0) >= 1: return True if entry is None or stop_loss is None: return False try: entry_f = float(entry) sl_f = float(stop_loss) except (TypeError, ValueError): return False if entry_f <= 0: return False tick = float(tick_size or 0) or max(abs(entry_f) * 1e-6, 0.01) be_mult = max(1, get_trailing_be_tick_buffer(get_setting)) d = (direction or "long").strip().lower() expected_be = entry_f + be_mult * tick if d == "long" else entry_f - be_mult * tick tol = be_mult * tick + tick * 0.05 if abs(sl_f - expected_be) <= tol: return True buf = tick * max(2, be_mult) near = abs(sl_f - entry_f) <= buf + tick if d == "long": return near and sl_f >= entry_f - tick * 0.05 return near and sl_f <= entry_f + tick * 0.05 def _schedule_recommend_refresh() -> None: from db_conn import DB_PATH schedule_recommend_refresh( db_path=DB_PATH, get_capital_fn=_recommend_capital, quote_fn=_main_quote, init_tables_fn=lambda c: init_strategy_tables(c), get_mode_fn=lambda: get_trading_mode(get_setting), get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), ) def _recommend_payload(conn) -> dict: mode = get_trading_mode(get_setting) return recommend_payload( conn, live_capital=_recommend_capital(conn), max_margin_pct=get_max_margin_pct(get_setting), trading_mode=mode, sizing_mode=get_sizing_mode(get_setting), fixed_lots=get_fixed_lots(get_setting), ) def _recommend_capital(conn) -> float: return get_recommend_capital(conn, get_setting) def _settings_dict() -> dict: return { "trading_mode": get_trading_mode(get_setting), "position_sizing_mode": get_sizing_mode(get_setting), "risk_percent": str(get_risk_percent(get_setting)), "max_margin_pct": str(get_max_margin_pct(get_setting)), } def _capital(conn) -> float: return get_account_capital(conn, get_setting) def _main_quote(product_ths: str) -> Optional[dict]: for p in PRODUCTS: if p["ths"] == product_ths: main = resolve_main_contract(p) if not main: return None sym = main.get("ths_code") or "" codes = ths_to_codes(sym) price = None if codes: price = fetch_price( sym, codes.get("market_code", ""), codes.get("sina_code", ""), ) return { "ths_code": sym, "price": price, "display": main.get("display") or sym, "name": main.get("name") or p.get("name"), } return None def _ctp_account(mode: str) -> dict: try: return ctp_get_account(mode) except Exception: return {} def _ctp_positions( mode: str, *, refresh_if_empty: bool = True, refresh_margin: bool = False, ) -> list: try: return ctp_list_positions( mode, refresh_if_empty=refresh_if_empty, refresh_margin=refresh_margin, ) except Exception: return [] def _ctp_pos_to_ths_code(p: dict) -> str: sym = (p.get("symbol") or "").strip() ex = (p.get("exchange") or "").strip() if not sym: return "" codes = ths_to_codes(sym) if codes: return codes.get("ths_code") or sym if ex: from vnpy_bridge import CtpBridge ths = CtpBridge._vnpy_sym_to_ths(sym, ex) if ths: return ths return sym def _resolve_position_margin( *, sym: str, direction: str, lots: int, entry: float, mode: str, ctp: Optional[dict] = None, mon_margin: Optional[float] = None, est_margin: Optional[float] = None, ) -> tuple[Optional[float], str]: """占用保证金:柜台持仓 > CTP 合约率估算 > 本地规格估算 > 库内缓存。""" ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0 if ctp_margin > 0: return round(ctp_margin, 2), "ctp" connected = bool(ctp_status(mode).get("connected")) ths_sym = sym if ctp: ths_sym = _ctp_pos_to_ths_code(ctp) or sym else: codes = ths_to_codes(sym) if codes and codes.get("ths_code"): ths_sym = codes["ths_code"] if connected and ths_sym and entry > 0 and lots > 0: per_lot = ctp_estimate_margin_one_lot( mode, ths_sym, entry, direction=direction, ) if per_lot and per_lot > 0: return round(per_lot * lots, 2), "ctp" if est_margin and float(est_margin) > 0: return round(float(est_margin), 2), "estimate" if not connected and mon_margin and float(mon_margin) > 0: return round(float(mon_margin), 2), "db" return None, "estimate" def _apply_account_margin_to_rows( rows: list[dict], mode: str, capital: float, ) -> list[dict]: """仅在持仓缺少柜台保证金时补全;已有 CTP 持仓保证金的行不覆盖。""" if not ctp_status(mode).get("connected"): return rows active = [ r for r in rows if r.get("order_state") != "pending" and int(r.get("lots") or 0) > 0 ] if not active: return rows def _has_ctp_margin(row: dict) -> bool: return ( float(row.get("margin") or 0) > 0 and row.get("margin_source") == "ctp" ) without_margin = [r for r in active if not _has_ctp_margin(r)] for row in active: if _has_ctp_margin(row) and capital > 0: m = float(row.get("margin") or 0) row["position_pct"] = round(m / capital * 100, 2) if not without_margin: return rows total_used = ctp_account_margin_used(mode) if not total_used: return rows known_sum = sum( float(r.get("margin") or 0) for r in active if _has_ctp_margin(r) ) pool = max(0.0, float(total_used) - known_sum) if known_sum > 0 else float(total_used) if pool <= 0: return rows weights: list[float] = [] for row in without_margin: sym = (row.get("symbol_code") or "").strip() lots = int(row.get("lots") or 0) entry = float(row.get("entry_price") or 0) if sym and lots > 0 and entry > 0: spec = get_contract_spec(sym) weights.append(entry * spec["mult"] * lots) else: weights.append(0.0) total_weight = sum(weights) assigned = 0.0 for i, row in enumerate(without_margin): if total_weight <= 0: margin = round(pool / len(without_margin), 2) elif i == len(without_margin) - 1: margin = round(pool - assigned, 2) else: margin = round(pool * weights[i] / total_weight, 2) assigned += margin row["margin"] = margin row["margin_source"] = "ctp" if capital > 0: row["position_pct"] = round(margin / capital * 100, 2) return rows def _persist_ctp_snapshot_to_monitors( conn, rows: list[dict], mode: str, ) -> None: """将柜台校正后的均价、手数、现价、浮盈、保证金等写入 trade_order_monitors。""" if not ctp_status(mode).get("connected"): return ensure_monitor_order_columns(conn) for row in rows: mid = row.get("monitor_id") if not mid or row.get("order_state") == "pending": continue entry_price = row.get("entry_price") lots = row.get("lots") mark_price = row.get("mark_price") if mark_price is None: mark_price = row.get("current_price") float_pnl = row.get("float_pnl") margin = row.get("margin") position_pct = row.get("position_pct") open_fee = row.get("est_fee") if ( entry_price is None and lots is None and mark_price is None and float_pnl is None and margin is None and position_pct is None and open_fee is None ): continue try: execute_retry( conn, """UPDATE trade_order_monitors SET entry_price=COALESCE(?, entry_price), lots=COALESCE(?, lots), mark_price=COALESCE(?, mark_price), float_pnl=COALESCE(?, float_pnl), margin=COALESCE(?, margin), position_pct=COALESCE(?, position_pct), open_fee=COALESCE(?, open_fee) WHERE id=? AND status='active'""", ( entry_price, lots, mark_price, float_pnl, margin, position_pct, open_fee, int(mid), ), ) except Exception as exc: logger.debug("persist monitor ctp snapshot %s: %s", mid, exc) def _positions_from_live_snapshot() -> list[dict]: snap = position_hub.get_snapshot() or {} out: list[dict] = [] for row in snap.get("rows") or []: lots = int(row.get("lots") or 0) if lots <= 0 or row.get("order_state") == "pending": continue sym = ( row.get("symbol_code") or row.get("ths_code") or row.get("symbol") or "" ) if not sym: continue out.append({ "symbol": sym, "direction": row.get("direction") or "long", "lots": lots, "avg_price": row.get("entry_price") or row.get("avg_price") or 0, "open_time": row.get("open_time") or "", "margin": row.get("margin"), "pnl": row.get("float_pnl"), "mark_price": row.get("mark_price") or row.get("current_price"), "exchange": row.get("exchange") or "", }) return out def _positions_for_monitor_restore(mode: str, *, allow_ctp: bool = True) -> list[dict]: if allow_ctp: positions = list(_ctp_positions(mode, refresh_if_empty=True) or []) if positions: return positions positions = list(trading_state.get_positions() or []) if positions: return positions positions = _positions_from_live_snapshot() if not allow_ctp: return positions margin_used = float(ctp_account_margin_used(mode) or 0) if margin_used <= 100 or not positions: return [] return positions def _cached_position_mark(sym: str, direction: str = "") -> Optional[float]: sym_l = (sym or "").strip().lower() direction_l = (direction or "").strip().lower() for p in list(trading_state.get_positions() or []) + _positions_from_live_snapshot(): if direction_l and (p.get("direction") or "long").strip().lower() != direction_l: continue ps = (p.get("symbol") or "").strip() if not ps: continue if not _match_ctp_symbol(ps, sym_l): continue for key in ("mark_price", "current_price", "last_price"): val = p.get(key) try: px = float(val or 0) except (TypeError, ValueError): px = 0.0 if px > 0: return px snap = position_hub.get_snapshot() or {} for row in snap.get("rows") or []: rs = row.get("symbol_code") or row.get("symbol") or "" if not rs or not _match_ctp_symbol(rs, sym_l): continue if direction_l and (row.get("direction") or "long").strip().lower() != direction_l: continue for key in ("mark_price", "current_price", "last_price", "entry_price"): try: px = float(row.get(key) or 0) except (TypeError, ValueError): px = 0.0 if px > 0: return px return None def _ensure_monitors_from_ctp(conn, mode: str, *, allow_ctp: bool = True) -> None: """CTP 有持仓但本地无监控时,自动补写一条 active 记录供展示。""" if not ctp_status(mode).get("connected"): return ctp_positions = _positions_for_monitor_restore(mode, allow_ctp=allow_ctp) for p in ctp_positions: lots = int(p.get("lots") or 0) if lots <= 0: continue direction = p.get("direction") or "long" ths = _ctp_pos_to_ths_code(p) if not ths: continue existing = _find_or_revive_monitor(conn, ths, direction) if existing: _sync_monitor_from_ctp( conn, int(existing["id"]), ths, direction, mode, ctp=p, capital=_capital(conn), ) continue sl, tp, trailing_be, initial_sl = _restore_sl_tp_from_closed(conn, ths, direction) ctp_open = (p.get("open_time") or "").strip() mid = _upsert_open_monitor( conn, sym=ths, direction=direction, lots=lots, price=float(p.get("avg_price") or 0), sl=sl, tp=tp, trailing_be=trailing_be, ctp_open_time=ctp_open or None, monitor_type="ctp_sync", ) if initial_sl is not None and sl is not None: conn.execute( "UPDATE trade_order_monitors SET initial_stop_loss=? WHERE id=?", (initial_sl, mid), ) if ctp_positions: return _ensure_monitors_from_sticky_state(conn, mode) def _ensure_monitors_from_sticky_state(conn, mode: str) -> None: """vnpy 持仓空窗但账户仍有保证金时,恢复本地 active 监控。""" if not ctp_status(mode).get("connected"): return margin_raw = ctp_account_margin_used(mode) if margin_raw is None or float(margin_raw or 0) <= 0: return if count_active_trade_monitors(conn) > 0: return capital = _capital(conn) for p in trading_state.get_positions() or []: lots = int(p.get("lots") or 0) if lots <= 0: continue direction = p.get("direction") or "long" ths = _ctp_pos_to_ths_code(p) or (p.get("symbol") or "") if not ths: continue existing = _find_or_revive_monitor(conn, ths, direction) if existing: _sync_monitor_from_ctp( conn, int(existing["id"]), ths, direction, mode, ctp=p, capital=capital, ) continue sl, tp, trailing_be, initial_sl = _restore_sl_tp_from_closed(conn, ths, direction) mid = _upsert_open_monitor( conn, sym=ths, direction=direction, lots=lots, price=float(p.get("avg_price") or 0), sl=sl, tp=tp, trailing_be=trailing_be, ctp_open_time=(p.get("open_time") or "").strip() or None, monitor_type="ctp_sync", ) if initial_sl is not None and sl is not None: conn.execute( "UPDATE trade_order_monitors SET initial_stop_loss=? WHERE id=?", (initial_sl, mid), ) if count_active_trade_monitors(conn) > 0: return today = datetime.now().strftime("%Y-%m-%d") for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='closed' " "AND open_time LIKE ? ORDER BY id DESC LIMIT 5", (f"{today}%",), ).fetchall(): mon = dict(r) if int(mon.get("lots") or 0) <= 0: continue revived = _revive_closed_monitor( conn, mon.get("symbol") or "", mon.get("direction") or "long", ) if revived: logger.info( "保证金占用下恢复监控 id=%s sym=%s", revived.get("id"), revived.get("symbol"), ) break def _restore_recent_pending_monitors(conn, mode: str) -> None: """重启或 vnpy 委托缓存丢失时,恢复当日最近一笔可能仍有效的开仓挂单。""" if not ctp_status(mode).get("connected"): return if conn.execute("SELECT 1 FROM trade_order_monitors WHERE status='pending' LIMIT 1").fetchone(): return today = datetime.now().strftime("%Y-%m-%d") row = conn.execute( """SELECT * FROM trade_order_monitors WHERE status='closed' AND monitor_type='manual' AND vt_order_id IS NOT NULL AND vt_order_id != '' AND open_time LIKE ? ORDER BY id DESC LIMIT 1""", (f"{today}%",), ).fetchone() if not row: return mon = dict(row) sym = mon.get("symbol") or "" direction = (mon.get("direction") or "long").strip().lower() if _find_active_monitor(conn, sym, direction): return for p in _ctp_positions(mode, refresh_if_empty=False): 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): return conn.execute( "UPDATE trade_order_monitors SET status='pending' WHERE id=?", (mon["id"],), ) logger.info("恢复挂单监控 id=%s sym=%s", mon.get("id"), sym) def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool: a = (ctp_sym or "").lower() b = (ths or "").lower() if a == b: return True if a and b and a.split(".")[0] == b.split(".")[0]: return True try: vnpy_sym, _ = ths_to_vnpy_symbol(ths) if a == vnpy_sym.lower(): return True except Exception: pass try: vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym) if vnpy_sym.lower() == b.split(".")[0]: return True except Exception: pass return False def _live_entry_price( sym: str, direction: str, mode: str, fallback: float = 0.0, *, allow_ctp: bool = False, ) -> float: """滚仓/展示用均价:仅柜台持仓价。""" if not ctp_status(mode).get("connected"): return fallback positions = list(trading_state.get_positions() or []) if not positions: positions = _positions_from_live_snapshot() if not positions and allow_ctp: positions = _ctp_positions(mode, refresh_if_empty=False) for p in positions: if (p.get("direction") or "long") != (direction or "long"): continue if not _match_ctp_symbol(p.get("symbol") or "", sym): continue avg = float(p.get("avg_price") or 0) if avg > 0: return avg return fallback def _resolve_ctp_entry_price( mode: str, sym: str, direction: str, ctp: Optional[dict], ) -> tuple[float, str]: del mode, direction if not ctp: return 0.0, "none" avg = float(ctp.get("avg_price") or 0) if avg > 0: return round_to_tick(avg, sym), "ctp" return 0.0, "none" def _open_commission_from_ctp_trades( mode: str, sym: str, direction: str, ) -> Optional[float]: """汇总该持仓开仓成交的柜台手续费(成交回报中的 commission)。""" if not ctp_status(mode).get("connected"): return None try: trades = ctp_list_trades(mode) except Exception: return None total = 0.0 has_commission = False for t in trades: if (t.get("offset") or "").strip().lower() != "open": continue pos_dir = ( t.get("position_direction") or t.get("direction") or "long" ).strip().lower() if pos_dir != (direction or "long").strip().lower(): continue if not _match_ctp_symbol(t.get("symbol") or "", sym): continue comm = float(t.get("commission") or 0) total += comm if comm > 0: has_commission = True return round(total, 2) if has_commission else None def _holding_duration(open_time: str, now_iso: str) -> str: try: from app import calc_holding_duration open_s = (open_time or "").strip().replace("T", " ")[:19] now_s = (now_iso or "").strip().replace("T", " ")[:19] if not open_s or not now_s: return "" return calc_holding_duration(open_s, now_s) except Exception: return "" def _restore_sl_tp_from_closed(conn, sym: str, direction: str) -> tuple: """重启后从最近关闭的同品种监控恢复止盈止损。""" direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT symbol, direction, stop_loss, take_profit, trailing_be, initial_stop_loss " "FROM trade_order_monitors WHERE status='closed' ORDER BY id DESC LIMIT 80" ).fetchall(): row = dict(r) if (row.get("direction") or "long") != direction: continue if not _match_ctp_symbol(sym, row.get("symbol") or ""): continue if row.get("stop_loss") is None and row.get("take_profit") is None: continue return ( row.get("stop_loss"), row.get("take_profit"), int(row.get("trailing_be") or 0), row.get("initial_stop_loss"), ) return None, None, 0, None def _restore_monitor_sl_tp_if_missing( conn, mon: Optional[dict], sym: str, direction: str, ) -> Optional[dict]: """活跃监控缺少止盈止损时,从最近关闭的同品种记录恢复并写回数据库。""" if not mon: return None sl = mon.get("stop_loss") tp = mon.get("take_profit") trailing = int(mon.get("trailing_be") or 0) if sl is not None or tp is not None or trailing: return mon rsl, rtp, rtrail, rinitial = _restore_sl_tp_from_closed(conn, sym, direction) if rsl is None and rtp is None and not rtrail: return mon execute_retry( conn, """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, trailing_be=?, initial_stop_loss=? WHERE id=? AND status='active'""", (rsl, rtp, rtrail, rinitial, int(mon["id"])), ) row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=?", (int(mon["id"]),), ).fetchone() if row: logger.info( "恢复止盈止损 monitor=%s sym=%s sl=%s tp=%s", mon.get("id"), sym, rsl, rtp, ) return dict(row) return mon 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 持仓的监控,并撤销残留止盈止损挂单。""" return reconcile_monitors_without_position(conn, mode) def _effective_active_position_count(conn, mode: str) -> int: """风控持仓数以柜台/快照实际持仓优先,本地监控作兜底。""" monitor_count = count_active_trade_monitors(conn) if not ctp_status(mode).get("connected"): return monitor_count keys: set[tuple[str, str]] = set() for p in _positions_for_monitor_restore(mode, allow_ctp=False): lots = int(p.get("lots") or 0) if lots <= 0: continue sym = ( p.get("symbol") or p.get("symbol_code") or p.get("ths_code") or "" ).strip().lower() direction = (p.get("direction") or "long").strip().lower() if sym: keys.add((sym, direction)) return max(monitor_count, len(keys)) def _build_pending_orders(conn, mode: str) -> list[dict]: pending: list[dict] = [] for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall(): mon = dict(r) sym = mon.get("symbol") or "" direction = mon.get("direction") or "long" lots = int(mon.get("lots") or 0) base = { "symbol_code": sym, "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "source": "monitor", "monitor_id": mon.get("id"), **_symbol_display_fields(sym), } sl = mon.get("stop_loss") tp = mon.get("take_profit") if sl is not None: pending.append({ **base, "order_kind": "stop_loss", "label": "止损监控", "price": float(sl), }) if tp is not None: pending.append({ **base, "order_kind": "take_profit", "label": "止盈监控", "price": float(tp), }) for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" ).fetchall(): mon = dict(r) sym = mon.get("symbol") or "" pending.append({ "symbol_code": sym, "direction": mon.get("direction") or "long", "direction_label": "做多" if (mon.get("direction") or "long") == "long" else "做空", "lots": int(mon.get("lots") or 0), "price": float(mon.get("order_price") or mon.get("entry_price") or 0), "order_kind": "open_pending", "label": "开仓挂单中", "source": "monitor", "monitor_id": mon.get("id"), "can_cancel_order": is_trading_session(), "cancel_allowed": is_trading_session(), **_symbol_display_fields(sym), }) ctp_st = ctp_status(mode) if ctp_st.get("connected"): for o in _ctp_active_orders(mode): sym = _ctp_pos_to_ths_code(o) or (o.get("symbol") or "") offset_s = (o.get("offset") or "").upper() kind = "limit" label = "委托挂单" if "CLOSE" in offset_s: label = "平仓委托" pending.append({ "symbol_code": sym, "symbol": _symbol_display_fields(sym).get("symbol_name") or sym, "direction": o.get("direction") or "long", "direction_label": "做多" if o.get("direction") == "long" else "做空", "lots": int(o.get("lots") or 0), "price": float(o.get("price") or 0), "order_kind": kind, "label": label, "source": "ctp", "order_id": o.get("order_id"), "vt_order_id": o.get("vt_order_id") or o.get("order_id"), "can_cancel_order": is_trading_session(), "cancel_allowed": is_trading_session(), **_symbol_display_fields(sym), }) return pending def _ctp_active_orders(mode: str) -> list: try: return ctp_list_active_orders(mode) except Exception: return [] def _canonical_position_key(symbol: str, direction: str, exchange: str = "") -> str: sym = (symbol or "").strip() d = (direction or "long").strip().lower() ex = (exchange or "").strip().upper() try: vnpy_sym, ex2 = ths_to_vnpy_symbol(sym) sym = vnpy_sym if not ex: ex = ex2 except Exception: sym = sym.lower() return position_key(ex, sym, d) def _position_key_from_ctp(p: dict) -> str: return position_key( p.get("exchange") or "", p.get("symbol") or "", p.get("direction") or "long", ) def _monitor_position_key(mon: dict, exchange: str = "") -> str: sym = (mon.get("symbol") or "").strip() d = (mon.get("direction") or "long").strip().lower() ex = (exchange or "").strip().upper() try: vnpy_sym, ex2 = ths_to_vnpy_symbol(sym) sym = vnpy_sym if not ex: ex = ex2 except Exception: sym = sym.lower() return position_key(ex, sym, d) def _monitors_by_position_key(conn) -> dict[str, dict]: ensure_monitor_order_columns(conn) out: dict[str, dict] = {} for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall(): mon = dict(r) pk = _monitor_position_key(mon) if pk not in out: out[pk] = mon return out def _find_active_monitor(conn, symbol: str, direction: str) -> Optional[dict]: direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall(): row = dict(r) if (row.get("direction") or "long") != direction: continue if _match_ctp_symbol(symbol, row.get("symbol") or ""): return row return None def _find_pending_monitor(conn, symbol: str, direction: str) -> Optional[dict]: """开仓委托 pending 仍带止损/移动保本元数据,需与 CTP 持仓关联展示。""" direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" ).fetchall(): row = dict(r) if (row.get("direction") or "long") != direction: continue if _match_ctp_symbol(symbol, row.get("symbol") or ""): return row return None def _has_pending_monitors(conn) -> bool: return bool( conn.execute( "SELECT 1 FROM trade_order_monitors WHERE status='pending' LIMIT 1" ).fetchone() ) def _overlay_sl_tp_readonly( conn, mon: Optional[dict], sym: str, direction: str, ) -> Optional[dict]: """只读:从已关闭监控补全止盈止损,不写库。""" if not mon: rsl, rtp, rtrail, rinitial = _restore_sl_tp_from_closed(conn, sym, direction) if rsl is None and rtp is None and not rtrail: return {"symbol": sym, "direction": direction} return { "symbol": sym, "direction": direction, "stop_loss": rsl, "take_profit": rtp, "trailing_be": rtrail, "initial_stop_loss": rinitial, } sl = mon.get("stop_loss") tp = mon.get("take_profit") trailing = int(mon.get("trailing_be") or 0) if sl is not None or tp is not None or trailing: return mon rsl, rtp, rtrail, rinitial = _restore_sl_tp_from_closed(conn, sym, direction) if rsl is None and rtp is None and not rtrail: return mon merged = dict(mon) merged["stop_loss"] = rsl merged["take_profit"] = rtp merged["trailing_be"] = rtrail merged["initial_stop_loss"] = rinitial return merged def _revive_closed_monitor(conn, symbol: str, direction: str) -> Optional[dict]: """柜台仍有持仓但本地监控被误关时,恢复最近一条同品种记录。""" direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='closed' ORDER BY id DESC LIMIT 40" ).fetchall(): row = dict(r) if (row.get("direction") or "long") != direction: continue if not _match_ctp_symbol(symbol, row.get("symbol") or ""): continue if int(row.get("lots") or 0) <= 0: continue execute_retry( conn, "UPDATE trade_order_monitors SET status='active' WHERE id=?", (row["id"],), ) row["status"] = "active" logger.info( "恢复误关闭监控 id=%s sym=%s dir=%s", row.get("id"), row.get("symbol"), direction, ) return row return None def _find_or_revive_monitor(conn, symbol: str, direction: str) -> Optional[dict]: active = _find_active_monitor(conn, symbol, direction) if active: return active return _revive_closed_monitor(conn, symbol, direction) def _close_all_monitors_for_sym_dir(conn, symbol: str, direction: str) -> None: direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT id, symbol, direction FROM trade_order_monitors " "WHERE status IN ('active', 'pending')" ).fetchall(): if (r["direction"] or "long") != direction: continue if _match_ctp_symbol(symbol, r["symbol"] or ""): conn.execute( "UPDATE trade_order_monitors SET status='closed' WHERE id=?", (r["id"],), ) def _close_duplicate_monitors(conn, symbol: str, direction: str, keep_id: int) -> None: direction = (direction or "long").strip().lower() for r in conn.execute( "SELECT id, symbol, direction FROM trade_order_monitors WHERE status='active'" ).fetchall(): if int(r["id"]) == int(keep_id): continue if (r["direction"] or "long") != direction: continue if _match_ctp_symbol(symbol, r["symbol"] or ""): conn.execute( "UPDATE trade_order_monitors SET status='closed' WHERE id=?", (r["id"],), ) def _upsert_open_monitor( conn, *, sym: str, direction: str, lots: int, price: float, sl, tp, trailing_be: int, ctp_open_time: Optional[str] = None, open_time: Optional[str] = None, monitor_type: str = "manual", status: str = "active", vt_order_id: Optional[str] = None, order_price: Optional[float] = None, ) -> int: ensure_monitor_order_columns(conn) codes = ths_to_codes(sym) or {} sl_f = float(sl) if sl not in (None, "") else None tp_f = float(tp) if tp not in (None, "") else None now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") status_val = status if status in ("pending", "active") else "active" order_px = float(order_price if order_price is not None else price) existing = _find_active_monitor(conn, sym, direction) if not existing: for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" ).fetchall(): row = dict(r) if (row.get("direction") or "long") != (direction or "long").strip().lower(): continue if _match_ctp_symbol(sym, row.get("symbol") or ""): existing = row break if existing: mid = int(existing["id"]) existing_status = (existing.get("status") or "active").strip().lower() if existing_status == "active" and status_val == "pending": status_val = "active" initial_sl = existing.get("initial_stop_loss") if sl_f is None: sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None if tp_f is None: tp_f = float(existing["take_profit"]) if existing.get("take_profit") is not None else None if sl_f is not None and initial_sl is None: initial_sl = sl_f if not trailing_be: trailing_be = int(existing.get("trailing_be") or 0) open_time_val = (existing.get("open_time") or "").strip() or now_s if open_time: open_time_val = open_time elif monitor_type == "ctp_sync" and ctp_open_time: open_time_val = ctp_open_time vt_val = vt_order_id or existing.get("vt_order_id") conn.execute( """UPDATE trade_order_monitors SET symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?, stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?, monitor_type=?, status=?, vt_order_id=?, order_price=?, risk_percent=COALESCE(risk_percent, ?) WHERE id=?""", ( sym, codes.get("name", sym), codes.get("market_code", ""), lots, price, sl_f, tp_f, initial_sl, trailing_be, open_time_val, monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"), status_val, vt_val, order_px, get_risk_percent(get_setting), mid, ), ) else: if open_time: open_time_val = open_time elif monitor_type == "ctp_sync" and ctp_open_time: open_time_val = ctp_open_time else: open_time_val = now_s conn.execute( """INSERT INTO trade_order_monitors ( symbol, symbol_name, market_code, direction, lots, entry_price, stop_loss, take_profit, initial_stop_loss, trailing_be, open_time, monitor_type, status, vt_order_id, order_price, risk_percent ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( sym, codes.get("name", sym), codes.get("market_code", ""), direction, lots, price, sl_f, tp_f, sl_f, trailing_be, open_time_val, monitor_type, status_val, vt_order_id, order_px, get_risk_percent(get_setting), ), ) mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) if status_val == "active": _close_duplicate_monitors(conn, sym, direction, mid) return mid def _sync_monitor_from_ctp( conn, mid: int, sym: str, direction: str, mode: str, *, ctp: Optional[dict] = None, capital: float = 0.0, ) -> None: """CTP 同步:均价、现价、保证金、仓位占比写入数据库;不覆盖期货下单的开仓时间。""" positions = [ctp] if ctp else _ctp_positions(mode, refresh_if_empty=False, refresh_margin=True) for p in positions: if not p or int(p.get("lots") or 0) <= 0: continue if (p.get("direction") or "long") != direction: continue if not _match_ctp_symbol(p.get("symbol") or "", sym): continue row = conn.execute( "SELECT open_time, monitor_type FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() db_open = (row["open_time"] or "").strip() if row else "" monitor_type = (row["monitor_type"] or "manual").strip().lower() if row else "manual" ctp_open = (p.get("open_time") or "").strip() or None open_time_val = db_open if monitor_type == "ctp_sync" and ctp_open: open_time_val = ctp_open lots = int(p.get("lots") or 0) entry = float(p.get("avg_price") or 0) ctp_margin = float(p.get("margin") or 0) mark = None if ctp_status(mode).get("connected"): mark = ctp_get_tick_price(mode, sym) if mark is None or mark <= 0: mark = entry if entry else None resolved_entry, _src = _resolve_ctp_entry_price( mode, sym, direction, p, ) if resolved_entry > 0: entry = resolved_entry float_pnl = None if mark and entry and lots > 0: float_pnl = calc_position_metrics( direction, entry, entry, entry, lots, mark, capital, sym, ).get("float_pnl") est = calc_position_metrics( direction, entry, entry, entry, lots, mark or entry, capital, sym, ).get("margin") margin, _src = _resolve_position_margin( sym=sym, direction=direction, lots=lots, entry=entry, mode=mode, ctp=p, est_margin=est, ) position_pct = None if margin and capital > 0: position_pct = round(float(margin) / float(capital) * 100, 2) open_commission = _open_commission_from_ctp_trades(mode, sym, direction) if open_commission is None: fee_info = calc_fee_breakdown( sym, entry, entry, lots, open_time_val or "", "", trading_mode=mode, ) open_commission = fee_info.get("open_fee") execute_retry( conn, """UPDATE trade_order_monitors SET lots=?, entry_price=?, open_time=?, margin=?, position_pct=?, mark_price=?, float_pnl=?, open_fee=? WHERE id=?""", ( lots, entry, open_time_val, margin, position_pct, float(mark) if mark else None, float_pnl, open_commission, mid, ), ) return def _sync_monitor_lots_from_ctp( conn, mid: int, sym: str, direction: str, mode: str, *, ctp: Optional[dict] = None, ) -> None: _sync_monitor_from_ctp( conn, mid, sym, direction, mode, ctp=ctp, capital=_capital(conn), ) def _compose_position_row( conn, *, mon: Optional[dict], ctp: Optional[dict], mode: str, capital: float, now_iso: str, fast: bool = False, ) -> Optional[dict]: if not mon and not ctp: return None if mon: sym = (mon.get("symbol") or "").strip() direction = mon.get("direction") or "long" lots = int(mon.get("lots") or 0) entry = float(mon.get("entry_price") or 0) source_label = monitor_source_label(mon.get("monitor_type")) open_time = (mon.get("open_time") or "").strip() open_time_source = "order" margin = mon.get("margin") position_pct = mon.get("position_pct") mark = mon.get("mark_price") float_pnl = mon.get("float_pnl") if float_pnl is not None: float_pnl = round(float(float_pnl), 2) else: sym = (ctp.get("symbol") or "").strip() direction = ctp.get("direction") or "long" lots = int(ctp.get("lots") or 0) entry = float(ctp.get("avg_price") or 0) source_label = "CTP 柜台" open_time = (ctp.get("open_time") or "").strip() open_time_source = "ctp" margin = None position_pct = None mark = None float_pnl = None if lots <= 0: return None if ctp: ctp_lots = int(ctp.get("lots") or 0) if ctp_lots > 0: lots = ctp_lots ths_sym = _ctp_pos_to_ths_code(ctp) or sym resolved_entry, _entry_src = _resolve_ctp_entry_price( mode, ths_sym, direction, ctp, ) if resolved_entry > 0: entry = resolved_entry elif float(ctp.get("avg_price") or 0) > 0: entry = float(ctp.get("avg_price") or 0) ctp_margin = float(ctp.get("margin") or 0) if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0: margin = ctp_margin if ctp_status(mode).get("connected"): source_label = "CTP 柜台" codes = ths_to_codes(sym) tick = calc_order_tick_metrics(sym, lots, entry, trading_mode=mode) sl = float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None holding = _holding_duration(open_time, now_iso) if open_time else "" if ctp_status(mode).get("connected"): live_mark = ctp_get_tick_price(mode, sym) if live_mark and live_mark > 0: mark = live_mark elif (mark is None or float(mark or 0) <= 0) and not fast and codes: mark = fetch_price( sym, codes.get("market_code", ""), codes.get("sina_code", ""), ) if mark is None or mark <= 0: mark = entry if entry else None close_est = float(mark) if mark and mark > 0 else entry if mark and entry and lots > 0: pos_tmp = calc_position_metrics( direction, entry, sl or entry, tp or entry, lots, mark, capital, sym, ) float_pnl = pos_tmp.get("float_pnl") if ctp and ctp_status(mode).get("connected"): ctp_pnl = float(ctp.get("pnl") or 0) if ctp_pnl != 0: float_pnl = round(ctp_pnl, 2) fee_info = calc_fee_breakdown( sym, entry, close_est, lots, open_time or now_iso, now_iso, trading_mode=mode, ) open_commission = _open_commission_from_ctp_trades(mode, sym, direction) if open_commission is None and mon and mon.get("open_fee") is not None: cached_fee = float(mon.get("open_fee") or 0) if cached_fee > 0: open_commission = cached_fee if open_commission is not None: display_fee = open_commission fee_source = "ctp" else: display_fee = fee_info["open_fee"] fee_source = fee_info.get("fee_source") or "local" est_net = None if float_pnl is not None: est_net = round(float(float_pnl) - fee_info["close_fee"], 2) pos_metrics = calc_position_metrics( direction, entry, sl if sl is not None else entry, tp if tp is not None else entry, lots, mark, capital, sym, ) mon_margin = margin margin, margin_source = _resolve_position_margin( sym=sym, direction=direction, lots=lots, entry=entry, mode=mode, ctp=ctp, mon_margin=mon_margin if isinstance(mon_margin, (int, float)) else None, est_margin=pos_metrics.get("margin"), ) if margin and capital > 0: position_pct = round(float(margin) / float(capital) * 100, 2) elif position_pct is None or float(position_pct or 0) <= 0: position_pct = pos_metrics.get("position_pct") elif position_pct is not None: position_pct = float(position_pct) order_st = monitor_order_status( mon or {}, mode=mode, ths_code=sym, direction=direction, ) pending_for_row: list[dict] = [] if sl is not None: pending_for_row.append({ "order_kind": "stop_loss", "label": "止损监控", "price": sl, "lots": lots, "source": "monitor", "monitor_id": mon["id"] if mon else None, }) if tp is not None: pending_for_row.append({ "order_kind": "take_profit", "label": "止盈监控", "price": tp, "lots": lots, "source": "monitor", "monitor_id": mon["id"] if mon else None, }) row_key = _canonical_position_key( sym, direction, (ctp or {}).get("exchange") or "", ) return { "key": row_key, "position_key": row_key, "source": "ctp", "source_label": source_label, "sync_pending": False, "monitor_id": mon["id"] if mon else None, "symbol_code": sym, **_symbol_display_fields(sym), "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": entry, "stop_loss": sl, "take_profit": tp, "open_time": open_time or None, "open_time_source": open_time_source or None, "holding_duration": holding or None, "mark_price": mark, "current_price": mark, "margin": margin, "margin_source": margin_source, "position_pct": position_pct, "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, "risk_pct": pos_metrics.get("risk_pct") if sl is not None else None, "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, "float_pnl": float_pnl, "est_fee": display_fee, "est_fee_open": display_fee, "est_fee_close": fee_info["close_fee"], "est_fee_close_type": fee_info["close_type"], "fee_source": fee_source, "est_pnl_net": est_net, "sl_order_active": order_st.get("sl_monitoring"), "tp_order_active": order_st.get("tp_monitoring"), "sl_monitoring": order_st.get("sl_monitoring"), "tp_monitoring": order_st.get("tp_monitoring"), "can_place_orders": False, "tick_value_total": tick.get("tick_value_total"), "price_precision": tick.get("price_precision"), "tick_size": tick.get("tick_size"), "can_close": True, "close_allowed": is_trading_session(), "pending_orders": pending_for_row, "trailing_be": bool(mon.get("trailing_be")) if mon else False, "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, "breakeven_locked": _breakeven_locked( entry=entry, stop_loss=sl, direction=direction, tick_size=tick.get("tick_size"), trailing_r_locked=int(mon.get("trailing_r_locked") or 0) if mon else 0, ), } def _compose_pending_row( mon: dict, *, mode: str, capital: float, now_iso: str, ) -> Optional[dict]: sym = (mon.get("symbol") or "").strip() direction = (mon.get("direction") or "long").strip().lower() lots = int(mon.get("lots") or 0) if not sym or lots <= 0: return None order_price = float(mon.get("order_price") or mon.get("entry_price") or 0) codes = ths_to_codes(sym) sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None pos_metrics = calc_position_metrics( direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym, ) open_time = (mon.get("open_time") or "").strip() timeout_sec = get_pending_order_timeout_sec(get_setting) remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec) return { "key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}", "order_state": "pending", "source": "pending", "source_label": "委托挂单中", "sync_pending": True, "monitor_id": mon.get("id"), "symbol_code": sym, **_symbol_display_fields(sym), "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": order_price, "order_price": order_price, "stop_loss": sl, "take_profit": tp, "open_time": open_time or None, "holding_duration": _holding_duration(open_time, now_iso) if open_time else None, "mark_price": order_price, "current_price": order_price, "margin": pos_metrics.get("margin"), "margin_source": "estimate", "position_pct": pos_metrics.get("position_pct"), "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, "float_pnl": None, "est_fee": None, "can_close": False, "close_allowed": False, "can_cancel_order": is_trading_session(), "cancel_allowed": is_trading_session(), "auto_cancel_sec": remain, "pending_timeout_sec": timeout_sec, "pending_timeout_min": max(1, timeout_sec // 60), "vt_order_id": mon.get("vt_order_id"), "sl_order_active": False, "tp_order_active": False, "sl_monitoring": bool(sl is not None), "tp_monitoring": bool(tp is not None), "can_place_orders": False, "pending_orders": [], "trailing_be": bool(mon.get("trailing_be")), "trailing_r_locked": int(mon.get("trailing_r_locked") or 0), } def _compose_ctp_open_order_row( o: dict, *, mode: str, capital: float, now_iso: str, ) -> Optional[dict]: offset_u = (o.get("offset") or "").upper() if offset_u and "OPEN" not in offset_u: return None sym = _ctp_pos_to_ths_code(o) or (o.get("symbol") or "").strip() direction = (o.get("direction") or "long").strip().lower() lots = int(o.get("lots") or 0) if not sym or lots <= 0: return None order_price = float(o.get("price") or 0) pos_metrics = calc_position_metrics( direction, order_price, order_price, order_price, lots, order_price, capital, sym, ) timeout_sec = get_pending_order_timeout_sec(get_setting) return { "key": f"{_canonical_position_key(sym, direction)}:pending:ctp:{o.get('order_id') or ''}", "order_state": "pending", "source": "ctp", "source_label": "委托挂单", "sync_pending": True, "monitor_id": None, "order_id": o.get("order_id"), "vt_order_id": o.get("vt_order_id") or o.get("order_id"), "symbol_code": sym, **_symbol_display_fields(sym), "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": order_price, "order_price": order_price, "stop_loss": None, "take_profit": None, "open_time": now_iso, "holding_duration": None, "mark_price": order_price, "current_price": order_price, "margin": pos_metrics.get("margin"), "margin_source": "estimate", "position_pct": pos_metrics.get("position_pct"), "float_pnl": None, "est_fee": None, "can_close": False, "close_allowed": False, "can_cancel_order": is_trading_session(), "cancel_allowed": is_trading_session(), "pending_timeout_sec": timeout_sec, "pending_timeout_min": max(1, timeout_sec // 60), "sl_order_active": False, "tp_order_active": False, "sl_monitoring": False, "tp_monitoring": False, "can_place_orders": False, "pending_orders": [], "trailing_be": False, "trailing_r_locked": 0, } def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> dict[str, int]: return reconcile_pending_orders( conn, mode, match_symbol_fn=_match_ctp_symbol, sync_monitor_fn=_sync_monitor_from_ctp, capital=capital, list_positions_fn=_ctp_positions, timeout_sec=get_pending_order_timeout_sec(get_setting), ) def _build_active_orders( conn, *, mode: str, capital: float, now_iso: str, ) -> list[dict]: """当前委托:CTP 已连接时读柜台;未连接时不展示本地 pending。""" orders: list[dict] = [] seen_keys: set[str] = set() connected = ctp_status(mode).get("connected") if connected: ctp_orders = trading_state.get_active_orders() if not ctp_orders: ctp_orders = _ctp_active_orders(mode) for o in ctp_orders: try: row = _compose_ctp_open_order_row( o, mode=mode, capital=capital, now_iso=now_iso, ) if not row: row = _compose_ctp_order_row_any( o, mode=mode, capital=capital, now_iso=now_iso, ) if row: orders.append(row) seen_keys.add(row.get("key") or "") except Exception as exc: logger.warning("compose ctp order row failed: %s", exc) ctp_active_map: dict[str, dict] = {} for o in ctp_orders or []: for key in (o.get("order_id"), o.get("vt_order_id")): if key: ctp_active_map[str(key)] = o for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" ).fetchall(): mon = dict(r) try: if not pending_monitor_has_live_order( mon, active_orders=ctp_active_map, active_order_list=ctp_orders or [], ): continue 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 ) 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( o: dict, *, mode: str, capital: float, now_iso: str, ) -> Optional[dict]: """CTP 任意未成交委托(含平仓)。""" sym = _ctp_pos_to_ths_code(o) or (o.get("symbol") or "").strip() direction = (o.get("direction") or "long").strip().lower() lots = int(o.get("lots") or 0) if not sym or lots <= 0: return None offset_u = (o.get("offset") or "").upper() is_open = not offset_u or "OPEN" in offset_u order_price = float(o.get("price") or 0) pos_metrics = calc_position_metrics( direction, order_price, order_price, order_price, lots, order_price, capital, sym, ) label = "开仓委托" if is_open else "平仓委托" timeout_sec = get_pending_order_timeout_sec(get_setting) ex = o.get("exchange") or "" pk = _canonical_position_key(sym, direction, ex) return { "key": f"{pk}:order:{o.get('order_id') or ''}", "order_state": "pending", "source": "ctp", "source_label": label, "sync_pending": False, "monitor_id": None, "order_id": o.get("order_id"), "vt_order_id": o.get("vt_order_id") or o.get("order_id"), "symbol_code": sym, **_symbol_display_fields(sym), "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": order_price, "order_price": order_price, "stop_loss": None, "take_profit": None, "open_time": now_iso, "mark_price": order_price, "current_price": order_price, "margin": pos_metrics.get("margin"), "margin_source": "estimate", "position_pct": pos_metrics.get("position_pct"), "float_pnl": None, "can_close": False, "close_allowed": False, "can_cancel_order": is_trading_session(), "cancel_allowed": is_trading_session(), "pending_timeout_sec": timeout_sec if is_open else None, "pending_timeout_min": max(1, timeout_sec // 60) if is_open else None, "sl_order_active": False, "tp_order_active": False, "sl_monitoring": False, "tp_monitoring": False, "can_place_orders": False, "pending_orders": [], "trailing_be": False, "trailing_r_locked": 0, } def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]: """当前持仓:以 CTP 为准,SQLite 仅叠加 SL/TP 元数据。""" from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") mode = get_trading_mode(get_setting) capital = _capital(conn) ctp_list: list[dict] = [] if ctp_status(mode).get("connected"): merged: dict[str, dict] = {} for p in list(_ctp_positions(mode) or []) + list(trading_state.get_positions() or []): lots = int(p.get("lots") or 0) if lots <= 0: continue pk = p.get("position_key") or _position_key_from_ctp(p) merged[pk] = p ctp_list = list(merged.values()) ensure_monitor_order_columns(conn) monitor_by_pk = _monitors_by_position_key(conn) rows: list[dict] = [] for p in ctp_list: lots = int(p.get("lots") or 0) if lots <= 0: continue pk = p.get("position_key") or _position_key_from_ctp(p) mon = monitor_by_pk.get(pk) if not mon: for mk, mv in monitor_by_pk.items(): if (mv.get("direction") or "long") != (p.get("direction") or "long"): continue if _match_ctp_symbol(p.get("symbol") or "", mv.get("symbol") or ""): mon = mv break ths = _ctp_pos_to_ths_code(p) or (p.get("symbol") or "") direction = p.get("direction") or "long" if not mon: mon = _find_pending_monitor(conn, ths, direction) if not mon: if fast: mon = _find_active_monitor(conn, ths, direction) else: mon = _find_or_revive_monitor(conn, ths, direction) if mon: if fast: mon = _overlay_sl_tp_readonly(conn, mon, ths, direction) or mon else: mon = _restore_monitor_sl_tp_if_missing(conn, mon, ths, direction) or mon _sync_monitor_from_ctp( conn, int(mon["id"]), mon.get("symbol") or ths, mon.get("direction") or direction, mode, ctp=p, capital=capital, ) mon = _find_active_monitor( conn, mon.get("symbol") or ths, mon.get("direction") or direction, ) or mon mon = _restore_monitor_sl_tp_if_missing(conn, mon, ths, direction) or mon elif fast: mon = _overlay_sl_tp_readonly(conn, None, ths, direction) try: row = _compose_position_row( conn, mon=mon, ctp=p, mode=mode, capital=capital, now_iso=now_iso, fast=fast, ) if row: rows.append(row) except Exception as exc: logger.warning("compose ctp position row failed: %s", exc) seen: set[str] = set() deduped: list[dict] = [] for row in rows: rk = row.get("key") or row.get("position_key") or "" if rk in seen: continue seen.add(rk) deduped.append(row) if not deduped and ctp_status(mode).get("connected"): margin_raw = ctp_account_margin_used(mode) margin_used = float(margin_raw or 0) if margin_raw is not None else 0.0 has_margin_hint = margin_raw is not None and margin_used > 0 has_active_mon = any( int(m.get("lots") or 0) > 0 for m in monitor_by_pk.values() ) since_connect = 9999.0 try: since_connect = time.time() - float( getattr(get_bridge(), "_last_connect_ok_ts", 0) or 0, ) except Exception: pass if has_margin_hint or has_active_mon or since_connect < 300: if not monitor_by_pk and has_margin_hint: _ensure_monitors_from_sticky_state(conn, mode) monitor_by_pk = _monitors_by_position_key(conn) for mon in monitor_by_pk.values(): lots = int(mon.get("lots") or 0) if lots <= 0: continue sym = (mon.get("symbol") or "").strip() direction = (mon.get("direction") or "long").strip().lower() if fast: mon = _overlay_sl_tp_readonly(conn, mon, sym, direction) or mon else: mon = ( _restore_monitor_sl_tp_if_missing(conn, mon, sym, direction) or mon ) try: row = _compose_position_row( conn, mon=mon, ctp=None, mode=mode, capital=capital, now_iso=now_iso, fast=fast, ) if not row: continue rk = row.get("key") or row.get("position_key") or "" if rk and rk in seen: continue if rk: seen.add(rk) deduped.append(row) except Exception as exc: logger.warning("compose monitor fallback row failed: %s", exc) if not deduped and ctp_status(mode).get("connected"): for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall(): mon = dict(r) lots = int(mon.get("lots") or 0) if lots <= 0: continue sym = (mon.get("symbol") or "").strip() direction = (mon.get("direction") or "long").strip().lower() rk = _monitor_position_key(mon) if rk in seen: continue if fast: mon = _overlay_sl_tp_readonly(conn, mon, sym, direction) or mon try: row = _compose_position_row( conn, mon=mon, ctp=None, mode=mode, capital=capital, now_iso=now_iso, fast=fast, ) if not row: continue row_key = row.get("key") or row.get("position_key") or rk if row_key in seen: continue seen.add(row_key) deduped.append(row) except Exception as exc: logger.warning("compose active monitor row failed: %s", exc) return deduped def _build_trading_live_payload(conn, *, fast: bool = False) -> dict: from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) if ctp_st.get("connected") and not fast: _reconcile_pending(conn, mode, capital=capital) if ctp_st.get("connected"): if not fast: _ensure_monitors_from_ctp(conn, mode) _sync_trade_monitors_with_ctp(conn, mode) elif count_active_trade_monitors(conn) == 0: margin_raw = ctp_account_margin_used(mode) if margin_raw is not None and float(margin_raw) > 0: _ensure_monitors_from_sticky_state(conn, mode) if not fast: _close_stale_roll_groups(conn) rows = _build_trading_live_rows(conn, fast=fast) active_orders = _build_active_orders( conn, mode=mode, capital=capital, now_iso=now_iso, ) rows = _apply_account_margin_to_rows(rows, mode, capital) if not fast: _persist_ctp_snapshot_to_monitors(conn, rows, mode) pending_orders = _build_pending_orders(conn, mode) risk = get_risk_status( conn, active_count=_effective_active_position_count(conn, mode), equity=capital, ) margin_used = ( ctp_account_margin_used(mode) if ctp_st.get("connected") else None ) display_sync_state = "ready" if rows else trading_state.sync_state display_sync_label = "已同步" if rows else trading_state.sync_label() return { "ok": True, "rows": rows, "active_orders": active_orders, "pending_orders": pending_orders, "capital": capital, "margin_used": margin_used, "ctp_status": ctp_st, "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, "trading_session": is_trading_session(), "night_session": is_night_trading_session(), "session_clock": trading_session_clock(), "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), "sync_state": display_sync_state, "sync_label": display_sync_label, } def _minimal_live_payload(conn) -> dict: """CTP 直出兜底:不跑对账/写库,避免与后台 worker 争锁。""" from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) rows: list[dict] = [] if ctp_st.get("connected"): for p in _ctp_positions(mode, refresh_if_empty=False): lots = int(p.get("lots") or 0) if lots <= 0: continue ths = _ctp_pos_to_ths_code(p) or (p.get("symbol") or "") direction = p.get("direction") or "long" mon = {"symbol": ths, "direction": direction} try: row = _compose_position_row( conn, mon=mon, ctp=p, mode=mode, capital=capital, now_iso=now_iso, fast=True, ) if row: rows.append(row) except Exception as exc: logger.warning("minimal live row failed: %s", exc) risk = get_risk_status( conn, active_count=_effective_active_position_count(conn, mode), equity=capital, ) return { "ok": True, "rows": rows, "active_orders": [], "pending_orders": [], "capital": capital, "ctp_status": ctp_st, "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, "trading_session": is_trading_session(), "night_session": is_night_trading_session(), "session_clock": trading_session_clock(), "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), "sync_state": trading_state.sync_state, "sync_label": trading_state.sync_label(), } def _normalize_live_payload(payload: dict) -> dict: if payload.get("rows"): payload = dict(payload) payload["sync_state"] = "ready" payload["sync_label"] = "已同步" return payload def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict: def _build() -> dict: mode = get_trading_mode(get_setting) if ctp_status(mode).get("connected") and not fast: try: with _ctp_td_lock: get_bridge().calibrate_trading_state() except Exception as exc: logger.debug("refresh calibrate: %s", exc) for p in trading_state.get_positions() or _ctp_positions(mode, refresh_if_empty=False): ths = _ctp_pos_to_ths_code(p) if ths: try: get_bridge().subscribe_symbol(ths) except Exception: pass conn = get_db() try: init_strategy_tables(conn) if not fast: ensure_monitor_order_columns(conn, migrate=True) payload = _build_trading_live_payload(conn, fast=fast) commit_retry(conn) prev = position_hub.get_snapshot() active_n = int((payload.get("risk_status") or {}).get("active_count") or 0) if ( prev and ctp_status(mode).get("connected") and not (payload.get("rows") or []) and (prev.get("rows") or []) ): margin_raw = payload.get("margin_used") if margin_raw is None: margin_raw = ctp_account_margin_used(mode) margin_used_val = float(margin_raw or 0) if margin_raw is not None else 0.0 if ( (margin_raw is not None and margin_used_val > 0) or trading_state.sync_state == "syncing" or active_n > 0 ): payload = dict(payload) payload["rows"] = prev["rows"] if trading_state.sync_state == "syncing": payload["sync_state"] = "syncing" payload["sync_label"] = "同步中…" elif ( ctp_status(mode).get("connected") and not (payload.get("rows") or []) and active_n > 0 ): payload = dict(payload) payload["rows"] = _build_trading_live_rows(conn, fast=fast) elif ctp_status(mode).get("connected") and not (payload.get("rows") or []): since_connect = time.time() - float( getattr(get_bridge(), "_last_connect_ok_ts", 0) or 0, ) if since_connect < 180: payload = dict(payload) payload["sync_state"] = "syncing" payload["sync_label"] = "持仓同步中…" return _normalize_live_payload(payload) finally: conn.close() if fast: if _live_refresh_lock.acquire(blocking=False): try: return _build() finally: _live_refresh_lock.release() snap = position_hub.get_snapshot() if snap: return snap if _live_refresh_lock.acquire(timeout=2.0): try: return _build() finally: _live_refresh_lock.release() conn = get_db() try: init_strategy_tables(conn) return _minimal_live_payload(conn) finally: conn.close() with _live_refresh_lock: return _build() def _push_position_snapshot_async(*, fast: bool = True) -> None: def _run() -> None: try: payload = _refresh_trading_live_snapshot(fast=fast) position_hub.broadcast("positions", payload) conn = get_db() try: rec = _recommend_payload(conn) recommend_hub.broadcast("recommend", {"ok": True, **rec}) finally: conn.close() except Exception as exc: logger.debug("push position snapshot: %s", exc) threading.Thread(target=_run, daemon=True).start() def _build_position_quotes_payload(mode: str) -> dict: """轻量现价/浮盈(仅读 tick 缓存,不走 SQLite)。""" if not ctp_status(mode).get("connected"): return {"ok": True, "quotes": []} from contract_specs import get_contract_spec positions = trading_state.get_positions() if not positions: positions = _ctp_positions(mode, refresh_if_empty=False) quotes: list[dict] = [] for p in positions: lots = int(p.get("lots") or 0) if lots <= 0: continue ths = _ctp_pos_to_ths_code(p) or (p.get("symbol") or "") if not ths: continue direction = (p.get("direction") or "long").strip().lower() mark = ctp_get_tick_price(mode, ths) if not mark or mark <= 0: continue entry, _ = _resolve_ctp_entry_price( mode, ths, direction, p, ) if entry <= 0: continue mult = float(get_contract_spec(ths).get("mult") or 10) ctp_pnl = float(p.get("pnl") or 0) if ctp_pnl != 0: float_pnl = round(ctp_pnl, 2) elif direction == "long": float_pnl = round((mark - entry) * mult * lots, 2) else: float_pnl = round((entry - mark) * mult * lots, 2) row_key = _canonical_position_key( ths, direction, (p.get("exchange") or ""), ) quotes.append({ "key": row_key, "position_key": row_key, "mark_price": mark, "current_price": mark, "float_pnl": float_pnl, }) return {"ok": True, "quotes": quotes} def _push_position_quotes_async() -> None: def _run() -> None: try: if not is_trading_session(): return mode = get_trading_mode(get_setting) if trading_state.try_lock_entry_prices(): _push_position_snapshot_async(fast=False) return payload = _build_position_quotes_payload(mode) if payload.get("quotes"): position_hub.push_event("position_quotes", payload) except Exception as exc: logger.debug("push position quotes: %s", exc) threading.Thread(target=_run, daemon=True, name="position-quotes").start() def _on_tick_sl_tp(exchange: str, symbol: str, price: float) -> None: from sl_tp_guard import check_sl_tp_on_tick from db_conn import DB_PATH, connect_db mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected"): return conn = connect_db(DB_PATH) try: _init_tables(conn) capital = _capital(conn) n = check_sl_tp_on_tick( conn, mode, exchange, symbol, price, capital=capital, notify_fn=send_wechat_msg, be_tick_mult=get_trailing_be_tick_buffer(get_setting), ) if n: conn.commit() _push_position_snapshot_async(fast=True) except Exception as exc: logger.debug("tick sl/tp: %s", exc) finally: conn.close() def _prime_position_snapshot() -> None: """进程启动同步预热:优先写入持仓/权益快照,页面打开即可读。""" try: payload = _refresh_trading_live_snapshot(fast=True) position_hub.set_snapshot(payload) n = len(payload.get("rows") or []) logger.info( "持仓快照已预热 capital=%s rows=%d", payload.get("capital"), n, ) except Exception as exc: logger.warning("prime position snapshot: %s", exc) def _bootstrap_trading_runtime() -> None: """进程启动:并发预热持仓快照 + CTP 连接,不阻塞 HTTP 监听。""" set_position_refresh_callback( lambda: _push_position_snapshot_async(fast=True) ) set_tick_quote_callback(_push_position_quotes_async) set_tick_sl_tp_callback(_on_tick_sl_tp) set_ctp_connected_callback(_on_ctp_connected) def _warm() -> None: try: payload = _refresh_trading_live_snapshot(fast=True) position_hub.set_snapshot(payload) position_hub.broadcast("positions", payload) mode = get_trading_mode(get_setting) if ctp_status(mode).get("connected"): try: with _ctp_td_lock: get_bridge().calibrate_trading_state() get_bridge().request_position_snapshot(force=True) except Exception as exc: logger.debug("bootstrap calibrate: %s", exc) payload = _refresh_trading_live_snapshot(fast=True) position_hub.set_snapshot(payload) position_hub.broadcast("positions", payload) def _slow_sync() -> None: time.sleep(20) try: pl = _refresh_trading_live_snapshot(fast=False) position_hub.set_snapshot(pl) position_hub.broadcast("positions", pl) except Exception as exc: logger.warning("bootstrap slow sync: %s", exc) threading.Thread(target=_slow_sync, daemon=True, name="boot-slow-sync").start() except Exception as exc: logger.warning("bootstrap position snapshot: %s", exc) def _start_ctp() -> None: try: from ctp_premarket_connect import should_auto_connect_now from vnpy_bridge import ctp_start_connect if should_auto_connect_now(): mode = get_trading_mode(get_setting) ctp_start_connect(mode, force=False, scheduled=True) except Exception as exc: logger.debug("bootstrap ctp connect: %s", exc) from concurrent.futures import ThreadPoolExecutor workers = max(2, int(os.getenv("QIHUO_STARTUP_WORKERS", "8") or 8)) with ThreadPoolExecutor(max_workers=min(workers, 4), thread_name_prefix="boot") as pool: pool.submit(_warm) pool.submit(_start_ctp) def _on_ctp_connected(mode: str) -> None: if mode != get_trading_mode(get_setting): return _schedule_recommend_refresh() _push_position_snapshot_async(fast=True) def _after_connect() -> None: try: try: with _ctp_td_lock: get_bridge().request_position_snapshot(force=True) get_bridge().calibrate_trading_state() except Exception as exc: logger.debug("ctp connected calibrate: %s", exc) _push_position_snapshot_async(fast=True) conn = get_db() try: init_strategy_tables(conn) _ensure_monitors_from_ctp(conn, mode) commit_retry(conn) finally: conn.close() _push_position_snapshot_async(fast=False) except Exception as exc: logger.debug("ctp connected monitor restore: %s", exc) threading.Thread(target=_after_connect, daemon=True, name="ctp-monitor-restore").start() @app.route("/trade") @login_required def trade_page(): return redirect(url_for("positions")) @app.route("/positions") @login_required def positions(): conn = get_db() try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) recommend_capital = _recommend_capital(conn) risk = get_risk_status( conn, active_count=_effective_active_position_count(conn, mode), equity=capital, ) 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" ).fetchone() monitor_count = conn.execute( "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" ).fetchone()["n"] roll_count = conn.execute( "SELECT COUNT(*) AS n FROM roll_groups WHERE status='active'" ).fetchone()["n"] conn.commit() sizing = get_sizing_mode(get_setting) max_pct = get_max_margin_pct(get_setting) rec_cache = _recommend_payload(conn) if rec_cache.get("needs_refresh"): _schedule_recommend_refresh() ctp_connected = is_ctp_connected(get_setting) margin_rec = small_account_margin_recommendations() bootstrap_live = position_hub.get_snapshot() if not bootstrap_live: bootstrap_live = { "ok": True, "rows": [], "active_orders": [], "pending_orders": [], "capital": capital, "ctp_status": dict(ctp_st), "risk_status": risk, "trading_session": is_trading_session(), "night_session": is_night_trading_session(), "session_clock": trading_session_clock(), "sync_state": trading_state.sync_state, "sync_label": trading_state.sync_label(), } else: bootstrap_live = dict(bootstrap_live) bootstrap_live.setdefault("capital", capital) bootstrap_live.setdefault("risk_status", risk) bootstrap_live["ctp_status"] = dict(ctp_st) return render_template( "trade.html", trading_mode=mode, trading_mode_label=trading_mode_label(get_setting), capital=capital, recommend_capital=recommend_capital, risk_status=risk, ctp_status=ctp_st, ctp_account=ctp_acc, active_trend=dict(active_trend) if active_trend else None, monitor_count=monitor_count, roll_count=roll_count, sizing_mode=sizing, sizing_mode_label=_sizing_mode_label(sizing), fixed_lots=get_fixed_lots(get_setting), fixed_amount=get_fixed_amount(get_setting), risk_percent=get_risk_percent(get_setting), max_margin_pct=get_max_margin_pct(get_setting), pending_order_timeout_min=get_pending_order_timeout_min(get_setting), ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting), recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), night_session=is_night_trading_session(), small_account_scope=should_apply_small_account_scope( capital, ctp_connected=ctp_connected, ), small_account_scope_hint=small_account_scope_hint(ctp_connected=ctp_connected), small_account_margin_rec=margin_rec if should_apply_small_account_scope( capital, ctp_connected=ctp_connected, ) else None, session_clock=trading_session_clock(), roll_max_margin_pct=get_roll_max_margin_pct(get_setting), product_categories=PRODUCT_CATEGORIES, bootstrap_live=bootstrap_live, ) finally: conn.close() @app.route("/recommend") @login_required def recommend_page(): return redirect(url_for("positions") + "#recommend") @app.route("/api/trading/live") @login_required def api_trading_live(): snap = position_hub.get_snapshot() if snap: return jsonify(_normalize_live_payload(snap)) payload = _refresh_trading_live_snapshot(fast=True) payload = _normalize_live_payload(payload) position_hub.set_snapshot(payload) return jsonify(payload) @app.route("/api/trading/stream") @login_required def api_trading_stream(): from queue import Empty def generate(): q = position_hub.subscribe() try: snap = position_hub.get_snapshot() if snap: yield sse_format("positions", snap) else: payload = _refresh_trading_live_snapshot(fast=True) position_hub.set_snapshot(payload) yield sse_format("positions", payload) while True: try: msg = q.get(timeout=25) yield sse_format(msg["event"], msg["data"]) except Empty: yield ": heartbeat\n\n" finally: position_hub.unsubscribe(q) return Response( generate(), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) @app.route("/api/trading/monitor/upsert", methods=["POST"]) @login_required def api_trading_monitor_upsert(): """为已有持仓补充/更新本地止盈止损监控。""" d = request.get_json(silent=True) or {} sym = (d.get("symbol_code") or d.get("symbol") or "").strip() direction = (d.get("direction") or "long").strip().lower() try: lots = max(1, int(d.get("lots") or 1)) entry = float(d.get("entry_price") or d.get("entry") or 0) sl = float(d["stop_loss"]) if d.get("stop_loss") not in (None, "") else None tp = float(d["take_profit"]) if d.get("take_profit") not in (None, "") else None except (TypeError, ValueError, KeyError): return jsonify({"ok": False, "error": "参数无效"}), 400 if not sym: return jsonify({"ok": False, "error": "缺少品种代码"}), 400 if sl is None and tp is None: return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400 trailing_on = bool(d.get("trailing_be")) if trailing_on and sl is None: return jsonify({"ok": False, "error": "移动保本须填写止损价"}), 400 if trailing_on: tp = None mode = get_trading_mode(get_setting) conn = get_db() try: init_strategy_tables(conn) mon = _find_active_monitor(conn, sym, direction) has_pos = bool(mon) ths_sym = sym if ctp_status(mode).get("connected"): for p in _ctp_positions(mode, refresh_if_empty=False): 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 lots = int(p.get("lots") or lots) entry = float(p.get("avg_price") or entry or 0) ths_sym = _ctp_pos_to_ths_code(p) or sym break if not has_pos: return jsonify({"ok": False, "error": "未找到对应持仓"}), 400 trailing_be = 1 if trailing_on else ( int(mon.get("trailing_be") or 0) if mon else 0 ) mid = _upsert_open_monitor( conn, sym=ths_sym, direction=direction, lots=lots, price=entry, sl=sl, tp=tp, trailing_be=trailing_be, ) if trailing_on and sl is not None: conn.execute( """UPDATE trade_order_monitors SET take_profit=NULL, initial_stop_loss=?, trailing_r_locked=0 WHERE id=?""", (sl, mid), ) conn.commit() _push_position_snapshot_async(fast=False) return jsonify({ "ok": True, "monitor_id": mid, "message": "止盈止损已保存,程序本地监控", }) finally: conn.close() @app.route("/api/trading/monitor/place-orders", methods=["POST"]) @login_required def api_trading_monitor_place_orders(): """本地监控模式:清理旧版柜台挂单,不再向交易所挂止盈止损。""" d = request.get_json(silent=True) or {} try: monitor_id = int(d.get("monitor_id") or 0) except (TypeError, ValueError): monitor_id = 0 conn = get_db() try: init_strategy_tables(conn) ensure_monitor_order_columns(conn) mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected"): return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 mon = None if monitor_id > 0: row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (monitor_id,), ).fetchone() mon = dict(row) if row else None if not mon: sym = (d.get("symbol_code") or "").strip() direction = (d.get("direction") or "long").strip().lower() for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active'" ).fetchall(): row = dict(r) if row.get("direction") != direction: continue if _match_ctp_symbol(sym, row.get("symbol") or ""): mon = row break if not mon: return jsonify({"ok": False, "error": "未找到有效监控快照"}), 404 result = place_monitor_exit_orders( conn, mon, mode=mode, force=bool(d.get("force")), ) if not result.get("ok"): return jsonify(result), 400 return jsonify(result) 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) mode = get_trading_mode(get_setting) row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')", (monitor_id,), ).fetchone() if not row: return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404 mon = dict(row) if (mon.get("status") or "").strip().lower() == "pending": if not is_trading_session(): return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 ok, msg = cancel_pending_monitor(conn, mon, mode) _push_position_snapshot_async(fast=False) return jsonify({"ok": ok, "message": msg}) conn.execute( "UPDATE trade_order_monitors SET status='closed' WHERE id=?", (monitor_id,), ) conn.commit() _push_position_snapshot_async(fast=False) return jsonify({"ok": True, "message": "已取消本地止盈止损监控"}) finally: conn.close() @app.route("/api/trading/monitor/cancel-open", methods=["POST"]) @login_required def api_trading_monitor_cancel_open(): """撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。""" 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) mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected"): return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 if not is_trading_session(): return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'", (monitor_id,), ).fetchone() if not row: return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404 ok, msg = cancel_pending_monitor(conn, dict(row), mode) _push_position_snapshot_async(fast=False) return jsonify({"ok": ok, "message": msg}) finally: conn.close() @app.route("/api/trading/order/cancel", methods=["POST"]) @login_required def api_trading_order_cancel(): """撤销柜台未成交委托(按 vt_order_id)。""" d = request.get_json(silent=True) or {} order_id = (d.get("order_id") or "").strip() if not order_id: return jsonify({"ok": False, "error": "无效的委托号"}), 400 mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected"): return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 if not is_trading_session(): return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 ok = ctp_cancel_order(mode, order_id) _push_position_snapshot_async(fast=False) if not ok: return jsonify({"ok": False, "error": "撤单失败,委托可能已成交或已撤销"}), 400 return jsonify({"ok": True, "message": "撤单已提交"}) @app.route("/api/trading/close", methods=["POST"]) @login_required def api_trading_close(): d = request.get_json(silent=True) or {} source = (d.get("source") or "").strip() conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected") and source in ("ctp", "program"): conn.close() return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 sym = (d.get("symbol_code") or d.get("symbol") or "").strip() direction = (d.get("direction") or "long").strip().lower() try: lots = max(1, int(d.get("lots") or 1)) price = float(d.get("price") or 0) except (TypeError, ValueError): conn.close() return jsonify({"ok": False, "error": "参数无效"}), 400 if not sym or price <= 0: conn.close() return jsonify({"ok": False, "error": "品种或价格无效"}), 400 offset = "close_long" if direction == "long" else "close_short" capital = _capital(conn) mon = None mid = int(d.get("monitor_id") or 0) if mid: row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mid,), ).fetchone() if row: mon = dict(row) if not mon: for r in conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active'" ).fetchall(): row = dict(r) if row.get("direction") != direction: continue if _match_ctp_symbol(sym, row.get("symbol") or ""): mon = row mid = int(row["id"]) break entry = float(mon.get("entry_price") or 0) if mon else 0.0 if entry <= 0: 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): entry = float(p.get("avg_price") or price) break try: execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), order_type="market", ) # 始终写本地记录:CTP 同步依赖内存开平配对,重启后或成交回报延迟时会漏记 write_manual_close_trade_log( conn, mon, symbol=sym, direction=direction, lots=lots, close_price=price, entry_price=entry or price, trading_mode=mode, capital=capital, stop_loss=float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None, take_profit=float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None, open_time=(mon.get("open_time") or "") if mon else "", symbol_name=(mon.get("symbol_name") or "") if mon else "", market_code=(mon.get("market_code") or "") if mon else "", ) _close_all_monitors_for_sym_dir(conn, sym, direction) conn.commit() try: from ctp_trade_sync import sync_trade_logs_from_ctp sync_trade_logs_from_ctp(conn, mode, capital=capital, trading_mode=mode) conn.commit() except Exception as exc: logger.debug("sync trades after close: %s", exc) conn.close() _push_position_snapshot_async() return jsonify({"ok": True, "message": "已平仓,交易记录已写入"}) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 def _roll_ui_modes(): return frozenset({ADD_MODE_MARKET, ADD_MODE_BREAKOUT}) def _cached_ctp_status(mode: str) -> dict: """页面渲染优先读持仓快照里的 CTP 状态,避免每次打 worker IPC。""" try: snap = position_hub.get_snapshot() or {} st = snap.get("ctp_status") if isinstance(st, dict) and st: return dict(st) except Exception: pass return dict(ctp_status(mode) or {}) def _roll_filled_lots_map(conn, group_ids: list[int]) -> dict[int, int]: if not group_ids: return {} placeholders = ",".join("?" * len(group_ids)) rows = conn.execute( f"""SELECT roll_group_id, COALESCE(SUM(lots), 0) AS n FROM roll_legs WHERE roll_group_id IN ({placeholders}) AND status=? GROUP BY roll_group_id""", (*group_ids, LEG_STATUS_FILLED), ).fetchall() return {int(r["roll_group_id"]): int(r["n"] or 0) for r in rows} def _build_roll_context(conn) -> dict: has_trend = bool(conn.execute( "SELECT 1 FROM trend_pullback_plans WHERE status='active' LIMIT 1", ).fetchone()) groups_by_monitor: dict[int, dict] = {} pending_monitors: set[int] = set() for row in conn.execute( "SELECT * FROM roll_groups WHERE status='active'", ).fetchall(): g = dict(row) mid = int(g.get("order_monitor_id") or 0) if mid: groups_by_monitor[mid] = g for row in conn.execute( """SELECT g.order_monitor_id FROM roll_legs l JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.status=? AND g.status='active'""", (LEG_STATUS_PENDING,), ).fetchall(): mid = int(row["order_monitor_id"] or 0) if mid: pending_monitors.add(mid) return { "has_trend": has_trend, "groups_by_monitor": groups_by_monitor, "pending_monitors": pending_monitors, } def _roll_eligibility_with_ctx(conn, mon: dict, ctx: dict) -> Optional[str]: mid = int(mon["id"]) grp = ctx["groups_by_monitor"].get(mid) legs_done = int(grp.get("leg_count") or 0) if grp else 0 return roll_eligibility_error( sizing_mode=get_sizing_mode(get_setting), monitor=mon, has_active_trend=ctx["has_trend"], legs_done=legs_done, has_pending_leg=mid in ctx["pending_monitors"], ) def _enrich_roll_group_row_fast(row: dict, filled_map: dict[int, int]) -> dict: out = dict(row) lots = float(out.get("mon_lots") or 0) entry = float(out.get("mon_entry") or 0) tp = float(out.get("mon_tp") or out.get("initial_take_profit") or 0) direction = (out.get("direction") or "long").strip().lower() sym = (out.get("symbol") or "").strip() mult = int(get_contract_spec(sym).get("mult") or 1) if sym else 1 gid = int(out.get("id") or 0) filled_add_lots = int(filled_map.get(gid) or 0) out["add_lots_filled"] = filled_add_lots out["first_lots"] = max(0, int(lots) - filled_add_lots) out["total_lots"] = int(lots) out["avg_entry"] = round(entry, 4) if entry > 0 else None if lots > 0 and entry > 0 and tp > 0: if direction == "long": out["reward_at_tp"] = round((tp - entry) * lots * mult, 2) else: out["reward_at_tp"] = round((entry - tp) * lots * mult, 2) else: out["reward_at_tp"] = None return out def _enrich_roll_group_row(conn, row: dict) -> dict: gid = int(row.get("id") or 0) filled_map = _roll_filled_lots_map(conn, [gid]) if gid > 0 else {} return _enrich_roll_group_row_fast(row, filled_map) def _archive_roll_group( conn, grp: dict, *, result_label: str = "持仓已结束", ) -> None: from zoneinfo import ZoneInfo now_s = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") gid = int(grp.get("id") or 0) if gid <= 0: return if conn.execute( "SELECT 1 FROM strategy_trade_snapshots WHERE strategy_type=? AND source_id=? LIMIT 1", (STRATEGY_ROLL, gid), ).fetchone(): conn.execute( "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=?", (now_s, gid), ) return legs = [ dict(r) for r in conn.execute( "SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY id", (gid,), ).fetchall() ] mon = None mid = int(grp.get("order_monitor_id") or 0) if mid: row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() mon = dict(row) if row else None payload = { "group": dict(grp), "legs": legs, "monitor": mon, } save_snapshot( conn, strategy_type=STRATEGY_ROLL, source_id=gid, symbol=grp.get("symbol") or (mon or {}).get("symbol") or "", direction=grp.get("direction") or (mon or {}).get("direction") or "", result_label=result_label, payload=payload, opened_at=grp.get("created_at") or "", ) conn.execute( "UPDATE roll_legs SET status=? WHERE roll_group_id=? AND status=?", (LEG_STATUS_CANCELLED, gid, LEG_STATUS_PENDING), ) conn.execute( "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=?", (now_s, gid), ) def _close_stale_roll_groups(conn) -> int: rows = conn.execute( """SELECT g.*, m.status AS monitor_status FROM roll_groups g LEFT JOIN trade_order_monitors m ON m.id = g.order_monitor_id WHERE g.status='active' AND (m.id IS NULL OR m.status != 'active')""" ).fetchall() for r in rows: _archive_roll_group(conn, dict(r), result_label="持仓已结束") return len(rows) def _enrich_roll_leg_row(row: dict, mode: str) -> dict: out = dict(row) sym = (out.get("symbol") or "").strip() mark = _cached_position_mark(sym, out.get("direction") or "") if sym else None out["current_price"] = round(float(mark), 4) if mark and mark > 0 else None return out def _enrich_roll_record_row(conn, row: dict) -> dict: out = dict(row) snap = out.get("snapshot") or {} group = snap.get("group") or {} legs = snap.get("legs") or [] monitor = snap.get("monitor") or {} filled_legs = [ l for l in legs if (l.get("status") or "").strip().lower() == LEG_STATUS_FILLED ] add_lots = sum(int(l.get("lots") or 0) for l in filled_legs) total_lots = int((monitor or {}).get("lots") or 0) first_lots = max(0, total_lots - add_lots) latest_sl = ( group.get("current_stop_loss") or (monitor or {}).get("stop_loss") or None ) close_log = None try: close_log = conn.execute( """SELECT close_price, pnl, pnl_net, close_time, lots FROM trade_logs WHERE lower(symbol)=lower(?) AND direction=? ORDER BY close_time DESC, id DESC LIMIT 1""", (out.get("symbol") or "", out.get("direction") or ""), ).fetchone() except Exception: close_log = None close_d = dict(close_log) if close_log else {} out["detail"] = { "first_lots": first_lots if first_lots > 0 else None, "add_count": len(filled_legs), "add_lots": add_lots, "total_lots": total_lots if total_lots > 0 else None, "latest_stop_loss": latest_sl, "close_price": close_d.get("close_price"), "close_time": close_d.get("close_time") or out.get("closed_at"), "pnl": close_d.get("pnl_net") if close_d.get("pnl_net") is not None else close_d.get("pnl"), "legs": filled_legs, "monitor": monitor, "group": group, } return out def _roll_leg_trigger_price(leg: dict): for key in ("breakthrough_price", "limit_price", "fill_price"): val = leg.get(key) if val not in (None, "", 0): return val return None @app.route("/strategy") @login_required @_nav("strategy") def strategy_page(): conn = get_db() try: init_strategy_tables(conn) ensure_monitor_order_columns(conn) capital = _capital(conn) active_trend = conn.execute( "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" ).fetchone() monitors_raw = conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall() mode = get_trading_mode(get_setting) roll_ctx = _build_roll_context(conn) roll_groups = conn.execute( """SELECT g.*, m.symbol_name, m.lots AS mon_lots, m.entry_price AS mon_entry, m.take_profit AS mon_tp FROM roll_groups g LEFT JOIN trade_order_monitors m ON m.id = g.order_monitor_id WHERE g.status='active' ORDER BY g.id DESC""" ).fetchall() roll_legs = conn.execute( """SELECT l.*, g.symbol, g.direction, g.order_monitor_id FROM roll_legs l JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.status=? AND g.status='active' ORDER BY l.id DESC LIMIT 30""", (LEG_STATUS_PENDING,), ).fetchall() sizing = get_sizing_mode(get_setting) roll_allowed = sizing == MODE_AMOUNT monitors = [] for m in monitors_raw: row = dict(m) err = _roll_eligibility_with_ctx(conn, row, roll_ctx) row["roll_eligible"] = roll_allowed and err is None if not roll_allowed: row["roll_block_reason"] = "仅固定金额(以损定仓)模式可滚仓" else: row["roll_block_reason"] = err or "" monitors.append(row) active_trend_row = dict(active_trend) if active_trend else None if active_trend_row: active_trend_row["period_label"] = trend_period_label( active_trend_row.get("period") or "15m", ) group_ids = [int(g["id"]) for g in roll_groups if g["id"]] filled_map = _roll_filled_lots_map(conn, group_ids) enriched_groups = [ _enrich_roll_group_row_fast(dict(g), filled_map) for g in roll_groups ] enriched_legs = [_enrich_roll_leg_row(dict(l), mode) for l in roll_legs] return render_template( "strategy.html", capital=capital, fixed_amount=get_fixed_amount(get_setting), sizing_mode=sizing, sizing_mode_label=_sizing_mode_label(sizing), roll_allowed=roll_allowed, active_trend=active_trend_row, monitors=monitors, roll_groups=enriched_groups, roll_legs=enriched_legs, trading_session=is_trading_session(), session_clock=trading_session_clock(), trend_periods=trend_strategy_periods(), add_mode_labels={ "market": "市价加仓", "breakout": "突破加仓", }, roll_leg_status_labels={ "pending": "监控中", "filled": "已成交", "cancelled": "已取消", }, ) finally: conn.close() @app.route("/strategy/records") @login_required def strategy_records_page(): conn = get_db() init_strategy_tables(conn) trend, roll = list_snapshots(conn) roll = [_enrich_roll_record_row(conn, r) for r in roll] conn.close() return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll) @app.route("/api/trade/quote") @login_required def api_trade_quote(): sym = (request.args.get("symbol") or "").strip() lots = request.args.get("lots") or "1" if not sym: return jsonify({"ok": False, "error": "缺少品种"}), 400 codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") try: lots_f = max(1, int(float(lots))) except (TypeError, ValueError): lots_f = 1 mode = get_trading_mode(get_setting) metrics = calc_order_tick_metrics(sym, lots_f, price, trading_mode=mode) spec = get_contract_spec(sym) name = codes.get("name", sym) if codes else sym pos_long = pos_short = 0 ctp_st = ctp_status(mode) if ctp_st.get("connected"): for p in _ctp_positions(mode): if not _match_ctp_symbol(p.get("symbol", ""), sym): continue if p["direction"] == "long": pos_long = int(p["lots"]) else: pos_short = int(p["lots"]) max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0 return jsonify({ "ok": True, "symbol": sym, "name": name, "price": price, "lots": lots_f, "metrics": metrics, "exchange": codes.get("exchange", "") if codes else "", "pos_long": pos_long, "pos_short": pos_short, "max_open_long": max_open, "max_open_short": max_open, "footer_text": ( f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} " f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}元 " f"精度{metrics['price_precision']}位小数" ), }) @app.route("/api/trade/preview", methods=["POST"]) @login_required def api_trade_preview(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() direction = (d.get("direction") or "long").strip().lower() try: entry = float(d.get("entry") or d.get("price") or 0) sl = float(d.get("stop_loss") or 0) tp = float(d.get("take_profit") or 0) except (TypeError, ValueError): return jsonify({"ok": False, "error": "价格参数无效"}), 400 conn = get_db() capital = _capital(conn) conn.close() sizing = get_sizing_mode(get_setting) margin_pct = get_max_margin_pct(get_setting) sizing_info = {} if sizing == MODE_AMOUNT: lots, err, sizing_info = calc_lots_by_amount( entry, sl, direction, get_fixed_amount(get_setting), sym, capital=capital, max_margin_pct=margin_pct, trading_mode=get_trading_mode(get_setting), ) if err: return jsonify({"ok": False, "error": err}), 400 elif sizing == MODE_FIXED: lots = get_fixed_lots(get_setting) else: try: lots = max(1, int(d.get("lots") or 1)) except (TypeError, ValueError): lots = 1 metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym) tick = calc_order_tick_metrics( sym, lots, entry, direction=direction, trading_mode=get_trading_mode(get_setting), ) return jsonify({ "ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital, "sizing_info": sizing_info, }) @app.route("/api/trade/order", methods=["POST"]) @login_required def api_trade_order(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() offset = (d.get("offset") or "open").strip().lower() direction = (d.get("direction") or "long").strip().lower() try: lots = max(1, int(d.get("lots") or 1)) price = float(d.get("price") or 0) except (TypeError, ValueError): return jsonify({"ok": False, "error": "手数或价格无效"}), 400 order_type = (d.get("order_type") or d.get("price_type") or "limit").strip().lower() if order_type == "market" and price <= 0: codes = ths_to_codes(sym) price = fetch_price( sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "", ) or 0 if not sym or price <= 0: return jsonify({"ok": False, "error": "品种或价格无效"}), 400 conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) if offset.startswith("open"): _sync_trade_monitors_with_ctp(conn, mode) if not is_trading_session(): conn.close() return jsonify({"ok": False, "error": "不在交易时间段"}), 403 if d.get("trailing_be") and not d.get("stop_loss"): conn.close() return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 err = assert_can_open( conn, active_count=_effective_active_position_count(conn, mode), equity=_capital(conn), ) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 scope_err = assert_product_allowed_for_capital( sym, _capital(conn), ctp_connected=is_ctp_connected(get_setting), ) if scope_err: conn.close() return jsonify({"ok": False, "error": scope_err}), 403 ctp_st = ctp_status(mode) if not ctp_st.get("connected"): conn.close() if get_bridge().connect_in_progress(): return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400 return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 sizing = get_sizing_mode(get_setting) if offset.startswith("open") and sizing == MODE_AMOUNT: sl = float(d.get("stop_loss") or 0) if sl <= 0: conn.close() return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400 lots_calc, err, _sizing_info = calc_lots_by_amount( price, sl, direction, get_fixed_amount(get_setting), sym, capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting), trading_mode=mode, ) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 lots = lots_calc or lots elif offset.startswith("open") and sizing == MODE_FIXED: lots = get_fixed_lots(get_setting) margin_pct = get_max_margin_pct(get_setting) usage = calc_margin_usage_pct( _ctp_positions(mode), _capital(conn), extra_symbol=sym if offset.startswith("open") else "", extra_lots=lots if offset.startswith("open") else 0, extra_price=price if offset.startswith("open") else 0, extra_direction=direction if offset.startswith("open") else "long", trading_mode=mode, ) if offset.startswith("open") and usage > margin_pct: conn.close() return jsonify({ "ok": False, "error": f"保证金占用 {usage:.1f}% 超过上限 {margin_pct:g}%(可在系统设置修改)", }), 403 if lots > DEFAULT_MAX_ORDER_LOTS: conn.close() return jsonify({ "ok": False, "error": f"单笔手数 {lots} 超过上限 {DEFAULT_MAX_ORDER_LOTS},请加大止损距离或改固定手数", }), 400 try: result = execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), order_type=order_type, ) if offset.startswith("open") and d.get("trailing_be") and not d.get("stop_loss"): conn.close() return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 if offset.startswith("open"): from zoneinfo import ZoneInfo sl = d.get("stop_loss") trailing_be = 1 if d.get("trailing_be") else 0 tp = None if trailing_be else d.get("take_profit") open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") vt_order_id = str(result.get("order_id") or "") mid = _upsert_open_monitor( conn, sym=sym, direction=direction, lots=lots, price=price, sl=sl, tp=tp, trailing_be=trailing_be, open_time=open_ts, monitor_type="manual", status="pending", vt_order_id=vt_order_id or None, order_price=price, ) conn.commit() try: with _ctp_td_lock: get_bridge().refresh_positions() except Exception: pass _reconcile_pending(conn, mode, capital=_capital(conn)) st_row = conn.execute( "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() filled = st_row and (st_row["status"] or "").strip().lower() == "active" rejected = st_row and (st_row["status"] or "").strip().lower() == "closed" if rejected: conn.commit() conn.close() _push_position_snapshot_async(fast=False) return jsonify({ "ok": False, "error": "委托已被柜台拒绝或撤销(请确认合约状态与交易时段)", "lots": lots, "filled": False, }), 400 if not filled: try: get_bridge().refresh_positions() except Exception: pass _reconcile_pending(conn, mode, capital=_capital(conn)) st_row = conn.execute( "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() filled = st_row and (st_row["status"] or "").strip().lower() == "active" rejected = st_row and (st_row["status"] or "").strip().lower() == "closed" if rejected: conn.commit() conn.close() _push_position_snapshot_async(fast=False) return jsonify({ "ok": False, "error": "委托已被柜台拒绝或撤销(请确认合约状态与交易时段)", "lots": lots, "filled": False, }), 400 if filled: _sync_monitor_from_ctp( conn, mid, sym, direction, mode, capital=_capital(conn), ) mon_row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() if mon_row and (sl or tp): try: ensure_monitor_order_columns(conn) cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode) except Exception as exc: logger.warning("清理旧版止盈止损挂单失败: %s", exc) conn.commit() _push_position_snapshot_async(fast=False) msg = ( f"开仓成功 · {lots} 手" if filled else ( f"委托已提交 · {lots} 手挂单中" f"({get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)" ) ) conn.commit() if offset.startswith("open"): from db_conn import DB_PATH from ai_worker import schedule_ai_event_analysis from trade_notify import notify_manual_open_filled if filled: open_sl = float(d.get("stop_loss") or 0) if d.get("stop_loss") else None open_tp = None if d.get("trailing_be") else d.get("take_profit") if open_tp is not None: try: open_tp = float(open_tp) except (TypeError, ValueError): open_tp = None codes = ths_to_codes(sym) or {} if open_sl and open_sl > 0: notify_manual_open_filled( send_wechat=send_wechat_msg, get_setting=get_setting, mode_label=trading_mode_label(get_setting), sym=sym, symbol_name=codes.get("name") or sym, direction=direction, entry=price, sl=open_sl, tp=open_tp, lots=lots, capital=_capital(conn), order_id=str(result.get("order_id") or ""), trailing_be=bool(d.get("trailing_be")), be_tick_buffer=get_trailing_be_tick_buffer(get_setting), schedule_ai_fn=schedule_ai_event_analysis, db_path=DB_PATH, ) else: send_wechat_msg( f"{trading_mode_label(get_setting)} 开仓 {sym} {direction} {lots}手 @{price}" ) elif not filled: send_wechat_msg( f"委托已提交 · {sym} {direction} {lots}手挂单中" f"({get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)" ) elif not offset.startswith("open"): send_wechat_msg( f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}" ) conn.close() _push_position_snapshot_async(fast=False) return jsonify({ "ok": True, "result": result, "lots": lots, "message": msg if offset.startswith("open") else "委托已提交柜台", "filled": filled if offset.startswith("open") else None, }) except (ValueError, RuntimeError) as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 except Exception as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 500 @app.route("/api/ctp/connect", methods=["POST"]) @login_required def api_ctp_connect(): from vnpy_bridge import ctp_start_connect from ctp_settings import CTP_DISABLED_HINT if not is_ctp_auto_connect_enabled(get_setting): mode = get_trading_mode(get_setting) st = ctp_status(mode) return jsonify({ "ok": False, "disabled": True, "error": CTP_DISABLED_HINT, "status": st, }), 400 mode = get_trading_mode(get_setting) body = request.get_json(silent=True) or {} force = bool(body.get("force")) auto = bool(body.get("auto")) # 自动连接仅由 qihuo-ctp 后台 worker 发起;Web 只读状态,避免换页重复 connect。 if auto and not force: st = ctp_status(mode) acc = _ctp_account(mode) if st.get("connected") else {} return jsonify({ "ok": True, "connecting": bool(st.get("connecting")), "backend_managed": True, "status": st, "account": acc, }) info = ctp_start_connect(mode, force=force) st = info.get("status") or ctp_status(mode) acc = _ctp_account(mode) if st.get("connected") else {} if st.get("connected"): return jsonify({"ok": True, "status": st, "account": acc}) if info.get("connecting") or info.get("started"): return jsonify({ "ok": True, "connecting": True, "status": st, "account": acc, }) if info.get("cooldown"): return jsonify({ "ok": False, "cooldown": True, "error": st.get("last_error") or "CTP 登录冷却中", "status": st, "account": acc, }), 400 return jsonify({ "ok": False, "error": st.get("last_error") or "CTP 连接未启动", "status": st, "account": acc, }), 400 @app.route("/api/ctp/status") @login_required def api_ctp_status(): mode = get_trading_mode(get_setting) st = ctp_status(mode) acc = {} if st.get("connected"): try: acc = _ctp_account(mode) except Exception: acc = {} return jsonify({"ok": True, "status": st, "account": acc}) @app.route("/api/account_snapshot") @login_required def api_account_snapshot(): conn = get_db() try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) risk = get_risk_status( conn, active_count=_effective_active_position_count(conn, mode), equity=capital, ) conn.commit() ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} positions = _ctp_positions(mode) if ctp_st.get("connected") else [] if ctp_st.get("connected") and not positions: positions = _positions_for_monitor_restore(mode) return jsonify({ "capital": capital, "trading_mode": mode, "trading_mode_label": trading_mode_label(get_setting), "sizing_mode": get_sizing_mode(get_setting), "risk_status": risk, "ctp_status": ctp_st, "ctp_account": ctp_acc, "positions": positions, }) finally: conn.close() @app.route("/api/recommend/list") @login_required def api_recommend_list(): """只读数据库缓存,不在请求时拉行情。""" conn = get_db() try: payload = _recommend_payload(conn) return jsonify({"ok": True, **payload}) finally: conn.close() @app.route("/api/recommend/stream") @login_required def api_recommend_stream(): from queue import Empty def generate(): q = recommend_hub.subscribe() try: conn = get_db() try: payload = _recommend_payload(conn) finally: conn.close() yield sse_format("recommend", {"ok": True, **payload}) while True: try: msg = q.get(timeout=25) yield sse_format(msg["event"], msg["data"]) except Empty: yield ": heartbeat\n\n" finally: recommend_hub.unsubscribe(q) return Response( stream_with_context(generate()), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.route("/api/recommend/refresh", methods=["POST"]) @login_required def api_recommend_refresh(): """手动触发一次后台刷新(仍写入数据库)。""" conn = get_db() try: init_strategy_tables(conn) capital = _recommend_capital(conn) mode = get_trading_mode(get_setting) rows = refresh_recommend_cache( conn, capital, _main_quote, trading_mode=mode, max_margin_pct=get_max_margin_pct(get_setting), ) max_pct = get_max_margin_pct(get_setting) payload = _recommend_payload(conn) recommend_hub.broadcast("recommend", {"ok": True, **payload}) return jsonify({"ok": True, "count": len(rows), **payload}) finally: conn.close() @app.route("/api/strategy/trend/preview", methods=["POST"]) @login_required def api_trend_preview(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() conn = get_db() if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): conn.close() return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400 capital = _capital(conn) codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") conn.close() if not price: return jsonify({"ok": False, "error": "无法获取现价"}), 400 plan, err = compute_trend_plan_futures( direction=d.get("direction") or "long", stop_loss=float(d.get("stop_loss") or 0), add_upper=float(d.get("add_upper") or 0), take_profit=float(d.get("take_profit") or 0), risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), capital=capital, live_price=price, ths_code=sym, dca_legs=int(d.get("dca_legs") or 5), ) if err: return jsonify({"ok": False, "error": err}), 400 period = normalize_trend_period(d.get("period")) sym_name = (d.get("symbol_name") or "").strip() if not sym_name and codes: sym_name = codes.get("name") or sym plan = enrich_trend_plan_preview( plan, symbol=sym, symbol_name=sym_name, period=period, ) return jsonify({"ok": True, "plan": plan}) @app.route("/api/strategy/trend/execute", methods=["POST"]) @login_required def api_trend_execute(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() conn = get_db() init_strategy_tables(conn) capital = _capital(conn) err = assert_can_open(conn, equity=capital) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 scope_err = assert_product_allowed_for_capital( sym, capital, ctp_connected=is_ctp_connected(get_setting), ) if scope_err: conn.close() return jsonify({"ok": False, "error": scope_err}), 403 codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") plan, perr = compute_trend_plan_futures( direction=d.get("direction") or "long", stop_loss=float(d.get("stop_loss") or 0), add_upper=float(d.get("add_upper") or 0), take_profit=float(d.get("take_profit") or 0), risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), capital=capital, live_price=price or float(d.get("live_price") or 0), ths_code=sym, ) if perr: conn.close() return jsonify({"ok": False, "error": perr}), 400 period = normalize_trend_period(d.get("period")) sym_name = (d.get("symbol_name") or "").strip() if not sym_name and codes: sym_name = codes.get("name") or sym plan = enrich_trend_plan_preview( plan, symbol=sym, symbol_name=sym_name, period=period, ) mode = get_trading_mode(get_setting) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(), ) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur = conn.execute( """INSERT INTO trend_pullback_plans ( status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit, risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots, dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price, lots_open, opened_at, period ) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?) RETURNING id""", ( sym, sym_name or (codes.get("name", sym) if codes else sym), plan["direction"], plan["stop_loss"], plan["add_upper"], plan["take_profit"], plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"], plan["target_lots"], plan["first_lots"], plan["remainder_lots"], plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"], price, plan["first_lots"], now, plan["period"], ), ) row = cur.fetchone() plan_id = int(row["id"] if isinstance(row, dict) else row[0]) conn.commit() conn.close() send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手") return jsonify({"ok": True, "plan_id": plan_id, "plan": plan}) def _roll_group_for_monitor(conn, monitor_id: int): return conn.execute( "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'", (int(monitor_id),), ).fetchone() def _roll_filled_legs(conn, monitor_id: int) -> int: grp = _roll_group_for_monitor(conn, monitor_id) if grp: return int(grp["leg_count"] or 0) return 0 def _roll_has_pending(conn, monitor_id: int) -> bool: grp = _roll_group_for_monitor(conn, monitor_id) if not grp: return False return bool(conn.execute( "SELECT 1 FROM roll_legs WHERE roll_group_id=? AND status=? LIMIT 1", (int(grp["id"]), LEG_STATUS_PENDING), ).fetchone()) def _roll_eligibility(conn, mon: dict, ctx: Optional[dict] = None) -> Optional[str]: if ctx is None: ctx = _build_roll_context(conn) return _roll_eligibility_with_ctx(conn, mon, ctx) def _roll_monitor_for_request(conn, mon_id: int) -> Optional[dict]: row = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=?", (int(mon_id),), ).fetchone() if not row: return None mon = dict(row) if (mon.get("status") or "").strip().lower() == "active": return mon mode = get_trading_mode(get_setting) if not _cached_ctp_status(mode).get("connected"): return None sym = (mon.get("symbol") or "").strip() direction = (mon.get("direction") or "long").strip().lower() for p in _positions_for_monitor_restore(mode, allow_ctp=False): if int(p.get("lots") or 0) <= 0: continue if (p.get("direction") or "long").strip().lower() != direction: continue if not _match_ctp_symbol(p.get("symbol") or "", sym): continue execute_retry( conn, "UPDATE trade_order_monitors SET status='active' WHERE id=?", (int(mon_id),), ) mon["status"] = "active" _sync_monitor_from_ctp( conn, int(mon_id), sym, direction, mode, ctp=p, capital=_capital(conn), ) fresh = conn.execute( "SELECT * FROM trade_order_monitors WHERE id=?", (int(mon_id),), ).fetchone() return dict(fresh) if fresh else mon return None def _roll_mark_price( sym: str, mon: dict, mode: str, *, allow_ctp: bool = False, ) -> float: mark = _cached_position_mark(sym, (mon or {}).get("direction") or "") if mark and mark > 0: return float(mark) mark = ( ctp_get_tick_price(mode, sym) if allow_ctp and ctp_status(mode).get("connected") else None ) if mark and mark > 0: return float(mark) px = fetch_price(sym) if px and px > 0: return float(px) return float(mon.get("entry_price") or 0) def _build_roll_preview(conn, d: dict, mon: dict, *, mode: str): sym = mon["symbol"] spec = get_contract_spec(sym) capital = _capital(conn) add_mode = (d.get("add_mode") or ADD_MODE_MARKET).strip().lower() off_session_breakout = add_mode == ADD_MODE_BREAKOUT and not is_trading_session() mark = _roll_mark_price(sym, mon, mode, allow_ctp=not off_session_breakout) if (not mark or mark <= 0) and off_session_breakout: bt = float(d.get("breakthrough_price") or 0) mark = bt if bt > 0 else float(mon.get("entry_price") or 0) entry_existing = _live_entry_price( sym, mon["direction"], mode, float(mon.get("entry_price") or 0), allow_ctp=False, ) if add_mode in FIB_MODES: return None, "斐波加仓已停用,请选市价或突破" if add_mode not in _roll_ui_modes(): return None, "仅支持市价加仓或突破加仓" risk_budget = get_fixed_amount(get_setting) legs_done = _roll_filled_legs(conn, int(mon["id"])) preview, err = preview_roll( direction=mon["direction"], symbol=sym, qty_existing=float(mon["lots"]), entry_existing=entry_existing, initial_take_profit=float(mon["take_profit"] or 0), add_mode=add_mode, new_stop_loss=float(d.get("new_stop_loss") or 0), risk_budget=risk_budget, mult=int(spec["mult"]), mark_price=mark, add_price=float(d.get("add_price") or 0) or mark, limit_price=d.get("limit_price"), breakthrough_price=d.get("breakthrough_price"), fib_upper=d.get("fib_upper"), fib_lower=d.get("fib_lower"), legs_done=legs_done, off_session_pending=off_session_breakout, ) if err: return None, err preview, merr = _apply_roll_margin_cap( preview, conn=conn, mode=mode, mon=dict(mon), capital=capital, ) if merr: return None, merr return preview, None def _commit_roll_fill( conn, *, mon: dict, preview: dict, add_mode: str, mode: str, pending_leg_id: Optional[int] = None, ) -> tuple[bool, str]: sym = mon["symbol"] mon_id = int(mon["id"]) price = float(preview["add_price"]) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=mon["direction"], lots=int(preview["add_lots"]), price=price, settings=_settings_dict(), ) except ValueError as exc: return False, str(exc) new_lots = int(mon["lots"]) + int(preview["add_lots"]) new_avg = preview["avg_entry_after"] new_sl = preview["new_stop_loss"] conn.execute( "UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?", (new_lots, new_avg, new_sl, mon_id), ) grp = _roll_group_for_monitor(conn, mon_id) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") risk_budget = float(preview.get("risk_budget") or get_fixed_amount(get_setting)) if grp: gid = int(grp["id"]) leg_n = int(grp["leg_count"] or 0) + 1 conn.execute( "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", (leg_n, new_sl, now, gid), ) else: cur = conn.execute( """INSERT INTO roll_groups ( order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss, current_stop_loss, risk_percent, leg_count, status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,1,'active',?,?) RETURNING id""", ( mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl, risk_budget, now, now, ), ) row = cur.fetchone() gid = int(row["id"] if isinstance(row, dict) else row[0]) leg_n = 1 if pending_leg_id: conn.execute( """UPDATE roll_legs SET status=?, fill_price=?, lots=?, new_stop_loss=?, created_at=? WHERE id=?""", ( LEG_STATUS_FILLED, price, int(preview["add_lots"]), new_sl, now, int(pending_leg_id), ), ) else: conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at, limit_price, breakthrough_price, last_mark_price, capital_snapshot ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( gid, leg_n, add_mode, price, int(preview["add_lots"]), new_sl, LEG_STATUS_FILLED, now, preview.get("limit_price"), preview.get("breakthrough_price"), preview.get("mark_price"), _capital(conn), ), ) conn.commit() send_wechat_msg( f"滚仓成交 {sym} {add_mode_label(add_mode)} +{preview['add_lots']}手 " f"新止损 {new_sl} 合计 {new_lots}手" ) _schedule_roll_entry_sync(mon_id, sym, mon["direction"], mode) return True, "成交" def _schedule_roll_entry_sync( mon_id: int, sym: str, direction: str, mode: str, ) -> None: """滚仓成交后从柜台同步加权均价到手数监控。""" def _run() -> None: import time as _time _time.sleep(1.5) try: conn = get_db() try: init_strategy_tables(conn) capital = _capital(conn) synced = False for p in trading_state.get_positions() or _ctp_positions(mode): if (p.get("direction") or "long") != (direction or "long"): continue if not _match_ctp_symbol(p.get("symbol") or "", sym): continue _sync_monitor_from_ctp( conn, mon_id, sym, direction, mode, ctp=p, capital=capital, ) synced = True break if synced: commit_retry(conn) finally: conn.close() if synced: _push_position_snapshot_async(fast=False) except Exception as exc: logger.debug("roll entry sync: %s", exc) threading.Thread(target=_run, daemon=True, name="roll-entry-sync").start() def _submit_roll_pending( conn, *, mon: dict, preview: dict, add_mode: str, ) -> tuple[bool, str]: mon_id = int(mon["id"]) grp = _roll_group_for_monitor(conn, mon_id) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") capital = _capital(conn) risk_budget = float(preview.get("risk_budget") or get_fixed_amount(get_setting)) if grp: gid = int(grp["id"]) else: cur = conn.execute( """INSERT INTO roll_groups ( order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss, current_stop_loss, risk_percent, leg_count, status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,0,'active',?,?) RETURNING id""", ( mon_id, mon["symbol"], mon["direction"], mon["take_profit"], mon["stop_loss"], preview["new_stop_loss"], risk_budget, now, now, ), ) row = cur.fetchone() gid = int(row["id"] if isinstance(row, dict) else row[0]) leg_n = int(conn.execute( "SELECT COUNT(*) AS n FROM roll_legs WHERE roll_group_id=? AND status=?", (gid, LEG_STATUS_FILLED), ).fetchone()["n"]) + 1 pending_n = conn.execute( "SELECT COUNT(*) AS n FROM roll_legs WHERE roll_group_id=? AND status=?", (gid, LEG_STATUS_PENDING), ).fetchone()["n"] if int(pending_n or 0) > 0: return False, "已有监控中的加仓腿" conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, lots, new_stop_loss, status, created_at, limit_price, breakthrough_price, last_mark_price, capital_snapshot ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", ( gid, leg_n, add_mode, int(preview["add_lots"]), preview["new_stop_loss"], LEG_STATUS_PENDING, now, preview.get("limit_price"), preview.get("breakthrough_price"), preview.get("mark_price"), capital, ), ) conn.commit() return True, "已提交监控,触价后自动市价加仓" def _fill_roll_leg_cb(mon: dict, grp: dict, leg: dict, preview: dict) -> tuple[bool, str]: conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) ok, msg = _commit_roll_fill( conn, mon=mon, preview=preview, add_mode=leg.get("add_mode") or ADD_MODE_MARKET, mode=mode, pending_leg_id=int(leg["id"]), ) conn.close() return ok, msg def _check_roll_monitors(): conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) try: check_roll_monitors( conn, get_mark_price_fn=lambda sym: _roll_mark_price(sym, {}, mode, allow_ctp=True), fill_roll_leg_fn=_fill_roll_leg_cb, is_trading_session_fn=is_trading_session, get_risk_budget_fn=lambda: get_fixed_amount(get_setting), get_entry_price_fn=lambda sym, d, fb: _live_entry_price( sym, d, mode, fb, allow_ctp=True, ), ) conn.commit() finally: conn.close() app._check_roll_monitors = _check_roll_monitors def _apply_roll_margin_cap( preview: dict, *, conn, mode: str, mon: dict, capital: float, ) -> tuple[dict, Optional[str]]: """滚仓:风险算手数后再按滚仓保证金上限收紧。""" if not preview: return preview, "预览无效" sym = mon["symbol"] direction = (mon.get("direction") or "long").strip().lower() price = float(preview.get("add_price") or 0) qty_existing = float(mon.get("lots") or 0) entry_existing = _live_entry_price( sym, direction, mode, float(mon.get("entry_price") or 0), allow_ctp=False, ) mult = int(get_contract_spec(sym).get("mult") or 1) roll_pct = get_roll_max_margin_pct(get_setting) add_lots = int(preview.get("add_lots") or 0) positions = _positions_for_monitor_restore(mode, allow_ctp=False) capped, usage = cap_lots_for_margin_budget( positions, capital, sym, direction, price, add_lots, roll_pct, trading_mode=mode, ) if capped < 1: return preview, f"滚仓后保证金占用将超过上限 {roll_pct:g}%" out = dict(preview) if capped < add_lots: out["add_lots"] = capped out["qty_after"] = int(qty_existing + capped) out["avg_entry_after"] = round( avg_entry_after_add(qty_existing, entry_existing, capped, price), 4, ) sl = float(out.get("new_stop_loss") or 0) tp = float(out.get("initial_take_profit") or 0) new_avg = float(out["avg_entry_after"]) new_qty = float(out["qty_after"]) if direction == "long": out["loss_at_sl"] = round((new_avg - sl) * new_qty * mult, 2) out["reward_at_tp"] = round((tp - new_avg) * new_qty * mult, 2) else: out["loss_at_sl"] = round((sl - new_avg) * new_qty * mult, 2) out["reward_at_tp"] = round((new_avg - tp) * new_qty * mult, 2) out["margin_capped"] = True out["margin_cap_note"] = ( f"按滚仓保证金上限 {roll_pct:g}% 收紧:" f"风险算 {add_lots} 手 → 实际 {capped} 手" ) out["margin_usage_pct"] = round(usage, 2) out["roll_max_margin_pct"] = roll_pct return out, None @app.route("/api/strategy/roll/preview", methods=["POST"]) @login_required def api_roll_preview(): d = request.get_json(silent=True) or {} conn = get_db() init_strategy_tables(conn) ensure_monitor_order_columns(conn) mon_id = int(d.get("monitor_id") or 0) roll_ctx = _build_roll_context(conn) mon = _roll_monitor_for_request(conn, mon_id) if not mon: conn.close() return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 conn.commit() mon_d = dict(mon) err = _roll_eligibility(conn, mon_d, roll_ctx) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 mode = get_trading_mode(get_setting) preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode) conn.close() if perr: return jsonify({"ok": False, "error": perr}), 400 return jsonify({"ok": True, "preview": preview}) @app.route("/api/strategy/roll/execute", methods=["POST"]) @login_required def api_roll_execute(): d = request.get_json(silent=True) or {} conn = get_db() init_strategy_tables(conn) ensure_monitor_order_columns(conn) mon_id = int(d.get("monitor_id") or 0) roll_ctx = _build_roll_context(conn) mon = _roll_monitor_for_request(conn, mon_id) if not mon: conn.close() return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 conn.commit() mon_d = dict(mon) err = _roll_eligibility(conn, mon_d, roll_ctx) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 mode = get_trading_mode(get_setting) preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode) if perr: conn.close() return jsonify({"ok": False, "error": perr}), 400 add_mode = (d.get("add_mode") or ADD_MODE_MARKET).strip().lower() if add_mode in PENDING_MODES: ok, msg = _submit_roll_pending(conn, mon=mon_d, preview=preview, add_mode=add_mode) conn.close() if not ok: return jsonify({"ok": False, "error": msg}), 400 note = "已提交监控,开盘触价后自动市价加仓" if not is_trading_session() else msg return jsonify({"ok": True, "message": note, "pending": True}) if not is_trading_session(): conn.close() return jsonify({"ok": False, "error": "不在交易时间段"}), 403 if not _cached_ctp_status(mode).get("connected"): conn.close() return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 ok, msg = _commit_roll_fill( conn, mon=mon_d, preview=preview, add_mode=add_mode, mode=mode, ) conn.close() if not ok: return jsonify({"ok": False, "error": msg}), 400 return jsonify({"ok": True, "message": msg, "preview": preview}) @app.route("/api/strategy/roll/cancel/", methods=["POST"]) @login_required def api_roll_cancel(leg_id: int): conn = get_db() init_strategy_tables(conn) ok, msg = cancel_roll_leg(conn, leg_id) if ok: conn.commit() conn.close() if not ok: return jsonify({"ok": False, "error": msg}), 400 return jsonify({"ok": True, "message": msg}) @app.route("/api/strategy/trend/stop", methods=["POST"]) @login_required def api_trend_stop(): d = request.get_json(silent=True) or {} plan_id = int(d.get("plan_id") or 0) conn = get_db() plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone() if not plan: conn.close() return jsonify({"ok": False, "error": "计划不存在"}), 404 mode = get_trading_mode(get_setting) price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0) try: if int(plan["lots_open"] or 0) > 0: execute_order( conn, mode=mode, offset="close", symbol=plan["symbol"], direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(), ) except ValueError: pass now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( "UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?", ("手动结束", plan_id), ) save_snapshot( conn, strategy_type=STRATEGY_TREND, source_id=plan_id, symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束", payload=dict(plan), opened_at=plan["opened_at"] or "", ) on_user_initiated_close(conn, trading_day=trading_day_label()) conn.commit() conn.close() return jsonify({"ok": True}) def check_trend_plans(app_ref): """后台:趋势补仓与止盈。""" conn = get_db() init_strategy_tables(conn) rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() mode = get_trading_mode(get_setting) for plan in rows: sym = plan["symbol"] price = fetch_price(sym) if not price: continue direction = plan["direction"] tp = float(plan["take_profit"] or 0) if tp > 0: hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp) if hit_tp: try: execute_order( conn, mode=mode, offset="close", symbol=sym, direction=direction, lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(), ) except ValueError: pass conn.execute( "UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?", ("程序止盈", plan["id"]), ) save_snapshot( conn, strategy_type=STRATEGY_TREND, source_id=plan["id"], symbol=sym, direction=direction, result_label="止盈", payload=dict(plan), opened_at=plan["opened_at"] or "", ) send_wechat_msg(f"趋势回调止盈 {sym}") continue try: grid = json.loads(plan["grid_prices_json"] or "[]") legs = json.loads(plan["leg_amounts_json"] or "[]") except Exception: grid, legs = [], [] done = int(plan["legs_done"] or 0) if done < len(grid) and done < len(legs): level = float(grid[done]) if trend_dca_level_reached(direction, price, level): add_lots = int(legs[done]) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=direction, lots=add_lots, price=price, settings=_settings_dict(), ) new_open = int(plan["lots_open"] or 0) + add_lots old_avg = float(plan["avg_entry_price"] or price) new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price conn.execute( """UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""", (done + 1, new_open, new_avg, plan["id"]), ) send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}") except ValueError: pass conn.commit() conn.close() app._check_trend_plans = check_trend_plans def _execute_key_breakout(conn, row, bar, break_side): """关键位箱体/收敛:5m 收盘突破后自动市价开仓。""" from key_monitor_lib import ( TYPE_BOX, calc_breakout_sl_tp, format_auto_breakout_msg, normalize_monitor_type, resolve_order_direction, ) sym = (row.get("symbol") or "").strip() bar_time = str(bar.get("time") or "")[:19] monitor_type = normalize_monitor_type(row.get("monitor_type") or "") trade_mode = row.get("trade_mode") or "顺势" direction = resolve_order_direction(break_side, trade_mode) trailing_be = int(row.get("trailing_be") or 0) try: rr = float(row.get("risk_reward") or (3 if trailing_be else 2)) except (TypeError, ValueError): rr = 3.0 if trailing_be else 2.0 if trailing_be and rr < 3: rr = 3.0 def _notify(ok: bool, detail: str, **kw): send_wechat_msg(format_auto_breakout_msg( row, break_side=break_side, direction=direction, entry=kw.get("entry", 0), sl=kw.get("sl", 0), tp=kw.get("tp", 0), lots=kw.get("lots", 0), bar_time=bar_time, ok=ok, detail=detail, )) if monitor_type == TYPE_BOX: cfg_dir = (row.get("direction") or "").strip().lower() if cfg_dir in ("long", "short") and direction != cfg_dir: dir_cn = "做多" if cfg_dir == "long" else "做空" _notify(False, f"突破方向与上方向({dir_cn})不一致", entry=0, sl=0, tp=0, lots=0) return False, "突破方向与上方向不一致" try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected"): _notify(False, "CTP 未连接") return False, "CTP 未连接" if not is_trading_session(): _notify(False, "非交易时段") return False, "非交易时段" try: entry = float(bar.get("close") or 0) except (TypeError, ValueError): _notify(False, "K 线收盘价无效") return False, "K 线收盘价无效" if entry <= 0: _notify(False, "K 线收盘价无效") return False, "K 线收盘价无效" sl, tp = calc_breakout_sl_tp( sym=sym, direction=direction, entry=entry, bar=bar, risk_reward=rr, ) err = assert_can_open( conn, active_count=_effective_active_position_count(conn, mode), equity=_capital(conn), ) if err: _notify(False, err, entry=entry, sl=sl, tp=tp, lots=0) return False, err capital = _capital(conn) lots, lot_err = calc_lots_by_risk( entry, sl, direction, capital, get_risk_percent(get_setting), sym, max_margin_pct=get_max_margin_pct(get_setting), trading_mode=mode, ) if lot_err or not lots: msg = lot_err or "手数计算失败" _notify(False, msg, entry=entry, sl=sl, tp=tp, lots=0) return False, msg result = execute_order( conn, mode=mode, offset="open", symbol=sym, direction=direction, lots=lots, price=entry, settings=_settings_dict(), order_type="market", ) open_ts = bar_time.replace("T", " ") if bar_time else datetime.now().strftime("%Y-%m-%d %H:%M:%S") vt_order_id = str(result.get("order_id") or "") mid = _upsert_open_monitor( conn, sym=sym, direction=direction, lots=lots, price=entry, sl=sl, tp=tp, trailing_be=trailing_be, open_time=open_ts, monitor_type=monitor_type, status="pending", vt_order_id=vt_order_id or None, order_price=entry, ) _reconcile_pending(conn, mode, capital=capital) st_row = conn.execute( "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() filled = st_row and (st_row["status"] or "").strip().lower() == "active" rejected = st_row and (st_row["status"] or "").strip().lower() == "closed" if rejected: conn.commit() _notify(False, "委托被柜台拒绝或撤销", entry=entry, sl=sl, tp=tp, lots=lots) return False, "委托被拒绝" if filled: _sync_monitor_from_ctp( conn, mid, sym, direction, mode, capital=capital, ) conn.commit() if filled: from db_conn import DB_PATH from ai_worker import schedule_ai_event_analysis from trade_notify import notify_key_breakout_open notify_key_breakout_open( send_wechat=send_wechat_msg, get_setting=get_setting, mode_label=trading_mode_label(get_setting), row=row, break_side=break_side, bar_time=bar_time, direction=direction, entry=entry, sl=sl, tp=tp, lots=lots, capital=capital, order_id=vt_order_id, schedule_ai_fn=schedule_ai_event_analysis, db_path=DB_PATH, ) else: _notify(True, "委托已提交,待成交", entry=entry, sl=sl, tp=tp, lots=lots) _push_position_snapshot_async(fast=False) return True, "已下单" if filled else "委托已提交" except Exception as exc: logger.warning("key breakout auto order: %s", exc) _notify(False, str(exc)) return False, str(exc) app._execute_key_breakout = _execute_key_breakout @app.route("/settings/trading", methods=["POST"]) @login_required def settings_trading_post(): return redirect(url_for("settings")) def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): if parse_mood_issues(behavior_tags): on_mood_journal_freeze(conn, trading_day=trading_day_label()) app._risk_review_hook = hook_review_mood from db_conn import DB_PATH def _init_tables(conn): init_strategy_tables(conn) threading.Thread( target=_prime_position_snapshot, daemon=True, name="position-prime", ).start() _pos_refresh_tick = {"n": 0} _last_full_calibrate = {"ts": 0.0} def _position_worker_refresh() -> dict: import time as _time from ctp_trading_state import CALIBRATE_INTERVAL_SEC _pos_refresh_tick["n"] += 1 mode = get_trading_mode(get_setting) connected = bool(ctp_status(mode).get("connected")) now = _time.time() since_connect = now - float( getattr(get_bridge(), "_last_connect_ok_ts", 0) or 0, ) if connected and since_connect < 45: return _refresh_trading_live_snapshot(fast=True) need_full = ( connected and ( trading_state.needs_calibrate() or (now - _last_full_calibrate["ts"]) >= CALIBRATE_INTERVAL_SEC ) ) if need_full: _last_full_calibrate["ts"] = now return _refresh_trading_live_snapshot(fast=False) return _refresh_trading_live_snapshot(fast=True) start_position_worker( refresh_fn=_position_worker_refresh, interval=1, idle_interval=3, ) if os.getenv("QIHUO_CTP_ROLE", "client").strip().lower() == "worker": _bootstrap_trading_runtime() start_ctp_reconnect_worker( get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, ) start_ctp_premarket_connect_worker( get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, ) start_sl_tp_guard_worker( db_path=DB_PATH, get_mode_fn=lambda: get_trading_mode(get_setting), init_tables_fn=_init_tables, get_capital_fn=_capital, get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), notify_fn=send_wechat_msg, interval=1, ) start_pending_order_worker( db_path=DB_PATH, get_mode_fn=lambda: get_trading_mode(get_setting), init_tables_fn=_init_tables, get_capital_fn=_capital, reconcile_fn=_reconcile_pending, on_changed_fn=lambda: _push_position_snapshot_async(fast=False), ) def _start_deferred_workers() -> None: time.sleep(2) start_recommend_worker( db_path=DB_PATH, get_capital_fn=_recommend_capital, quote_fn=_main_quote, init_tables_fn=_init_tables, get_mode_fn=lambda: get_trading_mode(get_setting), get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), ) if os.getenv("QIHUO_CTP_ROLE", "client").strip().lower() == "worker": start_ctp_fee_worker( get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, set_setting_fn=set_setting, ) from ai_worker import start_ai_worker start_ai_worker( db_path=DB_PATH, get_setting_fn=get_setting, set_setting_fn=set_setting, send_wechat_fn=send_wechat_msg, ) threading.Thread( target=_start_deferred_workers, daemon=True, name="deferred-workers", ).start()