"""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 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, load_pending_orders, save_pending_orders, ) from .strategy_runner import StrategyRunner logger = logging.getLogger(__name__) 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(Mac launchd 已推 NAS) bars = {} for sym in symbols: bar = data_source.fetch_day(sym, today, interval, adjust="raw", cfg=cfg) if bar is not None: bars[sym] = bar 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) pending_new, _closes = pe.step(today, 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)) 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 调)。 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: live_step(db_path, aid, data_source, cfg) except Exception as e: # noqa: BLE001 logger.error("live_step account %s 失败: %s", aid, e)