feat: 组合回测分析体系对齐单标的 — 组合级WF/一条龙/完整25项绩效/AI解读

组合回测(一策略×多标的)此前只能看 4 个数字,本轮把单标的的整条
分析链路在组合端补齐(WebUI/REST 双端):

- portfolio_engine:合并净值+汇总成交喂 PerformanceAnalyzer,输出
  完整 25 项指标(SQN/最大连胜连亏/Ulcer/VaR/CVaR 等)+ 组合层
  trades(symbol 列);修复假年化与回撤口径(负值+固定分母 →
  逐点峰值,与单标的/多策略一致)
- walkforward:新增 PortfolioWalkForwardEngine,按标的日期并集切窗、
  每窗独立开仓、合成组合窗内净值,复用 WalkForwardResult 结构
- benchmark:新增 evaluate_portfolio 一条龙(组合回测+组合WF+
  跨标的多数口径适配性体检+综合评分+组合评级+等权买入持有基准对比),
  报告结构与单标的 evaluate_strategy 同构
- performance:FIFO 持仓天数配对支持 symbol 分组
- Web:新增 POST /backtest/portfolio/wf/run/async 与
  /backtest/portfolio/evaluate/run/async;组合回测响应附带
  grade(组合净值口径)与 score;新增 _normalize_bars_dt 修复
  按标的取数路径的字符串日期/遗留 date 列崩溃(E2E 揭露)
- 前端:组合页新增附加分析勾选区与组合绩效指标/WF/一条龙/成交明细
  区块;buildPortfolioAiPrompt 组合版 Prompt;抽通用
  AiInterpretModal(回测页迁移共用,行为不变);TradeTable 支持
  showSymbol;EvaluatePanel 支持 gradeOverride
- 测试:后端 +17 例(pytest 1603 绿)、aiPrompt 组合版 2 例、
  Playwright 组合页 E2E 2 例(9/9 绿)
