"""组合策略模拟盘实走(E1,spec §13.2 前后端 session 职责)。 设计:每晚 20:30(scheduler 全局 job)对每个 strategy_type=portfolio 的 live 账户,用回测引擎从 start_date **全量重放到今天**,取末日持仓/当日成交/净值落 paper 表。不做增量引擎——回测引擎是单一真相源,避免增量状态与回测口径漂移; 1 年区间全量重放约 2-3 分钟(NAS 26G 库),20:30 后台跑可接受。区间拉长到 数年后可优化为 checkpoint 续跑(见 update_checkpoint)。 """ from __future__ import annotations import json import logging from datetime import datetime, timedelta from typing import Any logger = logging.getLogger(__name__) def _bj_today() -> str: """北京时间今天(NAS 容器 TZ 可能为 UTC)。""" return (datetime.utcnow() + timedelta(hours=8)).strftime("%Y-%m-%d") def _nas_provider_config() -> dict[str, Any]: """NAS 容器 unified provider 配置(与 portfolio_worker NAS 分支一致)。""" return { "db_path": "/volume1/stock/sanguo_vnpy_v2/data_backup/quant_trading.db", "data_dir": "/volume1/stock/sanguo_vnpy_v2/data", } def _provider_config() -> dict[str, Any]: import os if os.path.isdir("/app"): return _nas_provider_config() # 开发机兜底:默认 data 目录(Mac 无全量数据,step 会因数据缺失跳过) return {"mode": "backtest"} def run_portfolio_live_step(db_path: str, account_id: int, today: str | None = None) -> dict[str, Any]: """组合实走单日 step:全量重放 → 当日成交/末日持仓/净值 落库。 幂等:当日已结算(checkpoint_date == today 或最新净值日期 == today)则跳过。 """ import sqlite3 from sanguo_trader.persistence import ( load_checkpoint, load_last_balance, save_daily_balance, save_positions, save_trade, update_checkpoint, ) today = today or _bj_today() with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (account_id,)).fetchone() if not row: raise ValueError(f"paper account {account_id} not found") acc = dict(row) if (acc.get("strategy_type") or "cta") != "portfolio": raise ValueError(f"account {account_id} 不是 portfolio 类型") if acc["status"] != "running": return {"account_id": account_id, "skipped": f"status={acc['status']}"} # 幂等:今天已结算过 ck = load_checkpoint(db_path, account_id) last_bal = load_last_balance(db_path, account_id) if ck == today or (last_bal and last_bal.get("date") == today): return {"account_id": account_id, "skipped": "already stepped today"} strategies = json.loads(acc["strategies"] or "[]") if not strategies: raise ValueError("portfolio 账户缺少策略配置") strategy_name = strategies[0].get("name", "all_weather") pool = (json.loads(acc["symbols"] or "[]") or ["hs300_subset"])[0] max_pool = int(strategies[0].get("params", {}).get("max_pool", 0)) benchmark = strategies[0].get("params", {}).get("benchmark", "000300.XSHG") from sanguo_portfolio.runner_backtest import run_backtest_json result = run_backtest_json({ "strategy": strategy_name, "pool": pool, "max_pool": max_pool, "start_date": acc["start_date"], "end_date": today, "initial_cash": acc["initial_capital"], "benchmark": benchmark, "commission_rate": acc.get("rate") or 0.0003, "stamp_duty_rate": acc.get("stamp_duty_rate") or 0.001, "min_commission": acc.get("min_commission") or 5.0, "slippage": acc.get("slippage") or 0.0, "provider": "unified", "provider_config": json.dumps(_provider_config()), }) equity_curve = result.get("equity_curve") or [] if not equity_curve: return {"account_id": account_id, "skipped": "no equity point (数据未到?)"} last_point = equity_curve[-1] if last_point["date"] <= _acc_last_date(last_bal): return {"account_id": account_id, "skipped": "no new trading day"} settle_date = last_point["date"] # 当日成交(回放里 settle_date 发生的全部交易) n_trades = 0 for t in result.get("trades") or []: d = str(t.get("datetime") or t.get("date") or "") if not d.startswith(settle_date): continue side = str(t.get("side") or t.get("action") or "").lower() save_trade(db_path, account_id, { "strategy_id": strategy_name, "datetime": d, "symbol": str(t.get("code", "")), "direction": "long" if side in ("buy", "open", "多") else "short", "offset": "open" if side in ("buy", "open") else "close", "match_session": "current_close", "price": float(t.get("filled_price") or t.get("price") or 0), "volume": int(float(t.get("filled_amount") or t.get("amount") or 0)), "commission": float(t.get("commission") or 0), "stamp_duty": 0.0, "bar_date": settle_date, }) n_trades += 1 # 末日持仓 + 净值 stocks = result.get("stocks_selected") or [] positions_value = sum(float(s.get("value") or 0) for s in stocks) equity = float(last_point["equity"]) save_positions(db_path, account_id, "account", { str(s["code"]): {"volume": int(float(s.get("amount", 0))), "frozen": 0, "avg_price": float(s.get("avg_cost", 0) or 0)} for s in stocks }, date=settle_date) save_daily_balance(db_path, account_id, settle_date, cash=equity - positions_value, market_value=positions_value, total_equity=equity) update_checkpoint(db_path, account_id, settle_date) logger.info("portfolio live step aid=%s date=%s trades=%s equity=%.2f", account_id, settle_date, n_trades, equity) return {"account_id": account_id, "date": settle_date, "trades": n_trades, "equity": equity} def _acc_last_date(last_bal: dict | None) -> str: """账户已有净值的最新日期(无则空串,任何新数据都算新)。""" return (last_bal or {}).get("date") or ""