mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
release: v1.28.1 — WebUI 名词解释折叠帮助 + AI 解读 Prompt 导出 + WF 窗口序列化修复(to_json_native 有限 float 落入 str 兜底,逐窗指标变字符串)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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%/)
|
||||
})
|
||||
@@ -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<string, number | string | boolean>
|
||||
cash: number
|
||||
commission: number
|
||||
slippage: number
|
||||
execution: ExecutionMode
|
||||
result: BacktestResult
|
||||
/** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */
|
||||
wf?: WalkForwardResult | null
|
||||
evaluate?: EvaluateReport | null
|
||||
grade?: GradeResult | null
|
||||
/** 评级档位的一句话含义(GRADE_META[grade].hint,由组件传入) */
|
||||
gradeHint?: string
|
||||
}
|
||||
|
||||
// ── 展示辅助(自包含,避免运行时依赖其他模块)────────────────────────────────
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
DAY: '日线',
|
||||
WEEK: '周线',
|
||||
MONTH: '月线',
|
||||
MIN_5: '5 分钟',
|
||||
MIN_15: '15 分钟',
|
||||
MIN_30: '30 分钟',
|
||||
MIN_60: '60 分钟',
|
||||
}
|
||||
|
||||
const EXECUTION_LABELS: Record<ExecutionMode, string> = {
|
||||
next_open: '次日开盘价',
|
||||
next_close: '次日收盘价',
|
||||
}
|
||||
|
||||
/** 与 MetricTable 同源同序的 25 项指标清单(label + 数字格式) */
|
||||
const METRIC_LINES: Array<{
|
||||
key: keyof Performance
|
||||
label: string
|
||||
format: 'percent' | 'ratio' | 'int' | 'days'
|
||||
group: string
|
||||
}> = [
|
||||
{ key: 'total_return', label: '总收益率', format: 'percent', group: '收益' },
|
||||
{ key: 'annual_return', label: '年化收益', format: 'percent', group: '收益' },
|
||||
{ key: 'sharpe', label: '夏普比率', format: 'ratio', group: '收益' },
|
||||
{ key: 'sortino', label: '索提诺比率', format: 'ratio', group: '收益' },
|
||||
{ key: 'calmar', label: '卡玛比率', format: 'ratio', group: '收益' },
|
||||
{ key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' },
|
||||
{ key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' },
|
||||
{ key: 'volatility', label: '波动率(年化)', format: 'percent', group: '风险' },
|
||||
{ key: 'ulcer_index', label: 'Ulcer 指数', format: 'percent', group: '风险' },
|
||||
{ key: 'var_95', label: '日 VaR (95%)', format: 'percent', group: '风险' },
|
||||
{ key: 'cvar_95', label: '日 CVaR (95%)', format: 'percent', group: '风险' },
|
||||
{ key: 'total_trades', label: '总交易数', format: 'int', group: '交易' },
|
||||
{ key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' },
|
||||
{ key: 'lose_trades', label: '亏损次数', format: 'int', group: '交易' },
|
||||
{ key: 'win_rate', label: '胜率', format: 'percent', group: '交易' },
|
||||
{ key: 'profit_factor', label: '盈亏比(利润因子)', format: 'ratio', group: '交易' },
|
||||
{ key: 'avg_win', label: '平均盈利', format: 'percent', group: '交易' },
|
||||
{ key: 'avg_loss', label: '平均亏损', format: 'percent', group: '交易' },
|
||||
{ key: 'max_win', label: '最大盈利', format: 'percent', group: '交易' },
|
||||
{ key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' },
|
||||
{ key: 'avg_holding_days', label: '平均持仓天数', format: 'ratio', group: '交易' },
|
||||
{ key: 'sqn', label: 'SQN 系统质量', format: 'ratio', group: '交易' },
|
||||
{ key: 'max_consecutive_wins', label: '最大连胜', format: 'int', group: '交易' },
|
||||
{ key: 'max_consecutive_losses', label: '最大连亏', format: 'int', group: '交易' },
|
||||
{ key: 'rejected_trades', label: '拒单数', format: 'int', group: '交易' },
|
||||
]
|
||||
|
||||
function fmtMetric(format: 'percent' | 'ratio' | 'int' | 'days', v: number | undefined): string {
|
||||
if (v === undefined || v === null || !Number.isFinite(v)) return '-'
|
||||
if (format === 'percent') return `${(v * 100).toFixed(2)}%`
|
||||
if (format === 'int') return String(Math.round(v))
|
||||
if (format === 'days') return `${v.toFixed(0)} 天`
|
||||
return v.toFixed(3)
|
||||
}
|
||||
|
||||
function pct(v: number | undefined | null): string {
|
||||
if (v === undefined || v === null || !Number.isFinite(v)) return '-'
|
||||
return `${v >= 0 ? '+' : ''}${(v * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function ratio(v: number | undefined | null): string {
|
||||
if (v === undefined || v === null || !Number.isFinite(v)) return '-'
|
||||
return v.toFixed(2)
|
||||
}
|
||||
|
||||
function fmtParams(params: Record<string, number | string | boolean>): string {
|
||||
const entries = Object.entries(params)
|
||||
if (entries.length === 0) return '(默认参数)'
|
||||
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
||||
}
|
||||
|
||||
function fmtMoney(v: number): string {
|
||||
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
/** "2025-06-23T00:00:00" → "2025-06-23"(截掉时间部分,提示词更整洁) */
|
||||
function fmtDate(dt: string): string {
|
||||
return dt.slice(0, 10)
|
||||
}
|
||||
|
||||
/** WF 窗口字段防御性转数字:老版本后端曾把有限 float 序列化成字符串
|
||||
* (如 "-0.2078"),Number() 对数字原值透明、对字符串强制转换、非法值给 NaN。 */
|
||||
function n(v: number | string | null | undefined): number | undefined {
|
||||
const x = Number(v)
|
||||
return Number.isFinite(x) ? x : undefined
|
||||
}
|
||||
|
||||
// ── 各段落构建 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function sectionRole(): string {
|
||||
return [
|
||||
'# 角色设定',
|
||||
'',
|
||||
'你是一位做了十几年量化交易的老手,说话直来直去,心是好的。现在你正和一个刚入门的朋友(我)聊天,我什么都不懂,你解释什么都要说人话。',
|
||||
'',
|
||||
'# 任务',
|
||||
'',
|
||||
'下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:',
|
||||
'',
|
||||
'1. **先给结论**:这策略现在处于什么状态——「可以继续往下走」「底子不错但还差几步」还是「问题不小,得大改」?一句话说清,再讲理由;',
|
||||
'2. **优点和毛病都要讲**:先说说它强在哪(哪些数字是真的好看、说明策略做对了什么),再讲你担心什么。别只挑刺,也别光报喜——我是想知道这策略能不能用,不是来听审判也不是来听表扬的。挑最有说服力的几组数字讲,不用面面俱到;',
|
||||
'3. **说说持有体验**:真拿钱跑这个策略,过程大概什么感受——多久交易一次、最惨的时候有多惨、普通人拿不拿得住;',
|
||||
'4. **判断是规律还是运气**:从分时段数据(Walk-Forward 各窗收益、训练/验证/测试三段、和死拿不动的对比)找证据。有担心就直说,但像朋友提醒那样说,别像下判决书;',
|
||||
'5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;',
|
||||
'6. **最后打个分**:给这个策略一个 0-10 的「信心分」,代表你现在有多大把握它值得继续投入。打分要和前面说的话一致(前面夸的多就别打低分,反过来也一样),再用一两句话说说为什么是这个分、到几分你会建议我拿小仓位试试。参考刻度:0-3 建议放弃,4-6 值得继续改(说清往哪改),7-8 可以小仓位试错,9 以上才谈逐步加仓。',
|
||||
'',
|
||||
'# 说话方式(很重要)',
|
||||
'',
|
||||
'- 像靠谱朋友给建议:直接、坦诚,但不刻薄、不吓唬人。指出问题是为了帮我做决定,不是逞口舌之快;',
|
||||
'- 别像写报告:不要一堆小标题、密集加粗和列表堆砌,自然分段,最关键的结论偶尔加粗就行;',
|
||||
'- 禁止八股句式:「事实是」「我的推断是」「总的来说」「综上所述」「作为你的…」「好的,收到」这类全部不要;不确定的地方自然地说「我猜」「大概率」,不要贴标签;',
|
||||
'- 不要客套开场,也不要结尾祝词,上来就说正事,说完就停;',
|
||||
'- 全文控制在 800 字以内,一屏读完,句句有用;宁可少讲两点,也不要注水;',
|
||||
'- 只引用报告里有的数字,报告里没有的不要臆造;要估计就明说是估计;',
|
||||
'- 专业词第一次出现时顺口解释一句,别让我再去查。',
|
||||
'',
|
||||
'参考语气:「这策略胜率近八成、盈亏比四倍,底子是好的;但我最担心的是最近两年基本没赚钱——测试段只涨了 0.16%,这就是我不敢给高分的主要原因。」',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function sectionConfig(i: AiPromptInput): string {
|
||||
const lines = [
|
||||
'# 回测配置',
|
||||
'',
|
||||
`- 标的:${i.symbol}(${CATEGORY_LABELS[i.category] ?? i.category})`,
|
||||
`- 回测区间:${i.startDate} ~ ${i.endDate}(共 ${i.bars} 根 K 线)`,
|
||||
`- 策略:${i.strategyLabel}`,
|
||||
`- 参数:${fmtParams(i.params)}`,
|
||||
`- 初始资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`,
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionMetrics(perf: Performance): string {
|
||||
const lines = ['# 绩效指标', '']
|
||||
for (const g of ['收益', '风险', '交易']) {
|
||||
lines.push(`**${g}类**`)
|
||||
for (const m of METRIC_LINES.filter((x) => x.group === g)) {
|
||||
lines.push(`- ${m.label}:${fmtMetric(m.format, perf[m.key] as number | undefined)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionEquity(result: BacktestResult): string {
|
||||
const eq = result.equity_curve
|
||||
if (!eq || eq.length === 0) return ''
|
||||
let peak = eq[0]
|
||||
let trough = eq[0]
|
||||
for (const p of eq) {
|
||||
if (p.total > peak.total) peak = p
|
||||
if (p.total < trough.total) trough = p
|
||||
}
|
||||
return [
|
||||
'# 净值概览',
|
||||
'',
|
||||
`- 期初资产:${fmtMoney(eq[0].total)} 元(${fmtDate(eq[0].datetime)})`,
|
||||
`- 期末资产:${fmtMoney(eq[eq.length - 1].total)} 元(${fmtDate(eq[eq.length - 1].datetime)})`,
|
||||
`- 峰值:${fmtMoney(peak.total)} 元(${fmtDate(peak.datetime)});谷值:${fmtMoney(trough.total)} 元(${fmtDate(trough.datetime)})`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function sectionWf(wf: WalkForwardResult): string {
|
||||
if (!wf || wf.windows.length === 0) return ''
|
||||
const lines = [
|
||||
'# Walk-Forward 样本外验证(同参数跨时段稳定性)',
|
||||
'',
|
||||
`- 窗口数:${wf.n_windows};前 ${(wf.warmup_ratio * 100).toFixed(0)}% 数据为预热区不参与评估;每窗从空仓独立开仓`,
|
||||
`- 盈利窗占比:${(wf.consistency * 100).toFixed(0)}%(${wf.windows.filter((w) => (n(w.total_return) ?? 0) > 0).length}/${wf.windows.length})`,
|
||||
`- 连乘收益(各窗复利衔接):${pct(wf.chained_return)}`,
|
||||
`- 最差窗 / 最好窗:${pct(wf.worst_window)} / ${pct(wf.best_window)};平均夏普:${ratio(wf.mean_sharpe)};总交易:${wf.total_trades} 笔`,
|
||||
'',
|
||||
'逐窗收益(时间升序,红涨绿跌口径无关,正=赚):',
|
||||
'',
|
||||
]
|
||||
for (const w of wf.windows) {
|
||||
lines.push(
|
||||
`- 窗${w.index + 1}(${w.start} ~ ${w.end}):${pct(n(w.total_return))},夏普 ${ratio(n(w.sharpe))},最大回撤 ${pct(n(w.max_drawdown))},${w.total_trades} 笔(胜率 ${pct(n(w.win_rate))})`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionEvaluate(ev: EvaluateReport): string {
|
||||
const lines = ['# 一条龙评估', '']
|
||||
|
||||
// 综合评分
|
||||
const labels: Record<string, string> = {
|
||||
total_return: '收益',
|
||||
sharpe: '夏普',
|
||||
max_drawdown: '回撤',
|
||||
sortino: '索提诺',
|
||||
wf_consistency: 'WF 一致性',
|
||||
}
|
||||
lines.push(`**综合评分:${ev.score.total.toFixed(1)} / 100**(权重:${Object.entries(ev.score.weights_used)
|
||||
.map(([k, w]) => `${labels[k] ?? k} ${(w * 100).toFixed(0)}%`)
|
||||
.join(' + ')})`)
|
||||
lines.push(`- 分项:${Object.entries(ev.score.components)
|
||||
.map(([k, v]) => `${labels[k] ?? k} ${v.toFixed(0)}`)
|
||||
.join(',')}`)
|
||||
lines.push('')
|
||||
|
||||
// 基准对比
|
||||
const b = ev.benchmark
|
||||
lines.push('**对比买入持有基准**')
|
||||
lines.push(`- 策略总收益 ${pct(ev.performance.total_return)} vs 买入持有 ${pct(b.buy_hold.total_return)},超额收益 ${pct(b.excess_return)}`)
|
||||
if (b.alpha !== undefined || b.beta !== undefined) {
|
||||
lines.push(
|
||||
`- α(年化超额)${pct(b.alpha)};β(敏感度)${ratio(b.beta)};信息比率 ${ratio(b.information_ratio)};跟踪误差 ${pct(b.tracking_error)}`,
|
||||
)
|
||||
}
|
||||
lines.push(`- 买入持有基准:年化 ${pct(b.buy_hold.annual_return)},最大回撤 ${pct(b.buy_hold.max_drawdown)},夏普 ${ratio(b.buy_hold.sharpe)},卡玛 ${ratio(b.buy_hold.calmar)},波动率 ${pct(b.buy_hold.volatility)}`)
|
||||
lines.push('')
|
||||
|
||||
// 适配性体检
|
||||
const f = ev.fitness
|
||||
lines.push(
|
||||
`**适配性体检:${f.passed_count}/${f.total_checks} 通过(train/valid/test = ${f.split.map((s) => (s * 100).toFixed(0)).join('/')})${f.high_fitness ? ',达到「高适配」' : ''}**`,
|
||||
)
|
||||
for (const c of f.checks) {
|
||||
lines.push(`- ${c.passed ? '✓' : '✗'} ${c.detail}`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionGrade(grade: GradeResult, hint: string | undefined): string {
|
||||
const lines = [
|
||||
'# 评级(不看收益率,面向「普通人拿不拿得住」)',
|
||||
'',
|
||||
`- 档位:**${grade.grade}**(总分 ${grade.score.toFixed(1)}/100)${hint ? `——${hint}` : ''}`,
|
||||
]
|
||||
if (grade.dimensions.length > 0) {
|
||||
lines.push(
|
||||
`- 维度得分:${grade.dimensions
|
||||
.map((d) => `${d.label} ${d.score.toFixed(0)}/100(权重 ${(d.weight * 100).toFixed(0)}%)`)
|
||||
.join(',')}`,
|
||||
)
|
||||
}
|
||||
if (grade.vetoes.length > 0) {
|
||||
lines.push(`- ⚠ 一票否决:${grade.vetoes.map((v) => v.reason).join(';')}`)
|
||||
}
|
||||
if (grade.insufficientSample) {
|
||||
lines.push('- ⚠ 交易样本有限(< 10 笔),胜率/盈亏比已降权处理')
|
||||
}
|
||||
if (grade.isLosing) {
|
||||
lines.push('- ⚠ 系统亏损(利润因子 < 1)')
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionTrades(trades: Trade[]): string {
|
||||
const recent = trades.slice(-8)
|
||||
if (recent.length === 0) return ''
|
||||
const lines = ['# 最近成交(最后 8 笔)', '']
|
||||
for (const t of recent) {
|
||||
const dir = t.direction === 'BUY' ? '买入' : '卖出'
|
||||
const pnl = t.direction === 'SELL' && t.pnl !== 0 ? `,本笔盈亏 ${t.pnl >= 0 ? '+' : ''}${fmtMoney(t.pnl)} 元` : ''
|
||||
lines.push(`- ${fmtDate(t.datetime)} ${dir} ${Math.round(t.size)} 股 @ ${t.price.toFixed(2)}${pnl}`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionFooter(): string {
|
||||
return [
|
||||
'# 背景与免责',
|
||||
'',
|
||||
'以上数据来自 easy-tdx 的历史 K 线回测(已计入佣金与滑点)。历史回测存在幸存者偏差与未来不确定性,不构成投资建议,你的解读也以研究学习为目的。',
|
||||
'数据里缺失的项(显示 - 或整段没有的)直接跳过,不用专门解释局限。',
|
||||
'好了,开始吧。',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ── 主函数 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组装 AI 解读 Prompt(markdown 结构,任意 LLM 可直接消费)。 */
|
||||
export function buildAiPrompt(input: AiPromptInput): string {
|
||||
const parts: string[] = [
|
||||
sectionRole(),
|
||||
sectionConfig(input),
|
||||
sectionMetrics(input.result.performance),
|
||||
sectionEquity(input.result),
|
||||
]
|
||||
if (input.wf) parts.push(sectionWf(input.wf))
|
||||
if (input.evaluate) parts.push(sectionEvaluate(input.evaluate))
|
||||
if (input.grade) parts.push(sectionGrade(input.grade, input.gradeHint))
|
||||
const trades = sectionTrades(input.result.trades)
|
||||
if (trades) parts.push(trades)
|
||||
parts.push(sectionFooter())
|
||||
return parts.join('\n')
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import GradeDetails from './GradeDetails.vue'
|
||||
import GlossaryList from './GlossaryList.vue'
|
||||
import HelpCollapse from './HelpCollapse.vue'
|
||||
import { gradePerformance } from '../grading'
|
||||
import { evaluateGlossary } from '../data/glossary'
|
||||
import type { EvaluateReport } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -137,6 +140,11 @@ function fmtCapm(v: number, fmt: string): string {
|
||||
<!-- 评级(复用本地评级,与后端字段口径一致) -->
|
||||
<h4 class="sub-title">评级(不看收益率)</h4>
|
||||
<GradeDetails :result="grade" />
|
||||
|
||||
<!-- 名词解释(默认折叠,新手向) -->
|
||||
<HelpCollapse label="名词解释:综合评分 / 高适配 / α·β·IR / 适配性体检…">
|
||||
<GlossaryList :sections="evaluateGlossary" />
|
||||
</HelpCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
// 名词解释列表:按分组渲染词条(定义 / 公式 / 细节 / 怎么看)。
|
||||
// 重点与非重点的字重层级:词条名与 **粗体** 段落为亮色粗体,
|
||||
// 细节为细体暗色,"怎么看"里的阈值用暖色粗体突出。
|
||||
|
||||
import type { GlossarySection } from '../data/glossary'
|
||||
|
||||
defineProps<{
|
||||
sections: GlossarySection[]
|
||||
}>()
|
||||
|
||||
/** 把 "**重点**普通" 切成片段:奇数段为粗体重点 */
|
||||
function rich(text: string): Array<{ text: string; bold: boolean }> {
|
||||
return text
|
||||
.split('**')
|
||||
.map((t, i) => ({ text: t, bold: i % 2 === 1 }))
|
||||
.filter((p) => p.text.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glossary">
|
||||
<section v-for="s in sections" :key="s.title" class="g-section">
|
||||
<h5 class="g-title">{{ s.title }}</h5>
|
||||
<dl class="g-entries">
|
||||
<div v-for="e in s.entries" :key="e.term" class="g-entry">
|
||||
<dt class="g-term">{{ e.term }}</dt>
|
||||
<dd class="g-def">
|
||||
<p class="g-summary">
|
||||
<template v-for="(p, i) in rich(e.summary)" :key="i">
|
||||
<strong v-if="p.bold">{{ p.text }}</strong>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="e.formula" class="g-formula">{{ e.formula }}</p>
|
||||
<p v-if="e.detail" class="g-detail">
|
||||
<template v-for="(p, i) in rich(e.detail)" :key="i">
|
||||
<strong v-if="p.bold">{{ p.text }}</strong>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="e.guide" class="g-guide">
|
||||
<span class="g-guide-label">怎么看</span>
|
||||
<template v-for="(p, i) in rich(e.guide)" :key="i">
|
||||
<strong v-if="p.bold">{{ p.text }}</strong>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glossary {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.g-section + .g-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.g-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
/* 宽屏两列排布,缩短折叠后的滚动距离;词条整体不跨列拆断 */
|
||||
.g-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 8px 24px;
|
||||
}
|
||||
.g-entry {
|
||||
break-inside: avoid;
|
||||
margin: 0;
|
||||
}
|
||||
.g-term {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.g-def {
|
||||
margin: 0;
|
||||
}
|
||||
.g-def p {
|
||||
margin: 0;
|
||||
}
|
||||
.g-summary {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.g-summary strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.g-formula {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1px 8px;
|
||||
margin: 3px 0 !important;
|
||||
}
|
||||
/* 细节:细体暗色(非重点) */
|
||||
.g-detail {
|
||||
color: var(--text-dim);
|
||||
font-weight: 300;
|
||||
}
|
||||
.g-detail strong {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 怎么看:标签 + 阈值暖色粗体(重点) */
|
||||
.g-guide {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.g-guide-label {
|
||||
display: inline-block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 5px;
|
||||
margin-right: 6px;
|
||||
line-height: 1.5;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.g-guide strong {
|
||||
color: var(--warn);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 帮助下拉框:默认折叠,点击按钮展开(内容为任意插槽)。
|
||||
// 用于各报告框底部的名词解释,避免新手面对一堆指标名词无处下手。
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 按钮文案(如「名词解释」「图例说明」) */
|
||||
label?: string
|
||||
}>(),
|
||||
{ label: '名词解释' },
|
||||
)
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="help-collapse">
|
||||
<button class="help-toggle" type="button" :aria-expanded="open" @click="open = !open">
|
||||
<span class="help-q">?</span>
|
||||
<span>{{ label }}</span>
|
||||
<span class="chevron" :class="{ open }">▾</span>
|
||||
</button>
|
||||
<!-- grid-template-rows 0fr→1fr 实现高度自适应的展开动画;
|
||||
关闭态叠加 visibility:hidden(延迟到收起动画结束),
|
||||
避免 overflow 裁剪的内容对无障碍树/自动化仍"可见" -->
|
||||
<div class="help-body" :class="{ open }">
|
||||
<div class="help-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-collapse {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.help-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.help-toggle:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.help-q {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.help-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
grid-template-rows 0.25s ease,
|
||||
visibility 0s linear 0.25s;
|
||||
}
|
||||
.help-body.open {
|
||||
grid-template-rows: 1fr;
|
||||
visibility: visible;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
.help-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { metricsGlossary } from '../data/glossary'
|
||||
import type { Performance } from '../types'
|
||||
import GlossaryList from './GlossaryList.vue'
|
||||
import HelpCollapse from './HelpCollapse.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
perf: Performance
|
||||
@@ -85,22 +88,32 @@ function valueClass(row: MetricRow): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="metric-grid">
|
||||
<div v-for="[group, rows] in groups" :key="group" class="metric-group">
|
||||
<h4 class="group-title">{{ group }}</h4>
|
||||
<div class="metric-rows">
|
||||
<div v-for="row in rows" :key="row.key" class="metric-row">
|
||||
<span class="metric-label">{{ row.label }}</span>
|
||||
<span class="metric-value" :class="valueClass(row)">
|
||||
{{ formatVal(row, perf[row.key]) }}
|
||||
</span>
|
||||
<div class="metric-wrap">
|
||||
<div class="metric-grid">
|
||||
<div v-for="[group, rows] in groups" :key="group" class="metric-group">
|
||||
<h4 class="group-title">{{ group }}</h4>
|
||||
<div class="metric-rows">
|
||||
<div v-for="row in rows" :key="row.key" class="metric-row">
|
||||
<span class="metric-label">{{ row.label }}</span>
|
||||
<span class="metric-value" :class="valueClass(row)">
|
||||
{{ formatVal(row, perf[row.key]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 名词解释(默认折叠,新手向):每个指标是什么、怎么算、怎么看 -->
|
||||
<HelpCollapse label="名词解释:夏普 / 卡玛 / Ulcer / VaR / SQN…">
|
||||
<GlossaryList :sections="metricsGlossary" />
|
||||
</HelpCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric-grid {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts from '../echarts-setup'
|
||||
import { wfGlossary } from '../data/glossary'
|
||||
import type { WalkForwardResult } from '../types'
|
||||
import GlossaryList from './GlossaryList.vue'
|
||||
import HelpCollapse from './HelpCollapse.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
wf: WalkForwardResult
|
||||
@@ -122,6 +125,9 @@ watch(
|
||||
稳健策略的盈利窗占比应 ≥ 50%。
|
||||
</p>
|
||||
<div ref="container" class="wf-chart"></div>
|
||||
<HelpCollapse label="名词解释:切窗 / 每窗独立开仓 / 盈利窗占比…">
|
||||
<GlossaryList :sections="wfGlossary" />
|
||||
</HelpCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// 报告页名词解释(新手向)词条数据。
|
||||
//
|
||||
// 覆盖三个面板:Walk-Forward 样本外验证 / 一条龙评估 / 绩效指标(25 项)。
|
||||
// 文案口径与后端实现对齐:
|
||||
// - WF 切窗语义见 src/easy_tdx/backtest/walkforward.py(预热区 30%、每窗独立开仓);
|
||||
// - 综合评分权重见 scoring.py(收益 50/夏普 15/回撤 10/索提诺 5/WF 20);
|
||||
// - 适配性体检 8 项见 fitness.py(train/valid/test = 60/20/20,≥75% 高适配);
|
||||
// - 指标阈值参考 grading.py THRESHOLDS 的经验锚点。
|
||||
//
|
||||
// 排版约定:文案中的 **文字** 由 GlossaryList 渲染为粗体重点(阈值/口诀),
|
||||
// 其余为细体次要文字;formula 用等宽字体单独一行展示。
|
||||
|
||||
export interface GlossaryEntry {
|
||||
/** 词条名(面板上出现的原词) */
|
||||
term: string
|
||||
/** 一句话定义:这是什么 / 怎么算。支持 **粗体** 标重点 */
|
||||
summary: string
|
||||
/** 计算公式(等宽字体展示),可选 */
|
||||
formula?: string
|
||||
/** 补充细节:怎么来的、口径说明(细体)。支持 **粗体** */
|
||||
detail?: string
|
||||
/** 如何看 / 如何用:阈值与经验法则("怎么看"标签行)。支持 **粗体** */
|
||||
guide?: string
|
||||
}
|
||||
|
||||
export interface GlossarySection {
|
||||
/** 分组标题(如 收益类 / 风险类) */
|
||||
title: string
|
||||
entries: GlossaryEntry[]
|
||||
}
|
||||
|
||||
// ── Walk-Forward 样本外验证 ──────────────────────────────────────────────────
|
||||
|
||||
export const wfGlossary: GlossarySection[] = [
|
||||
{
|
||||
title: '这个面板在做什么',
|
||||
entries: [
|
||||
{
|
||||
term: 'Walk-Forward 样本外验证',
|
||||
summary:
|
||||
'把回测时间轴切成多个**连续窗口**,用**同一组参数**逐窗独立回测——检验策略**换个时段还赚不赚钱**。',
|
||||
detail:
|
||||
'全样本收益好,可能只是参数"恰好拟合"了某段行情(过拟合)。切窗后仍普遍盈利,说明赚的是可重复的模式而不是运气。',
|
||||
guide:
|
||||
'先看柱状图整体**红多绿少**(本页红=涨、绿=跌),再警惕**只有一两根大红柱撑起全部收益**——收益越集中,策略越脆弱。',
|
||||
},
|
||||
{
|
||||
term: '预热区',
|
||||
summary: '数据**前 30% 不参与评估**,专门留给指标"热身"。',
|
||||
detail:
|
||||
'技术指标需要历史 K 线才能算出(如 MA60 至少要 60 根 K 线)。预热区保证进入评估的窗口里指标已就绪,避免指标失真的窗口拖累成绩。',
|
||||
},
|
||||
{
|
||||
term: '每窗独立开仓',
|
||||
summary: '每个窗口都**从空仓开始**、窗口结束**强制清仓结算**,持仓不跨窗结转。',
|
||||
detail:
|
||||
'若允许持仓跨窗、把窗口首尾直接拼接,会**重复计算**跨窗持仓的收益,各窗成绩也无法独立比较。独立开仓是这套验证口径严格性的关键。',
|
||||
guide:
|
||||
'因为每窗独立结算,**连乘收益 ≠ 全样本回测总收益**:两者大致接近说明收益来自模式本身,差距大说明收益依赖跨段衔接的运气。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '汇总指标怎么读',
|
||||
entries: [
|
||||
{
|
||||
term: '盈利窗占比',
|
||||
summary: '赚钱窗口数 ÷ 总窗口数,**时间稳定性的核心指标**。',
|
||||
guide:
|
||||
'**≥ 50% 及格**(一半以上时段在赚钱),**≥ 70% 稳健**。占比低但总收益高 = 收益集中在少数时段,未来能否复制存疑。',
|
||||
},
|
||||
{
|
||||
term: '连乘收益',
|
||||
summary:
|
||||
'各窗收益按**复利**衔接的总收益,近似"每窗结束把本金和利润一起投入下一窗"。',
|
||||
formula: '∏(1 + 各窗收益率) − 1',
|
||||
guide:
|
||||
'只覆盖**评估区间**(不含预热区)。与逐窗柱子对照:**普遍小正、稳步累积**比"个别巨柱 + 大片绿柱"健康得多。',
|
||||
},
|
||||
{
|
||||
term: '最差窗 / 最好窗',
|
||||
summary: '所有窗口中收益**最差 / 最好**的一档,分别代表"最坏时段的体验"和"最好的运气"。',
|
||||
guide:
|
||||
'最差窗是**压力底线**:-20% 意味着曾有一段时间整体亏两成,先问自己拿不拿得住。**最好与最差差距越大**,稳定性越差。',
|
||||
},
|
||||
{
|
||||
term: '平均夏普',
|
||||
summary: '各窗口夏普比率的平均值——"平均每个时段,每承担 1 份波动换多少收益"。',
|
||||
guide: '**> 1 不错,> 2 优秀**。比全样本夏普更严格:它不允许用某段好行情掩盖其他时段的平庸。',
|
||||
},
|
||||
{
|
||||
term: '总交易',
|
||||
summary: '所有窗口的成交笔数之和,交易统计的**样本量**。',
|
||||
guide: '平均**每窗不足 10 笔**时,胜率、盈亏比等统计意义有限,参考权重应降低。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 一条龙评估 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const evaluateGlossary: GlossarySection[] = [
|
||||
{
|
||||
title: '顶部三个数',
|
||||
entries: [
|
||||
{
|
||||
term: '综合评分(0–100)',
|
||||
summary:
|
||||
'面向**策略研发迭代**的加权总分(与"评级"互补)。权重:**收益 50% + 夏普 15% + 回撤 10% + 索提诺 5% + WF 一致性 20%**。',
|
||||
detail:
|
||||
'无 WF 数据时权重自动归一到其余四项(不惩罚也不加分)。分数高说明"赚得多、拿得稳、跨时段可信"的综合面好。',
|
||||
guide:
|
||||
'**≥ 60** 值得继续深挖(改参数、换标的复验);低于 60 先看短板落在哪个分项,针对性改进。',
|
||||
},
|
||||
{
|
||||
term: '高适配',
|
||||
summary: '适配性体检 **8 项通过 ≥ 6 项(75%)** 且训练样本充分时打出的标记。',
|
||||
detail: '含义是:赚的钱更像**模式**而不是**运气**——三段都赚钱、收益不反号、样本够、没停摆。',
|
||||
guide: '未达标不等于不能用,但**不建议直接上实盘**,先看具体挂在哪几项检查上。',
|
||||
},
|
||||
{
|
||||
term: '对比买入持有 / 超额收益',
|
||||
summary:
|
||||
'同区间"第一天全仓买入、一动不动拿到最后"的对照成绩。**超额收益 = 策略总收益 − 买入持有总收益**。',
|
||||
guide:
|
||||
'策略**必须跑赢买入持有**才有存在价值——连死拿都不如,不如不折腾。超额收益 **< 0 是一票否决信号**:研发阶段直接回炉。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'α / β 与主动管理指标',
|
||||
entries: [
|
||||
{
|
||||
term: 'α 年化超额(Alpha)',
|
||||
summary: '用 CAPM 回归**剔除大盘涨跌贡献后**,策略自身"真本事"的年化收益。',
|
||||
formula: 'α ≈ 策略年化收益 − β × 基准年化收益',
|
||||
guide:
|
||||
'**α > 0** 说明扣掉"跟着大盘赚的钱"之后仍然赚钱,是选股/择时能力的净贡献;**α < 0** 说明策略其实是在给大盘打工。',
|
||||
},
|
||||
{
|
||||
term: 'β 敏感度(Beta)',
|
||||
summary: '基准涨 1% 时策略平均跟着涨/跌多少,衡量**对大盘的敏感度**。',
|
||||
formula: 'β = 协方差(策略, 基准) ÷ 基准方差',
|
||||
guide:
|
||||
'**β = 1 与大盘同涨同跌;β < 1 更抗跌;β > 1 放大波动**。中性指标、无好坏——想防守选低 β,想进攻选高 β。',
|
||||
},
|
||||
{
|
||||
term: '信息比率(IR)',
|
||||
summary: '**每承担 1 份跟踪误差,换来多少年化超额收益**——主动管理的核心性价比指标。',
|
||||
formula: 'IR = 年化超额收益 ÷ 年化跟踪误差',
|
||||
guide:
|
||||
'**> 0.5 良好,> 1 优秀**。与夏普的区别:夏普比"绝对收益 ÷ 总波动",IR 比"超额收益 ÷ 偏离基准的波动",衡量的是**主动偏离这件事做得值不值**。',
|
||||
},
|
||||
{
|
||||
term: '跟踪误差(TE)',
|
||||
summary: '策略与基准**日收益差值**的年化标准差,衡量"偏离基准走了多远"。',
|
||||
guide:
|
||||
'TE 大 = 策略走自己的路(可能是独特 α,也可能是跑偏);TE 小 = 紧贴基准。单独看无意义,**配合信息比率一起看**。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '体检与评级',
|
||||
entries: [
|
||||
{
|
||||
term: '适配性体检',
|
||||
summary:
|
||||
'把样本按时间切 **train / valid / test 三段(默认 60/20/20)** 独立回测,再用 **8 项可解释检查**判断"训练段学到的模式,在验证/测试段是否还成立"。',
|
||||
detail:
|
||||
'检查项包括:三段各自是否盈利、三段收益是否**同号**(防"段间反转")、测试段回撤是否可控、训练段是否 ≥ 5 笔、测试段是否仍活跃、样本外合并夏普是否 > 0。',
|
||||
guide:
|
||||
'通过率 **≥ 75%(6/8)且训练样本充分 → 高适配**。逐条看 ✓/✗ 清单,**失败项就是下一步的改进清单**。',
|
||||
},
|
||||
{
|
||||
term: '评级(不看收益率)',
|
||||
summary:
|
||||
'**S–D 档位**评级:卡玛/夏普/回撤/波动/胜率/盈亏比等维度加权 + **一票否决**,**刻意不看收益率**。',
|
||||
detail:
|
||||
'产品逻辑:哪怕近期收益率高,**长期风险大也该低评**。评级回答"普通人拿得住吗",综合评分回答"研发值不值得继续"。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 绩效指标(25 项)────────────────────────────────────────────────────────
|
||||
|
||||
export const metricsGlossary: GlossarySection[] = [
|
||||
{
|
||||
title: '收益类',
|
||||
entries: [
|
||||
{
|
||||
term: '总收益率',
|
||||
summary: '(期末资产 − 初始资金) ÷ 初始资金,整段回测**一共赚了百分之几**。',
|
||||
detail: '最直观也**最容易骗人**:单看它分不清"稳稳赚的"还是"赌一把赚的",必须配合风险类指标一起看。',
|
||||
guide: '先换算成**年化收益**再评价——总收益 50% 若花了 10 年,还不如存款。',
|
||||
},
|
||||
{
|
||||
term: '年化收益',
|
||||
summary: '把总收益**按复利折算成"每年"** 的收益率,让不同时长的回测可以公平比较。',
|
||||
formula: '(1 + 总收益)^(年数分之一) − 1',
|
||||
guide: '回测**时间太短(< 1 年)时会被大幅放大而失真**,参考意义有限;跨策略比较一律用年化。',
|
||||
},
|
||||
{
|
||||
term: '夏普比率',
|
||||
summary: '**每承担 1 份波动,换来多少超额收益**——风险调整收益的通用标尺。',
|
||||
formula: '(年化收益 − 无风险收益) ÷ 年化波动率',
|
||||
guide:
|
||||
'**> 1 不错,> 2 优秀,负数 = 跑不赢无风险利率**。缺点:上涨的"颠簸"也被当成风险扣分(看索提诺)。',
|
||||
},
|
||||
{
|
||||
term: '索提诺比率',
|
||||
summary: '夏普的改进版:分母**只计下行波动**(亏损方向的颠簸),上涨的颠簸是好事、不该扣分。',
|
||||
formula: '(年化收益 − 无风险收益) ÷ 下行波动率',
|
||||
guide: '同一策略**索提诺 ≥ 夏普**;两者差距大 = 收益主要靠上涨波动贡献,持有体验更好。',
|
||||
},
|
||||
{
|
||||
term: '卡玛比率',
|
||||
summary: '**每承受 1 份最大回撤,换来多少年化收益**——直接对应"套牢回本难度"。',
|
||||
formula: '年化收益 ÷ 最大回撤',
|
||||
guide:
|
||||
'**= 1 表示赚的和最深套牢幅度相当;≥ 2 优秀**。例:年化 20%、最大回撤 10% → 卡玛 2,最坏套牢一年内有望回本还有富余。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '风险类',
|
||||
entries: [
|
||||
{
|
||||
term: '最大回撤',
|
||||
summary: '净值从**峰值跌到谷底**的最大跌幅——历史最惨的时候亏了多少。',
|
||||
guide:
|
||||
'持有体验的核心:**亏 20% 要涨 25% 才回本,亏 50% 要涨 100%**。A 股个股策略 **< 20%** 比较舒服,**> 40%** 要认真考虑拿不拿得住。',
|
||||
},
|
||||
{
|
||||
term: '回撤持续',
|
||||
summary: '从净值峰值跌落、到**重新创新高**的最长天数——最长一次"套牢"了多久。',
|
||||
guide: '与最大回撤是一对:**深度决定亏多少,时长决定等多久**。回本等待超过 1 年,多数人早已割肉。',
|
||||
},
|
||||
{
|
||||
term: '波动率',
|
||||
summary: '日收益率的**年化标准差**,日常颠簸程度。',
|
||||
guide: '本身无好坏,是收益的"成本价"——同样收益下**波动越小越好**(这正是夏普衡量的东西)。',
|
||||
},
|
||||
{
|
||||
term: 'Ulcer 指数(溃疡指数)',
|
||||
summary:
|
||||
'名字来自"胃溃疡":对**持有期内每个时点的回撤深度取平方、求平均、再开方**——衡量整段持有的"闹心程度"。',
|
||||
formula: '√( mean( 回撤幅度² ) )',
|
||||
detail:
|
||||
'比最大回撤更全面:最大回撤只记最深一刀,Ulcer 把**整段旅程所有的坑**都计入,且**又深又久的坑按平方放大权重**。',
|
||||
guide: '**越小越好:< 5% 拿得安稳,> 10% 过程煎熬**。适合比较"最大回撤相同、但回撤形状不同"的两个策略。',
|
||||
},
|
||||
{
|
||||
term: '日 VaR(95%)',
|
||||
summary: 'Value at Risk 风险价值:**95% 的交易日,单日亏损不会超过这个数**(历史分位数法)。',
|
||||
guide:
|
||||
'如 3% 表示"平均每 20 个交易日里约 19 天,单日亏不过 3%"——剩下 5% 的日子**可能亏得更多**(有多糟看 CVaR)。',
|
||||
},
|
||||
{
|
||||
term: '日 CVaR(95%)',
|
||||
summary: '条件 VaR / 期望损失:**最坏那 5% 交易日的平均亏损**——"灾日"的平均伤害。',
|
||||
detail:
|
||||
'VaR 回答"平时最差会怎样",CVaR 回答"真正出事时平均亏多少"。数学上 **CVaR ≥ VaR 恒成立**。',
|
||||
guide: '风控更看 CVaR:它不忽略尾部。**CVaR 与 VaR 差距大 = 尾部风险肥**,极端行情来时更疼。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '交易类',
|
||||
entries: [
|
||||
{
|
||||
term: '总交易数',
|
||||
summary: '完成回合的成交笔数,一切交易统计的**样本量**。',
|
||||
guide: '**< 30 笔**时胜率/盈亏比的随机波动很大,结论慎用;本工具评级在 **< 10 笔**时会自动降权处理。',
|
||||
},
|
||||
{
|
||||
term: '胜率',
|
||||
summary: '盈利笔数 ÷ 总交易数,**做对的比例**。',
|
||||
guide:
|
||||
'**胜率高 ≠ 赚钱**:90% 胜率但"亏大赚小"照样亏钱。决定期望的是 **胜率 × 盈亏比** 的组合——40% 胜率配 3 倍盈亏比已是好系统。本页 **≥ 50%** 标红。',
|
||||
},
|
||||
{
|
||||
term: '盈亏比(利润因子)',
|
||||
summary: '总盈利 ÷ 总亏损(绝对值),**每亏 1 元换来多少盈利**。',
|
||||
guide:
|
||||
'**< 1 系统实际在亏钱;1–1.2 勉强平衡;> 2 健康**。与胜率互补:低胜率系统靠高盈亏比存活(趋势跟踪),高胜率系统靠小赚多次(网格、高抛低吸)。',
|
||||
},
|
||||
{
|
||||
term: '平均盈利 / 平均亏损',
|
||||
summary: '单笔盈利 / 单笔亏损的**平均幅度**,盈亏比的微观来源。',
|
||||
guide:
|
||||
'看两者比值:**平均盈利 ÷ 平均亏损 > 1.5** 且胜率 ≥ 40% 即正期望。平均亏损也是**单笔止损宽度**的参考。',
|
||||
},
|
||||
{
|
||||
term: '最大盈利 / 最大亏损',
|
||||
summary: '单笔最好 / 最坏的极端值。',
|
||||
guide: '用来检查"**一笔定乾坤**":把最大盈利那笔拿掉后总收益是否还成立?不成立说明成绩靠运气单,复制性差。',
|
||||
},
|
||||
{
|
||||
term: '平均持仓天数',
|
||||
summary: '每笔交易平均持有的天数,交易的**节奏**。',
|
||||
guide: '太短(如 < 3 天)**手续费磨损大**(对照拒单数);太长说明偏配置型。应与策略定位匹配。',
|
||||
},
|
||||
{
|
||||
term: 'SQN 系统质量',
|
||||
summary:
|
||||
'System Quality Number(Van Tharp 提出):**平均单笔盈亏相对其波动的强度,再乘样本量开方**,给交易系统打综合分。',
|
||||
formula: '√交易数 × 平均单笔盈亏 ÷ 单笔盈亏标准差',
|
||||
guide: '**> 2 可用,> 4 优秀,> 6 极佳**。同时奖励"期望正、波动小、样本足",一个数看系统健康度。',
|
||||
},
|
||||
{
|
||||
term: '最大连胜 / 最大连亏',
|
||||
summary: '连续赚钱 / 连续亏钱的**最长串**。',
|
||||
guide:
|
||||
'最大连亏是**心理压力测试**:40% 胜率的系统出现 8 连亏在数学上很正常(不代表失效)——实盘前先问自己**扛得住几连亏**,扛不住就会倒在黎明前。',
|
||||
},
|
||||
{
|
||||
term: '拒单数',
|
||||
summary: '因资金不足、涨跌停无法成交等原因**未能执行**的下单数。',
|
||||
guide: '拒单多说明信号与实际执行有偏差(如一字板买不进),回测成绩**偏乐观**,需要检查策略逻辑。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -17,7 +17,8 @@ import TradeTable from '../components/TradeTable.vue'
|
||||
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
|
||||
import { formatError, saveStrategy } from '../api'
|
||||
import { detectMarket } from '../market'
|
||||
import { gradePerformance } from '../grading'
|
||||
import { GRADE_META, gradePerformance } from '../grading'
|
||||
import { buildAiPrompt } from '../aiPrompt'
|
||||
import type { Category, ExecutionMode } from '../types'
|
||||
import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
@@ -199,6 +200,62 @@ async function onSave() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI 解读 Prompt(把当前报告组装成提示词,发给任意 LLM 解读)──────────────
|
||||
const showAiModal = ref(false)
|
||||
const aiMsg = ref('')
|
||||
|
||||
/** 实时组装:附加分析(WF/评估)跑完后内容自动变全 */
|
||||
const aiPromptText = computed(() => {
|
||||
if (!store.result) return ''
|
||||
return buildAiPrompt({
|
||||
symbol: fullSymbol(code.value),
|
||||
category: category.value,
|
||||
startDate: startDate.value,
|
||||
endDate: endDate.value,
|
||||
bars: store.ohlcv.length,
|
||||
strategyLabel: strategyLabel.value,
|
||||
params: params.value,
|
||||
cash: cash.value,
|
||||
commission: commission.value,
|
||||
slippage: slippage.value,
|
||||
execution: execution.value,
|
||||
result: store.result,
|
||||
wf: store.wfResult,
|
||||
evaluate: store.evaluateResult,
|
||||
grade: grade.value,
|
||||
gradeHint: grade.value ? GRADE_META[grade.value.grade].hint : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
function openAiModal() {
|
||||
aiMsg.value = ''
|
||||
showAiModal.value = true
|
||||
}
|
||||
|
||||
async function copyAiPrompt() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiPromptText.value)
|
||||
aiMsg.value = '✓ 已复制,粘贴给任意 AI 助手即可'
|
||||
} catch {
|
||||
// 剪贴板 API 不可用时退回选中文本,让用户手动 Ctrl+C
|
||||
const el = document.querySelector<HTMLTextAreaElement>('.ai-prompt-area')
|
||||
el?.focus()
|
||||
el?.select()
|
||||
aiMsg.value = document.execCommand('copy') ? '✓ 已复制' : '已全选文本,请按 Ctrl+C 复制'
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAiPrompt() {
|
||||
const blob = new Blob([aiPromptText.value], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `AI解读_${code.value}_${strategy.value}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
aiMsg.value = '✓ 已下载 .md 文件'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -298,6 +355,7 @@ async function onSave() {
|
||||
<div v-if="store.result" class="report-content">
|
||||
<div class="result-toolbar">
|
||||
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
|
||||
<button class="ghost" @click="openAiModal">🤖 AI 解读</button>
|
||||
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
|
||||
</div>
|
||||
|
||||
@@ -383,6 +441,33 @@ async function onSave() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 解读 Prompt 对话框 -->
|
||||
<div v-if="showAiModal" class="modal-overlay" @click.self="showAiModal = false">
|
||||
<div class="modal modal-wide">
|
||||
<h3>🤖 AI 解读 Prompt</h3>
|
||||
<p class="modal-desc">
|
||||
已把当前回测报告组装成提示词。复制后发给任意 AI 助手(ChatGPT / Claude /
|
||||
DeepSeek / 豆包…),即可获得针对性解读与改进建议。
|
||||
<template v-if="wfEnabled || evaluateEnabled">
|
||||
建议等附加分析跑完再复制,Walk-Forward / 一条龙评估的数据会一并打包。
|
||||
</template>
|
||||
</p>
|
||||
<textarea
|
||||
:value="aiPromptText"
|
||||
class="ai-prompt-area"
|
||||
readonly
|
||||
rows="16"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" @click="showAiModal = false">关闭</button>
|
||||
<button class="ghost" @click="downloadAiPrompt">⬇ 下载 .md</button>
|
||||
<button class="primary" @click="copyAiPrompt">复制 Prompt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -620,4 +705,27 @@ async function onSave() {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* AI 解读 Prompt 对话框(比保存对话框更宽,内容等宽小字可滚动) */
|
||||
.modal-wide {
|
||||
width: 640px;
|
||||
}
|
||||
.ai-prompt-area {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
max-height: 55vh;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
resize: vertical;
|
||||
}
|
||||
.ai-msg {
|
||||
font-size: 12px;
|
||||
color: var(--up);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user