release: v1.17.11 — Web UI 策略库(SQLite 持久化)+ 多策略资金分仓组合回测

新增两层能力:
1. 策略库:单标的/组合回测结果可保存到本地 SQLite 单文件
   (~/.easy_tdx/strategies.db),策略库页可载入回填、重跑、删除。
2. 多策略组合回测:勾选 N 个单标的策略,各拿 1/N 资金、各跑原标的,
   净值曲线按日期并集对齐求和,组合结果含 19 项完整绩效指标 + 持仓表。

后端:strategy_store.py(SQLite CRUD) + multi_strategy_engine.py(资金分仓引擎)
+ routers/strategies.py + /backtest/multi-strategy/run/async。
前端:StrategiesView.vue + 保存策略按钮 + 复用组合页图表组件。

895 单测全绿(+24 新增),ruff/mypy strict/前端 vue-tsc 全通过。
This commit is contained in:
Justin Gu
2026-07-04 20:40:57 +08:00
parent 4db7104209
commit 05dc9a74af
19 changed files with 2742 additions and 11 deletions
+23
View File
@@ -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 + contextsymbol 或 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 单测全绿(无回归)。
+13 -4
View File
@@ -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 AgentClaude 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 环境。
+1 -1
View File
@@ -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"
@@ -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)
+3
View File
@@ -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
+105
View File
@@ -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")
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
+119 -1
View File
@@ -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
+88
View File
@@ -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}' 不存在")
+232
View File
@@ -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=FalseFastAPI 后台任务跑在 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
+211
View File
@@ -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)
+256
View File
@@ -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"]
+1
View File
@@ -11,6 +11,7 @@
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
<RouterLink to="/strategies" active-class="active">策略库</RouterLink>
</nav>
</header>
<main class="app-main">
+51 -1
View File
@@ -7,11 +7,15 @@ import type {
BacktestResult,
Bar,
Category,
MultiStrategyBacktestRequest,
OptimizeAllBacktestRequest,
OptimizeBacktestRequest,
PortfolioBacktestRequest,
TaskListResponse,
SavedStrategy,
SavedStrategyCreate,
SavedStrategyListResponse,
StrategiesResponse,
TaskListResponse,
TaskState,
TaskSubmitResponse,
} from './types'
@@ -148,6 +152,19 @@ export async function submitPortfolioTask(
return (await resp.json()) as TaskSubmitResponse
}
/** 提交多策略组合回测后台任务(资金分仓),返回 task_id。 */
export async function submitMultiStrategyTask(
req: MultiStrategyBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/multi-strategy/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交参数网格寻优后台任务,返回 task_id。 */
export async function submitOptimizeTask(
req: OptimizeBacktestRequest,
@@ -214,3 +231,36 @@ export async function runBacktestWithPolling(
await new Promise((r) => setTimeout(r, intervalMs))
}
}
// ── 策略库(已保存策略)──────────────────────────────────────────────────────
/** 列出全部已保存策略(按创建时间倒序)。 */
export async function fetchSavedStrategies(): Promise<SavedStrategyListResponse> {
const resp = await fetch(`${BASE}/strategies`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategyListResponse
}
/** 查看单条已保存策略。 */
export async function fetchSavedStrategy(id: string): Promise<SavedStrategy> {
const resp = await fetch(`${BASE}/strategies/${id}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategy
}
/** 保存一条策略(含当时的标的上下文与成绩快照)。 */
export async function saveStrategy(req: SavedStrategyCreate): Promise<SavedStrategy> {
const resp = await fetch(`${BASE}/strategies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategy
}
/** 删除一条已保存策略。 */
export async function deleteSavedStrategy(id: string): Promise<void> {
const resp = await fetch(`${BASE}/strategies/${id}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
}
+3 -1
View File
@@ -4,13 +4,15 @@ import BacktestView from './views/BacktestView.vue'
import CompareView from './views/CompareView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
import StrategiesView from './views/StrategiesView.vue'
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize+ 结果对比(/compare)。
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize+ 结果对比(/compare+ 策略库(/strategies
const routes = [
{ path: '/', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
{ path: '/optimize', name: 'optimize', component: OptimizeView },
{ path: '/compare', name: 'compare', component: CompareView },
{ path: '/strategies', name: 'strategies', component: StrategiesView },
]
export const router = createRouter({
+45
View File
@@ -11,12 +11,14 @@ import {
submitPortfolioTask,
submitOptimizeAllTask,
submitOptimizeTask,
submitMultiStrategyTask,
fetchTask,
} from '../api'
import type {
BacktestRequest,
BacktestResult,
Bar,
MultiStrategyBacktestRequest,
PortfolioBacktestRequest,
PortfolioResult,
OptimizeAllBacktestRequest,
@@ -117,6 +119,45 @@ export const useBacktestStore = defineStore('backtest', () => {
error.value = ''
}
// ── 多策略组合回测(资金分仓) ─────────────────────────────────────────
const multiStrategyResult = ref<PortfolioResult | null>(null)
const multiStrategyRunning = ref(false)
/** 提交多策略组合回测后台任务并轮询直到完成。
* 结果结构同 PortfolioResult(复用组合页图表组件)。 */
async function runMultiStrategy(req: MultiStrategyBacktestRequest) {
multiStrategyRunning.value = true
error.value = ''
multiStrategyResult.value = null
try {
const { task_id } = await submitMultiStrategyTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
if (state.status === 'done' && state.result) {
multiStrategyResult.value = state.result as PortfolioResult
break
}
if (state.status === 'failed') {
throw new Error(state.error || '多策略组合回测失败')
}
if (Date.now() - start > 180_000) throw new Error('多策略组合回测超时(180s')
await new Promise((r) => setTimeout(r, 400))
}
} catch (e) {
error.value = formatError(e)
multiStrategyResult.value = null
} finally {
multiStrategyRunning.value = false
}
}
function clearMultiStrategy() {
multiStrategyResult.value = null
error.value = ''
}
// ── 参数网格寻优(Phase 4) ─────────────────────────────────────────────
const optimizeResult = ref<OptimizeResult | null>(null)
const optimizeRunning = ref(false)
@@ -195,6 +236,8 @@ export const useBacktestStore = defineStore('backtest', () => {
error,
portfolioResult,
portfolioRunning,
multiStrategyResult,
multiStrategyRunning,
optimizeResult,
optimizeRunning,
optimizeAllResult,
@@ -208,6 +251,8 @@ export const useBacktestStore = defineStore('backtest', () => {
clearResult,
runPortfolio,
clearPortfolio,
runMultiStrategy,
clearMultiStrategy,
runOptimize,
runOptimizeAll,
}
+66
View File
@@ -263,3 +263,69 @@ export interface ApiError {
error: string
detail: string
}
// ── 策略库(已保存策略,GET/POST/DELETE /api/v1/strategies ─────────────────
/** 新建一条已保存策略的请求体(前端在回测结果区点「保存」时提交)。 */
export interface SavedStrategyCreate {
name: string
kind: 'single' | 'portfolio'
strategy: string
strategy_label?: string
params?: Record<string, number | string | boolean>
/** 标的上下文:single 存 symbol/category/start_date/end_dateportfolio 存 stocks */
context?: Record<string, unknown>
/** 资金与成本配置(cash/commission/... */
trade_config?: Record<string, unknown>
/** 保存时的成绩快照(total_return/sharpe/... */
snapshot?: Record<string, unknown>
tags?: string[]
notes?: string
}
/** 一条已保存策略(响应模型,含 id 与时间戳)。 */
export interface SavedStrategy {
id: string
name: string
kind: 'single' | 'portfolio'
strategy: string
strategy_label: string
params: Record<string, number | string | boolean>
context: Record<string, unknown>
trade_config: Record<string, unknown>
snapshot: Record<string, unknown>
tags: string[]
notes: string
created_at: string
updated_at: string
app_version: string
}
export interface SavedStrategyListResponse {
strategies: SavedStrategy[]
count: number
}
// ── 多策略组合回测(资金分仓,POST /api/v1/backtest/multi-strategy/run/async ──
/** 多策略组合的单个策略槽位(一个策略 + 参数 + 它要跑的原标的 + 日期)。 */
export interface MultiStrategyItem {
strategy: string
strategy_label?: string
params?: Record<string, number | string | boolean>
symbol: string
category?: Category
start_date?: string
end_date?: string
}
/** 多策略组合回测请求(各策略各拿 1/N 资金,结果结构同 PortfolioResult)。 */
export interface MultiStrategyBacktestRequest {
items: MultiStrategyItem[]
cash?: number
commission?: number
min_commission?: number
stamp_tax?: number
slippage?: number
execution?: ExecutionMode
}
+226 -1
View File
@@ -3,7 +3,7 @@
// 编排:点击「开始回测」→ 自动取行情 → 回测 → 展示 K线+净值+指标+成交。
// 取行情已整合进「开始回测」(不再有单独的取行情按钮)。
import { nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import EquityChart from '../components/EquityChart.vue'
@@ -12,6 +12,7 @@ import MetricTable from '../components/MetricTable.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue'
import TradeTable from '../components/TradeTable.vue'
import { formatError, saveStrategy } from '../api'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
@@ -97,6 +98,83 @@ async function onRun() {
execution: execution.value,
})
}
// ── 保存策略(把当前结果 + 配置 + 上下文存进策略库)──────────────────────────
const showSaveForm = ref(false)
const saving = ref(false)
const saveName = ref('')
const saveTags = ref('')
const saveNotes = ref('')
const saveMsg = ref('') // 保存后提示(成功/失败)
const strategyLabel = computed(
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
)
// 当前股票完整代码(市场:6位),从 SymbolPicker 同步来的 code 是纯数字,
// 需要带上市场前缀。复用 SymbolPicker 内部已经算好的前缀更稳妥——这里简单按
// 交易所规则推断(6 位代码:6/9 开头 SH,其余 SZ;8/4 开头 BJ)。
function fullSymbol(code6: string): string {
if (/^(6|9)/.test(code6)) return `SH:${code6}`
if (/^(8|4)/.test(code6)) return `BJ:${code6}`
return `SZ:${code6}`
}
function openSaveForm() {
saveName.value = `${strategyLabel.value} · ${code.value}`
saveTags.value = ''
saveNotes.value = ''
saveMsg.value = ''
showSaveForm.value = true
}
async function onSave() {
if (!store.result || !saveName.value.trim()) return
saving.value = true
saveMsg.value = ''
try {
await saveStrategy({
name: saveName.value.trim(),
kind: 'single',
strategy: strategy.value,
strategy_label: strategyLabel.value,
params: params.value,
context: {
symbol: fullSymbol(code.value),
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
},
trade_config: {
cash: cash.value,
commission: commission.value,
min_commission: 5,
stamp_tax: 0.001,
slippage: slippage.value,
execution: execution.value,
},
snapshot: {
total_return: store.result.performance.total_return,
annual_return: store.result.performance.annual_return,
max_drawdown: store.result.performance.max_drawdown,
sharpe: store.result.performance.sharpe,
win_rate: store.result.performance.win_rate,
trades_count: store.result.performance.total_trades,
},
tags: saveTags.value
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
notes: saveNotes.value,
})
saveMsg.value = '✓ 已保存到策略库'
showSaveForm.value = false
} catch (e) {
saveMsg.value = `保存失败:${formatError(e)}`
} finally {
saving.value = false
}
}
</script>
<template>
@@ -167,6 +245,11 @@ async function onRun() {
</div>
<div v-if="store.result" class="report-content">
<div class="result-toolbar">
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
<section class="report-section">
<h3>K线 + 买卖点</h3>
<KlineChart :bars="store.ohlcv" :trades="store.result.trades" />
@@ -188,6 +271,38 @@ async function onRun() {
</section>
</div>
</main>
<!-- 保存策略对话框 -->
<div v-if="showSaveForm" class="modal-overlay" @click.self="showSaveForm = false">
<div class="modal">
<h3>保存到策略库</h3>
<p class="modal-desc">
将当前策略 + 标的上下文 + 成绩快照存下下次可在策略库载入或重跑
</p>
<div class="field">
<label>名称</label>
<input v-model="saveName" type="text" placeholder="给这个策略起个名" />
</div>
<div class="field">
<label>标签逗号分隔可选</label>
<input v-model="saveTags" type="text" placeholder="如:银行,长线观察" />
</div>
<div class="field">
<label>备注可选</label>
<textarea v-model="saveNotes" rows="2" placeholder="为什么觉得它好?"></textarea>
</div>
<div class="modal-summary">
{{ strategyLabel }} · {{ code }} ·
{{ store.result ? (store.result.performance.total_return * 100).toFixed(2) + '%' : '' }}
</div>
<div class="modal-actions">
<button class="ghost" :disabled="saving" @click="showSaveForm = false">取消</button>
<button class="primary" :disabled="saving || !saveName.trim()" @click="onSave">
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -268,4 +383,114 @@ async function onRun() {
color: var(--text-muted);
margin-bottom: 12px;
}
/* 结果工具条 + 保存对话框 */
.result-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.result-toolbar .ghost {
font-size: 12px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.result-toolbar .ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.save-msg {
font-size: 12px;
color: var(--up);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
width: 380px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal h3 {
font-size: 15px;
font-weight: 600;
}
.modal-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.modal .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal .field label {
font-size: 12px;
color: var(--text-muted);
}
.modal .field input,
.modal .field textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 9px;
font-size: 13px;
color: var(--text);
font-family: inherit;
resize: vertical;
}
.modal .field textarea {
font-family: inherit;
}
.modal-summary {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
padding: 8px 10px;
background: var(--bg);
border-radius: var(--radius);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.modal-actions .ghost {
font-size: 13px;
padding: 7px 16px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.modal-actions .primary {
font-size: 13px;
padding: 7px 16px;
cursor: pointer;
}
.modal-actions .primary:disabled,
.modal-actions .ghost:disabled {
opacity: 0.5;
cursor: default;
}
</style>
+243 -2
View File
@@ -1,17 +1,20 @@
<script setup lang="ts">
// 组合回测主页面:左配置(多标的 + 策略 + 日期)/ 右报告(组合净值 + 各标的对比)。
import { onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import EquityChart from '../components/EquityChart.vue'
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import StocksPicker from '../components/StocksPicker.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import { formatError, saveStrategy } from '../api'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
const route = useRoute()
const stocks = ref<string[]>(['SZ:000001', 'SH:600519'])
const strategy = ref('ma_cross')
@@ -36,10 +39,38 @@ function isoDaysFromNow(days: number): string {
const startDate = ref(isoDaysFromNow(-365 * 3))
const endDate = ref(isoDaysFromNow(0))
onMounted(() => {
onMounted(async () => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
// 从 URL query 回填(策略库「载入」组合策略跳转带来)
const qStrategy = route.query.strategy as string | undefined
const qParams = route.query.params as string | undefined
const qStocks = route.query.stocks as string | undefined
const qStartDate = route.query.startDate as string | undefined
const qEndDate = route.query.endDate as string | undefined
const qCategory = route.query.category as Category | undefined
if (qStrategy) {
strategy.value = qStrategy
await nextTick()
}
if (qParams) {
try {
params.value = JSON.parse(qParams) as Record<string, number | string | boolean>
} catch {
// 解析失败忽略
}
}
if (qStocks) {
stocks.value = qStocks
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (qStartDate) startDate.value = qStartDate
if (qEndDate) endDate.value = qEndDate
if (qCategory) category.value = qCategory
})
async function onRun() {
@@ -54,6 +85,68 @@ async function onRun() {
end_date: endDate.value,
})
}
// ── 保存策略(把当前组合结果 + 配置 + 上下文存进策略库)──────────────────────
const showSaveForm = ref(false)
const saving = ref(false)
const saveName = ref('')
const saveTags = ref('')
const saveNotes = ref('')
const saveMsg = ref('')
const strategyLabel = computed(
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
)
function openSaveForm() {
saveName.value = `${strategyLabel.value} · 组合${stocks.value.length}`
saveTags.value = ''
saveNotes.value = ''
saveMsg.value = ''
showSaveForm.value = true
}
async function onSave() {
if (!store.portfolioResult || !saveName.value.trim()) return
saving.value = true
saveMsg.value = ''
try {
const perf = store.portfolioResult.total_performance
await saveStrategy({
name: saveName.value.trim(),
kind: 'portfolio',
strategy: strategy.value,
strategy_label: strategyLabel.value,
params: params.value,
context: {
stocks: stocks.value,
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
},
trade_config: {
cash: cash.value,
execution: execution.value,
},
snapshot: {
total_return: perf.total_return,
annual_return: perf.annual_return,
total_stocks: perf.total_stocks,
},
tags: saveTags.value
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
notes: saveNotes.value,
})
saveMsg.value = '✓ 已保存到策略库'
showSaveForm.value = false
} catch (e) {
saveMsg.value = `保存失败:${formatError(e)}`
} finally {
saving.value = false
}
}
</script>
<template>
@@ -129,6 +222,11 @@ async function onRun() {
</div>
<div v-if="store.portfolioResult" class="report-content">
<div class="result-toolbar">
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
<section class="report-section">
<h3>组合整体绩效</h3>
<div class="perf-summary">
@@ -171,6 +269,42 @@ async function onRun() {
</section>
</div>
</main>
<!-- 保存策略对话框 -->
<div v-if="showSaveForm" class="modal-overlay" @click.self="showSaveForm = false">
<div class="modal">
<h3>保存到策略库</h3>
<p class="modal-desc">
将当前组合策略 + 标的列表 + 成绩快照存下下次可在策略库载入或重跑
</p>
<div class="field">
<label>名称</label>
<input v-model="saveName" type="text" placeholder="给这个组合策略起个名" />
</div>
<div class="field">
<label>标签逗号分隔可选</label>
<input v-model="saveTags" type="text" placeholder="如:消费,长线观察" />
</div>
<div class="field">
<label>备注可选</label>
<textarea v-model="saveNotes" rows="2" placeholder="为什么觉得它好?"></textarea>
</div>
<div class="modal-summary">
{{ strategyLabel }} · {{ stocks.length }} ·
{{
store.portfolioResult
? (store.portfolioResult.total_performance.total_return * 100).toFixed(2) + '%'
: ''
}}
</div>
<div class="modal-actions">
<button class="ghost" :disabled="saving" @click="showSaveForm = false">取消</button>
<button class="primary" :disabled="saving || !saveName.trim()" @click="onSave">
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -267,4 +401,111 @@ async function onRun() {
.neg {
color: var(--down);
}
/* 结果工具条 + 保存对话框 */
.result-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.result-toolbar .ghost {
font-size: 12px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.result-toolbar .ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.save-msg {
font-size: 12px;
color: var(--up);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
width: 380px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal h3 {
font-size: 15px;
font-weight: 600;
}
.modal-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.modal .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal .field label {
font-size: 12px;
color: var(--text-muted);
}
.modal .field input,
.modal .field textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 9px;
font-size: 13px;
color: var(--text);
font-family: inherit;
resize: vertical;
}
.modal-summary {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
padding: 8px 10px;
background: var(--bg);
border-radius: var(--radius);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.modal-actions .ghost {
font-size: 13px;
padding: 7px 16px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.modal-actions .primary {
font-size: 13px;
padding: 7px 16px;
cursor: pointer;
}
.modal-actions .primary:disabled,
.modal-actions .ghost:disabled {
opacity: 0.5;
cursor: default;
}
</style>
+811
View File
@@ -0,0 +1,811 @@
<script setup lang="ts">
// 策略库页面:列出用户保存的策略,支持「载入」(回填到对应回测页)+「删除」,
// 以及「组合回测」——勾选多个策略,各拿 1/N 资金、各跑原标的,看综合表现。
// 数据来自后端 SQLiteGET /api/v1/strategies)。空态提示去回测页保存。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import EquityChart from '../components/EquityChart.vue'
import MetricTable from '../components/MetricTable.vue'
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import {
deleteSavedStrategy,
fetchSavedStrategies,
formatError,
} from '../api'
import type { MultiStrategyItem, Performance, SavedStrategy } from '../types'
import { useBacktestStore } from '../stores/backtest'
const router = useRouter()
const store = useBacktestStore()
const strategies = ref<SavedStrategy[]>([])
const loading = ref(false)
const error = ref('')
const deletingId = ref<string | null>(null)
// ── 多策略组合回测:勾选 ─────────────────────────────────────────────────────
const selectedIds = ref<Set<string>>(new Set())
function toggleSelect(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
const selectedStrategies = computed(() =>
strategies.value.filter((s) => selectedIds.value.has(s.id)),
)
function clearSelection() {
selectedIds.value = new Set()
}
/** 组合回测:把勾选的策略组装成 MultiStrategyItem[],各跑原标的,资金均分。 */
async function onComboBacktest() {
if (selectedStrategies.value.length === 0) return
store.error = ''
// 只取有单标的上下文(symbol)的策略;组合类策略没有单一 symbol,跳过并提示。
const usable = selectedStrategies.value.filter((s) => s.context?.symbol)
const skipped = selectedStrategies.value.length - usable.length
if (usable.length === 0) {
store.error = '勾选的策略缺少标的上下文(symbol),无法组合回测。请勾选单标的策略。'
return
}
const items: MultiStrategyItem[] = usable.map((s) => ({
strategy: s.strategy,
strategy_label: s.strategy_label || s.strategy,
params: s.params,
symbol: s.context.symbol as string,
category: (s.context.category as MultiStrategyItem['category']) || 'DAY',
start_date: (s.context.start_date as string) || undefined,
end_date: (s.context.end_date as string) || undefined,
}))
await store.runMultiStrategy({ items, cash: 1_000_000 })
if (skipped > 0) {
store.error = `已跳过 ${skipped} 个缺少单一标的的策略(组合策略无 symbol)。`
}
}
onMounted(load)
async function load() {
loading.value = true
error.value = ''
try {
const resp = await fetchSavedStrategies()
strategies.value = resp.strategies
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
/** 载入:把保存的策略 + 标的上下文塞进 URL query,跳转对应回测页(页面 onMounted 时回填)。 */
function onLoad(s: SavedStrategy) {
const ctx = s.context
const params = JSON.stringify(s.params)
if (s.kind === 'portfolio') {
router.push({
path: '/portfolio',
query: {
strategy: s.strategy,
params,
stocks: Array.isArray(ctx.stocks) ? (ctx.stocks as string[]).join(',') : '',
startDate: (ctx.start_date as string) || undefined,
endDate: (ctx.end_date as string) || undefined,
category: (ctx.category as string) || undefined,
},
})
} else {
// 保存的 symbol 带"市场:6位代码"前缀(如 SH:601088,便于策略库展示),
// 但回测页 SymbolPicker 的 code 只接受纯 6 位数字(市场由 detectMarket 自动识别),
// 故载入时剥掉前缀,只传 6 位代码。
const rawSymbol = (ctx.symbol as string) || ''
const codeOnly = rawSymbol.includes(':') ? rawSymbol.split(':').pop()! : rawSymbol
router.push({
path: '/',
query: {
strategy: s.strategy,
params,
symbol: codeOnly || undefined,
startDate: (ctx.start_date as string) || undefined,
endDate: (ctx.end_date as string) || undefined,
category: (ctx.category as string) || undefined,
},
})
}
}
async function onDelete(s: SavedStrategy) {
if (!confirm(`确定删除「${s.name}」?此操作不可撤销。`)) return
deletingId.value = s.id
try {
await deleteSavedStrategy(s.id)
strategies.value = strategies.value.filter((x) => x.id !== s.id)
} catch (e) {
error.value = formatError(e)
} finally {
deletingId.value = null
}
}
// ── 展示辅助 ────────────────────────────────────────────────────────────────
function pct(v: unknown): string {
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? `${(n * 100).toFixed(2)}%` : '-'
}
function num(v: unknown, d = 2): string {
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n.toFixed(d) : '-'
}
function ctxLabel(s: SavedStrategy): string {
const ctx = s.context
if (s.kind === 'portfolio') {
const stocks = Array.isArray(ctx.stocks) ? (ctx.stocks as string[]) : []
return stocks.length ? `${stocks.length} 只:${stocks.slice(0, 3).join(' ')}${stocks.length > 3 ? ' …' : ''}` : '-'
}
return (ctx.symbol as string) || '-'
}
function dateRange(s: SavedStrategy): string {
const ctx = s.context
const s0 = (ctx.start_date as string) || ''
const s1 = (ctx.end_date as string) || ''
if (!s0 && !s1) return '-'
return `${s0 || '?'} ~ ${s1 || '?'}`
}
function createdShort(s: SavedStrategy): string {
// created_at 形如 "2026-07-04T15:30:22Z",截到分钟
return (s.created_at || '').replace('T', ' ').replace(/:\d{2}Z?$/, '').slice(0, 16)
}
// ── 组合回测:当前持仓(回测结束时各策略的持仓快照)────────────────────────────
// positions 是每根 K 线一行的快照序列,取最后一行 = 回测结束时的持仓。
// size > 0 表示该策略结束仍持有,size ≈ 0 表示已清仓。
interface Holding {
key: string // 策略槽位 key,如 "双均线交叉@SH:601088"
strategyLabel: string
symbol: string
size: number // 持仓数量(0 = 已清仓)
avgPrice: number // 持仓成本
marketValue: number // 市值
unrealizedPnl: number // 未实现盈亏(元)
unrealizedPct: number // 未实现收益率
holding: boolean // 是否在持仓中
}
const holdings = computed<Holding[]>(() => {
const res = store.multiStrategyResult
if (!res) return []
const out: Holding[] = []
for (const [key, br] of Object.entries(res.individual_results)) {
const positions = br.positions as Array<Record<string, unknown>>
if (!Array.isArray(positions) || positions.length === 0) continue
const last = positions[positions.length - 1]
const size = Number(last.size ?? 0)
const avgPrice = Number(last.avg_price ?? 0)
const marketValue = Number(last.market_value ?? 0)
const unrealizedPnl = Number(last.unrealized_pnl ?? 0)
const [strategyLabel, symbol] = key.split('@')
out.push({
key,
strategyLabel: strategyLabel || key,
symbol: symbol || '',
size,
avgPrice,
marketValue,
unrealizedPnl,
unrealizedPct: avgPrice > 0 ? unrealizedPnl / (avgPrice * Math.abs(size)) : 0,
holding: size > 0.5, // 容忍浮点误差
})
}
return out
})
const holdingCount = computed(() => holdings.value.filter((h) => h.holding).length)
// 组合整体绩效(19 项指标)。后端 total_performance 现含完整指标,转成
// MetricTable 需要的 Performance 类型(缺失字段补 0 兜底,保证渲染不崩)。
const comboPerf = computed<Performance | null>(() => {
const tp = store.multiStrategyResult?.total_performance
if (!tp) return null
const get = (k: string, d = 0): number => {
const v = (tp as Record<string, unknown>)[k]
return typeof v === 'number' ? v : d
}
return {
total_return: get('total_return'),
annual_return: get('annual_return'),
max_drawdown: get('max_drawdown'),
max_dd_duration: get('max_dd_duration'),
sharpe: get('sharpe'),
sortino: get('sortino'),
calmar: get('calmar'),
total_trades: get('total_trades'),
win_trades: get('win_trades'),
lose_trades: get('lose_trades'),
rejected_trades: get('rejected_trades'),
win_rate: get('win_rate'),
profit_factor: get('profit_factor'),
avg_win: get('avg_win'),
avg_loss: get('avg_loss'),
max_win: get('max_win'),
max_loss: get('max_loss'),
avg_holding_days: get('avg_holding_days'),
volatility: get('volatility'),
}
})
</script>
<template>
<div class="strategies-view">
<header class="page-header">
<div>
<h2>策略库</h2>
<p class="subtitle">
保存你觉得不错的策略下次直接载入或重跑 {{ strategies.length }}
勾选多个单标的策略可做组合回测各拿 1/N 资金各跑原标的看综合表现
</p>
</div>
<div class="header-actions">
<button
class="primary sm"
:disabled="selectedStrategies.length === 0 || store.multiStrategyRunning"
@click="onComboBacktest"
>
{{ store.multiStrategyRunning ? '组合回测中…' : `组合回测(${selectedStrategies.length}` }}
</button>
<button
v-if="selectedStrategies.length > 0"
class="ghost sm"
@click="clearSelection"
>
清除选择
</button>
<button class="ghost" :disabled="loading" @click="load">
{{ loading ? '刷新中' : ' 刷新' }}
</button>
</div>
</header>
<div v-if="error || store.error" class="error-banner"> {{ error || store.error }}</div>
<div v-if="!loading && strategies.length === 0 && !error" class="placeholder">
<p>还没有保存的策略</p>
<p class="hint">
单标的回测组合回测跑出满意结果后点结果区的保存策略即可收藏到这里
</p>
</div>
<div v-if="loading && strategies.length === 0" class="placeholder">
<p>加载中</p>
</div>
<div v-if="strategies.length" class="card-grid">
<article
v-for="s in strategies"
:key="s.id"
class="card"
:class="{ selected: selectedIds.has(s.id) }"
>
<div class="card-head">
<label class="select-box" :title="s.context?.symbol ? '加入组合回测' : '组合策略暂不支持组合回测'">
<input
type="checkbox"
:checked="selectedIds.has(s.id)"
:disabled="!s.context?.symbol"
@change="toggleSelect(s.id)"
/>
</label>
<span class="kind-badge" :class="s.kind">{{ s.kind === 'portfolio' ? '组合' : '单标的' }}</span>
<h3 class="card-title">{{ s.name }}</h3>
</div>
<div class="card-strategy">
{{ s.strategy_label || s.strategy }}
<span class="params">{{ JSON.stringify(s.params) }}</span>
</div>
<div class="card-meta">
<div class="meta-row"><span class="k">标的</span><span class="v">{{ ctxLabel(s) }}</span></div>
<div class="meta-row"><span class="k">区间</span><span class="v">{{ dateRange(s) }}</span></div>
</div>
<div v-if="Object.keys(s.snapshot).length" class="card-snapshot">
<div class="snap-item">
<span class="k">总收益</span>
<span class="v mono" :class="Number(s.snapshot.total_return) > 0 ? 'pos' : 'neg'">
{{ pct(s.snapshot.total_return) }}
</span>
</div>
<div class="snap-item">
<span class="k">夏普</span><span class="v mono">{{ num(s.snapshot.sharpe) }}</span>
</div>
<div class="snap-item">
<span class="k">回撤</span><span class="v mono neg">{{ pct(s.snapshot.max_drawdown) }}</span>
</div>
</div>
<div v-if="s.tags.length" class="card-tags">
<span v-for="t in s.tags" :key="t" class="tag">{{ t }}</span>
</div>
<p v-if="s.notes" class="card-notes">{{ s.notes }}</p>
<div class="card-foot">
<span class="created">{{ createdShort(s) }}</span>
<span class="actions">
<button class="primary sm" @click="onLoad(s)">载入</button>
<button
class="danger sm"
:disabled="deletingId === s.id"
@click="onDelete(s)"
>
{{ deletingId === s.id ? '…' : '删除' }}
</button>
</span>
</div>
</article>
</div>
<!-- 多策略组合回测结果复用组合页图表组件 -->
<section v-if="store.multiStrategyResult || store.multiStrategyRunning" class="combo-result">
<h3 class="combo-title">
组合回测结果
<span v-if="store.multiStrategyResult" class="combo-meta">
· {{ store.multiStrategyResult.total_performance.total_stocks }} 个策略 ·
总资金 {{ store.multiStrategyResult.total_performance.total_cash.toFixed(0) }}
</span>
</h3>
<div v-if="store.multiStrategyRunning && !store.multiStrategyResult" class="combo-loading">
组合回测中逐个策略取行情 + 回测请稍候
</div>
<div v-if="store.multiStrategyResult" class="combo-content">
<div class="combo-summary">
<div class="combo-stat">
<span class="label">组合总收益</span>
<span
class="value"
:class="store.multiStrategyResult.total_performance.total_return > 0 ? 'pos' : 'neg'"
>
{{ (store.multiStrategyResult.total_performance.total_return * 100).toFixed(2) }}%
</span>
</div>
</div>
<div class="combo-chart-block">
<h4>组合净值曲线</h4>
<EquityChart :equity="store.multiStrategyResult.combined_equity" />
</div>
<div v-if="comboPerf" class="combo-chart-block">
<h4>绩效指标</h4>
<MetricTable :perf="comboPerf" />
</div>
<div class="combo-chart-block">
<h4>各策略绩效对比</h4>
<PortfolioSummaryTable
:results="store.multiStrategyResult.individual_results"
:allocation="store.multiStrategyResult.equity_allocation"
/>
</div>
<div class="combo-chart-block">
<h4>各策略净值叠加归一化</h4>
<PortfolioCompareChart
:results="store.multiStrategyResult.individual_results"
/>
</div>
<div class="combo-chart-block">
<h4>
当前持仓{{ holdingCount }}/{{ holdings.length }} 在持仓中
<span class="holdings-hint">回测结束时各策略的持仓快照</span>
</h4>
<p v-if="holdings.length === 0" class="empty-text">无持仓数据</p>
<table v-else class="holdings-table">
<thead>
<tr>
<th>策略</th>
<th>标的</th>
<th>状态</th>
<th class="num">持仓数量</th>
<th class="num">成本价</th>
<th class="num">市值</th>
<th class="num">未实现盈亏</th>
<th class="num">收益率</th>
</tr>
</thead>
<tbody>
<tr v-for="h in holdings" :key="h.key" :class="{ cleared: !h.holding }">
<td>{{ h.strategyLabel }}</td>
<td class="sym">{{ h.symbol }}</td>
<td>
<span class="status-tag" :class="h.holding ? 'holding' : 'cleared'">
{{ h.holding ? '持仓' : '空仓' }}
</span>
</td>
<td class="num">{{ h.size > 0 ? h.size.toFixed(0) : '-' }}</td>
<td class="num">{{ h.holding ? h.avgPrice.toFixed(2) : '-' }}</td>
<td class="num">{{ h.holding ? h.marketValue.toFixed(0) : '-' }}</td>
<td class="num" :class="{ pos: h.unrealizedPnl > 0, neg: h.unrealizedPnl < 0 }">
{{ h.holding ? (h.unrealizedPnl > 0 ? '+' : '') + h.unrealizedPnl.toFixed(0) : '-' }}
</td>
<td
class="num"
:class="{ pos: h.unrealizedPct > 0, neg: h.unrealizedPct < 0 }"
>
{{ h.holding ? (h.unrealizedPct * 100).toFixed(2) + '%' : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.strategies-view {
height: 100%;
overflow-y: auto;
padding: 16px 20px 32px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h2 {
font-size: 16px;
font-weight: 600;
}
.subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions .sm {
font-size: 12px;
padding: 6px 12px;
cursor: pointer;
}
.header-actions .primary {
border-radius: var(--radius);
}
.ghost {
font-size: 12px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.ghost:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 60%;
color: var(--text-dim);
gap: 8px;
}
.placeholder .hint {
font-size: 12px;
max-width: 420px;
line-height: 1.6;
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
/* 卡片网格 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
}
/* 勾选框:加入组合回测 */
.select-box {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
}
.select-box input {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--accent);
}
.select-box input:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.kind-badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
background: rgba(74, 158, 255, 0.15);
color: var(--accent);
flex-shrink: 0;
}
.kind-badge.portfolio {
background: rgba(140, 110, 220, 0.18);
color: #b39ddb;
}
.card-title {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-strategy {
font-size: 13px;
font-weight: 500;
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.card-strategy .params {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.card-meta {
font-size: 12px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 3px;
}
.meta-row {
display: flex;
gap: 8px;
}
.meta-row .k {
color: var(--text-dim);
width: 32px;
flex-shrink: 0;
}
.meta-row .v {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-snapshot {
display: flex;
gap: 20px;
padding: 8px 0;
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.snap-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.snap-item .k {
font-size: 11px;
color: var(--text-dim);
}
.snap-item .v {
font-size: 15px;
font-weight: 600;
}
.mono {
font-family: var(--font-mono);
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--border);
color: var(--text-muted);
}
.card-notes {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
white-space: pre-wrap;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 6px;
}
.created {
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.actions {
display: flex;
gap: 8px;
}
.sm {
font-size: 12px;
padding: 4px 12px;
}
.danger {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--radius);
cursor: pointer;
}
.danger:hover:not(:disabled) {
border-color: var(--up);
color: var(--up);
}
.danger:disabled {
opacity: 0.5;
cursor: default;
}
/* 多策略组合回测结果区 */
.combo-result {
margin-top: 24px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
}
.combo-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 14px;
}
.combo-meta {
font-size: 12px;
color: var(--text-dim);
font-weight: 400;
}
.combo-loading {
padding: 24px;
text-align: center;
color: var(--text-dim);
font-size: 13px;
}
.combo-content {
display: flex;
flex-direction: column;
gap: 18px;
}
.combo-summary {
display: flex;
gap: 28px;
}
.combo-stat {
display: flex;
flex-direction: column;
gap: 3px;
}
.combo-stat .label {
font-size: 12px;
color: var(--text-dim);
}
.combo-stat .value {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
}
.combo-chart-block h4 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 10px;
}
.holdings-hint {
font-size: 11px;
font-weight: 400;
color: var(--text-dim);
margin-left: 6px;
}
.empty-text {
color: var(--text-dim);
font-size: 13px;
padding: 12px 0;
}
.holdings-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.holdings-table th,
.holdings-table td {
padding: 7px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.holdings-table th {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.holdings-table .num {
text-align: right;
font-family: var(--font-mono);
}
.holdings-table .sym {
font-family: var(--font-mono);
font-weight: 600;
}
.holdings-table tr.cleared {
opacity: 0.5;
}
.status-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
}
.status-tag.holding {
background: rgba(239, 65, 70, 0.12);
color: var(--up);
}
.status-tag.cleared {
background: var(--border);
color: var(--text-dim);
}
</style>