-**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。
+**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。v1.28.1 起新手与 AI 辅助两连击:三个报告框内置**名词解释折叠帮助**(33 个词条讲清每项指标是什么、怎么算、怎么看,默认收起点击展开,重点粗体、阈值橙色、细节细体);回测报告一键导出 **AI 解读 Prompt**——把配置 + 25 项指标 + WF 逐窗 + 一条龙评估 + 评级 + 成交摘要组装成结构化提示词,复制发给 ChatGPT / Claude / DeepSeek / 豆包,即可获得「老手朋友」口吻的通俗解读、改进建议与 **0-10 信心分**(附「该不该执行」行动刻度)。
**数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。
diff --git a/pyproject.toml b/pyproject.toml
index ae9d98d..3bb95c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
-version = "1.28.0"
+version = "1.28.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py
index 816c485..dd2562a 100644
--- a/src/easy_tdx/backtest/types.py
+++ b/src/easy_tdx/backtest/types.py
@@ -123,8 +123,11 @@ def to_json_native(obj: Any) -> Any:
return f if math.isfinite(f) else None
if isinstance(obj, np.bool_):
return bool(obj)
- if isinstance(obj, float) and not math.isfinite(obj):
- return None
+ if isinstance(obj, float):
+ # 有限 python float 保持数字(与 np.float64 分支同口径)。
+ # 此前有限 float 会落到末尾的 str() 兜底,导致 WF 窗口等
+ # 经 float() 包装的字段被序列化成字符串(前端严格判型时显示 "-")。
+ return obj if math.isfinite(obj) else None
if obj is None or isinstance(obj, str | int | bool):
return obj
if hasattr(obj, "isoformat"):
diff --git a/web-ui/e2e/backtest.spec.ts b/web-ui/e2e/backtest.spec.ts
index fbee881..f81107d 100644
--- a/web-ui/e2e/backtest.spec.ts
+++ b/web-ui/e2e/backtest.spec.ts
@@ -43,12 +43,65 @@ test('勾选附加分析后出现 WF 逐窗柱状图与一条龙评估卡', asyn
timeout: 60_000,
})
await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 })
- await expect(page.getByText('盈利窗占比', { exact: true })).toBeVisible()
+ // 收窄到汇总区:词条表里也有「盈利窗占比」(dt),全局 getByText 会触发 strict mode
+ await expect(page.locator('.wf-summary').getByText('盈利窗占比', { exact: true })).toBeVisible()
await expect(page.locator('.wf-summary .stat')).toHaveCount(6)
// 一条龙评估:综合评分 + 高适配徽标 + 基准对比
+ // 断言收窄到对应容器:名词解释按钮/词条文案里也含这些词,全局匹配会 strict mode
await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 })
- await expect(page.getByText('综合评分')).toBeVisible()
- await expect(page.getByText('对比买入持有')).toBeVisible()
+ await expect(page.locator('.eval-header').getByText('综合评分', { exact: true })).toBeVisible()
+ await expect(page.locator('.eval-header').getByText('对比买入持有')).toBeVisible()
await expect(page.getByText(/适配性体检 \d+\/\d+/)).toBeVisible()
})
+
+test('名词解释默认折叠,点击展开显示词条', async ({ page }) => {
+ await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31')
+ await page.getByLabel('Walk-Forward 样本外验证').check()
+ await page.getByLabel('一条龙评估').check()
+ await page.getByRole('button', { name: '开始回测' }).click()
+ await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 })
+
+ // 词条用 .g-term 类定位,与面板统计标签同名也不冲突
+ const wfTerm = page.locator('.wf-panel .g-term', { hasText: '每窗独立开仓' })
+ const evalTerm = page.locator('.eval-panel .g-term', { hasText: '高适配' })
+ const metricTerm = page.locator('.metric-wrap .g-term', { hasText: '卡玛比率' })
+
+ // 默认隐藏(内容在 DOM,但折叠框高度为 0)
+ await expect(wfTerm).toBeHidden()
+ await expect(evalTerm).toBeHidden()
+ await expect(metricTerm).toBeHidden()
+
+ // 点击「名词解释」按钮逐个展开
+ await page.locator('.wf-panel .help-toggle').click()
+ await expect(wfTerm).toBeVisible()
+ await page.locator('.eval-panel .help-toggle').click()
+ await expect(evalTerm).toBeVisible()
+ await page.locator('.metric-wrap .help-toggle').click()
+ await expect(metricTerm).toBeVisible()
+})
+
+test('AI 解读 Prompt 弹窗打包回测配置与各段报告', async ({ page }) => {
+ await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31')
+ await page.getByLabel('Walk-Forward 样本外验证').check()
+ await page.getByLabel('一条龙评估').check()
+ await page.getByRole('button', { name: '开始回测' }).click()
+ await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 })
+
+ await page.getByRole('button', { name: '🤖 AI 解读' }).click()
+ const area = page.locator('.ai-prompt-area')
+ await expect(area).toBeVisible()
+ // textarea 的内容在 value 属性而非文本节点,用 toHaveValue(正则=子串匹配)
+ await expect(area).toHaveValue(/# 角色设定/)
+ await expect(area).toHaveValue(/# 回测配置/)
+ await expect(area).toHaveValue(/SZ:000001(日线)/)
+ await expect(area).toHaveValue(/- 总收益率:/)
+ await expect(area).toHaveValue(/Walk-Forward 样本外验证/)
+ await expect(area).toHaveValue(/# 一条龙评估/)
+ await expect(area).toHaveValue(/# 评级(不看收益率/)
+ await expect(area).toHaveValue(/# 背景与免责/)
+
+ // 关闭弹窗回到报告
+ await page.getByRole('button', { name: '关闭' }).click()
+ await expect(area).toBeHidden()
+})
diff --git a/web-ui/src/__tests__/aiPrompt.test.ts b/web-ui/src/__tests__/aiPrompt.test.ts
new file mode 100644
index 0000000..f19b42c
--- /dev/null
+++ b/web-ui/src/__tests__/aiPrompt.test.ts
@@ -0,0 +1,219 @@
+// AI 解读 Prompt 生成器自检。
+//
+// 项目未引入 vitest,采用 Node 内置 test runner(node:test)跑。
+// aiPrompt.ts 只含 type-only 本地导入(编译期擦除),因此 node --test 可直跑,
+// 不需要 tsconfig 路径解析:
+//
+// node --test src/__tests__/aiPrompt.test.ts
+//
+// 关键断言:配置/25 项指标/可选段落(WF/评估/评级)随输入增减,
+// 缺省可选数据时对应标题不出现(发给 LLM 的内容不撒谎)。
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { buildAiPrompt } from '../aiPrompt.ts'
+import type { BacktestResult, EvaluateReport, Performance } from '../types.ts'
+import type { GradeResult } from '../grading/types.ts'
+
+// ── 京东方案例(与 grade.test.ts 同源的真实回测数据)─────────────────────────
+
+const PERF: Performance = {
+ total_return: 1.2643,
+ annual_return: 0.1401,
+ max_drawdown: -0.4165,
+ max_dd_duration: 1,
+ sharpe: 0.529,
+ sortino: 0.825,
+ calmar: 0.336,
+ total_trades: 90,
+ win_trades: 32,
+ lose_trades: 58,
+ rejected_trades: 0,
+ win_rate: 0.3556,
+ profit_factor: 1.107,
+ avg_win: 0.0444,
+ avg_loss: -0.0203,
+ max_win: 0.2554,
+ max_loss: -0.05,
+ avg_holding_days: 5.2,
+ volatility: 0.28,
+ ulcer_index: 0.08,
+ var_95: 0.025,
+ cvar_95: 0.038,
+ sqn: 1.8,
+ max_consecutive_wins: 5,
+ max_consecutive_losses: 8,
+}
+
+const RESULT: BacktestResult = {
+ performance: PERF,
+ equity_curve: [
+ { datetime: '2020-01-06', cash: 1000000, position_value: 0, total: 1000000, drawdown: 0, drawdown_pct: 0 },
+ { datetime: '2021-06-01', cash: 0, position_value: 2264300, total: 2264300, drawdown: 0, drawdown_pct: 0 },
+ { datetime: '2022-04-26', cash: 0, position_value: 1320000, total: 1320000, drawdown: -944300, drawdown_pct: -0.4165 },
+ ],
+ trades: [
+ { datetime: '2020-02-03', direction: 'BUY', size: 1000, price: 4.52, commission: 5, slippage: 0, pnl: 0, rejected: false },
+ { datetime: '2020-03-10', direction: 'SELL', size: 1000, price: 4.71, commission: 5, slippage: 0, pnl: 150, rejected: false },
+ ],
+ positions: [],
+ config: {},
+}
+
+const GRADE: GradeResult = {
+ grade: 'D',
+ score: 31.2,
+ dimensions: [
+ { key: 'calmar', label: '卡玛比率', raw: 0.336, score: 22.4, weight: 0.18 },
+ { key: 'max_drawdown', label: '最大回撤', raw: -0.4165, score: 30.2, weight: 0.17 },
+ ],
+ vetoes: [
+ { key: 'high_drawdown', reason: '最大回撤 41.7% > 50%,套牢难回本', cap: 'B' },
+ ],
+ insufficientSample: false,
+ isLosing: false,
+ scenario: 'single',
+}
+
+test('基础段:角色/任务/配置/25 项指标/净值/成交/免责齐全', () => {
+ const p = buildAiPrompt({
+ symbol: 'SZ:000001',
+ category: 'DAY',
+ startDate: '2020-01-06',
+ endDate: '2026-09-02',
+ bars: 1580,
+ strategyLabel: '双均线交叉',
+ params: { fast: 5, slow: 20 },
+ cash: 1000000,
+ commission: 0.0003,
+ slippage: 0,
+ execution: 'next_open',
+ result: RESULT,
+ })
+
+ assert.match(p, /# 角色设定/)
+ assert.match(p, /# 任务/)
+ // 语气要求:优点毛病都讲 + 0-10 信心分 + 禁臆造数字
+ assert.match(p, /优点和毛病都要讲/)
+ assert.match(p, /0-10 的「信心分」/)
+ assert.match(p, /报告里没有的不要臆造/)
+ assert.match(p, /# 回测配置/)
+ assert.match(p, /SZ:000001(日线)/)
+ assert.match(p, /双均线交叉/)
+ assert.match(p, /fast=5, slow=20/)
+ // 25 项指标全部出现
+ for (const label of [
+ '总收益率', '年化收益', '夏普比率', '索提诺比率', '卡玛比率',
+ '最大回撤', '回撤持续', '波动率(年化)', 'Ulcer 指数', '日 VaR (95%)', '日 CVaR (95%)',
+ '总交易数', '盈利次数', '亏损次数', '胜率', '盈亏比(利润因子)',
+ '平均盈利', '平均亏损', '最大盈利', '最大亏损', '平均持仓天数',
+ 'SQN 系统质量', '最大连胜', '最大连亏', '拒单数',
+ ]) {
+ assert.ok(p.includes(`- ${label}:`), `缺少指标行:${label}`)
+ }
+ assert.match(p, /- 总收益率:126\.43%/)
+ assert.match(p, /# 净值概览/)
+ assert.match(p, /# 最近成交(最后 8 笔)/)
+ assert.match(p, /本笔盈亏 \+150 元/)
+ assert.match(p, /# 背景与免责/)
+
+ // 未提供可选数据时,对应段落不出现(内容不撒谎)
+ assert.ok(!p.includes('Walk-Forward 样本外验证'))
+ assert.ok(!p.includes('一条龙评估'))
+ assert.ok(!p.includes('评级(不看收益率)'))
+})
+
+test('可选段:WF / 一条龙评估 / 评级按需拼接', () => {
+ const p = buildAiPrompt({
+ symbol: 'SZ:000001',
+ category: 'MIN_15',
+ startDate: '2024-01-01',
+ endDate: '2025-12-31',
+ bars: 480,
+ strategyLabel: 'MACD',
+ params: {},
+ cash: 100000,
+ commission: 0.0003,
+ slippage: 0.001,
+ execution: 'next_close',
+ result: RESULT,
+ wf: {
+ n_windows: 7,
+ warmup_ratio: 0.3,
+ windows: [
+ // 窗1 用字符串值(旧后端 to_json_native 会把有限 float 序列化成字符串),
+ // 锁定 buildAiPrompt 的防御性数字转换:必须照常渲染为数值
+ {
+ index: 0,
+ start: '2024-04-01',
+ end: '2024-07-01',
+ bars: 60,
+ total_return: '0.05' as unknown as number,
+ sharpe: '0.8' as unknown as number,
+ max_drawdown: '-0.06' as unknown as number,
+ total_trades: 12,
+ win_rate: '0.5' as unknown as number,
+ },
+ { index: 1, start: '2024-07-02', end: '2024-10-01', bars: 60, total_return: -0.02, sharpe: -0.3, max_drawdown: -0.08, total_trades: 9, win_rate: 0.44 },
+ ],
+ consistency: 0.5,
+ chained_return: 0.029,
+ mean_window_return: 0.015,
+ median_window_return: 0.015,
+ worst_window: -0.02,
+ best_window: 0.05,
+ mean_sharpe: 0.25,
+ worst_drawdown: -0.08,
+ total_trades: 21,
+ },
+ evaluate: {
+ performance: PERF,
+ score: {
+ total: 61.5,
+ components: { total_return: 78, sharpe: 55, max_drawdown: 30, sortino: 50, wf_consistency: 40 },
+ weights_used: { total_return: 0.5, sharpe: 0.15, max_drawdown: 0.1, sortino: 0.05, wf_consistency: 0.2 },
+ wf_provided: true,
+ },
+ walkforward: null as never,
+ fitness: {
+ segments: [],
+ checks: [
+ { name: 'train_profitable', passed: true, detail: '训练段收益 +18.2% > 0' },
+ { name: 'sign_consistent', passed: false, detail: '三段收益存在反号' },
+ ],
+ pass_ratio: 0.5,
+ passed_count: 1,
+ total_checks: 2,
+ high_fitness: false,
+ split: [0.6, 0.2, 0.2],
+ },
+ benchmark: {
+ buy_hold: { total_return: 0.32, annual_return: 0.06, max_drawdown: -0.35, sharpe: 0.4, calmar: 0.17, volatility: 0.24 },
+ excess_return: 0.94,
+ alpha: 0.09,
+ beta: 0.72,
+ information_ratio: 0.85,
+ tracking_error: 0.12,
+ },
+ config: {},
+ },
+ grade: GRADE,
+ gradeHint: '持有体验差或系统亏损,不建议参与',
+ })
+
+ assert.match(p, /15 分钟/)
+ assert.match(p, /(默认参数)/)
+ assert.match(p, /Walk-Forward 样本外验证(同参数跨时段稳定性)/)
+ assert.match(p, /盈利窗占比:50%(1\/2)/)
+ assert.match(p, /窗1(2024-04-01 ~ 2024-07-01):\+5\.00%,夏普 0\.80,最大回撤 -6\.00%,12 笔(胜率 \+50\.00%)/)
+ assert.match(p, /# 一条龙评估/)
+ assert.match(p, /综合评分:61\.5 \/ 100/)
+ assert.match(p, /超额收益 \+94\.00%/)
+ assert.match(p, /α(年化超额)\+9\.00%;β(敏感度)0\.72/)
+ assert.match(p, /适配性体检:1\/2 通过/)
+ assert.match(p, /✗ 三段收益存在反号/)
+ assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/)
+ assert.match(p, /档位:\*\*D\*\*(总分 31\.2\/100)——持有体验差或系统亏损,不建议参与/)
+ assert.match(p, /一票否决:最大回撤 41\.7%/)
+})
diff --git a/web-ui/src/aiPrompt.ts b/web-ui/src/aiPrompt.ts
new file mode 100644
index 0000000..a0d9abc
--- /dev/null
+++ b/web-ui/src/aiPrompt.ts
@@ -0,0 +1,349 @@
+// AI 解读 Prompt 生成器:把单标的回测报告组装成一段结构化提示词,
+// 用户复制后发给任意 LLM(ChatGPT / Claude / DeepSeek / 豆包…)即可获得针对性解读。
+//
+// 约束:本文件只允许 **type-only** 的本地导入(编译期擦除),
+// 保证 node --test 可直跑(与 grading/__tests__ 同一套自检方式);
+// 运行时逻辑全部自包含,不依赖其他模块的值。
+//
+// 文案口径与后端对齐:指标含义见 src/data/glossary.ts,
+// 评分权重见 scoring.py,WF/体检语义见 walkforward.py / fitness.py。
+
+import type {
+ BacktestResult,
+ Category,
+ EvaluateReport,
+ ExecutionMode,
+ Performance,
+ Trade,
+ WalkForwardResult,
+} from './types'
+import type { GradeResult } from './grading/types'
+
+// ── 输入 ─────────────────────────────────────────────────────────────────────
+
+export interface AiPromptInput {
+ /** 完整标的代码(带市场前缀,如 "SZ:000001") */
+ symbol: string
+ category: Category
+ startDate: string
+ endDate: string
+ /** K 线根数(store.ohlcv.length) */
+ bars: number
+ /** 策略中文名(如「双均线交叉」) */
+ strategyLabel: string
+ params: Record+ + {{ p.text }} + {{ p.text }} + +
+{{ e.formula }}
++ + {{ p.text }} + {{ p.text }} + +
++ 怎么看 + + {{ p.text }} + {{ p.text }} + +
+