mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
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:
@@ -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 单测全绿(无回归)。
|
||||
|
||||
@@ -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 环境。
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}' 不存在")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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_date;portfolio 存 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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
<script setup lang="ts">
|
||||
// 策略库页面:列出用户保存的策略,支持「载入」(回填到对应回测页)+「删除」,
|
||||
// 以及「组合回测」——勾选多个策略,各拿 1/N 资金、各跑原标的,看综合表现。
|
||||
// 数据来自后端 SQLite(GET /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>
|
||||
Reference in New Issue
Block a user