Files
sanguo_vnpy_v2/sanguo_api/routes_paper.py
T
claude_dev 41a788c431 feat(frontend): P2全平台聚宽级复刻—模拟盘/因子/回测表单页重做+模拟盘列表页
模拟盘模块:
- 新增模拟盘列表页(List.vue):统计行+富表格(名称/模式/频率/收益/最新净值/状态/操作)+搜索筛选
- 后端加 GET /paper 列表端点(类比 GET /task,带最新净值+收益率)
- paper/New 分区富表单(卡式模式选择+频率+撮合时点说明)
- paper/Result 重做(收益/年化/回撤/夏普/波动指标卡+净值曲线+归因表+成交明细,净值客户端算指标)

因子模块:
- factor/Result 重做(最优ICIR/平均IC/显著数指标卡+彩色IC表+tears报告tab化)
- factor/New 重做(因子按类分组多选+标的批量+实时计数)

回测模块:
- backtest/Progress 重做(步骤时间线+进度条+实时日志尾3s刷新)
- backtest/New 重做(策略/参数/标的区间/基准分区富表单)
- backtest/Optimize 重做(结果列头可排序+Top1高亮+按收益默认降序)
- History 迁移scoped chip→全局chips.css(DRY清理)

全局:深色主题统一,复用 tokens.css + chips.css
2026-07-11 21:10:27 +08:00

232 lines
8.7 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 sqlite3
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import verify_token as verify_token_impl
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
@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)
aid = save_account(db, req.model_dump())
status = "created"
if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502)
def _bg():
from sanguo_trader.persistence import update_account_status
try:
_run_replay(db, aid, req)
update_account_status(db, aid, "done")
except Exception as e: # noqa: BLE001
update_account_status(db, aid, "failed", str(e))
threading.Thread(target=_bg, daemon=True).start()
status = "running"
elif req.mode == "live": # 实走:全局 job 每日 20:30 遍历 step(不跑回放)
from sanguo_trader.persistence import update_account_status
update_account_status(db, aid, "running")
status = "running"
return {"account_id": aid, "status": status}
@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()
out = []
for r in rows:
item = dict(r)
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}
@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)
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()