149 lines
6.2 KiB
Python
149 lines
6.2 KiB
Python
"""组合策略模拟盘实走(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", 30))
|
||
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 ""
|