"""模拟盘 API 路由(spec §10)。 create 建 paper_account(持久化配置);GET 查询净值/成交/状态。 回放执行(engine.run)由 orchestrator 异步触发或容器内同步跑,端到端冒烟在容器 (本机无 NAS parquet + vnpy 完整依赖),本模块只做 account 管理 + 查询。 """ import json import sqlite3 import time from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel from .auth import verify_token as verify_token_impl from . import instance_store from .validation import validate_backtest_range router = APIRouter() _db_path = {"path": None} def set_db_path(p): _db_path["path"] = p if p: from sanguo_trader.persistence import init_db init_db(p) # app 启动建表(幂等),保证 GET 查询不报 no such table async def verify_token(authorization: str | None = Header(None)): if authorization is None or not authorization.startswith("Bearer "): raise HTTPException(401, "Missing/invalid authorization") return verify_token_impl(authorization.split(" ", 1)[1]) class StrategyCfg(BaseModel): name: str params: dict = {} match_session: str = "next_open" symbol: str listing_days: int = 0 max_allocation: float | None = None # 软限额(spec §195),None=用 initial_capital class PaperCreateRequest(BaseModel): name: str = "paper" mode: str = "replay" interval: str = "d" symbols: list[str] strategies: list[StrategyCfg] initial_capital: float = 1_000_000 rate: float = 0.0003 slippage: float = 0.0 pricetick: float = 0.01 stamp_duty_rate: float = 0.0005 transfer_fee_rate: float = 0.00001 min_commission: float = 5.0 start: str end: str # 组合策略实走(E1):strategy_type=portfolio 时 mode 必须 live, # strategies[0].name=组合策略名,pool/max_pool/benchmark 进 params strategy_type: str = "cta" # 撮合引擎(影子柜台 P1):eod_replay=日终回放(NAS 20:30) / shadow=影子柜台(VPS 盘中实时) engine: str = "eod_replay" pool: str = "all" max_pool: int = 0 benchmark: str = "000300.XSHG" # §12.6 实例做实:账户绑档案;空=发起即建档(自动创建实例再发起) instance_id: int | None = None def _resolve_file_by_class(class_name: str) -> str: """类名 → 策略文件名(发起即建档时反查 code_file;查不到返回空)。""" try: from .strategy_registry import list_strategy_files for f in list_strategy_files()["files"]: if f.get("class_name") == class_name: return f.get("name", "") except Exception: pass return "" def _ensure_instance_for_paper(req: PaperCreateRequest) -> int: """§12.6 D1/D5:发起时绑档案。带合法 instance_id 用之;否则发起即建档。""" from . import instance_store if req.instance_id and instance_store.get_instance_params_snapshot(req.instance_id): return req.instance_id strat = req.strategies[0] if req.strategies else None cls = strat.name if strat else "" sym = req.pool if req.strategy_type == "portfolio" else ",".join(req.symbols) return instance_store.create_instance({ "code_file": _resolve_file_by_class(cls), "name": f"{cls}·{req.mode}·{time.strftime('%m%d')}", "type": req.strategy_type, "params": dict(strat.params) if strat else {}, "symbol_or_pool": sym, "interval": req.interval, "match_session": strat.match_session if strat else "next_open", }) @router.post("/paper/create", dependencies=[Depends(verify_token)]) def create_paper(req: PaperCreateRequest): from sanguo_trader.persistence import init_db, save_account import threading db = _db_path["path"] or ":memory:" init_db(db) # 实例绑定先于日期归一(model_dump 要带上 instance_id 落库) req.instance_id = _ensure_instance_for_paper(req) # §12.6 D1 发起时快照:绑已有档案 → 账户参数用档案当时的参数(复印件), # 后续改档案不影响本账户(漂移可见,手动同步) snap = instance_store.get_instance_params_snapshot(req.instance_id) if req.instance_id else None if snap and req.strategies: req.strategies[0].params = dict(snap.get("params") or {}) # §12.6 补:发起时代码版本快照(运行可回溯当时跑的哪版代码) from .code_versions import snapshot_code code_snap = snapshot_code((snap or {}).get("code_file") or "") code_hash = code_snap["code_hash"] if code_snap else None # 实走/影子是开放账户:起止日期无意义,开始=创建当天(组合日终重放依赖 start_date, # 空值会崩),结束留空;仅回放保留用户填的历史区间 if req.mode in ("live", "shadow"): from datetime import date req.start = date.today().isoformat() req.end = "" elif req.mode == "replay": # 回放保留用户填的历史区间 → 校验(未来日期/超数据范围等 400,同回测口径) validate_backtest_range(req.start, req.end) if req.strategy_type == "portfolio": if req.mode not in ("live", "shadow"): raise HTTPException(400, "组合策略模拟盘仅支持实走(live)/影子(shadow)模式;历史回放请用「组合回测」") payload = req.model_dump() payload["code_hash"] = code_hash payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" payload["symbols"] = [req.pool] payload["strategies"] = [{ "name": (req.strategies[0].name if req.strategies else "all_weather"), "params": {"max_pool": req.max_pool, "benchmark": req.benchmark}, }] aid = save_account(db, payload) from sanguo_trader.persistence import update_account_status update_account_status(db, aid, "running") return {"account_id": aid, "status": "running"} cta_payload = req.model_dump() cta_payload["code_hash"] = code_hash cta_payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" aid = save_account(db, cta_payload) status = "created" if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502) def _bg(): from sanguo_trader.persistence import update_account_status from .instance_store import update_instance_run try: _run_replay(db, aid, req) update_account_status(db, aid, "done") # §12.6 回放完成回写档案(收益=末次净值/初始-1) if req.instance_id: from sanguo_trader.persistence import load_last_balance last = load_last_balance(db, aid) ret = None if last and req.initial_capital: ret = (last.get("total_equity", 0) - req.initial_capital) / req.initial_capital update_instance_run(req.instance_id, "replay", "done", ret, {"account_id": aid}) except Exception as e: # noqa: BLE001 update_account_status(db, aid, "failed", str(e)) if req.instance_id: update_instance_run(req.instance_id, "replay", "failed") threading.Thread(target=_bg, daemon=True).start() status = "running" elif req.mode in ("live", "shadow"): # 实走:每日 20:30 step;影子:等 VPS 影子柜台进程接管 from sanguo_trader.persistence import update_account_status update_account_status(db, aid, "running") status = "running" return {"account_id": aid, "status": status} @router.post("/paper/sync/{instance_id}", dependencies=[Depends(verify_token)]) def sync_instance_params(instance_id: int): """§12.6 D2/D3 参数同步:档案当前参数 → 该档案全部运行中模拟账户。 实走+影子一起换(影子与实盘/对照账户参数必须锁死,否则双轨对账失效)。 实盘不在此列(D2:实盘不提供在线改参,停了重发)。 生效时机(2026-08-15 实证):实走(CTA/组合)每日结算从 DB 重读 strategies → 次日 20:30 生效;影子柜台进程常驻内存 → 需重启影子进程生效。 """ from . import instance_store snap = instance_store.get_instance_params_snapshot(instance_id) if snap is None: raise HTTPException(404, "实例不存在") db = _db_path["path"] or ":memory:" synced = 0 with sqlite3.connect(db) as conn: rows = conn.execute( "SELECT id, strategies FROM paper_accounts " "WHERE instance_id=? AND status='running'", (instance_id,), ).fetchall() for aid, raw in rows: try: strats = json.loads(raw) if isinstance(raw, str) else (raw or []) except (ValueError, TypeError): continue if not strats: continue strats[0]["params"] = dict(snap["params"]) conn.execute( "UPDATE paper_accounts SET strategies=?, updated_at=? WHERE id=?", (json.dumps(strats, ensure_ascii=False), time.strftime("%Y-%m-%d %H:%M:%S"), aid), ) synced += 1 conn.commit() return {"synced": synced} @router.get("/paper", dependencies=[Depends(verify_token)]) def list_papers(): """模拟盘列表(启用聚宽级模拟交易列表页)。每行带最新净值 + 收益率。""" from sanguo_trader.persistence import load_last_balance db = _db_path["path"] with sqlite3.connect(db) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM paper_accounts ORDER BY id DESC" ).fetchall() # #71 持仓数(volume>0 的持仓行,对齐实盘列表) pos_counts = { r[0]: r[1] for r in conn.execute( "SELECT account_id, COUNT(*) FROM paper_positions " "WHERE volume > 0 GROUP BY account_id" ).fetchall() } out = [] for r in rows: item = dict(r) item["position_count"] = pos_counts.get(item["id"], 0) cap = item.get("initial_capital") or 0 last = load_last_balance(db, item["id"]) if last and cap: item["latest_equity"] = last.get("total_equity") item["latest_date"] = last.get("date") item["total_return"] = (last.get("total_equity", 0) - cap) / cap else: item["latest_equity"] = None item["latest_date"] = None item["total_return"] = None out.append(item) return {"accounts": out} # ===== 双轨对账(影子柜台 vs 实盘模拟,设计 §8.2 / 影子 P3 前半)===== # 注意:须注册在 /paper/{aid} 之前,否则 "reconcile" 被当作 aid → 422 @router.get("/paper/reconcile", dependencies=[Depends(verify_token)]) def list_reconcile_pairs(date: str | None = None): """双轨配对列表 + 各配对对账报告(按需现算并落库)。 自动配对:运行中影子账户(mode=shadow)按策略名匹配 live_accounts。 date 缺省 = 今天。 """ from sanguo_trader.shadow.reconcile_report import ( build_reconcile_report, find_dual_track_pairs, load_reconcile_report, save_reconcile_report, ) db = _db_path["path"] out = [] for pair in find_dual_track_pairs(db): saved = load_reconcile_report( db, pair["live_account_id"], pair["shadow_account_id"], date or "") report = saved or build_reconcile_report( db, pair["live_account_id"], pair["shadow_account_id"], date) save_reconcile_report(db, report) # §12.6 补:双轨代码版本一致性(对账 FAIL 先查这行——两边代码不同价差必然大) with sqlite3.connect(db) as conn: hashes = dict(conn.execute( "SELECT id, code_hash FROM paper_accounts WHERE id IN (?,?)", (pair["live_account_id"], pair["shadow_account_id"]), ).fetchall()) h1 = hashes.get(pair["live_account_id"]) h2 = hashes.get(pair["shadow_account_id"]) code_match = None if not (h1 and h2) else h1 == h2 out.append({**pair, "code_match": code_match, "report": report}) return {"pairs": out} @router.get("/paper/reconcile/{live_id}/{shadow_id}", dependencies=[Depends(verify_token)]) def get_reconcile(live_id: int, shadow_id: int, date: str | None = None, refresh: bool = False): """单配对对账报告。refresh=true 强制重算(缺省读已存,无则现算)。""" from sanguo_trader.shadow.reconcile_report import ( build_reconcile_report, load_reconcile_report, save_reconcile_report, ) db = _db_path["path"] if not refresh: saved = load_reconcile_report(db, live_id, shadow_id, date or "") if saved is not None: return saved report = build_reconcile_report(db, live_id, shadow_id, date) save_reconcile_report(db, report) return report @router.get("/paper/{aid}", dependencies=[Depends(verify_token)]) def get_paper(aid: int): db = _db_path["path"] with sqlite3.connect(db) as conn: conn.row_factory = sqlite3.Row row = conn.execute( "SELECT * FROM paper_accounts WHERE id=?", (aid,) ).fetchone() if not row: raise HTTPException(404, "account not found") return dict(row) @router.get("/paper/{aid}/equity", dependencies=[Depends(verify_token)]) def get_equity(aid: int): from sanguo_trader.persistence import list_daily_balance return list_daily_balance(_db_path["path"], aid) @router.get("/paper/{aid}/trades", dependencies=[Depends(verify_token)]) def get_trades(aid: int): from sanguo_trader.persistence import list_trades return list_trades(_db_path["path"], aid) @router.get("/paper/{aid}/strategies", dependencies=[Depends(verify_token)]) def get_strategies(aid: int): """分策略归因:成交/拒单/费用聚合(spec §7)。""" from sanguo_trader.persistence import list_strategy_summary return list_strategy_summary(_db_path["path"], aid) @router.get("/paper/{aid}/positions", dependencies=[Depends(verify_token)]) def get_positions(aid: int): """当前持仓快照(实走监控,Phase 3c)。{symbol:{volume,frozen,avg_price}} → list。""" from sanguo_trader.persistence import load_positions pos = load_positions(_db_path["path"], aid, "account") return [ {"symbol": sym, "volume": p["volume"], "frozen": p.get("frozen", 0), "avg_price": p["avg_price"]} for sym, p in pos.items() ] @router.get("/paper/{aid}/pending", dependencies=[Depends(verify_token)]) def get_pending(aid: int): """跨日 pending 订单(实走监控,Phase 3c)。""" from sanguo_trader.persistence import load_pending_orders return load_pending_orders(_db_path["path"], aid) # ===== 生命周期管理(spec §10 补全:停止/恢复/删除/编辑)===== def _get_account_row(db, aid: int) -> dict: with sqlite3.connect(db) as conn: conn.row_factory = sqlite3.Row row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (aid,)).fetchone() if not row: raise HTTPException(404, "account not found") return dict(row) @router.post("/paper/{aid}/stop", dependencies=[Depends(verify_token)]) def stop_paper(aid: int): """停止实走/影子:置 status=stopped,日终 step 与影子柜台都只选 running → 自动跳过。""" from sanguo_trader.persistence import update_account_status db = _db_path["path"] acc = _get_account_row(db, aid) if acc.get("mode") not in ("live", "shadow"): raise HTTPException(400, "仅实走/影子账户支持停止;回放账户为一次性任务") update_account_status(db, aid, "stopped") return {"account_id": aid, "status": "stopped"} @router.post("/paper/{aid}/resume", dependencies=[Depends(verify_token)]) def resume_paper(aid: int): """恢复实走/影子:次日 20:30 起继续 step / 影子柜台继续接管。""" from sanguo_trader.persistence import update_account_status db = _db_path["path"] acc = _get_account_row(db, aid) if acc.get("mode") not in ("live", "shadow"): raise HTTPException(400, "仅实走/影子账户支持恢复") update_account_status(db, aid, "running") return {"account_id": aid, "status": "running"} @router.delete("/paper/{aid}", dependencies=[Depends(verify_token)]) def delete_paper(aid: int): """删除模拟盘账户及其全部数据(净值/成交/持仓/挂单,不可恢复)。""" db = _db_path["path"] _get_account_row(db, aid) tables = ("paper_accounts", "paper_daily_balance", "paper_trades", "paper_positions", "paper_pending_orders", "paper_shadow_orders") with sqlite3.connect(db) as conn: for t in tables: if t == "paper_accounts": conn.execute("DELETE FROM paper_accounts WHERE id=?", (aid,)) else: conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,)) conn.commit() return {"account_id": aid, "deleted": True} class PaperUpdateRequest(BaseModel): """可编辑字段(其余字段沿用原值;策略参数/标的改动自下次 step 生效)。""" name: str | None = None symbols: list[str] | None = None strategies: list[StrategyCfg] | None = None initial_capital: float | None = None @router.put("/paper/{aid}", dependencies=[Depends(verify_token)]) def update_paper(aid: int, req: PaperUpdateRequest): db = _db_path["path"] _get_account_row(db, aid) sets, args = [], [] if req.name is not None: sets.append("name=?"); args.append(req.name) if req.symbols is not None: sets.append("symbols=?"); args.append(json.dumps(req.symbols)) if req.strategies is not None: sets.append("strategies=?") args.append(json.dumps([s.model_dump() for s in req.strategies])) if req.initial_capital is not None: sets.append("initial_capital=?"); args.append(req.initial_capital) if not sets: return {"account_id": aid, "updated": False} with sqlite3.connect(db) as conn: conn.execute(f"UPDATE paper_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid)) conn.commit() return {"account_id": aid, "updated": True} class _DataSourceWrapper: """包装 iter_bars/fetch_day 给 PaperEngine/live_orchestrator。""" def __init__(self, cfg): self.cfg = cfg def iter_bars(self, symbols, start, end, interval, adjust="qfq", cfg=None): from sanguo_trader.data_source import iter_bars return iter_bars(symbols, start, end, interval, adjust, cfg or self.cfg) def fetch_day(self, symbol, date, interval, adjust="qfq", cfg=None): from sanguo_trader.data_source import fetch_day return fetch_day(symbol, date, interval, adjust, cfg or self.cfg) def _run_replay(db, aid, req: PaperCreateRequest): """构造引擎 + 跑回放(容器内有 vnpy_ctastrategy + NAS parquet,本机仅空转)。""" from sanguo_trader.account import Account from sanguo_trader.cta_adapter import PaperCtaEngine from sanguo_trader.engine import PaperEngine from sanguo_trader.models import AccountConfig from sanguo_trader.strategy_runner import StrategyRunner from sanguo_data.config import find_config_path, load_config from sanguo_data.datareader import guess_exchange from .strategy_registry import get_strategy_class data_cfg = load_config(find_config_path()) acc_cfg = AccountConfig( initial_capital=req.initial_capital, rate=req.rate, slippage=req.slippage, pricetick=req.pricetick, stamp_duty_rate=req.stamp_duty_rate, transfer_fee_rate=req.transfer_fee_rate, min_commission=req.min_commission, ) account = Account(req.initial_capital) runners: list = [] for s in req.strategies: cls = get_strategy_class(s.name) if cls is None: continue # 策略不可用(本机无 vnpy_ctastrategy)→ 跳过 from sanguo_trader.limit import lot_size_for cta = PaperCtaEngine(s.name, match_session=s.match_session, listing_days=s.listing_days, size=lot_size_for(s.symbol)) # 主板 100 / 科创 200 股一手 vt_symbol = f"{s.symbol}.{guess_exchange(s.symbol).value}" strat = cls(cta, s.name, vt_symbol, s.params) # CtaTemplate(cta_engine, name, vt_symbol, setting) strat.trading = True # 允许 send_order(等价 on_start) try: from vnpy.trader.utility import ArrayManager if not hasattr(strat, "am"): strat.am = ArrayManager(20) # 默认 100 根才 inited,短区间不够;用 20 兼容 except Exception: pass cta.set_strategy(strat) runners.append(StrategyRunner( s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol, max_allocation=(s.max_allocation if s.max_allocation is not None else req.initial_capital))) from sanguo_data.dividend_source import build_dividend_calendar div_calendar = build_dividend_calendar(req.symbols, req.start, req.end) pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg, db, aid, req.symbols, req.start, req.end, req.interval, risk_free_rate=getattr(data_cfg, "risk_free_rate", 0.0), dividends_by_date=div_calendar) pe.run()