Files
sanguo_vnpy_v2/sanguo_api/routes_paper.py
T

516 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟盘 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 = 30
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()