"""C-S3 实走编排(task 2):每日 live_step 单根推进。 架构(简化,避开 APScheduler per-account 闭包注入): - 容器 APScheduler 每日 20:30 调 live_runner.run_live_step(全局 job,遍历 live accounts) - live_step(account_id) 自包含:恢复状态 → 读当日 raw bar → engine.step → 存状态 - 当日 raw 由 Mac launchd 增量推 NAS(run_daily_update.sh 加 raw 增量) 状态恢复:cash=最后余额 / positions=paper_positions / pending=paper_pending_orders TODO(分期项):prev_close 从昨日 raw close 读(首版用当日 open 兜底); listing_days 从 IPO 日算(首版 stub 0);realized_pnl 未恢复(归因次日重置)。 """ import json import logging import os import sqlite3 from datetime import datetime, timedelta from .account import Account from .cta_adapter import PaperCtaEngine from .engine import PaperEngine from .limit import lot_size_for from .models import AccountConfig, MatchSession, OrderSide, PaperOrder from .position_ledger import PositionLedger from .persistence import ( load_last_balance, load_positions, save_positions, save_daily_balance, load_pending_orders, save_pending_orders, save_shadow_order, is_trade_shadowed, ) from .strategy_runner import StrategyRunner logger = logging.getLogger(__name__) def _make_exec_client(cfg): """构造执行客户端(Phase 2)。 SANGUO_USE_QMT_GATEWAY=1 → 进程内 vnpy_qmt 直连 miniQMT(bridge 已退); 否则 → HTTP bridge(需 bridge_url + token,缺则 warning 返回 None)。 返回 None 表示跳过本次影子/reconcile(不阻断 live_step)。 """ if os.environ.get("SANGUO_USE_QMT_GATEWAY", "").strip().lower() in ("1", "true", "yes", "on"): from .qmt_gateway_client import QmtGatewayClient return QmtGatewayClient() live_cfg = getattr(cfg, "live", None) or {} token = live_cfg.get("bridge_token") or os.environ.get("BRIDGE_TOKEN") url = live_cfg.get("bridge_url") if not token or not url: logger.warning("影子下单:bridge 模式但 bridge_url/token 未配(或设 SANGUO_USE_QMT_GATEWAY=1 走 vnpy_qmt),跳过") return None from .bridge_client import BridgeClient return BridgeClient(url, token) def _get_account(db_path: str, account_id: int) -> dict: with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row return dict(conn.execute( "SELECT * FROM paper_accounts WHERE id=?", (account_id,) ).fetchone()) def _restore_ledger(positions: dict) -> dict: """{symbol:{volume,frozen,avg_price}} → {symbol: PositionLedger}。""" return {sym: PositionLedger(sym, p["volume"], p.get("frozen", 0), p["avg_price"]) for sym, p in positions.items()} def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None = None) -> None: """实走单日 step(scheduler 每日调)。data_source=_DataSourceWrapper, cfg=data 配置。 today: 默认 datetime.now();验证可指定历史日期(有 raw)。 """ from sanguo_data.datareader import guess_exchange from sanguo_api.strategy_registry import get_strategy_class from vnpy.trader.utility import ArrayManager acc = _get_account(db_path, account_id) symbols = json.loads(acc["symbols"] or "[]") strategies = json.loads(acc["strategies"] or "[]") interval = acc.get("interval") or "d" initial = acc["initial_capital"] # 1. 恢复 account(cash + positions) account = Account(initial) last_bal = load_last_balance(db_path, account_id) if last_bal: account.cash = last_bal["cash"] account.positions = _restore_ledger(load_positions(db_path, account_id, "account")) # 2. 构造 runners(恢复 positions) runners: list = [] for s in strategies: cls = get_strategy_class(s["name"]) if cls is None: continue cta = PaperCtaEngine(s["name"], match_session=s.get("match_session", "next_open"), listing_days=s.get("listing_days", 0), size=lot_size_for(s["symbol"])) vt = f"{s['symbol']}.{guess_exchange(s['symbol']).value}" strat = cls(cta, s["name"], vt, s.get("params", {})) strat.trading = True if not hasattr(strat, "am"): strat.am = ArrayManager(20) cta.set_strategy(strat) max_a = s.get("max_allocation") runner = StrategyRunner(s["name"], strategy=strat, paper_cta_engine=cta, symbol=s["symbol"], max_allocation=max_a if max_a is not None else initial) runner.positions = _restore_ledger( load_positions(db_path, account_id, f"strategy:{s['name']}")) runners.append(runner) if not runners: logger.warning("live_step %s: 无可用策略(容器缺 vnpy_ctastrategy?),跳过", account_id) return # 3. 日期 + warmup(重放 start~昨日到策略 am 使其 inited;丢弃 warmup 单) # 实走每日单根 bar,ArrayManager 需 warmup 才 inited,否则策略无信号。 today = today or datetime.now().strftime("%Y-%m-%d") yesterday = (datetime.strptime(today, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") start_date = acc.get("start_date") or today if start_date < yesterday: for _wd, wbars in data_source.iter_bars( symbols, start_date, yesterday, interval, adjust="qfq", cfg=cfg ): for runner in runners: if runner.symbol in wbars: runner.paper_cta_engine.on_bar(wbars[runner.symbol]) runner.paper_cta_engine.pop_orders() # warmup 单丢弃(不撮合) # 4. 当日 raw bar(撮合)+ qfq bar(策略 on_bar 信号) bars = {} qfq_bars = {} for sym in symbols: rbar = data_source.fetch_day(sym, today, interval, adjust="raw", cfg=cfg) if rbar is not None: bars[sym] = rbar qbar = data_source.fetch_day(sym, today, interval, adjust="qfq", cfg=cfg) if qbar is not None: qfq_bars[sym] = qbar if not bars: logger.info("live_step %s: 当日无 raw bar(%s 非交易日或未推?),跳过", account_id, today) return # 5. 恢复 pending + prev_close pending = [] for o in load_pending_orders(db_path, account_id): runner = next((r for r in runners if r.strategy_id == o["strategy_id"]), None) if runner is None: continue pending.append((PaperOrder( o["strategy_id"], o["symbol"], OrderSide(o["side"]), o["price"], o["volume"], o["is_market"], MatchSession(o["match_session"]), o["listing_days"], ), runner)) # prev_close:昨日 raw close(fetch_day 昨日);失败兜底用当日 open prev_close = {} for sym in symbols: ybar = data_source.fetch_day(sym, yesterday, interval, adjust="raw", cfg=cfg) prev_close[sym] = ybar.close_price if ybar else bars[sym].open_price # 5. engine.step(单根当日) acc_cfg = AccountConfig( initial_capital=initial, rate=acc["rate"], slippage=acc["slippage"], pricetick=acc["pricetick"], stamp_duty_rate=acc["stamp_duty_rate"], transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"], ) pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id, symbols, acc.get("start_date") or today, today, interval, risk_free_rate=getattr(cfg, "risk_free_rate", 0.0)) pending_new, _closes = pe.step(today, bars, qfq_bars, prev_close, pending) # 6. 存状态(pending + positions) save_pending_orders(db_path, account_id, [ {"strategy_id": o.strategy_id, "symbol": o.symbol, "side": o.side.value, "price": o.price, "volume": o.volume, "is_market": o.is_market, "match_session": o.match_session.value, "listing_days": o.listing_days} for o, _r in pending_new ]) save_positions(db_path, account_id, "account", { sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price} for sym, p in account.positions.items()}, today) for r in runners: save_positions(db_path, account_id, f"strategy:{r.strategy_id}", { sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price} for sym, p in r.positions.items()}, today) logger.info("live_step %s 完成 @%s,pending=%d positions=%d", account_id, today, len(pending_new), len(account.positions)) # 7. 影子下单(D-3,spec §5 模式 A):当日成交 POST bridge,默认关闭 # enabled=false 时 _shadow_trades_to_bridge 立即 return,live_step 行为完全不变 _shadow_trades_to_bridge(db_path, account_id, today, cfg) # 8. 模式 B reconcile(D-4c,spec §5.3 模式 B):bridge 回报校正 account 账本 # mode_b=false 时 reconcile_from_bridge 立即 return,live_step 行为不变 reconcile_from_bridge(db_path, account_id, today, account, cfg) def _shadow_trades_to_bridge(db_path: str, account_id: int, today: str, cfg) -> None: """当日成交影子下单到 bridge(D-3,spec §5 模式 A)。 PaperEngine 模拟撮合为准,当日成交信号同步 POST 到 bridge 影子下单到 miniQMT。 默认关闭(cfg.live.enabled=false);任何失败仅记日志,不阻断 live_step。 幂等:paper_shadow_orders UNIQUE(account_id, trade_id) 保证 scheduler 重跑不重复下单。 """ try: live_cfg = getattr(cfg, "live", None) or {} if not live_cfg.get("enabled"): return from .bridge_client import to_bridge_code client = _make_exec_client(cfg) if client is None: return with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row cur = conn.execute( "SELECT id, strategy_id, symbol, direction, price, volume " "FROM paper_trades WHERE account_id=? AND bar_date=? AND rejected=0", (account_id, today), ) trades = [dict(r) for r in cur.fetchall()] shadowed = 0 for t in trades: if is_trade_shadowed(db_path, account_id, t["id"]): continue code = to_bridge_code(t["symbol"]) resp = client.place_order( code, t["direction"], t["price"], t["volume"], reason=f"shadow:strategy:{t['strategy_id']}", ) ok = bool(resp and resp.get("ok")) status = "ok" if ok else "failed" bridge_order_id = resp.get("order_id") if resp else None save_shadow_order(db_path, account_id, t["id"], bridge_order_id, status) if ok: shadowed += 1 else: logger.warning("live_step %s: 影子下单失败 trade=%s code=%s err=%s", account_id, t["id"], code, resp.get("error") if resp else "no_response") if trades: logger.info("live_step %s 影子下单: %d/%d ok", account_id, shadowed, len(trades)) except Exception as e: # noqa: BLE001 影子下单绝不阻断 live_step logger.warning("live_step %s: 影子下单异常(不阻断): %s", account_id, e) def reconcile_from_bridge(db_path: str, account_id: int, today: str, account: Account, cfg) -> None: """模式 B: bridge 真实回报校正 account 账本(spec §5.2/§5.3 模式 B)。 bridge /account + /positions 为准,覆盖 account.cash/market_value/positions, 纠模拟撮合漂移(实盘成交价/分红/拆股等导致的账本偏差)。 默认关闭(cfg.live.mode_b != True);任何失败仅记日志,不阻断 live_step。 bridge 失败 → warning return,降级用模拟账本(live_step step 6 已存的 simulation 状态)。 """ try: live_cfg = getattr(cfg, "live", None) or {} if not live_cfg.get("enabled"): return if not live_cfg.get("mode_b"): return from .bridge_client import from_bridge_code client = _make_exec_client(cfg) if client is None: return # 1. 校正资金(bridge /account 为准) acc_resp = client.get_account() if acc_resp is None: logger.warning("reconcile %s: get_account 失败,降级模拟账本", account_id) return account.cash = float(acc_resp.get("cash", account.cash)) account.market_value = float(acc_resp.get("market_value", account.market_value)) total = acc_resp.get("total") if total is None: total = account.equity # 2. 重建持仓(bridge /positions 为准;bridge code → 纯数字 key) pos_resp = client.get_positions() if pos_resp is None: logger.warning("reconcile %s: get_positions 失败,降级模拟账本", account_id) return new_positions: dict[str, PositionLedger] = {} for p in pos_resp: code = from_bridge_code(p.get("code", "")) vol = int(p.get("volume", 0)) if vol <= 0: continue can_use = int(p.get("can_use", vol)) new_positions[code] = PositionLedger( code, volume=vol, frozen=max(vol - can_use, 0), avg_price=float(p.get("avg_price", 0.0)), ) account.positions = new_positions # 3. 持久化校正后账本(account scope + daily balance) save_positions(db_path, account_id, "account", { sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price} for sym, p in account.positions.items()}, today) save_daily_balance(db_path, account_id, today, account.cash, account.market_value, total) logger.info("reconcile %s @%s 完成: cash=%.2f mv=%.2f positions=%d", account_id, today, account.cash, account.market_value, len(account.positions)) except Exception as e: # noqa: BLE001 reconcile 绝不阻断 live_step logger.warning("reconcile %s: 异常(不阻断): %s", account_id, e) def list_live_accounts(db_path: str) -> list[int]: """所有 mode=live & status=running 的 account_id(live_runner 遍历用)。""" with sqlite3.connect(db_path) as conn: rows = conn.execute( "SELECT id FROM paper_accounts WHERE mode='live' AND status='running'" ).fetchall() return [r[0] for r in rows] def run_live_step(db_path: str) -> None: """每日全局 step:遍历所有 live accounts 调 live_step(scheduler 20:30 调)。 strategy_type=portfolio 的账户走组合实走(全量重放,portfolio_paper)。 lazy import _DataSourceWrapper 避免与 routes_paper 循环 import。 """ from sanguo_data.config import find_config_path, load_config from sanguo_api.routes_paper import _DataSourceWrapper cfg = load_config(find_config_path()) data_source = _DataSourceWrapper(cfg) for aid in list_live_accounts(db_path): try: if _is_portfolio_account(db_path, aid): from .portfolio_paper import run_portfolio_live_step run_portfolio_live_step(db_path, aid) else: live_step(db_path, aid, data_source, cfg) except Exception as e: # noqa: BLE001 logger.error("live_step account %s 失败: %s", aid, e) def _is_portfolio_account(db_path: str, account_id: int) -> bool: """strategy_type=portfolio → 组合实走引擎。""" with sqlite3.connect(db_path) as conn: row = conn.execute( "SELECT strategy_type FROM paper_accounts WHERE id=?", (account_id,) ).fetchone() return bool(row) and (row[0] or "cta") == "portfolio"