This commit is contained in:
GitHub
2026-09-03 23:22:48 +08:00
parent 0ab5101188
commit c49ba4c4b5
21 changed files with 2052 additions and 245 deletions
+73
View File
@@ -0,0 +1,73 @@
// 组合回测页 E2E:多标的 + 策略 → 开始组合回测 → 完整绩效指标 + 各标的对比
// + 附加分析(组合 WF / 组合一条龙)+ 组合成交明细 + AI 解读弹窗。
//
// 行情来自 mock /bars(确定性合成 OHLCV2600 根/标的),组合回测/WF/一条龙
// 走真实后端引擎。
import { expect, test } from '@playwright/test'
test('组合回测全流程:评级 + 净值 + 完整指标 + 对比 + 成交明细', async ({ page }) => {
await page.goto('/portfolio?startDate=2023-01-01&endDate=2025-12-31')
// 默认两只标的(SZ:000001 / SH:600519),默认策略 ma_cross
await expect(page.getByRole('button', { name: '开始组合回测' })).toBeEnabled()
await page.getByRole('button', { name: '开始组合回测' }).click()
// 组合评级 + 组合整体绩效(含年化收益)
await expect(page.getByRole('heading', { name: '组合评级' })).toBeVisible({ timeout: 60_000 })
const perfSummary = page.locator('.report-section', { hasText: '组合整体绩效' })
await expect(perfSummary.getByText('年化收益', { exact: true })).toBeVisible()
// 组合净值曲线(echarts canvas
await expect(page.getByRole('heading', { name: '组合净值曲线' })).toBeVisible()
await expect(page.locator('.report-section canvas').first()).toBeVisible()
// 完整绩效指标(v1.31 与单标的同口径,含 SQN/最大连胜)
const perfSection = page.locator('.report-section', { hasText: '组合绩效指标' })
await expect(perfSection.locator('.metric-label', { hasText: 'SQN 系统质量' })).toBeVisible()
await expect(perfSection.locator('.metric-label', { hasText: '最大连胜' })).toBeVisible()
await expect(perfSection.locator('.metric-label', { hasText: '最大连亏' })).toBeVisible()
// 各标的对比 + 组合成交明细(带标的列)
await expect(page.getByRole('heading', { name: '各标的绩效对比' })).toBeVisible()
await expect(page.getByRole('heading', { name: /组合成交明细(\d+ 笔/ })).toBeVisible()
const tradeSection = page.locator('.report-section', { hasText: '组合成交明细' })
await expect(tradeSection.locator('th', { hasText: '标的' })).toBeVisible()
})
test('勾选附加分析后出现组合 WF 面板、一条龙评估与 AI 组合 Prompt', async ({ page }) => {
await page.goto('/portfolio?startDate=2023-01-01&endDate=2025-12-31')
await page.getByLabel('Walk-Forward 样本外验证').check()
await expect(page.getByLabel('一条龙评估')).toBeVisible()
await page.getByLabel('一条龙评估').check()
await page.getByRole('button', { name: '开始组合回测' }).click()
// 组合 WF:与单标的同构面板(逐窗柱状图 + 6 项汇总)
await expect(page.getByRole('heading', { name: 'Walk-Forward 样本外验证' })).toBeVisible({
timeout: 60_000,
})
await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 })
await expect(page.locator('.wf-summary .stat')).toHaveCount(6)
// 组合一条龙:综合评分 + 基准对比(等权买入持有组合)
await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 180_000 })
await expect(page.locator('.eval-header').getByText('综合评分', { exact: true })).toBeVisible()
await expect(page.locator('.eval-header').getByText('对比买入持有')).toBeVisible()
// AI 解读弹窗:组合版 Prompt 打包(组合配置 + 各标的表现 + WF + 一条龙)
await page.getByRole('button', { name: '🤖 AI 解读' }).click()
const area = page.locator('.ai-prompt-area')
await expect(area).toBeVisible()
await expect(area).toHaveValue(/组合回测报告(同一个策略分别跑在一篮子标的上/)
await expect(area).toHaveValue(/# 组合回测配置/)
await expect(area).toHaveValue(/SZ:000001、SH:600519/)
await expect(area).toHaveValue(/# 各标的表现(按收益降序/)
await expect(area).toHaveValue(/Walk-Forward 样本外验证/)
await expect(area).toHaveValue(/# 一条龙评估/)
await expect(area).toHaveValue(/# 背景与免责/)
await page.getByRole('button', { name: '关闭' }).click()
await expect(area).toBeHidden()
})
+113
View File
@@ -217,3 +217,116 @@ test('可选段:WF / 一条龙评估 / 评级按需拼接', () => {
assert.match(p, /档位:\*\*D\*\*(总分 31\.2\/100)——持有体验差或系统亏损,不建议参与/)
assert.match(p, /一票否决:最大回撤 41\.7%/)
})
// ── 组合版 PromptbuildPortfolioAiPromptv1.31)────────────────────────────
import { buildPortfolioAiPrompt } from '../aiPrompt.ts'
import type { PortfolioResult } from '../types.ts'
const PORTFOLIO_RESULT: PortfolioResult = {
total_performance: {
...PERF,
total_return: 0.42,
annual_return: 0.098,
max_drawdown: 0.18,
total_stocks: 2,
total_cash: 1000000,
},
individual_results: {
'SZ:000001': RESULT,
'SH:600519': RESULT,
},
equity_allocation: { 'SZ:000001': 0.5, 'SH:600519': 0.5 },
combined_equity: [
{ datetime: '2020-01-06', cash: 1000000, position_value: 0, total: 1000000, drawdown: 0, drawdown_pct: 0 },
{ datetime: '2022-04-26', cash: 0, position_value: 1420000, total: 1420000, drawdown: 0, drawdown_pct: 0 },
],
trades: [
{ symbol: 'SZ:000001', datetime: '2020-02-03', direction: 'BUY', size: 1000, price: 4.52, commission: 5, slippage: 0, pnl: 0, rejected: false },
{ symbol: 'SH:600519', datetime: '2020-03-10', direction: 'SELL', size: 500, price: 4.71, commission: 5, slippage: 0, pnl: 90, rejected: false },
],
}
test('组合版:组合配置/标的清单/完整指标/各标的表现/组合成交齐全', () => {
const p = buildPortfolioAiPrompt({
stocks: ['SZ:000001', 'SH:600519'],
category: 'DAY',
startDate: '2020-01-06',
endDate: '2026-09-02',
strategyLabel: '双均线交叉',
params: { fast: 5, slow: 20 },
cash: 1000000,
commission: 0.0003,
slippage: 0,
execution: 'next_open',
result: PORTFOLIO_RESULT,
})
// 组合角色设定(明确「一篮子标的、资金均分」语境)
assert.match(p, /# 角色设定/)
assert.match(p, /组合回测报告(同一个策略分别跑在一篮子标的上/)
// 配置段
assert.match(p, /# 组合回测配置/)
assert.match(p, /2 只标的上,资金均分(各拿总额的 50\.0%)/)
assert.match(p, /SZ:000001、SH:600519/)
assert.match(p, /组合总资金:1,000,000 元/)
// 完整 25 项指标(含 SQN/连胜连亏)
for (const label of ['SQN 系统质量', '最大连胜', '最大连亏', 'Ulcer 指数']) {
assert.ok(p.includes(`- ${label}`), `缺少指标行:${label}`)
}
assert.match(p, /- 总收益率:42\.00%/)
// 净值概览 + 各标的表现(降序)
assert.match(p, /# 净值概览/)
assert.match(p, /# 各标的表现(按收益降序;全部)/)
assert.match(p, /- SZ:000001:总收益 \+126\.43%,最大回撤 -41\.65%,夏普 0\.5390 笔(胜率 \+35\.56%/)
// 组合成交(带标的)
assert.match(p, /# 最近成交(组合合计的最后 8 笔)/)
assert.match(p, /SH:600519 2020-03-10 卖出 500 股 @ 4\.71,本笔盈亏 \+90 元/)
assert.match(p, /# 背景与免责/)
// 未提供可选数据时,对应段落不出现
assert.ok(!p.includes('Walk-Forward 样本外验证'))
assert.ok(!p.includes('一条龙评估'))
assert.ok(!p.includes('评级(不看收益率)'))
})
test('组合版:WF / 一条龙 / 评级按需拼接', () => {
const p = buildPortfolioAiPrompt({
stocks: ['SZ:000001', 'SH:600519'],
category: 'DAY',
startDate: '2020-01-06',
endDate: '2026-09-02',
strategyLabel: '双均线交叉',
params: {},
cash: 1000000,
commission: 0.0003,
slippage: 0,
execution: 'next_open',
result: PORTFOLIO_RESULT,
wf: {
n_windows: 5,
warmup_ratio: 0.3,
windows: [
{ index: 0, start: '2021-01-01', end: '2021-12-31', bars: 240, total_return: 0.03, sharpe: 0.6, max_drawdown: -0.05, total_trades: 30, win_rate: 0.53 },
{ index: 1, start: '2022-01-01', end: '2022-12-31', bars: 240, total_return: -0.01, sharpe: -0.2, max_drawdown: -0.09, total_trades: 26, win_rate: 0.46 },
],
consistency: 0.5,
chained_return: 0.0197,
mean_window_return: 0.01,
median_window_return: 0.01,
worst_window: -0.01,
best_window: 0.03,
mean_sharpe: 0.2,
worst_drawdown: -0.09,
total_trades: 56,
},
grade: { ...GRADE, scenario: 'portfolio' },
gradeHint: '持有体验差或系统亏损,不建议参与',
})
assert.match(p, /Walk-Forward 样本外验证(同参数跨时段稳定性)/)
assert.match(p, /窗口数:5/)
assert.match(p, /窗12021-01-01 ~ 2021-12-31):\+3\.00%,夏普 0\.60,最大回撤 -5\.00%30 笔(胜率 \+53\.00%/)
assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/)
assert.match(p, /档位:\*\*D\*\*/)
})
+123 -4
View File
@@ -11,9 +11,12 @@
import type {
BacktestResult,
Category,
EquityPoint,
EvaluateReport,
ExecutionMode,
Performance,
PortfolioResult,
PortfolioTrade,
Trade,
WalkForwardResult,
} from './types'
@@ -45,6 +48,27 @@ export interface AiPromptInput {
gradeHint?: string
}
export interface PortfolioAiPromptInput {
/** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"] */
stocks: string[]
category: Category
startDate: string
endDate: string
strategyLabel: string
params: Record<string, number | string | boolean>
cash: number
commission: number
slippage: number
execution: ExecutionMode
result: PortfolioResult
/** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */
wf?: WalkForwardResult | null
evaluate?: EvaluateReport | null
grade?: GradeResult | null
/** 评级档位的一句话含义(GRADE_META[grade].hint,由组件传入) */
gradeHint?: string
}
// ── 展示辅助(自包含,避免运行时依赖其他模块)────────────────────────────────
const CATEGORY_LABELS: Record<Category, string> = {
@@ -139,7 +163,15 @@ function n(v: number | string | null | undefined): number | undefined {
// ── 各段落构建 ───────────────────────────────────────────────────────────────
function sectionRole(): string {
function sectionRole(kind: 'single' | 'portfolio' = 'single'): string {
const intro =
kind === 'portfolio'
? '下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
: '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
const step5 =
kind === 'portfolio'
? '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;'
: '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;'
return [
'# 角色设定',
'',
@@ -147,13 +179,13 @@ function sectionRole(): string {
'',
'# 任务',
'',
'下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:',
intro,
'',
'1. **先给结论**:这策略现在处于什么状态——「可以继续往下走」「底子不错但还差几步」还是「问题不小,得大改」?一句话说清,再讲理由;',
'2. **优点和毛病都要讲**:先说说它强在哪(哪些数字是真的好看、说明策略做对了什么),再讲你担心什么。别只挑刺,也别光报喜——我是想知道这策略能不能用,不是来听审判也不是来听表扬的。挑最有说服力的几组数字讲,不用面面俱到;',
'3. **说说持有体验**:真拿钱跑这个策略,过程大概什么感受——多久交易一次、最惨的时候有多惨、普通人拿不拿得住;',
'4. **判断是规律还是运气**:从分时段数据(Walk-Forward 各窗收益、训练/验证/测试三段、和死拿不动的对比)找证据。有担心就直说,但像朋友提醒那样说,别像下判决书;',
'5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;',
step5,
'6. **最后打个分**:给这个策略一个 0-10 的「信心分」,代表你现在有多大把握它值得继续投入。打分要和前面说的话一致(前面夸的多就别打低分,反过来也一样),再用一两句话说说为什么是这个分、到几分你会建议我拿小仓位试试。参考刻度:0-3 建议放弃,4-6 值得继续改(说清往哪改),7-8 可以小仓位试错,9 以上才谈逐步加仓。',
'',
'# 说话方式(很重要)',
@@ -198,7 +230,10 @@ function sectionMetrics(perf: Performance): string {
}
function sectionEquity(result: BacktestResult): string {
const eq = result.equity_curve
return sectionEquityPoints(result.equity_curve)
}
function sectionEquityPoints(eq: EquityPoint[] | undefined): string {
if (!eq || eq.length === 0) return ''
let peak = eq[0]
let trough = eq[0]
@@ -327,9 +362,74 @@ function sectionFooter(): string {
'以上数据来自 easy-tdx 的历史 K 线回测(已计入佣金与滑点)。历史回测存在幸存者偏差与未来不确定性,不构成投资建议,你的解读也以研究学习为目的。',
'数据里缺失的项(显示 - 或整段没有的)直接跳过,不用专门解释局限。',
'好了,开始吧。',
'# 重要提醒',
'禁止使用状语',
].join('\n')
}
// ── 组合版段落 ───────────────────────────────────────────────────────────────
function sectionPortfolioConfig(i: PortfolioAiPromptInput): string {
const stockList =
i.stocks.length <= 12
? i.stocks.join('、')
: `${i.stocks.slice(0, 12).join('、')}${i.stocks.length}`
const lines = [
'# 组合回测配置',
'',
`- 组合形式:同一个策略分别跑在 ${i.stocks.length} 只标的上,资金均分(各拿总额的 ${(
100 / i.stocks.length
).toFixed(1)}%),标的间独立回测、净值按日加总`,
`- 标的列表:${stockList}`,
`- 回测区间:${i.startDate} ~ ${i.endDate}${CATEGORY_LABELS[i.category] ?? i.category}`,
`- 策略:${i.strategyLabel}`,
`- 参数:${fmtParams(i.params)}`,
`- 组合总资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`,
'',
]
return lines.join('\n')
}
/** 各标的表现摘要:按收益降序,超过 12 只时只列最好 6 只 + 最差 6 只。 */
function sectionStocksSummary(result: PortfolioResult): string {
const entries = Object.entries(result.individual_results)
if (entries.length === 0) return ''
const sorted = entries
.map(([symbol, r]) => ({ symbol, perf: r.performance }))
.sort((a, b) => (b.perf.total_return ?? 0) - (a.perf.total_return ?? 0))
const shown =
sorted.length <= 12
? sorted
: [...sorted.slice(0, 6), ...sorted.slice(sorted.length - 6)]
const lines = [
'# 各标的表现(按收益降序;' +
(sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 只,其余为最好/最差各 6 只`) +
'',
'',
]
for (const { symbol, perf } of shown) {
lines.push(
`- ${symbol}:总收益 ${pct(perf.total_return)},最大回撤 ${pct(perf.max_drawdown)},夏普 ${ratio(perf.sharpe)}${Math.round(perf.total_trades ?? 0)} 笔(胜率 ${pct(perf.win_rate)}`,
)
}
lines.push('')
return lines.join('\n')
}
function sectionPortfolioTrades(trades: PortfolioTrade[] | undefined): string {
if (!trades || trades.length === 0) return ''
const recent = trades.slice(-8)
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(`- ${t.symbol} ${fmtDate(t.datetime)} ${dir} ${Math.round(t.size)} 股 @ ${t.price.toFixed(2)}${pnl}`)
}
lines.push('')
return lines.join('\n')
}
// ── 主函数 ───────────────────────────────────────────────────────────────────
/** 组装 AI 解读 Promptmarkdown 结构,任意 LLM 可直接消费)。 */
@@ -348,3 +448,22 @@ export function buildAiPrompt(input: AiPromptInput): string {
parts.push(sectionFooter())
return parts.join('\n')
}
/** 组装组合回测的 AI 解读 Prompt(与单标的同构,段落随附加分析增减)。 */
export function buildPortfolioAiPrompt(input: PortfolioAiPromptInput): string {
const parts: string[] = [
sectionRole('portfolio'),
sectionPortfolioConfig(input),
sectionMetrics(input.result.total_performance),
sectionEquityPoints(input.result.combined_equity),
]
const stocksSummary = sectionStocksSummary(input.result)
if (stocksSummary) parts.push(stocksSummary)
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 = sectionPortfolioTrades(input.result.trades)
if (trades) parts.push(trades)
parts.push(sectionFooter())
return parts.join('\n')
}
+27
View File
@@ -201,6 +201,33 @@ export async function submitPortfolioTask(
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */
export async function submitPortfolioWalkforwardTask(
req: PortfolioBacktestRequest,
nWindows = 7,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/wf/run/async?n_windows=${nWindows}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合级一条龙评估后台任务(组合回测+WF+适配性+评分+基准对比)。 */
export async function submitPortfolioEvaluateTask(
req: PortfolioBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/evaluate/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 submitMultiStrategyTask(
req: MultiStrategyBacktestRequest,
+256
View File
@@ -0,0 +1,256 @@
<script setup lang="ts">
// AI 解读弹窗(单标的/组合回测通用):Prompt 预览 + 复制/下载 + 一键直接解读。
// Prompt 由父组件实时组装传入(附加分析跑完内容自动变全),本组件只管交互;
// 直接解读走后端 LLM 后台任务(配置见「AI 设置」页),解读记录旁路落历史库。
import { onMounted, ref, watch } from 'vue'
import { formatError, fetchLlmConfig, runLlmChatWithPolling } from '../api'
import type { LlmChatContext, LlmChatResult } from '../types'
const props = defineProps<{
/** 已组装好的 Prompt 全文(computed 传入,实时更新) */
prompt: string
/** 下载文件名(如 AI解读_SZ000001_ma_cross.md */
filename: string
/** 直接解读时随 Prompt 落历史库的策略上下文(历史页「去回测」引导用) */
context?: LlmChatContext
/** 弹窗描述里的附加提示(如「建议等附加分析跑完再发」) */
tip?: string
}>()
const emit = defineEmits<{ close: [] }>()
const aiMsg = ref('')
// 直接解读(服务端 LLM 已配置时可用,配置见「AI 设置」页)
const llmReady = ref(false)
const llmLabel = ref('')
const aiRunning = ref(false)
const aiElapsed = ref(0)
const aiReply = ref('')
let aiTimer = 0
onMounted(() => {
// 打开时探测 LLM 是否已配置(失败静默——导出 Prompt 的老路径不依赖后端)
fetchLlmConfig()
.then((resp) => {
llmReady.value = resp.configured
const p = resp.providers.find((x) => x.id === resp.config.provider)
llmLabel.value = p ? `${p.label} · ${resp.resolved.model}` : resp.resolved.model
})
.catch(() => {
llmReady.value = false
})
})
watch(
() => props.prompt,
() => {
// 配置更新后重置旧的失败/成功消息之外的回复?保持回复不动,仅清错误提示
if (aiMsg.value.startsWith('解读失败')) aiMsg.value = ''
},
)
/** 直接解读:把组装好的 Prompt 提交为后台任务并轮询(不占 HTTP 连接)。 */
async function runAiInterpret() {
if (!props.prompt || aiRunning.value) return
aiRunning.value = true
aiMsg.value = ''
aiReply.value = ''
// 后台任务模式:模型生成 1-3 分钟正常——显示已耗时防误判卡死
aiElapsed.value = 0
aiTimer = window.setInterval(() => {
aiElapsed.value += 1
}, 1000)
try {
const state = await runLlmChatWithPolling(props.prompt, props.context)
// TaskState.result 是多任务类型联合,按 LLM 任务结构收窄
const r = state.result as LlmChatResult | null
// 后端已保证非空正文(空白正文会以 failed 上浮),前端再拦一道纯空白
if (state.status === 'done' && r?.reply?.trim()) {
aiReply.value = r.reply
aiMsg.value = `${r.provider} · ${r.model} 已解读(${aiElapsed.value}s`
} else if (state.status === 'done') {
aiMsg.value = '解读失败:模型返回了空正文(可能被 Max Tokens 截断),可在「AI 设置」调大后重试'
} else {
aiMsg.value = `解读失败:${state.error ?? '未知错误'}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
}
} catch (e) {
aiMsg.value = `解读失败:${formatError(e)}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
} finally {
window.clearInterval(aiTimer)
aiRunning.value = false
}
}
async function copyAiPrompt() {
try {
await navigator.clipboard.writeText(props.prompt)
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([props.prompt], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.filename
a.click()
URL.revokeObjectURL(url)
aiMsg.value = '✓ 已下载 .md 文件'
}
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal modal-wide">
<h3>🤖 AI 解读</h3>
<p class="modal-desc">
已把当前回测报告组装成提示词
<template v-if="llmReady">
点击直接解读发送给已配置的模型{{ llmLabel }}
</template>
<template v-else>
AI 设置页配置模型后可一键直接解读也可
</template>
复制后发给任意 AI 助手ChatGPT / Claude / DeepSeek / 豆包
<template v-if="tip"> {{ tip }}</template>
</p>
<textarea
:value="prompt"
class="ai-prompt-area"
:class="{ collapsed: !!aiReply }"
readonly
:rows="aiReply ? 6 : 16"
spellcheck="false"
></textarea>
<div v-if="aiReply" class="ai-reply">{{ aiReply }}</div>
<div v-if="aiReply" class="ai-note">
以上解读由 AI 模型生成可能存在错误或过时信息仅供参考不构成投资建议
</div>
<span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span>
<div class="modal-actions">
<button class="ghost" @click="emit('close')">关闭</button>
<button class="ghost" @click="downloadAiPrompt"> 下载 .md</button>
<button class="ghost" @click="copyAiPrompt">复制 Prompt</button>
<button
v-if="llmReady"
class="primary"
:disabled="aiRunning || !prompt"
@click="runAiInterpret"
>
{{ aiRunning ? `解读中… ${aiElapsed}s` : '✨ 直接解读' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.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-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;
}
/* 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;
}
/* 直接解读出结果后 Prompt 区收窄,把版面让给回复 */
.ai-prompt-area.collapsed {
max-height: 18vh;
}
.ai-reply {
margin-top: 8px;
max-height: 38vh;
overflow: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg {
font-size: 12px;
color: var(--up);
}
.ai-note {
margin-top: 4px;
font-size: 11px;
color: var(--warn, #ffc107);
}
</style>
+5 -1
View File
@@ -10,9 +10,13 @@ import HelpCollapse from './HelpCollapse.vue'
import { gradePerformance } from '../grading'
import { evaluateGlossary } from '../data/glossary'
import type { EvaluateReport } from '../types'
import type { GradeResult } from '../grading/types'
const props = defineProps<{
report: EvaluateReport
/** 评级覆盖:组合级报告传入组合口径评级(gradePortfolio / 后端
* grade_portfolio_equity),缺省时按单标的 6 维度本地重算。 */
gradeOverride?: GradeResult | null
}>()
/** 综合评分分项(含权重,展示顺序固定) */
@@ -32,7 +36,7 @@ const scoreComponents = computed(() => {
}))
})
const grade = computed(() => gradePerformance(props.report.performance))
const grade = computed(() => props.gradeOverride ?? gradePerformance(props.report.performance))
const excess = computed(() => props.report.benchmark.excess_return)
+6 -1
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
// 成交记录表。展示每笔成交的方向/数量/价格/费用/盈亏。
// showSymbol 时多一列来源标的(组合页展示各标的汇总成交用)。
import type { Trade } from '../types'
defineProps<{
trades: Trade[]
/** 行类型兼容组合交易明细(PortfolioTrade = Trade & { symbol } */
trades: (Trade & { symbol?: string })[]
showSymbol?: boolean
}>()
function fmtDate(s: string): string {
@@ -21,6 +24,7 @@ function fmtNum(v: number, digits = 2): string {
<table v-else class="trade-table">
<thead>
<tr>
<th v-if="showSymbol">标的</th>
<th>日期</th>
<th>方向</th>
<th class="num">数量</th>
@@ -31,6 +35,7 @@ function fmtNum(v: number, digits = 2): string {
</thead>
<tbody>
<tr v-for="(t, i) in trades" :key="i" :class="{ rejected: t.rejected }">
<td v-if="showSymbol" class="muted">{{ t.symbol }}</td>
<td>{{ fmtDate(t.datetime) }}</td>
<td :class="t.direction">{{ t.direction }}</td>
<td class="num">{{ fmtNum(t.size, 0) }}</td>
+67
View File
@@ -9,6 +9,8 @@ import {
formatError,
runBacktest,
submitPortfolioTask,
submitPortfolioEvaluateTask,
submitPortfolioWalkforwardTask,
submitOptimizeAllTask,
submitOptimizeTask,
submitMultiStrategyTask,
@@ -187,6 +189,62 @@ export const useBacktestStore = defineStore('backtest', () => {
error.value = ''
}
// ── 组合附加分析:组合级 Walk-Forward / 一条龙评估 ────────────────────────
const portfolioWfResult = ref<WalkForwardResult | null>(null)
const portfolioWfRunning = ref(false)
const portfolioWfError = ref<string>('')
const portfolioEvaluateResult = ref<EvaluateReport | null>(null)
const portfolioEvaluateRunning = ref(false)
const portfolioEvaluateError = ref<string>('')
/** 提交组合级 WF 样本外验证后台任务并轮询(N 标的 × N 窗,比单标的慢)。 */
async function runPortfolioWalkforward(req: PortfolioBacktestRequest, nWindows = 7) {
portfolioWfRunning.value = true
portfolioWfError.value = ''
portfolioWfResult.value = null
try {
const { task_id } = await submitPortfolioWalkforwardTask(req, nWindows)
const body = await pollTask<{ walkforward: WalkForwardResult }>(
task_id,
300_000,
'组合 WF 验证',
)
portfolioWfResult.value = body.walkforward
} catch (e) {
portfolioWfError.value = formatError(e)
portfolioWfResult.value = null
} finally {
portfolioWfRunning.value = false
}
}
/** 提交组合级一条龙评估后台任务并轮询(组合回测+WF+适配性+评分+基准对比)。 */
async function runPortfolioEvaluate(req: PortfolioBacktestRequest) {
portfolioEvaluateRunning.value = true
portfolioEvaluateError.value = ''
portfolioEvaluateResult.value = null
try {
const { task_id } = await submitPortfolioEvaluateTask(req)
portfolioEvaluateResult.value = await pollTask<EvaluateReport>(
task_id,
600_000,
'组合一条龙评估',
)
} catch (e) {
portfolioEvaluateError.value = formatError(e)
portfolioEvaluateResult.value = null
} finally {
portfolioEvaluateRunning.value = false
}
}
function clearPortfolioExtraAnalysis() {
portfolioWfResult.value = null
portfolioWfError.value = ''
portfolioEvaluateResult.value = null
portfolioEvaluateError.value = ''
}
// ── 多策略组合回测(资金分仓) ─────────────────────────────────────────
const multiStrategyResult = ref<PortfolioResult | null>(null)
const multiStrategyRunning = ref(false)
@@ -319,6 +377,12 @@ export const useBacktestStore = defineStore('backtest', () => {
error,
portfolioResult,
portfolioRunning,
portfolioWfResult,
portfolioWfRunning,
portfolioWfError,
portfolioEvaluateResult,
portfolioEvaluateRunning,
portfolioEvaluateError,
multiStrategyResult,
multiStrategyRunning,
optimizeResult,
@@ -344,6 +408,9 @@ export const useBacktestStore = defineStore('backtest', () => {
clearExtraAnalysis,
runPortfolio,
clearPortfolio,
runPortfolioWalkforward,
runPortfolioEvaluate,
clearPortfolioExtraAnalysis,
runMultiStrategy,
clearMultiStrategy,
runOptimize,
+19 -6
View File
@@ -2,6 +2,8 @@
// 与 src/easy_tdx/web/backtest_schemas.py 及 backtest router 的响应保持一致。
// 后端是唯一事实源;这里只做类型契约。
import type { GradeResult } from './grading/types'
// ── 策略 schemaGET /api/v1/backtest/strategies ───────────────────────────
export type ParamType = 'int' | 'float' | 'bool' | 'str'
@@ -197,16 +199,27 @@ export interface PortfolioBacktestRequest {
end_date?: string
}
/** 组合整体绩效:与单标的同口径的完整指标(PerformanceAnalyzer 算出,
* 含 SQN/最大连胜连亏等)+ 组合专属的标的数与总资金。 */
export type PortfolioTotalPerformance = Performance & {
total_stocks: number
total_cash: number
}
/** 组合交易明细行:单标的 Trade 附来源标的(组合层汇总成交表)。 */
export type PortfolioTrade = Trade & { symbol: string }
export interface PortfolioResult {
total_performance: {
total_return: number
annual_return: number
total_stocks: number
total_cash: number
}
total_performance: PortfolioTotalPerformance
individual_results: Record<string, BacktestResult>
equity_allocation: Record<string, number>
combined_equity: EquityPoint[]
/** 组合层汇总成交(各标的 concat + symbol 列;v1.31 起返回,老结果缺省) */
trades?: PortfolioTrade[]
/** 后端组合评级(净值曲线 5 维度口径,v1.31 起返回,老结果缺省) */
grade?: GradeResult
/** 后端综合评分(v1.31 起返回,老结果缺省) */
score?: StrategyScoreReport
}
// ── 参数网格寻优(Phase 4) ──────────────────────────────────────────────────
+30 -182
View File
@@ -6,6 +6,7 @@
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AiInterpretModal from '../components/AiInterpretModal.vue'
import EquityChart from '../components/EquityChart.vue'
import EvaluatePanel from '../components/EvaluatePanel.vue'
import GradeDetails from '../components/GradeDetails.vue'
@@ -15,11 +16,11 @@ import StrategyPicker from '../components/StrategyPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue'
import TradeTable from '../components/TradeTable.vue'
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
import { formatError, saveStrategy, fetchLlmConfig, runLlmChatWithPolling } from '../api'
import { formatError, saveStrategy } from '../api'
import { detectMarket } from '../market'
import { GRADE_META, gradePerformance } from '../grading'
import { buildAiPrompt } from '../aiPrompt'
import type { Category, ExecutionMode, LlmChatResult } from '../types'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
@@ -202,15 +203,9 @@ async function onSave() {
}
// ── AI 解读 Prompt(把当前报告组装成提示词,发给任意 LLM 解读)──────────────
// 弹窗交互(复制/下载/直接解读)抽在 AiInterpretModal 通用组件里,
// 与组合回测页共用;这里只负责实时组装 Prompt 与策略上下文。
const showAiModal = ref(false)
const aiMsg = ref('')
// 直接解读(服务端 LLM 已配置时可用,配置见「AI 设置」页)
const llmReady = ref(false)
const llmLabel = ref('')
const aiRunning = ref(false)
const aiElapsed = ref(0)
const aiReply = ref('')
let aiTimer = 0
/** 实时组装:附加分析(WF/评估)跑完后内容自动变全 */
const aiPromptText = computed(() => {
@@ -235,87 +230,22 @@ const aiPromptText = computed(() => {
})
})
function openAiModal() {
aiMsg.value = ''
aiReply.value = ''
showAiModal.value = true
// 打开时探测 LLM 是否已配置(失败静默——导出 Prompt 的老路径不依赖后端)
fetchLlmConfig()
.then((resp) => {
llmReady.value = resp.configured
const p = resp.providers.find((x) => x.id === resp.config.provider)
llmLabel.value = p ? `${p.label} · ${resp.resolved.model}` : resp.resolved.model
})
.catch(() => {
llmReady.value = false
})
}
/** 随解读落历史库的策略上下文(AI 解读历史页「去回测」引导用) */
const aiContext = computed(() => ({
strategy: strategy.value,
strategy_label: strategyLabel.value,
symbol: code.value,
category: category.value,
params: { ...params.value },
start_date: startDate.value,
end_date: endDate.value,
}))
/** 直接解读:把组装好的 Prompt 提交为后台任务并轮询(不占 HTTP 连接)。 */
async function runAiInterpret() {
if (!aiPromptText.value || aiRunning.value) return
aiRunning.value = true
aiMsg.value = ''
aiReply.value = ''
// 后台任务模式:模型生成 1-3 分钟正常——显示已耗时防误判卡死
aiElapsed.value = 0
aiTimer = window.setInterval(() => {
aiElapsed.value += 1
}, 1000)
try {
// 策略上下文随解读落历史库(AI 解读历史页「去回测」引导用)
const ctx = {
strategy: strategy.value,
strategy_label: strategyLabel.value,
symbol: code.value,
category: category.value,
params: { ...params.value },
start_date: startDate.value,
end_date: endDate.value,
}
const state = await runLlmChatWithPolling(aiPromptText.value, ctx)
// TaskState.result 是多任务类型联合,按 LLM 任务结构收窄
const r = state.result as LlmChatResult | null
// 后端已保证非空正文(空白正文会以 failed 上浮),前端再拦一道纯空白
if (state.status === 'done' && r?.reply?.trim()) {
aiReply.value = r.reply
aiMsg.value = `${r.provider} · ${r.model} 已解读(${aiElapsed.value}s`
} else if (state.status === 'done') {
aiMsg.value = '解读失败:模型返回了空正文(可能被 Max Tokens 截断),可在「AI 设置」调大后重试'
} else {
aiMsg.value = `解读失败:${state.error ?? '未知错误'}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
}
} catch (e) {
aiMsg.value = `解读失败:${formatError(e)}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
} finally {
window.clearInterval(aiTimer)
aiRunning.value = false
}
}
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 文件'
}
const aiTip = computed(() =>
wfEnabled.value || evaluateEnabled.value
? '建议等附加分析跑完再发,Walk-Forward / 一条龙评估的数据会一并打包。'
: undefined,
)
</script>
<template>
@@ -415,7 +345,7 @@ function downloadAiPrompt() {
<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>
<button class="ghost" @click="showAiModal = true">🤖 AI 解读</button>
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
@@ -502,51 +432,15 @@ function downloadAiPrompt() {
</div>
</div>
<!-- AI 解读 Prompt 对话框 -->
<div v-if="showAiModal" class="modal-overlay" @click.self="showAiModal = false">
<div class="modal modal-wide">
<h3>🤖 AI 解读</h3>
<p class="modal-desc">
已把当前回测报告组装成提示词
<template v-if="llmReady">
点击直接解读发送给已配置的模型{{ llmLabel }}
</template>
<template v-else>
AI 设置页配置模型后可一键直接解读也可
</template>
复制后发给任意 AI 助手ChatGPT / Claude / DeepSeek / 豆包
<template v-if="wfEnabled || evaluateEnabled">
建议等附加分析跑完再发Walk-Forward / 一条龙评估的数据会一并打包
</template>
</p>
<textarea
:value="aiPromptText"
class="ai-prompt-area"
:class="{ collapsed: !!aiReply }"
readonly
:rows="aiReply ? 6 : 16"
spellcheck="false"
></textarea>
<div v-if="aiReply" class="ai-reply">{{ aiReply }}</div>
<div v-if="aiReply" class="ai-note">
以上解读由 AI 模型生成可能存在错误或过时信息仅供参考不构成投资建议
</div>
<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="ghost" @click="copyAiPrompt">复制 Prompt</button>
<button
v-if="llmReady"
class="primary"
:disabled="aiRunning || !aiPromptText"
@click="runAiInterpret"
>
{{ aiRunning ? `解读中… ${aiElapsed}s` : '✨ 直接解读' }}
</button>
</div>
</div>
</div>
<!-- AI 解读 Prompt 对话框单标的/组合通用组件 -->
<AiInterpretModal
v-if="showAiModal && store.result"
:prompt="aiPromptText"
:filename="`AI解读_${code}_${strategy}.md`"
:context="aiContext"
:tip="aiTip"
@close="showAiModal = false"
/>
</div>
</template>
@@ -784,50 +678,4 @@ function downloadAiPrompt() {
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;
}
/* 直接解读出结果后 Prompt 区收窄,把版面让给回复 */
.ai-prompt-area.collapsed {
max-height: 18vh;
}
.ai-reply {
margin-top: 8px;
max-height: 38vh;
overflow: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg {
font-size: 12px;
color: var(--up);
}
.ai-note {
margin-top: 4px;
font-size: 11px;
color: var(--warn, #ffc107);
}
</style>
+245 -10
View File
@@ -1,18 +1,26 @@
<script setup lang="ts">
// 组合回测主页面:左配置(多标的 + 策略 + 日期)/ 右报告(组合净值 + 各标的对比)。
// 组合回测主页面:左配置(多标的 + 策略 + 日期 + 附加分析/ 右报告
// (组合净值 + 完整绩效指标 + 各标的对比 + 附加分析 WF/一条龙 + 成交明细 + AI 解读)。
// 附加分析与单标的回测页(BacktestView)同构:勾选后随「开始组合回测」并行运行。
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AiInterpretModal from '../components/AiInterpretModal.vue'
import EquityChart from '../components/EquityChart.vue'
import EvaluatePanel from '../components/EvaluatePanel.vue'
import GradeDetails from '../components/GradeDetails.vue'
import MetricTable from '../components/MetricTable.vue'
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import StocksPicker from '../components/StocksPicker.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import TradeTable from '../components/TradeTable.vue'
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
import { formatError, saveStrategy } from '../api'
import { gradePortfolio } from '../grading'
import type { Category, ExecutionMode } from '../types'
import { buildPortfolioAiPrompt } from '../aiPrompt'
import { GRADE_META, gradePortfolio } from '../grading'
import type { Category, ExecutionMode, PortfolioTrade } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
@@ -41,6 +49,11 @@ function isoDaysFromNow(days: number): string {
const startDate = ref('2020-01-06')
const endDate = ref(isoDaysFromNow(0))
// 附加分析开关(与单标的回测页同构):组合级 WF / 一条龙评估
const wfEnabled = ref(false)
const wfWindows = ref(7)
const evaluateEnabled = ref(false)
onMounted(async () => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
@@ -75,8 +88,8 @@ onMounted(async () => {
if (qCategory) category.value = qCategory
})
async function onRun() {
await store.runPortfolio({
function currentRequest() {
return {
strategy: strategy.value,
params: params.value,
cash: cash.value,
@@ -85,7 +98,20 @@ async function onRun() {
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
})
}
}
async function onRun() {
store.error = ''
store.clearPortfolioExtraAnalysis()
// 1. 主组合回测(后端返回完整 25 项指标 + grade/score
await store.runPortfolio(currentRequest())
// 2. 附加分析:勾选的组合级 WF / 一条龙并行跑(互不阻塞,各自独立错误提示)
if (!store.portfolioResult) return
const jobs: Promise<void>[] = []
if (wfEnabled.value) jobs.push(store.runPortfolioWalkforward(currentRequest(), wfWindows.value))
if (evaluateEnabled.value) jobs.push(store.runPortfolioEvaluate(currentRequest()))
await Promise.allSettled(jobs)
}
// ── 保存策略(把当前组合结果 + 配置 + 上下文存进策略库)──────────────────────
@@ -100,8 +126,8 @@ const strategyLabel = computed(
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
)
// 组合评级:从 combined_equity 重算夏普/卡玛/波动率等(组合级净值算不出胜率/利润因子)
// 用 5 维度评分。净值点数过少(< 60 个交易日)视为样本不足。
// 组合评级:从 combined_equity 重算夏普/卡玛/波动率等(组合级 5 维度口径
// 与后端 grade_portfolio_equity 一致)。净值点数过少(< 60 个交易日)视为样本不足。
const grade = computed(() =>
store.portfolioResult ? gradePortfolio(store.portfolioResult) : null,
)
@@ -155,6 +181,62 @@ async function onSave() {
saving.value = false
}
}
// ── AI 解读(与单标的回测页共用 AiInterpretModal)────────────────────────────
const showAiModal = ref(false)
const aiPromptText = computed(() => {
if (!store.portfolioResult) return ''
return buildPortfolioAiPrompt({
stocks: stocks.value,
category: category.value,
startDate: startDate.value,
endDate: endDate.value,
strategyLabel: strategyLabel.value,
params: params.value,
cash: cash.value,
commission: 0.0003,
slippage: 0,
execution: execution.value,
result: store.portfolioResult,
wf: store.portfolioWfResult,
evaluate: store.portfolioEvaluateResult,
grade: grade.value,
gradeHint: grade.value ? GRADE_META[grade.value.grade].hint : undefined,
})
})
/** 随解读落历史库的策略上下文(历史页「去回测」引导用) */
const aiContext = computed(() => ({
strategy: strategy.value,
strategy_label: strategyLabel.value,
kind: 'portfolio',
symbol: stocks.value.join(','),
category: category.value,
params: { ...params.value },
start_date: startDate.value,
end_date: endDate.value,
}))
const aiTip = computed(() =>
wfEnabled.value || evaluateEnabled.value
? '建议等附加分析跑完再发,Walk-Forward / 一条龙评估的数据会一并打包。'
: undefined,
)
// ── 组合成交明细(各标的汇总,按时间倒序)────────────────────────────────────
const TRADES_SHOW_LIMIT = 200
const portfolioTrades = computed<PortfolioTrade[]>(() => {
const r = store.portfolioResult
if (!r) return []
// 优先用后端汇总的成交表;老结果无该字段时从 individual_results 客户端汇总
const rows: PortfolioTrade[] = r.trades
? [...r.trades]
: Object.entries(r.individual_results).flatMap(([symbol, res]) =>
res.trades.map((t) => ({ ...t, symbol })),
)
return rows.sort((a, b) => String(b.datetime).localeCompare(String(a.datetime)))
})
</script>
<template>
@@ -210,12 +292,45 @@ async function onSave() {
</div>
</section>
<section class="panel-section">
<h3>附加分析</h3>
<div class="check-row">
<label
class="check-label"
title="按全部标的日期并集切窗,每窗各标的独立回测后合成组合净值,检验跨时段稳定性"
>
<input v-model="wfEnabled" type="checkbox" />
<span>Walk-Forward 样本外验证</span>
</label>
<span v-if="wfEnabled" class="wf-windows">
窗口数
<input v-model.number="wfWindows" type="number" min="2" max="12" step="1" />
</span>
</div>
<div class="check-row">
<label
class="check-label"
title="组合回测+组合WF+跨标的适配性体检+综合评分+等权买入持有基准对比,一份报告"
>
<input v-model="evaluateEnabled" type="checkbox" />
<span>一条龙评估</span>
</label>
</div>
<p class="extra-hint">勾选后随开始组合回测自动附加运行标的越多越慢</p>
</section>
<button
class="primary run-btn"
:disabled="store.portfolioRunning || stocks.length === 0"
:disabled="
store.portfolioRunning || store.portfolioWfRunning || store.portfolioEvaluateRunning || stocks.length === 0
"
@click="onRun"
>
{{ store.portfolioRunning ? '组合回测中…' : '开始组合回测' }}
{{
store.portfolioRunning || store.portfolioWfRunning || store.portfolioEvaluateRunning
? '组合回测中…'
: '开始组合回测'
}}
</button>
</aside>
@@ -232,6 +347,7 @@ async function onSave() {
<div v-if="store.portfolioResult" class="report-content">
<div class="result-toolbar">
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
<button class="ghost" @click="showAiModal = true">🤖 AI 解读</button>
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
@@ -252,6 +368,15 @@ async function onSave() {
{{ (store.portfolioResult.total_performance.total_return * 100).toFixed(2) }}%
</span>
</div>
<div class="perf-item">
<span class="label">年化收益</span>
<span
class="value"
:class="store.portfolioResult.total_performance.annual_return > 0 ? 'pos' : 'neg'"
>
{{ (store.portfolioResult.total_performance.annual_return * 100).toFixed(2) }}%
</span>
</div>
<div class="perf-item">
<span class="label">标的数量</span>
<span class="value">{{ store.portfolioResult.total_performance.total_stocks }}</span>
@@ -263,11 +388,52 @@ async function onSave() {
</div>
</section>
<!-- 附加分析组合级 Walk-Forwardv1.31与单标的同构面板 -->
<section
v-if="store.portfolioWfRunning || store.portfolioWfResult || store.portfolioWfError"
class="report-section"
>
<h3>Walk-Forward 样本外验证</h3>
<p v-if="store.portfolioWfRunning" class="loading-text">
验证中标的数 × 窗口数 次回测约需十几秒
</p>
<div v-else-if="store.portfolioWfError" class="error-banner">
{{ store.portfolioWfError }}
</div>
<WalkForwardPanel v-else-if="store.portfolioWfResult" :wf="store.portfolioWfResult" />
</section>
<!-- 附加分析组合级一条龙评估v1.31与单标的同构面板 -->
<section
v-if="
store.portfolioEvaluateRunning || store.portfolioEvaluateResult || store.portfolioEvaluateError
"
class="report-section"
>
<h3>一条龙评估</h3>
<p v-if="store.portfolioEvaluateRunning" class="loading-text">
评估中…(组合回测 + 组合WF + 跨标的适配性 + 基准对比,可能需要一两分钟)
</p>
<div v-else-if="store.portfolioEvaluateError" class="error-banner">
⚠ {{ store.portfolioEvaluateError }}
</div>
<EvaluatePanel
v-else-if="store.portfolioEvaluateResult"
:report="store.portfolioEvaluateResult"
:grade-override="grade"
/>
</section>
<section class="report-section">
<h3>组合净值曲线</h3>
<EquityChart :equity="store.portfolioResult.combined_equity" />
</section>
<section class="report-section">
<h3>组合绩效指标</h3>
<MetricTable :perf="store.portfolioResult.total_performance" />
</section>
<section class="report-section">
<h3>各标的绩效对比</h3>
<PortfolioSummaryTable
@@ -280,6 +446,17 @@ async function onSave() {
<h3>各标的净值叠加(归一化)</h3>
<PortfolioCompareChart :results="store.portfolioResult.individual_results" />
</section>
<section class="report-section">
<h3>组合成交明细({{ portfolioTrades.length }} 笔,按时间倒序)</h3>
<p v-if="portfolioTrades.length > TRADES_SHOW_LIMIT" class="loading-text">
仅显示最近 {{ TRADES_SHOW_LIMIT }} 笔,导出请用「对比分析」页的任务导出
</p>
<TradeTable
:trades="portfolioTrades.slice(0, TRADES_SHOW_LIMIT)"
show-symbol
/>
</section>
</div>
</main>
@@ -318,6 +495,16 @@ async function onSave() {
</div>
</div>
</div>
<!-- AI 解读 Prompt 对话框(单标的/组合通用组件) -->
<AiInterpretModal
v-if="showAiModal && store.portfolioResult"
:prompt="aiPromptText"
:filename="`AI解读_组合${stocks.length}只_${strategy}.md`"
:context="aiContext"
:tip="aiTip"
@close="showAiModal = false"
/>
</div>
</template>
@@ -351,6 +538,53 @@ async function onSave() {
color: var(--text-dim);
font-size: 12px;
}
/* 附加分析开关:勾选框靠左、文字单行不折行,窗口数同行跟排(与回测页一致) */
.check-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
margin-bottom: 8px;
min-width: 0;
}
.check-label {
display: inline-flex; /* 覆盖全局 label { display: block } */
align-items: center;
gap: 6px;
margin-bottom: 0;
font-size: 12px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.check-label input[type='checkbox'] {
width: auto;
flex-shrink: 0;
margin: 0;
accent-color: var(--accent, #4a9eff);
}
.wf-windows {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
}
.wf-windows input {
width: 44px;
padding: 3px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
color: var(--text);
}
.extra-hint {
font-size: 11px;
color: var(--text-dim);
margin: 2px 0 0;
}
.run-btn {
width: 100%;
padding: 10px;
@@ -393,6 +627,7 @@ async function onSave() {
.perf-summary {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.perf-item {
display: flex;