41a788c431
模拟盘模块: - 新增模拟盘列表页(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
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
"""模拟盘 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()
|