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