forked from azmove/jin-ce-zhi-suan
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbacktest_report.html
More file actions
667 lines (642 loc) · 38.3 KB
/
Copy pathbacktest_report.html
File metadata and controls
667 lines (642 loc) · 38.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>回测报告中心</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.enter-anim {
animation: fadeSlideIn .22s ease-out;
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="max-w-7xl mx-auto px-3 sm:px-6 py-4 sm:py-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-2xl font-bold"><i class="fa-solid fa-chart-line text-blue-400 mr-2"></i>回测报告中心</h1>
<div class="flex items-center gap-2 w-full sm:w-auto">
<button id="refresh-btn" class="text-sm bg-blue-700 hover:bg-blue-600 px-3 py-2 rounded border border-blue-600 w-1/2 sm:w-auto whitespace-nowrap transition-all duration-200 hover:-translate-y-0.5 active:scale-95">刷新报告</button>
<a href="/" class="text-sm bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded border border-slate-700 text-center w-1/2 sm:w-auto whitespace-nowrap transition-all duration-200 hover:-translate-y-0.5 active:scale-95">返回控制台</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-4">
<div class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
<div class="text-lg font-semibold mb-3">历史回测记录</div>
<div id="history-list" class="space-y-2 max-h-[40vh] lg:max-h-[75vh] overflow-auto pr-1"></div>
</div>
</div>
<div class="lg:col-span-8 space-y-6">
<div id="summary-card" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden"></div>
<div id="insight-card" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden"></div>
<div id="ai-review-card" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden"></div>
<div class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
<div class="text-lg font-semibold mb-3">多策略业绩对比</div>
<div id="ranking-mobile" class="md:hidden space-y-2"></div>
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 bg-slate-800/70">
<tr>
<th class="px-3 py-2 text-left">排名</th>
<th class="px-3 py-2 text-left">策略ID</th>
<th class="px-3 py-2 text-left">使用周期</th>
<th class="px-3 py-2 text-left">评级</th>
<th class="px-3 py-2 text-left">评分</th>
<th class="px-3 py-2 text-left">年化收益</th>
<th class="px-3 py-2 text-left">最大回撤</th>
<th class="px-3 py-2 text-left">胜率</th>
<th class="px-3 py-2 text-left">卡玛</th>
<th class="px-3 py-2 text-left">报告</th>
</tr>
</thead>
<tbody id="ranking-body" class="divide-y divide-slate-800"></tbody>
</table>
</div>
</div>
<div class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
<div class="text-lg font-semibold mb-3">单策略回测报告</div>
<div id="strategy-report-list" class="grid grid-cols-1 xl:grid-cols-2 gap-4"></div>
</div>
</div>
</div>
</div>
<div id="trade-detail-modal" class="fixed inset-0 bg-black/60 hidden z-50 items-center justify-center p-4">
<div class="w-full max-w-5xl bg-slate-900 border border-slate-700 rounded-lg shadow-2xl overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div class="text-white font-semibold text-base" id="trade-detail-title">交易明细</div>
<button id="trade-detail-close-btn" class="text-slate-400 hover:text-white text-lg leading-none">×</button>
</div>
<div class="max-h-[70vh] overflow-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 bg-slate-800/80 sticky top-0">
<tr>
<th class="px-3 py-2 text-left">时间</th>
<th class="px-3 py-2 text-left">方向</th>
<th class="px-3 py-2 text-right">价格</th>
<th class="px-3 py-2 text-right">数量</th>
<th class="px-3 py-2 text-right">成交额</th>
<th class="px-3 py-2 text-right">费用</th>
<th class="px-3 py-2 text-left">说明</th>
<th class="px-3 py-2 text-right">已实现盈亏</th>
</tr>
</thead>
<tbody id="trade-detail-body" class="divide-y divide-slate-800"></tbody>
</table>
</div>
</div>
</div>
<div id="report-kline-modal" class="fixed inset-0 bg-black/70 hidden z-50 items-center justify-center p-4">
<div class="w-full max-w-6xl h-[86vh] bg-slate-900 border border-slate-700 rounded-lg shadow-2xl overflow-hidden flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div class="text-white font-semibold text-base" id="report-kline-title">单策略K线</div>
<button id="report-kline-close-btn" class="text-slate-400 hover:text-white text-lg leading-none">×</button>
</div>
<div id="report-kline-subtitle" class="px-4 py-2 text-xs text-slate-400 border-b border-slate-700">--</div>
<div class="relative flex-1 bg-slate-950 overflow-hidden">
<div id="report-kline-chart" class="w-full h-full"></div>
<div id="report-kline-loading" class="hidden absolute inset-0 z-20 bg-slate-950/80 flex items-center justify-center">
<div class="flex items-center gap-2 text-slate-200 text-sm">
<i class="fa-solid fa-spinner fa-spin text-cyan-300"></i>
<span id="report-kline-loading-text">K线加载中...</span>
</div>
</div>
</div>
</div>
</div>
<script>
let currentReportId = null;
const strategyReportMap = {};
let reportKlineChart = null;
let reportKlineCandleSeries = null;
let reportKlineVolumeSeries = null;
let reportKlineLoadingShownAt = 0;
const reportKlinePayloadCache = {};
let strategyMetaLoaded = false;
const STRATEGY_NAME_MAP = {
"00": "长期持有一次买入",
"01": "三周期共振波段",
"02": "短线弱转强烂板",
"03": "ETF行业轮动",
"04": "龙头首阴反包",
"05": "3N法则主升浪",
"06": "海豚交易法",
"07": "跳空交易系统",
"08": "神奇九转",
"09": "箱体降本策略"
};
const STRATEGY_PERIOD_MAP = {
"00": "日线",
"01": "60分钟",
"02": "1分钟",
"03": "日线",
"04": "日线",
"05": "30分钟",
"06": "1分钟",
"07": "15分钟",
"08": "1分钟",
"09": "日线"
};
function periodLabelFromKlineType(v) {
const tf = String(v || '').trim().toLowerCase();
if (!tf) return '--';
if (['d', '1d', 'day', 'daily'].includes(tf)) return '日线';
if (tf.endsWith('min')) return `${tf.replace('min', '')}分钟`;
return tf;
}
function renderMarkdownHtml(mdText) {
const src = String(mdText || '').replace(/</g, '<').replace(/>/g, '>');
if (window.marked && typeof window.marked.parse === 'function') {
return window.marked.parse(src);
}
return `<pre class="whitespace-pre-wrap text-sm leading-7 text-slate-200">${src}</pre>`;
}
async function ensureStrategyMetaLoaded() {
if (strategyMetaLoaded) return;
try {
const res = await fetch('/api/strategy_manager/list');
const data = await res.json();
if (data && data.status === 'success' && Array.isArray(data.strategies)) {
data.strategies.forEach(s => {
const sid = String(s.id || '');
const sname = String(s.name || '').trim();
const skline = String(s.kline_type || '').trim();
if (sid && sname) STRATEGY_NAME_MAP[sid] = sname;
if (sid && skline) STRATEGY_PERIOD_MAP[sid] = periodLabelFromKlineType(skline);
});
}
} catch (_) {}
strategyMetaLoaded = true;
}
async function loadReport(reportId = null) {
const url = reportId ? `/api/report/${reportId}` : '/api/report/latest';
try {
await ensureStrategyMetaLoaded();
const res = await fetch(url);
if (!res.ok) {
const txt = await res.text();
throw new Error(`${res.status} ${txt}`);
}
const data = await res.json();
currentReportId = reportId;
renderSummary(data.summary);
renderRanking(data.ranking || []);
renderStrategyReports(data.strategy_reports || []);
renderInsight(data.summary, data.ranking || [], data.strategy_reports || []);
const aiCard = document.getElementById('ai-review-card');
if (aiCard) {
const cached = String(data.ai_review_text || '').trim();
if (cached) {
aiCard.classList.remove('hidden');
aiCard.innerHTML = `<div class="text-lg font-semibold text-white mb-2">AI复盘分析 <span class="text-xs text-slate-500">(缓存)</span></div><div class="text-sm leading-7 text-slate-200">${renderMarkdownHtml(cached)}</div>`;
} else {
aiCard.classList.add('hidden');
aiCard.innerHTML = '';
}
}
} catch (err) {
const msg = (err && err.message) ? err.message : String(err);
renderSummary({ stock: "--", period: "--", total_trades: 0 });
const card = document.getElementById('summary-card');
card.innerHTML += `<div class="text-rose-300 text-xs mt-2">报告加载失败:${msg}</div>`;
renderRanking([]);
renderStrategyReports([]);
renderInsight(null, [], []);
}
}
async function loadHistory() {
const res = await fetch('/api/report/history');
const data = await res.json();
const wrap = document.getElementById('history-list');
wrap.innerHTML = '';
const reports = data.reports || [];
if (!reports.length) {
wrap.innerHTML = '<div class="text-slate-500 text-sm">暂无历史回测记录</div>';
return;
}
if (!currentReportId && reports.length) {
currentReportId = String(reports[0].report_id);
} else if (currentReportId && !reports.some(x => String(x.report_id) === String(currentReportId))) {
currentReportId = reports.length ? String(reports[0].report_id) : null;
}
reports.forEach((r, idx) => {
const rid = String(r.report_id);
const active = currentReportId && String(currentReportId) === rid;
const row = document.createElement('div');
row.className = `flex items-center justify-between border rounded px-3 py-2 text-sm enter-anim transition-all duration-200 ${active ? 'bg-blue-900/30 border-blue-700' : 'bg-slate-800/50 border-slate-700 hover:bg-slate-800/80'}`;
row.innerHTML = `
<div class="text-slate-300 flex-1 min-w-0 pr-3">
<div class="truncate">
<span class="font-mono text-white mr-2">${r.stock_code || '--'}</span>
<span class="mr-2">${r.period || '--'}</span>
</div>
<div class="text-slate-500 truncate">${r.created_at || ''}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button class="text-blue-400 hover:text-blue-300 underline cursor-pointer whitespace-nowrap px-1 py-0.5 rounded transition-all duration-200 hover:bg-blue-900/30 active:scale-95" data-rid="${rid}">${idx === 0 ? '查看(最新)' : '查看'}</button>
<button class="text-rose-400 hover:text-rose-300 underline cursor-pointer whitespace-nowrap px-1 py-0.5 rounded transition-all duration-200 hover:bg-rose-900/30 active:scale-95" data-del-rid="${rid}">删除</button>
</div>
`;
wrap.appendChild(row);
});
}
async function deleteHistoryReport(reportId) {
const rid = String(reportId || '').trim();
if (!rid) return;
try {
const res = await fetch('/api/report/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report_id: rid })
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.msg || '删除失败');
if (currentReportId && String(currentReportId) === rid) currentReportId = null;
await loadHistory();
await loadReport(currentReportId || null);
} catch (_) {}
}
function renderSummary(summary) {
const card = document.getElementById('summary-card');
if (!summary) {
card.classList.remove('hidden');
card.innerHTML = '<div class="text-slate-400">暂无回测数据,请先在控制台执行一次回测。</div>';
return;
}
card.classList.remove('hidden');
card.innerHTML = `
<div class="text-sm text-slate-400">标的:<span class="text-white font-mono">${summary.stock || '--'}</span></div>
<div class="text-sm text-slate-400">区间:<span class="text-white">${summary.period || '--'}</span></div>
<div class="text-sm text-slate-400">总交易数:<span class="text-white font-mono">${summary.total_trades ?? 0}</span></div>
`;
}
function renderRanking(ranking) {
const body = document.getElementById('ranking-body');
const mobile = document.getElementById('ranking-mobile');
body.innerHTML = '';
mobile.innerHTML = '';
if (!ranking.length) {
body.innerHTML = '<tr><td colspan="10" class="px-3 py-6 text-center text-slate-500">暂无策略对比数据</td></tr>';
mobile.innerHTML = '<div class="text-center text-slate-500 py-3">暂无策略对比数据</div>';
return;
}
ranking.forEach(r => {
const sid = String(r.strategy_id);
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40';
const roi = Number(r.annualized_roi || 0);
const dd = Number(r.max_dd || 0);
const winRate = Number(r.win_rate || 0);
const score = Number(r.score_total || 0);
tr.innerHTML = `
<td class="px-3 py-2">#${r.rank ?? '-'}</td>
<td class="px-3 py-2 font-mono">${sid}</td>
<td class="px-3 py-2">${STRATEGY_PERIOD_MAP[sid] || '--'}</td>
<td class="px-3 py-2">${r.rating ?? '-'}</td>
<td class="px-3 py-2 text-yellow-300">${score.toFixed(1)}</td>
<td class="px-3 py-2 ${roi >= 0 ? 'text-rose-400' : 'text-emerald-400'}">${(roi*100).toFixed(2)}%</td>
<td class="px-3 py-2 text-rose-300">${(dd*100).toFixed(2)}%</td>
<td class="px-3 py-2 text-cyan-300">${(winRate*100).toFixed(2)}%</td>
<td class="px-3 py-2">${Number(r.calmar || 0).toFixed(2)}</td>
<td class="px-3 py-2"><a href="#strategy-${sid}" class="text-blue-400 hover:text-blue-300 underline">单策略回测报告</a></td>
`;
body.appendChild(tr);
const card = document.createElement('div');
card.className = 'bg-slate-800/50 border border-slate-700 rounded p-3 text-sm enter-anim transition-all duration-200 hover:border-slate-500 hover:-translate-y-0.5';
card.innerHTML = `
<div class="text-white font-semibold mb-1">#${r.rank ?? '-'} 策略 ${sid} · ${r.rating ?? '-'}</div>
<div class="text-yellow-300">评分:${score.toFixed(1)} / 100</div>
<div class="text-slate-300">使用周期:${STRATEGY_PERIOD_MAP[sid] || '--'}</div>
<div class="${roi >= 0 ? 'text-rose-400' : 'text-emerald-400'}">年化收益:${(roi*100).toFixed(2)}%</div>
<div class="text-rose-300">最大回撤:${(dd*100).toFixed(2)}%</div>
<div class="text-cyan-300">胜率:${(winRate*100).toFixed(2)}%</div>
<div class="text-slate-300">卡玛:${Number(r.calmar || 0).toFixed(2)}</div>
<a href="#strategy-${sid}" class="text-blue-400 hover:text-blue-300 underline">查看单策略报告</a>
`;
mobile.appendChild(card);
});
}
function renderInsight(summary, ranking, reports) {
const card = document.getElementById('insight-card');
if (!summary || !ranking.length) {
card.classList.add('hidden');
card.innerHTML = '';
return;
}
const best = ranking[0] || {};
const active = reports.filter(r => Number(r.trade_count || 0) > 0);
const activeCount = active.length;
const total = reports.length;
const netState = Number(summary.total_trades || 0) === 0 ? '全策略空仓' : '策略已恢复成交';
card.classList.remove('hidden');
card.innerHTML = `
<div class="text-lg font-semibold text-white mb-2">内阁总评</div>
<div class="text-sm text-slate-300 leading-7">
本次回测状态:<span class="font-semibold text-blue-300">${netState}</span>。
最佳策略为 <span class="font-semibold text-emerald-300">${best.strategy_id || '--'}</span>,
评级 <span class="font-semibold text-yellow-300">${best.rating || '--'}</span>,
年化收益 <span class="font-semibold ${(Number(best.annualized_roi || 0) >= 0 ? 'text-rose-300' : 'text-emerald-300')}">${(Number(best.annualized_roi || 0) * 100).toFixed(2)}%</span>。
当前有 <span class="font-semibold text-cyan-300">${activeCount}</span>/<span class="font-semibold text-cyan-300">${total}</span> 个策略产生实盘级成交。
</div>
<div class="mt-3">
<button onclick="runAiReview()" class="text-xs bg-violet-700 hover:bg-violet-600 px-3 py-1.5 rounded border border-violet-500 text-white">AI复盘分析</button>
</div>
`;
}
function renderStrategyReports(reports) {
const wrap = document.getElementById('strategy-report-list');
wrap.innerHTML = '';
Object.keys(strategyReportMap).forEach(k => delete strategyReportMap[k]);
if (!reports.length) {
wrap.innerHTML = '<div class="text-slate-500">暂无单策略报告</div>';
return;
}
reports.forEach(r => {
const sid = String(r.strategy_id);
strategyReportMap[sid] = r;
if (r.period_label) STRATEGY_PERIOD_MAP[sid] = String(r.period_label);
const sname = STRATEGY_NAME_MAP[sid] || "未知策略";
const card = document.createElement('div');
card.id = `strategy-${sid}`;
card.className = 'bg-slate-800/50 border border-slate-700 rounded-lg p-4 text-sm enter-anim transition-all duration-200 hover:border-slate-500 hover:-translate-y-0.5';
const sc = r.scorecard || {};
const dim = sc.dimensions || {};
const profitability = Number((dim.profitability || {}).score || 0);
const riskControl = Number((dim.risk_control || {}).score || 0);
const pnlQuality = Number((dim.pnl_quality || {}).score || 0);
const practicality = Number((dim.practicality || {}).score || 0);
const forceCloseCount = Number(r.force_close_count || 0);
const tradeCount = Number(r.trade_count || 0);
const notes = Array.isArray(r.report_notes) ? r.report_notes : [];
const noTradeHint = tradeCount === 0
? '<div class="text-amber-300 mt-1">本次无成交,可能未触发信号、未纳入本次回测策略或下单数量不足100股。</div>'
: '';
card.innerHTML = `
<div class="font-bold text-white mb-2">策略 ${sid} · ${sname}</div>
<div class="text-yellow-300 mb-2">评分卡:${Number(sc.total_score || 0).toFixed(1)} / 100 · ${sc.grade || '--'}(${sc.conclusion || '--'})</div>
<div class="mb-2 flex items-center gap-2">
<button onclick="openTradeDetails('${sid}')" class="text-xs bg-blue-700 hover:bg-blue-600 text-white px-2 py-1 rounded border border-blue-600">交易明细</button>
<button onclick="openStrategyKline('${sid}')" class="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-2 py-1 rounded border border-indigo-600">K线显示</button>
</div>
<div class="text-slate-300">使用周期:${STRATEGY_PERIOD_MAP[sid] || '--'}</div>
<div class="text-slate-300">回测周期:${r.start_date} ~ ${r.end_date}</div>
<div class="text-slate-300">初始资金:${Number(r.init_capital || 0).toFixed(2)} 元</div>
<div class="text-slate-300">结束资金:${Number(r.end_capital || 0).toFixed(2)} 元</div>
<div class="text-slate-300">总收益:${(Number(r.total_return || 0)*100).toFixed(2)}%</div>
<div class="text-slate-300">年化收益:${(Number(r.annual_return || 0)*100).toFixed(2)}%</div>
<div class="text-slate-300">最大回撤:${(Number(r.max_drawdown || 0)*100).toFixed(2)}%</div>
<div class="text-slate-300">总交易次数:${tradeCount}</div>
<div class="text-slate-300">盈利次数:${r.win_num ?? 0} | 亏损次数:${r.loss_num ?? 0}</div>
<div class="text-slate-300">胜率:${(Number(r.win_rate || 0)*100).toFixed(2)}%</div>
<div class="text-slate-300">平均盈利:${Number(r.avg_win || 0).toFixed(2)} | 平均亏损:${Number(r.avg_loss || 0).toFixed(2)}</div>
<div class="text-slate-300">盈亏比:${Number(r.profit_ratio || 0).toFixed(2)}</div>
<div class="text-slate-300">兜底强平次数:${forceCloseCount}</div>
${notes.length ? `<div class="text-amber-300">说明:${notes.join(';')}</div>` : ''}
${noTradeHint}
<div class="text-slate-300 mt-2">收益能力:${profitability.toFixed(1)} / 30</div>
<div class="text-slate-300">风险控制:${riskControl.toFixed(1)} / 35</div>
<div class="text-slate-300">盈亏质量:${pnlQuality.toFixed(1)} / 20</div>
<div class="text-slate-300">实战可行:${practicality.toFixed(1)} / 15</div>
`;
wrap.appendChild(card);
});
}
async function runAiReview() {
const card = document.getElementById('ai-review-card');
if (!card || !currentReportId) return;
card.classList.remove('hidden');
card.innerHTML = '<div class="text-sm text-slate-300"><i class="fa-solid fa-spinner fa-spin mr-2"></i>AI复盘生成中...</div>';
try {
const res = await fetch(`/api/report/${encodeURIComponent(currentReportId)}/ai_review`, { method: 'POST' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.msg || '生成失败');
const md = String(data.analysis || '').trim() || '暂无复盘内容';
card.innerHTML = `
<div class="text-lg font-semibold text-white mb-2">AI复盘分析 ${data.cached ? '<span class="text-xs text-slate-500">(缓存)</span>' : ''}</div>
<div class="text-sm leading-7 text-slate-200">${renderMarkdownHtml(md)}</div>
`;
} catch (e) {
card.innerHTML = `<div class="text-rose-300 text-sm">AI复盘失败:${e}</div>`;
}
}
function ensureReportKlineChart() {
if (reportKlineChart && reportKlineCandleSeries && reportKlineVolumeSeries) return true;
const container = document.getElementById('report-kline-chart');
if (!container || !window.LightweightCharts) return false;
reportKlineChart = LightweightCharts.createChart(container, {
layout: { background: { color: '#020617' }, textColor: '#cbd5e1' },
grid: { vertLines: { color: 'rgba(148,163,184,0.1)' }, horzLines: { color: 'rgba(148,163,184,0.1)' } },
rightPriceScale: { borderColor: '#334155' },
timeScale: { borderColor: '#334155', timeVisible: true, secondsVisible: false }
});
const candleOpt = {
upColor: '#ef4444',
downColor: '#22c55e',
borderVisible: false,
wickUpColor: '#ef4444',
wickDownColor: '#22c55e'
};
const volumeOpt = {
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
lastValueVisible: false,
priceLineVisible: false
};
if (typeof reportKlineChart.addCandlestickSeries === 'function') {
reportKlineCandleSeries = reportKlineChart.addCandlestickSeries(candleOpt);
reportKlineVolumeSeries = reportKlineChart.addHistogramSeries(volumeOpt);
} else if (typeof reportKlineChart.addSeries === 'function' && window.LightweightCharts?.CandlestickSeries && window.LightweightCharts?.HistogramSeries) {
reportKlineCandleSeries = reportKlineChart.addSeries(LightweightCharts.CandlestickSeries, candleOpt);
reportKlineVolumeSeries = reportKlineChart.addSeries(LightweightCharts.HistogramSeries, volumeOpt);
} else {
return false;
}
reportKlineChart.priceScale('right').applyOptions({ scaleMargins: { top: 0.08, bottom: 0.32 } });
reportKlineChart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.75, bottom: 0 } });
return true;
}
async function setReportKlineLoading(show, text = 'K线加载中...') {
const box = document.getElementById('report-kline-loading');
const label = document.getElementById('report-kline-loading-text');
if (label) label.innerText = text;
if (!box) return;
if (show) {
reportKlineLoadingShownAt = Date.now();
box.classList.remove('hidden');
return;
}
const minShowMs = 260;
const waitMs = Math.max(0, minShowMs - (Date.now() - reportKlineLoadingShownAt));
if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
box.classList.add('hidden');
}
async function preloadStrategyKline(reportId, strategyId) {
const rid = String(reportId || '');
const sid = String(strategyId || '');
if (!rid || !sid) return;
const key = `${rid}|${sid}`;
if (reportKlinePayloadCache[key]) return;
try {
const q = `report_id=${encodeURIComponent(rid)}&strategy_id=${encodeURIComponent(sid)}`;
const res = await fetch(`/api/report/strategy/kline_data?${q}`);
const data = await res.json();
if (data.status === 'success' && Array.isArray(data.candles) && data.candles.length) {
reportKlinePayloadCache[key] = data;
}
} catch (_) {}
}
async function openStrategyKline(strategyId) {
const sid = String(strategyId || '');
if (!sid || !currentReportId) return;
await setReportKlineLoading(true, 'K线加载中...');
const report = strategyReportMap[sid] || {};
const periodLabel = STRATEGY_PERIOD_MAP[sid] || '--';
const modal = document.getElementById('report-kline-modal');
const title = document.getElementById('report-kline-title');
const subtitle = document.getElementById('report-kline-subtitle');
if (title) title.innerText = `策略 ${sid} K线与成交量`;
if (subtitle) subtitle.innerText = `周期 ${periodLabel} · 区间 ${report.start_date || '--'} ~ ${report.end_date || '--'}`;
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
}
if (!ensureReportKlineChart()) return;
const container = document.getElementById('report-kline-chart');
if (container && reportKlineChart) {
reportKlineChart.applyOptions({ width: container.clientWidth, height: container.clientHeight });
}
try {
const key = `${currentReportId}|${sid}`;
let data = reportKlinePayloadCache[key] || null;
if (!data) {
const q = `report_id=${encodeURIComponent(currentReportId)}&strategy_id=${encodeURIComponent(sid)}`;
const res = await fetch(`/api/report/strategy/kline_data?${q}`);
data = await res.json();
if (data.status === 'success' && Array.isArray(data.candles) && data.candles.length) {
reportKlinePayloadCache[key] = data;
}
}
if (data.status !== 'success') return;
const stockCode = data.stock || '--';
const periodText = data.period_label || periodLabel || '--';
const startText = report.start_date || '--';
const endText = report.end_date || '--';
if (title) title.innerText = `${stockCode} · ${periodText} · ${startText} ~ ${endText}`;
if (subtitle) subtitle.innerText = `策略 ${sid} · K线与成交量`;
const candles = Array.isArray(data.candles) ? data.candles : [];
const volumes = Array.isArray(data.volumes) ? data.volumes : [];
const markers = Array.isArray(data.markers) ? data.markers : [];
reportKlineCandleSeries.setData(candles);
reportKlineVolumeSeries.setData(volumes);
if (typeof reportKlineCandleSeries.setMarkers === 'function') {
reportKlineCandleSeries.setMarkers(markers);
} else if (typeof LightweightCharts?.createSeriesMarkers === 'function') {
LightweightCharts.createSeriesMarkers(reportKlineCandleSeries, markers);
}
reportKlineChart.timeScale().fitContent();
} catch (_) {}
finally {
await setReportKlineLoading(false);
}
}
function closeReportKline() {
const modal = document.getElementById('report-kline-modal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
function openTradeDetails(strategyId) {
const sid = String(strategyId || '');
const report = strategyReportMap[sid] || {};
const rows = Array.isArray(report.trade_details) ? report.trade_details : [];
const title = document.getElementById('trade-detail-title');
if (title) title.innerText = `策略 ${sid || '--'} 交易明细`;
const body = document.getElementById('trade-detail-body');
if (body) {
if (!rows.length) {
body.innerHTML = '<tr><td colspan="8" class="px-3 py-6 text-center text-slate-500">暂无交易明细</td></tr>';
} else {
body.innerHTML = rows.map(t => {
const dt = String(t.dt || '--');
const dir = String(t.direction || '--');
const price = Number(t.price || 0);
const qty = Number(t.quantity || 0);
const amount = Number(t.amount || 0);
const cost = Number(t.cost || 0);
const pnl = Number(t.pnl || 0);
const reason = String(t.reason || '--');
const dirCls = dir === 'BUY' ? 'text-rose-300' : (dir === 'SELL' ? 'text-emerald-300' : 'text-slate-300');
const dirText = dir === 'BUY' ? '买入' : (dir === 'SELL' ? '卖出' : dir);
const pnlCls = pnl >= 0 ? 'text-rose-300' : 'text-emerald-300';
return `
<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 text-slate-300 font-mono">${dt}</td>
<td class="px-3 py-2 ${dirCls}">${dirText}</td>
<td class="px-3 py-2 text-right text-slate-200">${price.toFixed(2)}</td>
<td class="px-3 py-2 text-right text-slate-200">${qty}</td>
<td class="px-3 py-2 text-right text-slate-200">${amount.toFixed(2)}</td>
<td class="px-3 py-2 text-right text-slate-200">${cost.toFixed(2)}</td>
<td class="px-3 py-2 text-slate-300">${reason}</td>
<td class="px-3 py-2 text-right ${pnlCls}">${pnl.toFixed(2)}</td>
</tr>
`;
}).join('');
}
}
const modal = document.getElementById('trade-detail-modal');
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
}
}
function closeTradeDetails() {
const modal = document.getElementById('trade-detail-modal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
async function refreshAll() {
await loadHistory();
await loadReport(currentReportId || null);
}
document.getElementById('refresh-btn').addEventListener('click', refreshAll);
document.getElementById('history-list').addEventListener('click', async (e) => {
const target = e.target.closest('button[data-rid]');
if (!target) return;
currentReportId = String(target.dataset.rid);
await loadReport(currentReportId);
await loadHistory();
});
document.getElementById('history-list').addEventListener('click', async (e) => {
const target = e.target.closest('button[data-del-rid]');
if (!target) return;
await deleteHistoryReport(String(target.dataset.delRid || target.getAttribute('data-del-rid') || ''));
});
document.getElementById('trade-detail-close-btn').addEventListener('click', closeTradeDetails);
document.getElementById('trade-detail-modal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'trade-detail-modal') closeTradeDetails();
});
document.getElementById('report-kline-close-btn').addEventListener('click', closeReportKline);
document.getElementById('report-kline-modal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'report-kline-modal') closeReportKline();
});
window.addEventListener('resize', () => {
const container = document.getElementById('report-kline-chart');
if (reportKlineChart && container) {
reportKlineChart.applyOptions({ width: container.clientWidth, height: container.clientHeight });
}
});
setInterval(refreshAll, 15000);
refreshAll();
</script>
</body>
</html>