{{ s.name }}
+{{ s.notes }}
+ +diff --git a/CHANGELOG.md b/CHANGELOG.md
index 463b546..cdd1bd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,29 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
+## [1.17.11] — 2026-07-04
+
+**Web UI 新增「策略库」与「多策略组合回测」** —— 此前回测结果存在进程内存,重启即丢,用户无法留存自己反复验证过的好策略。本次落地两层能力:(1) **策略库**——在单标的/组合回测结果区点「保存策略」,把策略 + 标的上下文 + 成绩快照(总收益/夏普/回撤/胜率)一起存进本地 SQLite 单文件(`~/.easy_tdx/strategies.db`),策略库页可载入回填、一键重跑、删除;(2) **多策略组合回测**——策略库勾选 N 个单标的策略,各拿 1/N 资金、各跑原标的(取最新行情),净值曲线按日期并集对齐求和,组合结果复用单标的的 19 项完整绩效指标(基于合并净值曲线 + 汇总成交用 `PerformanceAnalyzer` 算出),并展示各策略当前持仓。**895 单测全绿**(+24 新增),ruff format/check / mypy strict / 前端 vue-tsc 全通过。
+
+### 新增
+
+- **策略库后端**(`src/easy_tdx/web/strategy_store.py`、`routers/strategies.py`)—— SQLite 单文件 CRUD(加入/列出/查看/删除),落库路径随 `EASY_TDX_CONFIG_DIR` 环境变量走(与 `config.py` 同约定),线程安全(写操作串行锁 + `check_same_thread=False`)。5 个接口:`GET/POST /api/v1/strategies`、`GET/DELETE /strategies/{id}`。保存记录含 strategy + params + context(symbol 或 stocks + 日期 + 周期)+ trade_config + snapshot(成绩快照)+ tags + notes。
+- **策略库前端**(`web-ui/src/views/StrategiesView.vue` + 路由 `/strategies` + 导航)—— 卡片网格列表,展示策略名/标的/收益/夏普/回撤/标签/备注/创建时间。「载入」跳转对应回测页并自动回填(单标的剥掉市场前缀只传 6 位代码;组合新增 URL query 回填);「删除」二次确认。空态提示去回测页保存。
+- **保存策略按钮**(`BacktestView.vue` / `PortfolioView.vue` 结果区)—— 弹窗填名称/标签/备注,其余(策略参数、标的上下文、成绩快照)自动从当前请求 + 结果填入。
+- **多策略组合回测引擎**(`src/easy_tdx/backtest/multi_strategy_engine.py`)—— `MultiStrategyEngine`:N 个策略各拿 1/N 资金、各跑原标的,曲线按日期并集 ffill 对齐求和。输出结构同 `PortfolioResult`(`individual_results` key 形如 `"双均线交叉@SH:601088"`),前端复用组合页图表零改动。
+- **多策略组合回测接口**(`web/routers/backtest.py` `POST /backtest/multi-strategy/run/async`)—— 勾选 N 个策略,逐个在 async 上下文取行情 + 构造策略实例(失败跳过),后台线程跑引擎。组合整体绩效基于合并净值曲线 + 汇总成交喂 `PerformanceAnalyzer`,得到与单标的同口径的 19 项指标。
+- **策略库组合回测 UI**(`StrategiesView.vue`)—— 每张卡片加复选框(组合策略无单一 symbol 自动 disabled),顶部「组合回测(N)」按钮,结果区复用 `EquityChart` + `MetricTable`(19 项绩效)+ `PortfolioSummaryTable` + `PortfolioCompareChart` + 当前持仓表(各策略回测结束持仓快照)。
+
+### 变更
+
+- **`PortfolioView.vue` 新增 URL query 回填** —— 此前组合页不读 query,策略库「载入组合策略」无法回填;新增 `onMounted` 读取 `strategy/params/stocks/startDate/endDate/category`,与单标的页回填风格一致。
+- **修正多策略合并净值曲线回撤符号** —— `_build_combined_equity` 原用 `drawdown = total - peak`(负值),改为 `peak - total`(正值),与单标的 `PortfolioTracker`、`PerformanceAnalyzer`、`EquityChart`(前端取负向下画)的正值约定一致;否则最大回撤算成 0、夏普/卡玛比率失真。
+
+### 已知约束(非 bug)
+
+- **多策略组合回测仅支持资金分仓(并行制)** —— 每个策略各拿 1/N 资金独立回测后曲线相加;不支持信号共振(投票制,`combo.py` 已有但未暴露 Web API)。资金/成本统一一组均分,不支持每策略单独配置。
+- **组合回测结果暂不回存策略库** —— 当前可保存的是单次回测的策略;多策略组合的结果暂未支持存为"策略的组合"。
+
## [1.17.10] — 2026-07-04
**Web UI 一键寻优「查看」按钮跳转携带完整行情上下文** —— `/optimize` 页策略排名表的两个「查看」按钮此前跳转只带 `strategy` + `params`,丢失了股票代码、周期、起止日期,导致跳到回测页后用户得手动重选标的与日期才能复现寻优行情。本次让跳转 URL 额外携带 `symbol/startDate/endDate/category`,回测页 `onMounted` 自动回填到 `SymbolPicker` 表单(股票代码/周期/起止日期全部就位),用户只需点「开始回测」即可完整复现。**向后兼容**:老书签(只有 `strategy/params`)仍正常工作,缺失字段保持默认值。前端 `vue-tsc --noEmit` / `vite build` 通过,后端 870 单测全绿(无回归)。
diff --git a/README.md b/README.md
index 5a617c8..ee57280 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普
**缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。
**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。
-**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,全程零代码。
+**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。
装上就能跑。**Python API + CLI + Web API 三通道**,输出 JSON 天然喂给 AI Agent:Claude Code、OpenClaw、Hermes 直接吃。`easy-tdx serve` 一键起 REST 服务,浏览器打开就是交互式 API 文档。
@@ -467,7 +467,7 @@ cd web-ui && npm run dev
> 前端开发服务器通过 Vite proxy 把 `/api` 请求转发到后端 `127.0.0.1:8000`,无需处理跨域。后端行情连接失败时回测路由仍可用(用内联数据),但取行情功能需要后端连通通达信服务器。
-打开浏览器后,顶部导航栏有四个页面:
+打开浏览器后,顶部导航栏有五个页面:
**1. 单标的回测**(首页 `/`)
@@ -477,11 +477,13 @@ cd web-ui && npm run dev
- **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参
- **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交)
- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细
+- 结果区右上角有「💾 保存策略」按钮,把当前策略 + 标的 + 成绩快照存进策略库,下次直接载入或参与组合回测
**2. 组合回测**(`/portfolio`)
- 添加多只标的(如 SZ:000001、SH:600519),选策略和日期范围
- 点「开始组合回测」,右侧出:组合整体绩效(加权收益率)、组合净值曲线(各标的按日期对齐求和)、各标的净值归一化叠加对比图、各标的绩效横向对比表
+- 同样有「保存策略」按钮,可把整个组合配置存进策略库
**3. 参数寻优**(`/optimize`)
@@ -495,9 +497,16 @@ cd web-ui && npm run dev
- 左侧列出最近 20 个已完成的回测任务(含单标的和组合)
- 勾选 2-4 个,右侧出:归一化净值叠加图(初始=1,看相对走势)、8 项核心指标横向对比表(总收益/夏普/最大回撤/胜率/盈亏比/交易数/年化/波动率)
-> ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。
+**5. 策略库**(`/strategies`,v1.17.11 新增)
-技术栈:Vue 3 + Vite + TypeScript + Pinia + ECharts(按需引入,构建产物约 725KB)。前端代码在 `web-ui/` 目录,独立 `package.json`,不依赖 Python 环境。
+- 保存你觉得不错的策略,下次直接载入或重跑。数据存在本地 SQLite 单文件(`~/.easy_tdx/strategies.db`,重启不丢)
+- 每张卡片展示策略名、标的、保存时的成绩快照(总收益/夏普/回撤)、标签、备注、创建时间
+- **载入**:点「载入」跳转对应回测页(单标的/组合),自动回填标的、日期、策略参数,可直接重跑
+- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、19 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票)
+
+> ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。**策略库除外**——保存到策略库的策略持久存在 SQLite,重启不丢。
+
+技术栈:Vue 3 + Vite + TypeScript + Pinia + ECharts(按需引入,构建产物约 800KB)。前端代码在 `web-ui/` 目录,独立 `package.json`,不依赖 Python 环境。
diff --git a/pyproject.toml b/pyproject.toml
index b24e0b5..24a9591 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
-version = "1.17.10"
+version = "1.17.11"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/easy_tdx/backtest/multi_strategy_engine.py b/src/easy_tdx/backtest/multi_strategy_engine.py
new file mode 100644
index 0000000..fd8847c
--- /dev/null
+++ b/src/easy_tdx/backtest/multi_strategy_engine.py
@@ -0,0 +1,245 @@
+"""多策略组合回测引擎(资金分仓 / 并行制)。
+
+与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine` 的区别:
+- 后者是「**一个**策略 × **多只**股票」,资金按股票均分。
+- 本引擎是「**多个**策略 × **各自**原标的」,资金按策略均分,每个策略独立回测,
+ 各自的净值曲线按日期对齐后求和,得到组合整体净值曲线。
+
+典型场景:用户在策略库勾选若干「好策略」,各跑在它保存时的标的上,看综合表现。
+
+用法::
+
+ engine = MultiStrategyEngine(
+ strategies=[
+ StrategySlot(label="双均线交叉", symbol="SH:601088", strategy=strat_a, df=df_a),
+ StrategySlot(label="RSI反转", symbol="SZ:000001", strategy=strat_b, df=df_b),
+ ],
+ total_cash=1_000_000,
+ )
+ result = engine.run()
+ print(result.total_performance)
+
+输出结构与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioResult` 一致,便于
+前端复用组合页的净值曲线 / 对比表 / 叠加图组件。``individual_results`` 的 key 形如
+``"双均线交叉@SH:601088"``(既能区分同标的不同策略,又一眼看清跑哪个票)。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import pandas as pd
+
+from easy_tdx.backtest.engine import BacktestEngine
+from easy_tdx.backtest.strategy import Strategy
+from easy_tdx.backtest.types import BacktestResult
+
+
+@dataclass
+class StrategySlot:
+ """单个策略槽位:一个已构造的策略实例 + 它要跑的标的标识与 K 线。
+
+ Attributes:
+ label: 策略展示名(如 "双均线交叉"),用于拼 individual_results 的 key。
+ symbol: 标的完整代码(如 "SH:601088"),仅用于标识与展示。
+ strategy: 已构造(带参数)的策略实例。
+ df: 该标的的 K 线 DataFrame。
+ """
+
+ label: str
+ symbol: str
+ strategy: Strategy
+ df: pd.DataFrame
+
+
+@dataclass
+class MultiStrategyResult:
+ """多策略组合回测结果(字段语义与 PortfolioResult 对齐,便于前端复用)。
+
+ Attributes:
+ total_performance: 组合整体绩效(资金加权收益率 + 策略数 + 总资金)。
+ individual_results: 每个策略槽位的独立回测结果,key 形如 "{label}@{symbol}"。
+ equity_allocation: 每个槽位的资金分配比例(均分时各 1/N)。
+ combined_equity: 组合整体净值曲线(各槽位按日期并集 ffill 对齐后求和),
+ 列: datetime / total / drawdown / drawdown_pct。
+ """
+
+ total_performance: dict[str, float]
+ individual_results: dict[str, BacktestResult]
+ equity_allocation: dict[str, float]
+ combined_equity: pd.DataFrame
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "total_performance": self.total_performance,
+ "individual_results": {k: v.to_dict() for k, v in self.individual_results.items()},
+ "equity_allocation": self.equity_allocation,
+ "combined_equity": self.combined_equity.to_dict(orient="records"),
+ }
+
+
+class MultiStrategyEngine:
+ """多策略资金分仓组合回测引擎。
+
+ 把总资金按策略数均分,每个策略在各自的 K 线上独立回测(各跑各的),
+ 再把各净值曲线按日期对齐求和,得到组合整体净值。资金分配方式固定为
+ "equal"(均分)——多策略组合的目标是"看综合表现",均分是最直接的基线。
+
+ 参数与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine`
+ 对齐(``strategy``/``stocks`` 换成 ``strategies``),便于复用资金/成本配置。
+ """
+
+ def __init__(
+ self,
+ strategies: list[StrategySlot],
+ total_cash: float = 1_000_000.0,
+ commission: float = 0.0003,
+ min_commission: float = 5.0,
+ stamp_tax: float = 0.001,
+ slippage: float = 0.0,
+ execution: str = "next_open",
+ ) -> None:
+ self._strategies = strategies
+ self._total_cash = total_cash
+ self._commission = commission
+ self._min_commission = min_commission
+ self._stamp_tax = stamp_tax
+ self._slippage = slippage
+ self._execution = execution
+
+ def _compute_allocations(self) -> dict[str, float]:
+ """资金均分:每个策略槽位拿 total_cash / N。"""
+ n = len(self._strategies)
+ if n == 0:
+ return {}
+ per = self._total_cash / n
+ return {self._key(s): per for s in self._strategies}
+
+ @staticmethod
+ def _key(s: StrategySlot) -> str:
+ """individual_results / allocation 的统一 key:"{label}@{symbol}"。"""
+ return f"{s.label}@{s.symbol}"
+
+ def run(self) -> MultiStrategyResult:
+ """逐策略独立回测,再汇总成组合整体绩效与合并净值曲线。"""
+ allocations = self._compute_allocations()
+ individual_results: dict[str, BacktestResult] = {}
+
+ for slot in self._strategies:
+ key = self._key(slot)
+ cash = allocations.get(key, 0)
+ engine = BacktestEngine(
+ strategy=slot.strategy,
+ cash=cash,
+ commission=self._commission,
+ min_commission=self._min_commission,
+ stamp_tax=self._stamp_tax,
+ slippage=self._slippage,
+ execution=self._execution,
+ )
+ individual_results[key] = engine.run(slot.df)
+
+ total_alloc = sum(allocations.values())
+ equity_pct = {k: v / total_alloc if total_alloc > 0 else 0 for k, v in allocations.items()}
+ combined_equity = self._build_combined_equity(individual_results, allocations)
+ total_perf = self._aggregate_performance(individual_results, allocations, combined_equity)
+
+ return MultiStrategyResult(
+ total_performance=total_perf,
+ individual_results=individual_results,
+ equity_allocation=equity_pct,
+ combined_equity=combined_equity,
+ )
+
+ def _aggregate_performance(
+ self,
+ results: dict[str, BacktestResult],
+ allocations: dict[str, float],
+ combined_equity: pd.DataFrame,
+ ) -> dict[str, float]:
+ """组合整体绩效:基于合并净值曲线 + 汇总成交算完整 19 项指标。
+
+ 与 PortfolioBacktestEngine 仅给 4 个字段不同,这里把合并净值曲线和所有
+ 槽位的成交汇总,喂给 PerformanceAnalyzer,得到与单标的回测同口径的完整
+ 指标(夏普/回撤/胜率/盈亏比等),便于前端复用 MetricTable 展示。
+ """
+ from easy_tdx.backtest.performance import PerformanceAnalyzer
+
+ total_cash = sum(allocations.values())
+ base: dict[str, float] = {
+ "total_stocks": float(len(results)), # 字段名沿用 PortfolioResult
+ "total_cash": total_cash,
+ }
+ if not results or len(combined_equity) < 2:
+ base.update({"total_return": 0.0, "annual_return": 0.0})
+ return base
+
+ # 汇总所有槽位的成交(concat 成一张表,PerformanceAnalyzer 据此算
+ # 胜率/盈亏比/平均盈亏等交易类指标)。所有策略均无成交时给空表兜底。
+ trade_frames = [r.trades for r in results.values() if len(r.trades) > 0]
+ all_trades = (
+ pd.concat(trade_frames, ignore_index=True)
+ if trade_frames
+ else pd.DataFrame(columns=["direction", "pnl", "rejected"])
+ )
+
+ analyzer = PerformanceAnalyzer(equity_curve=combined_equity, trades=all_trades)
+ metrics = analyzer.compute()
+ metrics["total_stocks"] = float(len(results))
+ metrics["total_cash"] = total_cash
+ return metrics
+
+ def _build_combined_equity(
+ self,
+ results: dict[str, BacktestResult],
+ allocations: dict[str, float],
+ ) -> pd.DataFrame:
+ """把各策略独立净值曲线按日期并集 ffill 对齐后求和。
+
+ 算法与 ``PortfolioBacktestEngine._build_combined_equity`` 一致:
+ 各策略回测日期范围可能不同(取数差异、停牌),取 datetime 并集,
+ 每个策略的 total 列 forward-fill 对齐到并集后求和得组合总净值,
+ 再算回撤。
+ """
+ del allocations # 资金分配不参与曲线形状(各策略独立 full cash 回测,
+ # 合并的是 normalized 的净值贡献;保持签名与 Portfolio 版一致便于对照)
+ empty = pd.DataFrame(columns=["datetime", "total", "drawdown", "drawdown_pct"])
+ if not results:
+ return empty
+
+ series_list: list[pd.Series] = []
+ for key, result in results.items():
+ ec = result.equity_curve
+ if len(ec) == 0:
+ continue
+ dt = ec["datetime"]
+ if dt.dtype.kind in "iu": # int YYYYMMDD
+ dt = pd.to_datetime(dt.astype(str), format="%Y%m%d")
+ elif dt.dtype != "datetime64[ns]":
+ dt = pd.to_datetime(dt)
+ s = pd.Series(ec["total"].to_numpy(), index=dt, name=key)
+ series_list.append(s)
+
+ if not series_list:
+ return empty
+
+ aligned = pd.concat(series_list, axis=1).sort_index()
+ aligned = aligned.ffill().fillna(0)
+ total = aligned.sum(axis=1)
+
+ # 回撤:用正值约定(peak - total),与单标的 PortfolioTracker.equity_curve
+ # 及 PerformanceAnalyzer 一致;EquityChart 也按正值展示(前端取负向下画)。
+ peak = total.cummax()
+ drawdown = peak - total
+ initial = peak.iloc[0] if len(peak) > 0 and peak.iloc[0] != 0 else 1.0
+ drawdown_pct = drawdown / initial
+
+ return pd.DataFrame(
+ {
+ "datetime": total.index,
+ "total": total.to_numpy(),
+ "drawdown": drawdown.to_numpy(),
+ "drawdown_pct": drawdown_pct.to_numpy(),
+ }
+ ).reset_index(drop=True)
diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py
index a17c499..75aa12f 100644
--- a/src/easy_tdx/web/app.py
+++ b/src/easy_tdx/web/app.py
@@ -166,6 +166,7 @@ def _create_app(
from easy_tdx.web.routers.market import router as market_router
from easy_tdx.web.routers.realtime import router as realtime_router
from easy_tdx.web.routers.sina import router as sina_router
+ from easy_tdx.web.routers.strategies import router as strategies_router
app.include_router(market_router, prefix="/api/v1")
app.include_router(bars_router, prefix="/api/v1")
@@ -187,5 +188,7 @@ def _create_app(
app.include_router(sina_router, prefix="/api/v1")
# 回测路由(纯计算,不依赖行情连接 lifespan)
app.include_router(backtest_router, prefix="/api/v1")
+ # 策略库路由(SQLite 持久化,纯数据 CRUD)
+ app.include_router(strategies_router, prefix="/api/v1")
return app
diff --git a/src/easy_tdx/web/backtest_schemas.py b/src/easy_tdx/web/backtest_schemas.py
index 32ba94a..a966286 100644
--- a/src/easy_tdx/web/backtest_schemas.py
+++ b/src/easy_tdx/web/backtest_schemas.py
@@ -20,6 +20,11 @@ __all__ = [
"OptimizeAllBacktestRequest",
"OptimizeAllResult",
"OptimizeAllRankEntry",
+ "SavedStrategy",
+ "SavedStrategyCreate",
+ "SavedStrategyListResponse",
+ "MultiStrategyItem",
+ "MultiStrategyBacktestRequest",
"serialize_result",
]
@@ -258,6 +263,106 @@ class OptimizeAllResult(BaseModel):
total_grid_points: int = 0 # 所有策略网格点合计
+# ── 已保存策略(策略库 / StrategyLibrary)───────────────────────────────────────
+
+
+class SavedStrategyCreate(BaseModel):
+ """新建一条已保存策略的请求体。
+
+ 前端在单标的/组合回测结果区点「保存策略」时提交。``strategy`` + ``params``
+ 是回测引擎可直接消费的最小复现形态;``context`` 记录当时测的标的/日期,
+ ``snapshot`` 记录保存时的关键绩效指标("为什么觉得它好")。
+ """
+
+ name: str = Field(..., min_length=1, max_length=120, description="策略名称(用户自拟)")
+ kind: Literal["single", "portfolio"] = Field(..., description="来源:单标的/组合")
+ strategy: str = Field(..., description="策略名(注册表 key,如 ma_cross)")
+ strategy_label: str = Field(default="", description="策略展示名")
+ params: dict[str, Any] = Field(default_factory=dict)
+ context: dict[str, Any] = Field(
+ default_factory=dict,
+ description="标的上下文:single 存 symbol/category/start_date/end_date;"
+ "portfolio 存 stocks 列表",
+ )
+ trade_config: dict[str, Any] = Field(
+ default_factory=dict, description="资金与成本配置(cash/commission/...)"
+ )
+ snapshot: dict[str, Any] = Field(
+ default_factory=dict, description="保存时的成绩快照(total_return/sharpe/...)"
+ )
+ tags: list[str] = Field(default_factory=list)
+ notes: str = Field(default="", max_length=2000)
+
+
+class SavedStrategy(BaseModel):
+ """一条已保存策略(响应模型,含 id 与时间戳)。"""
+
+ id: str
+ name: str
+ kind: Literal["single", "portfolio"]
+ strategy: str
+ strategy_label: str = ""
+ params: dict[str, Any] = {}
+ context: dict[str, Any] = {}
+ trade_config: dict[str, Any] = {}
+ snapshot: dict[str, Any] = {}
+ tags: list[str] = []
+ notes: str = ""
+ created_at: str = ""
+ updated_at: str = ""
+ app_version: str = ""
+
+
+class SavedStrategyListResponse(BaseModel):
+ """策略库列表响应。"""
+
+ strategies: list[SavedStrategy]
+ count: int
+
+
+# ── 多策略组合回测(资金分仓 / 并行制)──────────────────────────────────────────
+
+
+class MultiStrategyItem(BaseModel):
+ """多策略组合回测的单条策略槽位。
+
+ 每条 = 一个策略 + 它的参数 + 它要跑的原标的 + 日期范围。资金由请求体的
+ ``cash`` 统一给出,引擎按策略数均分到各条。
+ """
+
+ strategy: str = Field(..., description="策略名(注册表 key,如 ma_cross)")
+ strategy_label: str = Field(default="", description="策略展示名(用于结果 key)")
+ params: dict[str, Any] = Field(default_factory=dict)
+ symbol: str = Field(
+ ...,
+ pattern=r"^(SZ|SH|BJ):\d{6}$",
+ description='标的完整代码,格式 "市场:6位代码",如 "SH:601088"',
+ )
+ category: Literal["DAY", "WEEK", "MONTH", "MIN_5", "MIN_15", "MIN_30", "MIN_60"] = Field(
+ default="DAY"
+ )
+ start_date: str | None = Field(default=None, description="开始日期 YYYY-MM-DD(可选过滤)")
+ end_date: str | None = Field(default=None, description="结束日期 YYYY-MM-DD(可选过滤)")
+
+
+class MultiStrategyBacktestRequest(BaseModel):
+ """多策略组合回测请求(资金分仓)。
+
+ 勾选 N 个策略,各跑在各自原标的上,总资金按策略数均分。响应该请求的后台任务
+ 结果是 ``MultiStrategyResult``(结构同 ``PortfolioResult``,前端复用组合页图表)。
+ """
+
+ items: list[MultiStrategyItem] = Field(
+ ..., min_length=1, max_length=20, description="策略槽位列表(1~20 条)"
+ )
+ cash: float = Field(default=1_000_000.0, gt=0, description="组合总资金(均分给各策略)")
+ commission: float = Field(default=0.0003, ge=0, le=0.01)
+ min_commission: float = Field(default=5.0, ge=0)
+ stamp_tax: float = Field(default=0.001, ge=0, le=0.01)
+ slippage: float = Field(default=0.0, ge=0, le=0.05)
+ execution: Literal["next_open", "next_close"] = Field(default="next_open")
+
+
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
diff --git a/src/easy_tdx/web/routers/backtest.py b/src/easy_tdx/web/routers/backtest.py
index f7dbba6..2c3abd5 100644
--- a/src/easy_tdx/web/routers/backtest.py
+++ b/src/easy_tdx/web/routers/backtest.py
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends
from easy_tdx.web.backtest_schemas import (
BacktestRequest,
BacktestResultResponse,
+ MultiStrategyBacktestRequest,
OptimizeAllBacktestRequest,
OptimizeAllRankEntry,
OptimizeAllResult,
@@ -182,7 +183,37 @@ async def run_portfolio_backtest_async(
return TaskSubmitResponse(task_id=task_id, status=status)
-# ── 参数网格寻优 ─────────────────────────────────────────────────────────────
+# ── 多策略组合回测(资金分仓) ───────────────────────────────────────────────
+
+
+@router.post(
+ "/backtest/multi-strategy/run/async", response_model=TaskSubmitResponse, status_code=202
+)
+async def run_multi_strategy_backtest_async(
+ req: MultiStrategyBacktestRequest,
+ client: Any = Depends(get_client),
+) -> TaskSubmitResponse:
+ """提交多策略组合回测后台任务(资金分仓 / 并行制)。
+
+ 勾选 N 个策略,各自在原标的(取最新行情)上独立回测,各拿总资金 1/N。
+ 单个策略取数失败则跳过(不中断整组),全部失败返回 400。结果为
+ MultiStrategyResult(结构同 PortfolioResult),通过 GET /backtest/tasks/{task_id} 轮询。
+ """
+ slots = await _fetch_multi_strategy_bars(client, req.items)
+ if not slots:
+ raise ValueError("所有策略槽位均未取到有效行情数据")
+
+ snapshot = req.model_copy()
+ description = f"多策略组合 | {len(slots)}个策略"
+
+ runner = get_runner()
+ task_id = runner.submit(
+ lambda: _run_multi_strategy_backtest(slots, snapshot),
+ description=description,
+ )
+ state = runner.get(task_id)
+ status: Any = state.status if state.status in ("pending", "running") else "running"
+ return TaskSubmitResponse(task_id=task_id, status=status)
@router.post("/backtest/optimize/run/async", response_model=TaskSubmitResponse, status_code=202)
@@ -426,6 +457,93 @@ async def _fetch_portfolio_bars(
return stock_data_list
+async def _fetch_multi_strategy_bars(
+ client: Any,
+ items: list[Any],
+) -> list[Any]:
+ """逐个策略槽位取行情 + 构造策略实例,组装 StrategySlot 列表(async)。
+
+ 每条 item 自带 symbol(如 "SH:601088")、category、start/end_date、strategy+params。
+ 单条取数或策略构造失败则跳过(不中断整组)。返回的 StrategySlot 已绑定好策略
+ 实例与 df,可直接交给后台线程跑引擎(避免把 async client 带进线程)。
+ """
+ from easy_tdx.backtest.multi_strategy_engine import StrategySlot
+ from easy_tdx.backtest.strategies import get_registry
+ from easy_tdx.web.convert import category_from_str, market_from_str
+
+ registry = get_registry()
+ slots: list[StrategySlot] = []
+ for item in items:
+ # 1. 解析策略(未知策略跳过)
+ try:
+ entry = registry.get(item.strategy)
+ except KeyError:
+ continue
+ # 2. 逐页取行情(覆盖 start_date,最多 10 页 = 8000 根)
+ market_str, code = item.symbol.split(":", 1)
+ frames: list[pd.DataFrame] = []
+ for page in range(10):
+ try:
+ page_df = await client.get_security_bars(
+ market_from_str(market_str),
+ code,
+ category_from_str(item.category),
+ page * 800,
+ 800,
+ )
+ except Exception:
+ break
+ if len(page_df) == 0:
+ break
+ frames.append(page_df)
+ if item.start_date and len(page_df) > 0:
+ dt_col = "datetime" if "datetime" in page_df.columns else "date"
+ oldest = str(page_df[dt_col].iloc[-1])[:10]
+ if oldest <= item.start_date:
+ break
+ if len(page_df) < 800:
+ break
+ if not frames:
+ continue
+ df = pd.concat(frames, ignore_index=True)
+ if "datetime" not in df.columns and "date" in df.columns:
+ df = df.copy()
+ df["datetime"] = df["date"]
+ df = df.sort_values("datetime").reset_index(drop=True)
+ # 日期范围过滤
+ if item.start_date or item.end_date:
+ df = _filter_df_by_date(df, item.start_date, item.end_date)
+ if len(df) < 2:
+ continue
+ # 3. 构造策略实例(参数非法跳过该条)
+ try:
+ strategy = entry.build(item.params)
+ except ValueError:
+ continue
+ label = item.strategy_label or entry.label
+ slots.append(StrategySlot(label=label, symbol=item.symbol, strategy=strategy, df=df))
+ return slots
+
+
+def _run_multi_strategy_backtest(
+ slots: list[Any], req: MultiStrategyBacktestRequest
+) -> dict[str, Any]:
+ """执行多策略组合回测并返回清洗后的结果字典(后台线程内调用)。"""
+ from easy_tdx.backtest.multi_strategy_engine import MultiStrategyEngine
+
+ engine = MultiStrategyEngine(
+ strategies=slots,
+ total_cash=req.cash,
+ commission=req.commission,
+ min_commission=req.min_commission,
+ stamp_tax=req.stamp_tax,
+ slippage=req.slippage,
+ execution=req.execution,
+ )
+ result = engine.run()
+ return serialize_result(result)
+
+
def _run_optimize(df: pd.DataFrame, req: OptimizeBacktestRequest) -> dict[str, Any]:
"""执行参数网格寻优并返回清洗后的结果字典(后台线程内调用)。"""
from easy_tdx.backtest.optimizer import ParamGridOptimizer
diff --git a/src/easy_tdx/web/routers/strategies.py b/src/easy_tdx/web/routers/strategies.py
new file mode 100644
index 0000000..939d6d3
--- /dev/null
+++ b/src/easy_tdx/web/routers/strategies.py
@@ -0,0 +1,88 @@
+"""策略库路由:列出 / 查看 / 保存 / 删除用户收藏的策略。
+
+设计要点:
+- 持久化走 :class:`~easy_tdx.web.strategy_store.StrategyStore`(SQLite 单文件),
+ 与回测路由解耦——本路由纯数据 CRUD,不依赖行情连接。
+- 纯计算路径,不注入 tdx_client(与 backtest router 同理由)。
+- ``app_version`` 从 importlib.metadata 取,缺失时留空。
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from easy_tdx.web.backtest_schemas import (
+ SavedStrategy,
+ SavedStrategyCreate,
+ SavedStrategyListResponse,
+)
+from easy_tdx.web.strategy_store import (
+ SavedStrategy as SavedStrategyRecord,
+)
+from easy_tdx.web.strategy_store import (
+ get_store,
+)
+
+router = APIRouter(tags=["strategies"])
+
+
+def _app_version() -> str:
+ try:
+ from importlib.metadata import version
+
+ return version("easy-tdx")
+ except Exception: # noqa: BLE001 — importlib 在某些环境不可用,留空即可
+ return ""
+
+
+def _to_response(rec: SavedStrategyRecord) -> SavedStrategy:
+ """dataclass 记录 → Pydantic 响应模型。"""
+ return SavedStrategy(**rec.to_dict())
+
+
+@router.get("/strategies", response_model=SavedStrategyListResponse)
+async def list_saved_strategies() -> SavedStrategyListResponse:
+ """列出全部已保存策略(按创建时间倒序)。"""
+ store = get_store()
+ items = [_to_response(r) for r in store.list_all()]
+ return SavedStrategyListResponse(strategies=items, count=len(items))
+
+
+@router.get("/strategies/{strategy_id}", response_model=SavedStrategy)
+async def get_saved_strategy(strategy_id: str) -> SavedStrategy:
+ """按 id 查看单条已保存策略。"""
+ store = get_store()
+ rec = store.get(strategy_id)
+ if rec is None:
+ raise ValueError(f"策略 '{strategy_id}' 不存在")
+ return _to_response(rec)
+
+
+@router.post("/strategies", response_model=SavedStrategy, status_code=201)
+async def create_saved_strategy(req: SavedStrategyCreate) -> SavedStrategy:
+ """保存一条策略(含当时的标的上下文与成绩快照)。"""
+ store = get_store()
+ rec = SavedStrategyRecord(
+ id="", # store.add 会自动生成
+ name=req.name,
+ kind=req.kind,
+ strategy=req.strategy,
+ strategy_label=req.strategy_label,
+ params=req.params,
+ context=req.context,
+ trade_config=req.trade_config,
+ snapshot=req.snapshot,
+ tags=req.tags,
+ notes=req.notes,
+ app_version=_app_version(),
+ )
+ saved = store.add(rec)
+ return _to_response(saved)
+
+
+@router.delete("/strategies/{strategy_id}", status_code=204)
+async def delete_saved_strategy(strategy_id: str) -> None:
+ """按 id 删除一条已保存策略。不存在则 404。"""
+ store = get_store()
+ if not store.delete(strategy_id):
+ raise ValueError(f"策略 '{strategy_id}' 不存在")
diff --git a/src/easy_tdx/web/strategy_store.py b/src/easy_tdx/web/strategy_store.py
new file mode 100644
index 0000000..2872842
--- /dev/null
+++ b/src/easy_tdx/web/strategy_store.py
@@ -0,0 +1,232 @@
+"""已保存策略的 SQLite 持久化(用户在 Web UI 上"收藏"的策略 + 成绩快照)。
+
+设计要点:
+- 单文件 SQLite,落在项目统一配置目录(``~/.easy_tdx/strategies.db``,
+ 随 ``EASY_TDX_CONFIG_DIR`` 环境变量走),与 ``config.py`` 同约定。
+- 只提供"加入 / 列出 / 查看 / 删除"四个动作(CRUD 中的 CR**D**,不含编辑),
+ 对应用户诉求:"策略能加入,也要能删除"。
+- 线程安全:每个公共方法内部 ``with sqlite3.connect(...)`` 短连接,配合
+ ``check_same_thread=False`` + 写操作串行(SQLite 单写者锁兜底)。Web 后台
+ 任务在 ThreadPool 内调用,故默认 ``check_same_thread=False``。
+- 表结构简单:单表 ``strategies``,结构化字段建索引,JSON 字段(params /
+ context / snapshot)存 TEXT。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+import threading
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+__all__ = [
+ "SavedStrategy",
+ "StrategyStore",
+ "get_store",
+]
+
+# 写操作串行锁:SQLite 单写者,多线程并发写时保证一次只进一个事务,避免 "database is locked"。
+_write_lock = threading.Lock()
+
+
+def _config_dir() -> Path:
+ """返回统一配置目录(与 config.py 同约定,受 EASY_TDX_CONFIG_DIR 覆盖)。"""
+ return Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
+
+
+def _default_db_path() -> Path:
+ return _config_dir() / "strategies.db"
+
+
+def _now_iso() -> str:
+ """UTC ISO8601 时间戳(带 Z 后缀,JSON 友好)。"""
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+@dataclass
+class SavedStrategy:
+ """一条已保存策略记录(存配置 + 当时成绩快照 + 上下文)。
+
+ - ``strategy`` + ``params`` 是回测引擎可直接消费的最小可复现形态。
+ - ``context`` 记录当时测的是什么(单标的 symbol 或组合 stocks、日期、周期)。
+ - ``snapshot`` 记录"为什么觉得它好"(保存时的关键绩效指标)。
+ """
+
+ id: str
+ name: str
+ kind: str # "single" | "portfolio"
+ strategy: str
+ strategy_label: str = ""
+ params: dict[str, Any] = field(default_factory=dict)
+ context: dict[str, Any] = field(default_factory=dict)
+ trade_config: dict[str, Any] = field(default_factory=dict)
+ snapshot: dict[str, Any] = field(default_factory=dict)
+ tags: list[str] = field(default_factory=list)
+ notes: str = ""
+ created_at: str = ""
+ updated_at: str = ""
+ app_version: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "kind": self.kind,
+ "strategy": self.strategy,
+ "strategy_label": self.strategy_label,
+ "params": self.params,
+ "context": self.context,
+ "trade_config": self.trade_config,
+ "snapshot": self.snapshot,
+ "tags": self.tags,
+ "notes": self.notes,
+ "created_at": self.created_at,
+ "updated_at": self.updated_at,
+ "app_version": self.app_version,
+ }
+
+ @classmethod
+ def from_row(cls, row: sqlite3.Row) -> SavedStrategy:
+ """从数据库行构造(JSON 字段反序列化,tags 为 JSON 数组)。"""
+ tags = json.loads(row["tags"]) if row["tags"] else []
+ return cls(
+ id=row["id"],
+ name=row["name"],
+ kind=row["kind"],
+ strategy=row["strategy"],
+ strategy_label=row["strategy_label"] or "",
+ params=json.loads(row["params"]) if row["params"] else {},
+ context=json.loads(row["context"]) if row["context"] else {},
+ trade_config=json.loads(row["trade_config"]) if row["trade_config"] else {},
+ snapshot=json.loads(row["snapshot"]) if row["snapshot"] else {},
+ tags=tags,
+ notes=row["notes"] or "",
+ created_at=row["created_at"] or "",
+ updated_at=row["updated_at"] or "",
+ app_version=row["app_version"] or "",
+ )
+
+
+class StrategyStore:
+ """已保存策略的 SQLite 存储。
+
+ 单例由 :func:`get_store` 提供;测试时可注入独立 ``db_path``(用 tmp_path)。
+ """
+
+ _SCHEMA = """
+ CREATE TABLE IF NOT EXISTS strategies (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ strategy TEXT NOT NULL,
+ strategy_label TEXT NOT NULL DEFAULT '',
+ params TEXT NOT NULL DEFAULT '{}',
+ context TEXT NOT NULL DEFAULT '{}',
+ trade_config TEXT NOT NULL DEFAULT '{}',
+ snapshot TEXT NOT NULL DEFAULT '{}',
+ tags TEXT NOT NULL DEFAULT '[]',
+ notes TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL DEFAULT '',
+ app_version TEXT NOT NULL DEFAULT ''
+ );
+ CREATE INDEX IF NOT EXISTS idx_strategies_kind ON strategies(kind);
+ CREATE INDEX IF NOT EXISTS idx_strategies_strategy ON strategies(strategy);
+ CREATE INDEX IF NOT EXISTS idx_strategies_created ON strategies(created_at);
+ """
+
+ def __init__(self, db_path: Path | None = None) -> None:
+ self.db_path = db_path or _default_db_path()
+ self._ensure_schema()
+
+ # ── 内部 ───────────────────────────────────────────────────────────────
+
+ def _connect(self) -> sqlite3.Connection:
+ # check_same_thread=False:FastAPI 后台任务跑在 ThreadPool 内会跨线程访问。
+ conn = sqlite3.connect(self.db_path, check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _ensure_schema(self) -> None:
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ with self._connect() as conn:
+ conn.executescript(self._SCHEMA)
+
+ @staticmethod
+ def _new_id() -> str:
+ """生成短 id(uuid4 前 12 位十六进制),足够避免本地单用户碰撞。"""
+ return uuid.uuid4().hex[:12]
+
+ # ── 公共 API ───────────────────────────────────────────────────────────
+
+ def add(self, record: SavedStrategy) -> SavedStrategy:
+ """加入一条策略记录。``id`` / ``created_at`` / ``updated_at`` 为空时自动填充。"""
+ now = _now_iso()
+ if not record.id:
+ record.id = self._new_id()
+ if not record.created_at:
+ record.created_at = now
+ record.updated_at = now
+ with _write_lock, self._connect() as conn:
+ conn.execute(
+ """INSERT INTO strategies
+ (id, name, kind, strategy, strategy_label, params, context,
+ trade_config, snapshot, tags, notes, created_at, updated_at, app_version)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ record.id,
+ record.name,
+ record.kind,
+ record.strategy,
+ record.strategy_label,
+ json.dumps(record.params, ensure_ascii=False),
+ json.dumps(record.context, ensure_ascii=False),
+ json.dumps(record.trade_config, ensure_ascii=False),
+ json.dumps(record.snapshot, ensure_ascii=False),
+ json.dumps(record.tags, ensure_ascii=False),
+ record.notes,
+ record.created_at,
+ record.updated_at,
+ record.app_version,
+ ),
+ )
+ return record
+
+ def list_all(self) -> list[SavedStrategy]:
+ """列出全部策略,按创建时间倒序(最新保存的在前)。"""
+ with self._connect() as conn:
+ rows = conn.execute("SELECT * FROM strategies ORDER BY created_at DESC").fetchall()
+ return [SavedStrategy.from_row(r) for r in rows]
+
+ def get(self, strategy_id: str) -> SavedStrategy | None:
+ """按 id 查看单条;不存在返回 None。"""
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM strategies WHERE id = ?", (strategy_id,)).fetchone()
+ return SavedStrategy.from_row(row) if row else None
+
+ def delete(self, strategy_id: str) -> bool:
+ """按 id 删除;返回是否确实删掉了一条(False = id 不存在)。"""
+ with _write_lock, self._connect() as conn:
+ cur = conn.execute("DELETE FROM strategies WHERE id = ?", (strategy_id,))
+ return cur.rowcount > 0
+
+
+# ── 单例 ───────────────────────────────────────────────────────────────────
+
+_store: StrategyStore | None = None
+_store_lock = threading.Lock()
+
+
+def get_store() -> StrategyStore:
+ """返回全局 StrategyStore 单例(首次调用惰性建库)。"""
+ global _store
+ if _store is None:
+ with _store_lock:
+ if _store is None:
+ _store = StrategyStore()
+ return _store
diff --git a/tests/unit/test_multi_strategy.py b/tests/unit/test_multi_strategy.py
new file mode 100644
index 0000000..763174e
--- /dev/null
+++ b/tests/unit/test_multi_strategy.py
@@ -0,0 +1,211 @@
+"""单元测试:多策略资金分仓组合回测引擎(MultiStrategyEngine)。
+
+覆盖:
+- 基本多策略回测(2~3 个策略,各跑各的 df,合并曲线)
+- 资金均分(1/N)
+- individual_results 的 key 格式 "{label}@{symbol}"
+- 合并净值曲线列结构 + 日期并集对齐
+- 空策略列表兜底
+- 同标的不同策略可区分
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from easy_tdx.backtest.multi_strategy_engine import (
+ MultiStrategyEngine,
+ StrategySlot,
+)
+from easy_tdx.backtest.strategy import Strategy
+
+
+class SimpleBuyStrategy(Strategy):
+ """简单策略:bar 5 买入,bar 30 卖出。"""
+
+ def init(self) -> None:
+ pass
+
+ def next(self) -> None:
+ if self._bar_index == 5 and self.position["size"] == 0:
+ self.buy(size=0)
+ elif self._bar_index == 30 and self.position["size"] > 0:
+ self.sell(size=0)
+
+
+class HoldStrategy(Strategy):
+ """从不交易的策略(净值曲线恒等于初始资金)。"""
+
+ def init(self) -> None:
+ pass
+
+ def next(self) -> None:
+ pass
+
+
+def _make_df(n: int = 100, seed: int = 42, start: str = "2024-01-01") -> pd.DataFrame:
+ """生成随机 OHLCV DataFrame(与 test_portfolio_engine 同构造方式)。"""
+ rng = np.random.default_rng(seed)
+ close = 100.0 + np.cumsum(rng.normal(0, 1, n))
+ high = close + rng.uniform(0, 1, n)
+ low = close - rng.uniform(0, 1, n)
+ open_ = low + rng.uniform(0, high - low, n)
+ vol = rng.integers(1_000_000, 10_000_000, n).astype(float)
+ return pd.DataFrame(
+ {
+ "datetime": pd.date_range(start, periods=n, freq="D"),
+ "open": open_,
+ "high": high,
+ "low": low,
+ "close": close,
+ "vol": vol,
+ "amount": vol * close,
+ }
+ )
+
+
+class TestMultiStrategyEngine:
+ def test_basic_run_two_strategies(self) -> None:
+ """两个策略各跑各的 df,应产出合并结果。"""
+ slots = [
+ StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
+ StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=1_000_000)
+ result = engine.run()
+
+ # individual_results 的 key 形如 "{label}@{symbol}"
+ assert set(result.individual_results.keys()) == {
+ "双均线@SH:601088",
+ "RSI@SZ:000001",
+ }
+ # 整体绩效含基本字段
+ assert "total_return" in result.total_performance
+ assert result.total_performance["total_stocks"] == 2
+ assert result.total_performance["total_cash"] == 1_000_000
+
+ def test_total_performance_has_full_metrics(self) -> None:
+ """组合整体绩效应含完整 19 项指标(夏普/回撤/胜率/盈亏比等),与单标的同口径。"""
+ slots = [
+ StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
+ StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
+ ]
+ perf = MultiStrategyEngine(slots, total_cash=1_000_000).run().total_performance
+ # 关键指标都应在(来自 PerformanceAnalyzer)
+ for key in [
+ "total_return",
+ "annual_return",
+ "sharpe",
+ "sortino",
+ "calmar",
+ "max_drawdown",
+ "max_dd_duration",
+ "volatility",
+ "total_trades",
+ "win_trades",
+ "lose_trades",
+ "win_rate",
+ "profit_factor",
+ "avg_win",
+ "avg_loss",
+ "max_win",
+ "max_loss",
+ ]:
+ assert key in perf, f"缺少指标 {key}"
+ # max_drawdown 用正值约定(与单标的一致),介于 0~1
+ assert 0 <= perf["max_drawdown"] <= 1
+ # 合并净值曲线的 drawdown 也应是正值
+ result = MultiStrategyEngine(slots, total_cash=1_000_000).run()
+ assert (result.combined_equity["drawdown"] >= 0).all()
+
+ def test_capital_split_equal(self) -> None:
+ """资金按策略数均分:每个槽位 1/N。"""
+ slots = [
+ StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
+ StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), _make_df(50, seed=2)),
+ StrategySlot("C", "SZ:000002", SimpleBuyStrategy(), _make_df(50, seed=3)),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=900_000)
+ allocs = engine._compute_allocations() # noqa: SLF001 — 测试内部均分逻辑
+ assert len(allocs) == 3
+ assert all(v == 300_000 for v in allocs.values())
+ # equity_allocation 是占比,各 1/3
+ result = engine.run()
+ assert all(abs(v - 1 / 3) < 1e-9 for v in result.equity_allocation.values())
+
+ def test_combined_equity_has_expected_columns(self) -> None:
+ """合并净值曲线应有 datetime/total/drawdown/drawdown_pct 列。"""
+ slots = [
+ StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(60, seed=7)),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=500_000)
+ result = engine.run()
+ cols = set(result.combined_equity.columns)
+ assert {"datetime", "total", "drawdown", "drawdown_pct"} <= cols
+ assert len(result.combined_equity) > 0
+
+ def test_combined_equity_aligns_disjoint_dates(self) -> None:
+ """两个策略日期范围不同时,合并曲线应按并集对齐(ffill)。"""
+ # 策略 A 跑 2024-01 起 60 根,策略 B 跑 2024-03 起 60 根
+ df_a = _make_df(60, seed=1, start="2024-01-01")
+ df_b = _make_df(60, seed=2, start="2024-03-01")
+ slots = [
+ StrategySlot("A", "SH:601088", SimpleBuyStrategy(), df_a),
+ StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), df_b),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=1_000_000)
+ result = engine.run()
+ # 合并曲线长度应至少覆盖两个范围的最晚结束日(并集)
+ assert len(result.combined_equity) >= 60
+
+ def test_empty_strategies_returns_empty_result(self) -> None:
+ """空策略列表应返回空结果,不抛异常。"""
+ engine = MultiStrategyEngine([], total_cash=1_000_000)
+ result = engine.run()
+ assert result.individual_results == {}
+ assert result.total_performance["total_return"] == 0.0
+ # combined_equity 为带表头的空 DataFrame
+ assert len(result.combined_equity) == 0
+ assert set(result.combined_equity.columns) == {
+ "datetime",
+ "total",
+ "drawdown",
+ "drawdown_pct",
+ }
+
+ def test_same_symbol_different_strategies_distinguished(self) -> None:
+ """同标的不同策略应能区分(key 含 label)。"""
+ df = _make_df(60, seed=5)
+ slots = [
+ StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), df.copy()),
+ StrategySlot("RSI", "SH:601088", HoldStrategy(), df.copy()),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=1_000_000)
+ result = engine.run()
+ # 两个 key 不同,都带同一 symbol
+ assert "双均线@SH:601088" in result.individual_results
+ assert "RSI@SH:601088" in result.individual_results
+
+ def test_hold_strategy_keeps_initial_capital(self) -> None:
+ """从不交易的策略,其净值曲线末值应等于初始分得资金。"""
+ slots = [
+ StrategySlot("Hold", "SH:601088", HoldStrategy(), _make_df(40, seed=1)),
+ ]
+ engine = MultiStrategyEngine(slots, total_cash=1_000_000)
+ result = engine.run()
+ ec = result.individual_results["Hold@SH:601088"].equity_curve
+ # 不交易 → 末值 ≈ 初始资金 1_000_000(单策略拿全部)
+ assert abs(ec["total"].iloc[-1] - 1_000_000) < 1.0
+
+ def test_to_dict_serializable(self) -> None:
+ """to_dict 应产出 JSON 兼容结构(含 individual_results / combined_equity)。"""
+ slots = [
+ StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
+ ]
+ result = MultiStrategyEngine(slots, total_cash=500_000).run()
+ d = result.to_dict()
+ assert "total_performance" in d
+ assert "individual_results" in d
+ assert "combined_equity" in d
+ assert isinstance(d["individual_results"]["A@SH:601088"], dict)
diff --git a/tests/unit/test_strategy_store.py b/tests/unit/test_strategy_store.py
new file mode 100644
index 0000000..871fab5
--- /dev/null
+++ b/tests/unit/test_strategy_store.py
@@ -0,0 +1,256 @@
+"""策略库(已保存策略)持久化 + Web API 测试(离线,无网络)。
+
+覆盖:
+- ``StrategyStore``:加入 / 列出 / 查看 / 删除 / 时间戳自动填充 / 重复 id
+- 路由端到端:POST 创建、GET 列表、GET 详情、DELETE、404 路径、校验
+"""
+
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+
+pytest.importorskip("fastapi")
+
+from fastapi import FastAPI # noqa: E402
+from fastapi.testclient import TestClient # noqa: E402
+
+from easy_tdx.web.strategy_store import SavedStrategy, StrategyStore # noqa: E402
+
+# ── StrategyStore 单元测试 ────────────────────────────────────────────────────
+
+
+@pytest.fixture()
+def store(tmp_path) -> StrategyStore:
+ """每个测试独立 SQLite 文件,互不污染。"""
+ return StrategyStore(db_path=tmp_path / "test_strategies.db")
+
+
+def _sample_single(name: str = "双均线·平安") -> SavedStrategy:
+ return SavedStrategy(
+ id="",
+ name=name,
+ kind="single",
+ strategy="ma_cross",
+ strategy_label="双均线交叉",
+ params={"fast": 5, "slow": 20},
+ context={
+ "symbol": "SZ:000001",
+ "category": "DAY",
+ "start_date": "2023-01-01",
+ "end_date": "2024-12-31",
+ },
+ trade_config={"cash": 1_000_000, "commission": 0.0003, "execution": "next_open"},
+ snapshot={"total_return": 0.352, "max_drawdown": -0.12, "sharpe": 1.42},
+ tags=["银行", "长线"],
+ notes="回撤可控",
+ )
+
+
+def _sample_portfolio(name: str = "组合·消费双雄") -> SavedStrategy:
+ return SavedStrategy(
+ id="",
+ name=name,
+ kind="portfolio",
+ strategy="rsi_reversal",
+ strategy_label="RSI 反转",
+ params={"period": 14, "oversold": 30},
+ context={"stocks": ["SH:600519", "SZ:000858"]},
+ snapshot={"total_return": 0.18},
+ )
+
+
+def test_add_assigns_id_and_timestamps(store: StrategyStore):
+ rec = store.add(_sample_single())
+ assert rec.id and len(rec.id) == 12
+ assert rec.created_at
+ assert rec.updated_at == rec.created_at
+
+
+def test_list_round_trip_preserves_all_fields(store: StrategyStore):
+ original = store.add(_sample_single())
+ items = store.list_all()
+ assert len(items) == 1
+ got = items[0]
+ assert got.id == original.id
+ assert got.name == "双均线·平安"
+ assert got.kind == "single"
+ assert got.params == {"fast": 5, "slow": 20}
+ assert got.context["symbol"] == "SZ:000001"
+ assert got.trade_config["cash"] == 1_000_000
+ assert got.snapshot["total_return"] == pytest.approx(0.352)
+ assert got.tags == ["银行", "长线"]
+ assert got.notes == "回撤可控"
+
+
+def test_list_orders_by_created_desc(store: StrategyStore):
+ a = store.add(_sample_single(name="first"))
+ b = store.add(_sample_portfolio(name="second"))
+ names = [x.name for x in store.list_all()]
+ # 后加的在前
+ assert names == ["second", "first"]
+ assert {x.id for x in (a, b)} == {a.id, b.id}
+
+
+def test_get_returns_none_for_missing(store: StrategyStore):
+ assert store.get("nope") is None
+
+
+def test_get_returns_record(store: StrategyStore):
+ rec = store.add(_sample_portfolio())
+ got = store.get(rec.id)
+ assert got is not None
+ assert got.kind == "portfolio"
+ assert got.context["stocks"] == ["SH:600519", "SZ:000858"]
+
+
+def test_delete_removes_record(store: StrategyStore):
+ rec = store.add(_sample_single())
+ assert store.delete(rec.id) is True
+ assert store.get(rec.id) is None
+ assert store.list_all() == []
+
+
+def test_delete_missing_returns_false(store: StrategyStore):
+ assert store.delete("nonexistent") is False
+
+
+def test_store_creates_db_file_and_schema(tmp_path):
+ db_path = tmp_path / "nested" / "strategies.db"
+ s = StrategyStore(db_path=db_path)
+ assert db_path.exists()
+ # schema 已建表 + 索引
+ with sqlite3.connect(db_path) as conn:
+ tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
+ indexes = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")}
+ assert "strategies" in tables
+ assert {"idx_strategies_kind", "idx_strategies_strategy", "idx_strategies_created"} <= indexes
+ # 可正常写入
+ s.add(_sample_single())
+ assert len(s.list_all()) == 1
+
+
+def test_json_fields_with_unicode(store: StrategyStore):
+ """中文标签/备注应无损往返(ensure_ascii=False 落库)。"""
+ rec = store.add(
+ SavedStrategy(
+ id="",
+ name="测试·中文🎉",
+ kind="single",
+ strategy="macd",
+ notes="这是一段中文备注",
+ tags=["标签一", "标签二"],
+ )
+ )
+ got = store.get(rec.id)
+ assert got is not None
+ assert got.name == "测试·中文🎉"
+ assert got.notes == "这是一段中文备注"
+ assert got.tags == ["标签一", "标签二"]
+
+
+# ── 路由端到端测试(TestClient)──────────────────────────────────────────────
+
+
+@pytest.fixture()
+def client(tmp_path, monkeypatch) -> TestClient:
+ """构造一个用临时 SQLite 文件的独立 app + store 单例。"""
+ # 用 monkeypatch 替换 get_store 返回的路径,保证测试隔离
+ from easy_tdx.web import strategy_store as mod
+
+ test_store = StrategyStore(db_path=tmp_path / "router_strategies.db")
+ # 替换单例,避免污染全局
+ monkeypatch.setattr(mod, "_store", test_store)
+
+ from easy_tdx.web.routers.strategies import router as strategies_router
+
+ app = FastAPI()
+ app.include_router(strategies_router, prefix="/api/v1")
+ # 复用项目的 ValueError → 400 处理
+ from easy_tdx.web.errors import register_exception_handlers
+
+ register_exception_handlers(app)
+ return TestClient(app)
+
+
+def _create_payload(kind: str = "single", **over) -> dict:
+ base = {
+ "name": "我的策略",
+ "kind": kind,
+ "strategy": "ma_cross",
+ "strategy_label": "双均线交叉",
+ "params": {"fast": 5, "slow": 20},
+ "context": {"symbol": "SZ:000001"},
+ "trade_config": {"cash": 1000000},
+ "snapshot": {"total_return": 0.35, "sharpe": 1.4},
+ "tags": ["银行"],
+ "notes": "观察中",
+ }
+ base.update(over)
+ return base
+
+
+def test_router_create_then_list_get_delete(client: TestClient):
+ # 1. 创建
+ resp = client.post("/api/v1/strategies", json=_create_payload())
+ assert resp.status_code == 201
+ created = resp.json()
+ assert created["id"]
+ assert created["name"] == "我的策略"
+ assert created["params"] == {"fast": 5, "slow": 20}
+ assert created["created_at"]
+ sid = created["id"]
+
+ # 2. 列表
+ resp = client.get("/api/v1/strategies")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["count"] == 1
+ assert body["strategies"][0]["id"] == sid
+
+ # 3. 详情
+ resp = client.get(f"/api/v1/strategies/{sid}")
+ assert resp.status_code == 200
+ assert resp.json()["snapshot"]["total_return"] == pytest.approx(0.35)
+
+ # 4. 删除
+ resp = client.delete(f"/api/v1/strategies/{sid}")
+ assert resp.status_code == 204
+
+ # 5. 列表为空
+ assert client.get("/api/v1/strategies").json()["count"] == 0
+
+
+def test_router_get_missing_returns_400(client: TestClient):
+ # 不存在的 id → ValueError → 400(项目错误处理约定)
+ resp = client.get("/api/v1/strategies/nonexistent")
+ assert resp.status_code == 400
+
+
+def test_router_delete_missing_returns_400(client: TestClient):
+ resp = client.delete("/api/v1/strategies/nonexistent")
+ assert resp.status_code == 400
+
+
+def test_router_rejects_empty_name(client: TestClient):
+ resp = client.post("/api/v1/strategies", json=_create_payload(name=""))
+ assert resp.status_code == 422 # Pydantic 校验失败
+
+
+def test_router_rejects_invalid_kind(client: TestClient):
+ resp = client.post("/api/v1/strategies", json=_create_payload(kind="bogus"))
+ assert resp.status_code == 422
+
+
+def test_router_accepts_portfolio_kind(client: TestClient):
+ payload = _create_payload(
+ kind="portfolio",
+ strategy="rsi_reversal",
+ context={"stocks": ["SH:600519", "SZ:000858"]},
+ )
+ resp = client.post("/api/v1/strategies", json=payload)
+ assert resp.status_code == 201
+ body = resp.json()
+ assert body["kind"] == "portfolio"
+ assert body["context"]["stocks"] == ["SH:600519", "SZ:000858"]
diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue
index 48ae2d2..cc63149 100644
--- a/web-ui/src/App.vue
+++ b/web-ui/src/App.vue
@@ -11,6 +11,7 @@
K线 + 买卖点
+ 保存你觉得不错的策略,下次直接载入或重跑。共 {{ strategies.length }} 条。 + 勾选多个单标的策略可做「组合回测」——各拿 1/N 资金、各跑原标的,看综合表现。 +
+还没有保存的策略。
++ 在「单标的回测」或「组合回测」跑出满意结果后,点结果区的「保存策略」即可收藏到这里。 +
+加载中…
+{{ s.notes }}
+ +无持仓数据
+| 策略 | +标的 | +状态 | +持仓数量 | +成本价 | +市值 | +未实现盈亏 | +收益率 | +
|---|---|---|---|---|---|---|---|
| {{ h.strategyLabel }} | +{{ h.symbol }} | ++ + {{ h.holding ? '持仓' : '空仓' }} + + | +{{ h.size > 0 ? h.size.toFixed(0) : '-' }} | +{{ h.holding ? h.avgPrice.toFixed(2) : '-' }} | +{{ h.holding ? h.marketValue.toFixed(0) : '-' }} | ++ {{ h.holding ? (h.unrealizedPnl > 0 ? '+' : '') + h.unrealizedPnl.toFixed(0) : '-' }} + | ++ {{ h.holding ? (h.unrealizedPct * 100).toFixed(2) + '%' : '-' }} + | +