feat: 实盘模拟(live) + 组合回测MVP(portfolio)
[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端): - sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL) - sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status) - frontend live: New/List/Monitor + api/live.ts; config/live.yaml [portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证): - runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import) - sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑 - frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线 - 路由/菜单注册(/backtest/portfolio 组合回测) - 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
python -m sanguo_portfolio.runner_backtest \\
|
||||
--start 2020-01-01 --end 2024-12-31 --cash 1000000
|
||||
|
||||
JSON 输出(供 SSH 捕获,前端 MVP 用):
|
||||
python -m sanguo_portfolio.runner_backtest --json \\
|
||||
--start 2024-01-01 --end 2024-02-29 --cash 1000000
|
||||
|
||||
Mac 没装 xtquant,这里仅作为入口脚本(测试用 mock,实际跑 rsync 到 VPS)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +18,7 @@ import os
|
||||
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -35,6 +40,10 @@ def parse_args() -> argparse.Namespace:
|
||||
"--result-file", default="docs/portfolio_backtest_result.md",
|
||||
help="结果输出文件(.md)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json", action="store_true",
|
||||
help="JSON 模式:print(json.dumps(result)) 到 stdout,供 SSH 捕获",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@@ -91,7 +100,7 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
BulletTrade 的 BacktestEngine 接受 strategy_file 或 initialize 等函数。
|
||||
我们把 AllWeatherStrategy 包成 initialize 函数:initialize 闭包挂 run_daily 等。
|
||||
"""
|
||||
from bullet_trade.core import BacktestEngine # type: ignore
|
||||
from bullet_trade import BacktestEngine # type: ignore
|
||||
from bullet_trade.data.api import set_data_provider # type: ignore
|
||||
|
||||
from .strategies import AllWeatherStrategy
|
||||
@@ -145,8 +154,9 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
)
|
||||
result = engine.run()
|
||||
|
||||
# 输出结果摘要到 markdown
|
||||
_write_result_md(result, args.result_file, args)
|
||||
# 输出结果摘要到 markdown(JSON 模式时 result_file="" 跳过)
|
||||
if getattr(args, "result_file", ""):
|
||||
_write_result_md(result, args.result_file, args)
|
||||
return result
|
||||
|
||||
|
||||
@@ -181,10 +191,193 @@ def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace
|
||||
logger.warning("写结果文件失败: %s", exc)
|
||||
|
||||
|
||||
def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""JSON 入口(供 SSH 触发,前端 MVP 用)。
|
||||
|
||||
Args:
|
||||
params: {
|
||||
pool: 标的池(暂未实际使用,占位),
|
||||
start_date, end_date: YYYY-MM-DD,
|
||||
initial_cash: 初始资金,
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"strategy": "all_weather",
|
||||
"period": {"start": ..., "end": ..., "trading_days": N},
|
||||
"stocks_selected": [{"code":..., "name":...}, ...], # 末日持仓
|
||||
"trades": [{date, code, side, amount, price, ...}, ...],
|
||||
"equity_curve": [{"date":..., "equity":...}, ...],
|
||||
"metrics": {total_return, annual_return, max_drawdown, sharpe, ...},
|
||||
}
|
||||
"""
|
||||
# 构造一个 Namespace 复用 run_backtest
|
||||
args = argparse.Namespace(
|
||||
start=params.get("start_date", "2024-01-01"),
|
||||
end=params.get("end_date", "2024-02-29"),
|
||||
cash=float(params.get("initial_cash", 1_000_000.0)),
|
||||
benchmark=params.get("benchmark", "000300.XSHG"),
|
||||
frequency="day",
|
||||
provider_config="{}",
|
||||
result_file="", # JSON 模式不写 md
|
||||
)
|
||||
raw = run_backtest(args)
|
||||
|
||||
summary = raw.get("summary", {}) if isinstance(raw, dict) else {}
|
||||
metrics = _extract_metrics(summary)
|
||||
|
||||
# 净值曲线:daily_records 是 DataFrame,index=date,列含 total_value
|
||||
equity_curve = _extract_equity_curve(raw.get("daily_records"))
|
||||
|
||||
# 选股(末日持仓):daily_positions 最后一日
|
||||
stocks_selected = _extract_last_positions(raw.get("daily_positions"))
|
||||
|
||||
# 成交明细
|
||||
trades = _extract_trades(raw.get("trades"))
|
||||
|
||||
meta = raw.get("meta", {}) if isinstance(raw, dict) else {}
|
||||
return {
|
||||
"strategy": "all_weather",
|
||||
"period": {
|
||||
"start": meta.get("start_date", args.start),
|
||||
"end": meta.get("end_date", args.end),
|
||||
"trading_days": len(equity_curve),
|
||||
},
|
||||
"stocks_selected": stocks_selected,
|
||||
"trades": trades,
|
||||
"equity_curve": equity_curve,
|
||||
"metrics": metrics,
|
||||
"raw_summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
"""从 string/number 提取 float,失败返 None。bullet-trade summary 多为 '12.34%' 字符串。"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip().replace("%", "").replace(",", "")
|
||||
try:
|
||||
return float(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_metrics(summary: Dict[str, Any]) -> Dict[str, float | None]:
|
||||
"""bullet-trade summary 用中文 key('策略收益'/'最大回撤'/...)。
|
||||
百分比按字面数值(12.34% → 12.34),前端按需 /100 显示。
|
||||
"""
|
||||
return {
|
||||
"total_return": _to_float(summary.get("策略收益")),
|
||||
"annual_return": _to_float(summary.get("策略年化收益")),
|
||||
"max_drawdown": _to_float(summary.get("最大回撤")),
|
||||
"sharpe": _to_float(summary.get("夏普比率")),
|
||||
"win_rate_daily": _to_float(summary.get("日胜率")),
|
||||
"win_rate_trade": _to_float(summary.get("交易胜率")),
|
||||
"trading_days": _to_float(summary.get("交易天数")),
|
||||
}
|
||||
|
||||
|
||||
def _extract_equity_curve(daily_records: Any) -> list[Dict[str, Any]]:
|
||||
"""daily_records: DataFrame,index=date,列含 total_value。"""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if daily_records is None:
|
||||
return out
|
||||
try:
|
||||
import pandas as pd # type: ignore
|
||||
if isinstance(daily_records, pd.DataFrame):
|
||||
df = daily_records.reset_index()
|
||||
date_col = "date" if "date" in df.columns else df.columns[0]
|
||||
val_col = "total_value" if "total_value" in df.columns else None
|
||||
if val_col is None:
|
||||
return out
|
||||
for _, row in df.iterrows():
|
||||
d = row[date_col]
|
||||
out.append({
|
||||
"date": getattr(d, "strftime", lambda f: str(d))("%Y-%m-%d"),
|
||||
"equity": float(row[val_col]),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("解析 equity_curve 失败: %s", exc)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_last_positions(daily_positions: Any) -> list[Dict[str, Any]]:
|
||||
"""daily_positions: DataFrame,列含 date/code/amount/avg_cost/price/value。
|
||||
取最后一日的非零持仓作为选股名单。"""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if daily_positions is None:
|
||||
return out
|
||||
try:
|
||||
import pandas as pd # type: ignore
|
||||
if isinstance(daily_positions, pd.DataFrame) and not daily_positions.empty:
|
||||
df = daily_positions
|
||||
if "date" in df.columns:
|
||||
last_date = df["date"].max()
|
||||
df = df[df["date"] == last_date]
|
||||
for _, row in df.iterrows():
|
||||
amt = row.get("amount", 0)
|
||||
if amt is None or float(amt) <= 0:
|
||||
continue
|
||||
out.append({
|
||||
"code": str(row.get("code", "")),
|
||||
"name": str(row.get("code", "")), # name 字段 bullet-trade 没存,前端展示 code
|
||||
"amount": float(amt),
|
||||
"avg_cost": float(row.get("avg_cost", 0) or 0),
|
||||
"price": float(row.get("price", 0) or 0),
|
||||
"value": float(row.get("value", 0) or 0),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("解析 last_positions 失败: %s", exc)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_trades(trades: Any) -> list[Dict[str, Any]]:
|
||||
"""trades: list[Trade],用 __dict__ 或属性兜底提取关键字段。"""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if not trades:
|
||||
return out
|
||||
keys = ("datetime", "date", "code", "side", "action", "amount",
|
||||
"filled_amount", "price", "filled_price", "commission", "status")
|
||||
for t in trades:
|
||||
item: Dict[str, Any] = {}
|
||||
for k in keys:
|
||||
v = None
|
||||
if hasattr(t, k):
|
||||
v = getattr(t, k)
|
||||
elif isinstance(t, dict):
|
||||
v = t.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
# datetime 类转字符串
|
||||
if hasattr(v, "strftime"):
|
||||
v = v.strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
if isinstance(v, (int, float)):
|
||||
v = float(v)
|
||||
except Exception:
|
||||
pass
|
||||
item[k] = v
|
||||
if item:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
args = parse_args()
|
||||
run_backtest(args)
|
||||
if args.json:
|
||||
# JSON 模式:stderr 仍打日志,stdout 只输出 JSON(供 SSH 捕获)
|
||||
result = run_backtest_json({
|
||||
"start_date": args.start,
|
||||
"end_date": args.end,
|
||||
"initial_cash": args.cash,
|
||||
"benchmark": args.benchmark,
|
||||
})
|
||||
print(json.dumps(result, ensure_ascii=False, default=str))
|
||||
else:
|
||||
run_backtest(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user