@tradejs/app 2.0.18 → 2.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -8
- package/src/app/actions/backtest.ts +2 -1
- package/src/app/actions/scanner.ts +2 -1
- package/src/app/api/backtest/files/route.ts +2 -1
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +1 -1
- package/src/app/api/derivatives/[symbol]/[interval]/route.ts +1 -1
- package/src/app/api/derivatives/summary/route.ts +1 -1
- package/src/app/api/spread/[symbol]/[interval]/route.ts +1 -1
- package/src/app/api/spread/summary/route.ts +1 -1
- package/src/app/api/strategies/runtime/route.ts +4 -674
- package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +1 -1
- package/src/app/api/user/runtime-deployments/route.ts +2 -2
- package/src/app/api/user/runtime-strategy-configs/route.ts +22 -212
- package/src/app/api/user/trading-accounts/[accountId]/route.ts +1 -1
- package/src/app/components/Backtest/TestList/index.tsx +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/circle.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/label.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/star.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/index.tsx +2 -1
- package/src/app/components/Shared/Filters/Root/index.tsx +1 -1
- package/src/app/components/Shared/Filters/context.ts +1 -1
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +81 -858
- package/src/app/components/Strategies/StrategyPerformanceCharts.tsx +419 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +122 -937
- package/src/app/components/UI/Segment/index.tsx +1 -1
- package/src/app/components/UI/Select/index.tsx +1 -1
- package/src/app/components/UI/SelectWithSearch/index.tsx +1 -1
- package/src/app/lib/backtestJobContracts.ts +67 -0
- package/src/app/lib/backtestJobProgress.ts +28 -0
- package/src/app/lib/backtestJobRequest.ts +107 -0
- package/src/app/lib/backtestJobs.ts +25 -257
- package/src/app/lib/runtimeDashboard.ts +684 -0
- package/src/app/lib/runtimeStrategies.ts +24 -454
- package/src/app/lib/runtimeStrategyConfigService.ts +279 -0
- package/src/app/lib/runtimeStrategyLineage.ts +264 -0
- package/src/app/lib/runtimeTradeReconciliation.ts +113 -0
- package/src/app/lib/runtimeTradeSync.ts +1 -1
- package/src/app/lib/strategyPerformance.ts +387 -0
- package/src/app/routes/dashboard/Dashboard.tsx +2 -6
- package/src/app/routes/derivatives/derivativesViewModel.ts +253 -0
- package/src/app/routes/derivatives/page.tsx +70 -262
- package/src/app/store/filters.ts +2 -1
- package/src/app/store/indicators.ts +2 -1
- package/src/app/store/tests.ts +1 -1
- package/src/app/store/tickers.ts +2 -1
- package/src/app/types/ui.ts +20 -0
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Stat,
|
|
15
15
|
Text,
|
|
16
16
|
} from '@chakra-ui/react';
|
|
17
|
-
import {
|
|
17
|
+
import { useMemo, useState } from 'react';
|
|
18
18
|
import {
|
|
19
19
|
calculateAdvancedTradeMetrics,
|
|
20
20
|
getFormatted,
|
|
@@ -41,9 +41,26 @@ import {
|
|
|
41
41
|
type OrdersDrawerOrder,
|
|
42
42
|
} from '#components/Shared/OrdersDrawer';
|
|
43
43
|
import { deleteStrategyCard } from '#actions/strategies';
|
|
44
|
+
import {
|
|
45
|
+
buildQuarterlyMonthlyStats,
|
|
46
|
+
buildStrategyPerformanceViewModel,
|
|
47
|
+
calculateMaxDrawdownValue,
|
|
48
|
+
calculateMaxGrossStreak,
|
|
49
|
+
calculateMaxLossStreak,
|
|
50
|
+
formatMaxDrawdownPercent as calculateMaxDrawdownPercent,
|
|
51
|
+
getEquityStepPnl as getSnapshotStepPnl,
|
|
52
|
+
} from '#app/lib/strategyPerformance';
|
|
44
53
|
import { toaster } from '#ui';
|
|
45
54
|
import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
|
|
46
55
|
import { StrategySnapshotChart } from './StrategySnapshotChart';
|
|
56
|
+
import {
|
|
57
|
+
ChartPanel,
|
|
58
|
+
DrawdownTimelineChart,
|
|
59
|
+
PnlDistributionChart,
|
|
60
|
+
RollingPerformanceChart,
|
|
61
|
+
TimeOfDaySessionChart,
|
|
62
|
+
WinLossStreakTimelineChart,
|
|
63
|
+
} from './StrategyPerformanceCharts';
|
|
47
64
|
|
|
48
65
|
const MS_IN_HOUR = 60 * 60 * 1000;
|
|
49
66
|
const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
|
|
@@ -79,28 +96,6 @@ interface DirectionStatGroup {
|
|
|
79
96
|
hasData: boolean;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
interface MonthlyStat {
|
|
83
|
-
id: string;
|
|
84
|
-
year: number;
|
|
85
|
-
monthIndex: number;
|
|
86
|
-
monthLabel: string;
|
|
87
|
-
orders: number;
|
|
88
|
-
wins: number;
|
|
89
|
-
pnl: number;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface YearlyMonthlyStats {
|
|
93
|
-
year: number;
|
|
94
|
-
months: MonthlyStat[];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface QuarterlyMonthlyStats {
|
|
98
|
-
label: string;
|
|
99
|
-
monthIndexes: readonly number[];
|
|
100
|
-
months: (MonthlyStat | null)[];
|
|
101
|
-
hasData: boolean;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
99
|
interface SymbolPnlRank {
|
|
105
100
|
symbol: string;
|
|
106
101
|
pnl: number;
|
|
@@ -125,47 +120,6 @@ interface AiDiagnosticGroup {
|
|
|
125
120
|
metrics: AiDiagnosticMetric[];
|
|
126
121
|
}
|
|
127
122
|
|
|
128
|
-
interface SnapshotTradePoint {
|
|
129
|
-
index: number;
|
|
130
|
-
timestamp: number;
|
|
131
|
-
pnl: number;
|
|
132
|
-
equity: number;
|
|
133
|
-
hour: number;
|
|
134
|
-
session: TradingSession;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
interface DrawdownPoint {
|
|
138
|
-
timestamp: number;
|
|
139
|
-
drawdownPercent: number;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
interface RollingPerformancePoint {
|
|
143
|
-
index: number;
|
|
144
|
-
winRate: number;
|
|
145
|
-
pnl: number;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
interface DistributionBin {
|
|
149
|
-
id: string;
|
|
150
|
-
min: number;
|
|
151
|
-
max: number;
|
|
152
|
-
count: number;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
type TradingSession = 'Asia' | 'Europe' | 'US';
|
|
156
|
-
|
|
157
|
-
interface SessionPnlStat {
|
|
158
|
-
session: TradingSession;
|
|
159
|
-
pnl: number;
|
|
160
|
-
orders: number;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
interface HourlyPnlStat {
|
|
164
|
-
hour: number;
|
|
165
|
-
pnl: number;
|
|
166
|
-
orders: number;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
123
|
const getMetricColor = (tone: StrategyChartMetric['tone']) => {
|
|
170
124
|
switch (tone) {
|
|
171
125
|
case 'success':
|
|
@@ -181,86 +135,9 @@ const getMetricColor = (tone: StrategyChartMetric['tone']) => {
|
|
|
181
135
|
}
|
|
182
136
|
};
|
|
183
137
|
|
|
184
|
-
const calculateMaxDrawdownValue = (orderLog: Array<[number, number]>) => {
|
|
185
|
-
if (!orderLog.length) {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let peak = orderLog[0]?.[1] ?? 0;
|
|
190
|
-
let maxDrawdownPercent = 0;
|
|
191
|
-
|
|
192
|
-
for (const [, amount] of orderLog) {
|
|
193
|
-
if (!Number.isFinite(amount)) {
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
peak = Math.max(peak, amount);
|
|
198
|
-
if (peak <= 0) {
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const drawdownPercent = ((peak - amount) / peak) * 100;
|
|
203
|
-
maxDrawdownPercent = Math.max(maxDrawdownPercent, drawdownPercent);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return maxDrawdownPercent;
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const calculateMaxDrawdownPercent = (orderLog: Array<[number, number]>) => {
|
|
210
|
-
const maxDrawdownPercent = calculateMaxDrawdownValue(orderLog);
|
|
211
|
-
return maxDrawdownPercent == null
|
|
212
|
-
? null
|
|
213
|
-
: `${maxDrawdownPercent.toFixed(1)}%`;
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const getSnapshotStepPnl = (
|
|
217
|
-
orderLog: StrategyChartSnapshot['orderLog'],
|
|
218
|
-
index: number,
|
|
219
|
-
) => {
|
|
220
|
-
const current = orderLog[index];
|
|
221
|
-
const previous = orderLog[index - 1];
|
|
222
|
-
|
|
223
|
-
if (!current || !previous) {
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return current[1] - previous[1];
|
|
228
|
-
};
|
|
229
|
-
|
|
230
138
|
const asFiniteNumber = (value: unknown) =>
|
|
231
139
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
232
140
|
|
|
233
|
-
const calculateMaxPnlStreak = (
|
|
234
|
-
orderLog: StrategyChartSnapshot['orderLog'],
|
|
235
|
-
isStreakPnl: (pnl: number) => boolean,
|
|
236
|
-
) => {
|
|
237
|
-
let currentStreak = 0;
|
|
238
|
-
let maxStreak = 0;
|
|
239
|
-
|
|
240
|
-
for (let index = 1; index < orderLog.length; index += 1) {
|
|
241
|
-
const pnl = getSnapshotStepPnl(orderLog, index);
|
|
242
|
-
if (typeof pnl !== 'number' || !Number.isFinite(pnl)) {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (isStreakPnl(pnl)) {
|
|
247
|
-
currentStreak += 1;
|
|
248
|
-
maxStreak = Math.max(maxStreak, currentStreak);
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
currentStreak = 0;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return maxStreak;
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const calculateMaxGrossStreak = (orderLog: StrategyChartSnapshot['orderLog']) =>
|
|
259
|
-
calculateMaxPnlStreak(orderLog, (pnl) => pnl > 0);
|
|
260
|
-
|
|
261
|
-
const calculateMaxLossStreak = (orderLog: StrategyChartSnapshot['orderLog']) =>
|
|
262
|
-
calculateMaxPnlStreak(orderLog, (pnl) => pnl < 0);
|
|
263
|
-
|
|
264
141
|
const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
|
|
265
142
|
const orderPnls = snapshot.orders
|
|
266
143
|
.map((order) => asFiniteNumber(order.pnl))
|
|
@@ -359,185 +236,6 @@ const buildSnapshotSummaryMetrics = (snapshot: StrategyChartSnapshot) => {
|
|
|
359
236
|
});
|
|
360
237
|
};
|
|
361
238
|
|
|
362
|
-
const resolveTradingSession = (hour: number): TradingSession => {
|
|
363
|
-
if (hour < 8) {
|
|
364
|
-
return 'Asia';
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (hour < 16) {
|
|
368
|
-
return 'Europe';
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return 'US';
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const buildSnapshotTradePoints = (
|
|
375
|
-
orderLog: StrategyChartSnapshot['orderLog'],
|
|
376
|
-
): SnapshotTradePoint[] => {
|
|
377
|
-
const points: SnapshotTradePoint[] = [];
|
|
378
|
-
|
|
379
|
-
for (let index = 1; index < orderLog.length; index += 1) {
|
|
380
|
-
const current = orderLog[index];
|
|
381
|
-
const previous = orderLog[index - 1];
|
|
382
|
-
if (!current || !previous) {
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const [timestamp, equity] = current;
|
|
387
|
-
const pnl = equity - previous[1];
|
|
388
|
-
if (
|
|
389
|
-
!Number.isFinite(timestamp) ||
|
|
390
|
-
!Number.isFinite(equity) ||
|
|
391
|
-
!Number.isFinite(pnl)
|
|
392
|
-
) {
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const hour = new Date(timestamp).getUTCHours();
|
|
397
|
-
points.push({
|
|
398
|
-
index,
|
|
399
|
-
timestamp,
|
|
400
|
-
pnl,
|
|
401
|
-
equity,
|
|
402
|
-
hour,
|
|
403
|
-
session: resolveTradingSession(hour),
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return points;
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
const buildDrawdownPoints = (
|
|
411
|
-
orderLog: StrategyChartSnapshot['orderLog'],
|
|
412
|
-
): DrawdownPoint[] => {
|
|
413
|
-
let peak = orderLog[0]?.[1] ?? 0;
|
|
414
|
-
|
|
415
|
-
return orderLog
|
|
416
|
-
.map(([timestamp, equity]) => {
|
|
417
|
-
if (!Number.isFinite(equity)) {
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
peak = Math.max(peak, equity);
|
|
422
|
-
const drawdownPercent = peak > 0 ? ((peak - equity) / peak) * 100 : 0;
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
timestamp,
|
|
426
|
-
drawdownPercent,
|
|
427
|
-
};
|
|
428
|
-
})
|
|
429
|
-
.filter((point): point is DrawdownPoint => point != null);
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
const buildRollingPerformance = (
|
|
433
|
-
trades: SnapshotTradePoint[],
|
|
434
|
-
windowSize = 50,
|
|
435
|
-
): RollingPerformancePoint[] =>
|
|
436
|
-
trades.map((trade, index) => {
|
|
437
|
-
const windowTrades = trades.slice(
|
|
438
|
-
Math.max(0, index - windowSize + 1),
|
|
439
|
-
index + 1,
|
|
440
|
-
);
|
|
441
|
-
const wins = windowTrades.filter((item) => item.pnl > 0).length;
|
|
442
|
-
const pnl = windowTrades.reduce((sum, item) => sum + item.pnl, 0);
|
|
443
|
-
|
|
444
|
-
return {
|
|
445
|
-
index: trade.index,
|
|
446
|
-
winRate: windowTrades.length > 0 ? (wins / windowTrades.length) * 100 : 0,
|
|
447
|
-
pnl,
|
|
448
|
-
};
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
const buildPnlDistribution = (
|
|
452
|
-
trades: SnapshotTradePoint[],
|
|
453
|
-
binCount = 12,
|
|
454
|
-
): DistributionBin[] => {
|
|
455
|
-
if (!trades.length) {
|
|
456
|
-
return [];
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const pnlValues = trades.map((trade) => trade.pnl);
|
|
460
|
-
const min = Math.min(...pnlValues);
|
|
461
|
-
const max = Math.max(...pnlValues);
|
|
462
|
-
|
|
463
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
464
|
-
return [];
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (min === max) {
|
|
468
|
-
return [
|
|
469
|
-
{
|
|
470
|
-
id: `${min}:${max}`,
|
|
471
|
-
min,
|
|
472
|
-
max,
|
|
473
|
-
count: trades.length,
|
|
474
|
-
},
|
|
475
|
-
];
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const step = (max - min) / binCount;
|
|
479
|
-
const bins = Array.from({ length: binCount }, (_, index) => ({
|
|
480
|
-
id: String(index),
|
|
481
|
-
min: min + step * index,
|
|
482
|
-
max: index === binCount - 1 ? max : min + step * (index + 1),
|
|
483
|
-
count: 0,
|
|
484
|
-
}));
|
|
485
|
-
|
|
486
|
-
for (const pnl of pnlValues) {
|
|
487
|
-
const rawIndex = Math.floor((pnl - min) / step);
|
|
488
|
-
const index = Math.max(0, Math.min(binCount - 1, rawIndex));
|
|
489
|
-
const bin = bins[index];
|
|
490
|
-
if (bin) {
|
|
491
|
-
bin.count += 1;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return bins;
|
|
496
|
-
};
|
|
497
|
-
|
|
498
|
-
const buildSessionPnlStats = (
|
|
499
|
-
trades: SnapshotTradePoint[],
|
|
500
|
-
): SessionPnlStat[] => {
|
|
501
|
-
const stats = new Map<TradingSession, SessionPnlStat>(
|
|
502
|
-
(['Asia', 'Europe', 'US'] as const).map((session) => [
|
|
503
|
-
session,
|
|
504
|
-
{ session, pnl: 0, orders: 0 },
|
|
505
|
-
]),
|
|
506
|
-
);
|
|
507
|
-
|
|
508
|
-
for (const trade of trades) {
|
|
509
|
-
const stat = stats.get(trade.session);
|
|
510
|
-
if (!stat) {
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
stat.pnl += trade.pnl;
|
|
515
|
-
stat.orders += 1;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
return [...stats.values()];
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
const buildHourlyPnlStats = (trades: SnapshotTradePoint[]): HourlyPnlStat[] => {
|
|
522
|
-
const stats = Array.from({ length: 24 }, (_, hour) => ({
|
|
523
|
-
hour,
|
|
524
|
-
pnl: 0,
|
|
525
|
-
orders: 0,
|
|
526
|
-
}));
|
|
527
|
-
|
|
528
|
-
for (const trade of trades) {
|
|
529
|
-
const stat = stats[trade.hour];
|
|
530
|
-
if (!stat) {
|
|
531
|
-
continue;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
stat.pnl += trade.pnl;
|
|
535
|
-
stat.orders += 1;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
return stats;
|
|
539
|
-
};
|
|
540
|
-
|
|
541
239
|
const formatPrice = (value: number | null | undefined) =>
|
|
542
240
|
formatPriceUsdt(value);
|
|
543
241
|
|
|
@@ -555,403 +253,6 @@ const formatBps = (value: number | null | undefined) => {
|
|
|
555
253
|
const formatAiExitReason = (reason: string | null | undefined) =>
|
|
556
254
|
reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
|
|
557
255
|
|
|
558
|
-
const CHART_WIDTH = 640;
|
|
559
|
-
const CHART_HEIGHT = 120;
|
|
560
|
-
const CHART_PADDING = 10;
|
|
561
|
-
const POSITIVE_CHART_COLOR = '#5eead4';
|
|
562
|
-
const NEGATIVE_CHART_COLOR = '#f87171';
|
|
563
|
-
const NEUTRAL_CHART_COLOR = '#6b7280';
|
|
564
|
-
|
|
565
|
-
const getChartPnlColor = (value: number) =>
|
|
566
|
-
value > 0
|
|
567
|
-
? POSITIVE_CHART_COLOR
|
|
568
|
-
: value < 0
|
|
569
|
-
? NEGATIVE_CHART_COLOR
|
|
570
|
-
: NEUTRAL_CHART_COLOR;
|
|
571
|
-
|
|
572
|
-
const buildPolylinePoints = (values: number[]) => {
|
|
573
|
-
if (!values.length) {
|
|
574
|
-
return '';
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const min = Math.min(...values);
|
|
578
|
-
const max = Math.max(...values);
|
|
579
|
-
const range = max - min || 1;
|
|
580
|
-
const drawableWidth = CHART_WIDTH - CHART_PADDING * 2;
|
|
581
|
-
const drawableHeight = CHART_HEIGHT - CHART_PADDING * 2;
|
|
582
|
-
|
|
583
|
-
return values
|
|
584
|
-
.map((value, index) => {
|
|
585
|
-
const x =
|
|
586
|
-
CHART_PADDING +
|
|
587
|
-
(values.length === 1
|
|
588
|
-
? 0
|
|
589
|
-
: (index / (values.length - 1)) * drawableWidth);
|
|
590
|
-
const y = CHART_PADDING + ((max - value) / range) * drawableHeight;
|
|
591
|
-
|
|
592
|
-
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
593
|
-
})
|
|
594
|
-
.join(' ');
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
const ChartPanel = ({
|
|
598
|
-
title,
|
|
599
|
-
subtitle,
|
|
600
|
-
children,
|
|
601
|
-
}: {
|
|
602
|
-
title: string;
|
|
603
|
-
subtitle?: string;
|
|
604
|
-
children: ReactNode;
|
|
605
|
-
}) => (
|
|
606
|
-
<Box
|
|
607
|
-
p={4}
|
|
608
|
-
borderWidth="1px"
|
|
609
|
-
borderColor="gray.800"
|
|
610
|
-
borderRadius="md"
|
|
611
|
-
bg="gray.900"
|
|
612
|
-
minH="210px"
|
|
613
|
-
>
|
|
614
|
-
<Flex justify="space-between" align="baseline" gap={3} mb={3}>
|
|
615
|
-
<Text fontSize="sm" color="gray.300" fontWeight="semibold">
|
|
616
|
-
{title}
|
|
617
|
-
</Text>
|
|
618
|
-
{subtitle ? (
|
|
619
|
-
<Text fontSize="xs" color="gray.500" textAlign="right">
|
|
620
|
-
{subtitle}
|
|
621
|
-
</Text>
|
|
622
|
-
) : null}
|
|
623
|
-
</Flex>
|
|
624
|
-
{children}
|
|
625
|
-
</Box>
|
|
626
|
-
);
|
|
627
|
-
|
|
628
|
-
const EmptyChart = () => (
|
|
629
|
-
<Flex h="140px" align="center" justify="center">
|
|
630
|
-
<Text fontSize="sm" color="gray.500">
|
|
631
|
-
No trade data
|
|
632
|
-
</Text>
|
|
633
|
-
</Flex>
|
|
634
|
-
);
|
|
635
|
-
|
|
636
|
-
const DrawdownTimelineChart = ({ points }: { points: DrawdownPoint[] }) => {
|
|
637
|
-
if (points.length < 2) {
|
|
638
|
-
return <EmptyChart />;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const values = points.map((point) => -point.drawdownPercent);
|
|
642
|
-
const maxDrawdown = Math.max(...points.map((point) => point.drawdownPercent));
|
|
643
|
-
const linePoints = buildPolylinePoints(values);
|
|
644
|
-
|
|
645
|
-
return (
|
|
646
|
-
<Box>
|
|
647
|
-
<svg
|
|
648
|
-
width="100%"
|
|
649
|
-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
650
|
-
role="img"
|
|
651
|
-
aria-label="Drawdown timeline"
|
|
652
|
-
>
|
|
653
|
-
<line
|
|
654
|
-
x1={CHART_PADDING}
|
|
655
|
-
x2={CHART_WIDTH - CHART_PADDING}
|
|
656
|
-
y1={CHART_PADDING}
|
|
657
|
-
y2={CHART_PADDING}
|
|
658
|
-
stroke="#374151"
|
|
659
|
-
strokeDasharray="4 4"
|
|
660
|
-
/>
|
|
661
|
-
<polyline
|
|
662
|
-
points={linePoints}
|
|
663
|
-
fill="none"
|
|
664
|
-
stroke={NEGATIVE_CHART_COLOR}
|
|
665
|
-
strokeWidth="2"
|
|
666
|
-
/>
|
|
667
|
-
</svg>
|
|
668
|
-
<Flex justify="space-between" align="center" mt={2}>
|
|
669
|
-
<Text fontSize="xs" color="gray.500">
|
|
670
|
-
max drawdown
|
|
671
|
-
</Text>
|
|
672
|
-
<Text
|
|
673
|
-
fontSize="sm"
|
|
674
|
-
color="orange.300"
|
|
675
|
-
fontFamily="mono"
|
|
676
|
-
fontWeight="bold"
|
|
677
|
-
>
|
|
678
|
-
{formatPercent(maxDrawdown)}
|
|
679
|
-
</Text>
|
|
680
|
-
</Flex>
|
|
681
|
-
</Box>
|
|
682
|
-
);
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
const WinLossStreakTimelineChart = ({
|
|
686
|
-
trades,
|
|
687
|
-
}: {
|
|
688
|
-
trades: SnapshotTradePoint[];
|
|
689
|
-
}) => {
|
|
690
|
-
if (!trades.length) {
|
|
691
|
-
return <EmptyChart />;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const maxAbsPnl = Math.max(...trades.map((trade) => Math.abs(trade.pnl)), 1);
|
|
695
|
-
const centerY = CHART_HEIGHT / 2;
|
|
696
|
-
const barWidth = CHART_WIDTH / trades.length;
|
|
697
|
-
|
|
698
|
-
return (
|
|
699
|
-
<Box>
|
|
700
|
-
<svg
|
|
701
|
-
width="100%"
|
|
702
|
-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
703
|
-
role="img"
|
|
704
|
-
aria-label="Win loss streak timeline"
|
|
705
|
-
>
|
|
706
|
-
<line
|
|
707
|
-
x1="0"
|
|
708
|
-
x2={CHART_WIDTH}
|
|
709
|
-
y1={centerY}
|
|
710
|
-
y2={centerY}
|
|
711
|
-
stroke="#374151"
|
|
712
|
-
/>
|
|
713
|
-
{trades.map((trade, index) => {
|
|
714
|
-
const height = Math.max(2, (Math.abs(trade.pnl) / maxAbsPnl) * 48);
|
|
715
|
-
const isWin = trade.pnl > 0;
|
|
716
|
-
const y = isWin ? centerY - height : centerY;
|
|
717
|
-
|
|
718
|
-
return (
|
|
719
|
-
<rect
|
|
720
|
-
key={`${trade.timestamp}-${index}`}
|
|
721
|
-
x={index * barWidth}
|
|
722
|
-
y={y}
|
|
723
|
-
width={Math.max(1, barWidth - 0.4)}
|
|
724
|
-
height={height}
|
|
725
|
-
fill={getChartPnlColor(trade.pnl)}
|
|
726
|
-
opacity="0.9"
|
|
727
|
-
/>
|
|
728
|
-
);
|
|
729
|
-
})}
|
|
730
|
-
</svg>
|
|
731
|
-
<Flex justify="space-between" align="center" mt={2}>
|
|
732
|
-
<Text fontSize="xs" color="gray.500">
|
|
733
|
-
wins above line, losses below
|
|
734
|
-
</Text>
|
|
735
|
-
<Text
|
|
736
|
-
fontSize="sm"
|
|
737
|
-
color="gray.300"
|
|
738
|
-
fontFamily="mono"
|
|
739
|
-
fontWeight="bold"
|
|
740
|
-
>
|
|
741
|
-
{formatInteger(trades.length)} trades
|
|
742
|
-
</Text>
|
|
743
|
-
</Flex>
|
|
744
|
-
</Box>
|
|
745
|
-
);
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
const PnlDistributionChart = ({ bins }: { bins: DistributionBin[] }) => {
|
|
749
|
-
if (!bins.length) {
|
|
750
|
-
return <EmptyChart />;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const maxCount = Math.max(...bins.map((bin) => bin.count), 1);
|
|
754
|
-
const barWidth = CHART_WIDTH / bins.length;
|
|
755
|
-
|
|
756
|
-
return (
|
|
757
|
-
<Box>
|
|
758
|
-
<svg
|
|
759
|
-
width="100%"
|
|
760
|
-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
761
|
-
role="img"
|
|
762
|
-
aria-label="P and L distribution"
|
|
763
|
-
>
|
|
764
|
-
{bins.map((bin, index) => {
|
|
765
|
-
const height = Math.max(2, (bin.count / maxCount) * 92);
|
|
766
|
-
const x = index * barWidth + 2;
|
|
767
|
-
const y = CHART_HEIGHT - height - CHART_PADDING;
|
|
768
|
-
const midpoint = (bin.min + bin.max) / 2;
|
|
769
|
-
|
|
770
|
-
return (
|
|
771
|
-
<rect
|
|
772
|
-
key={bin.id}
|
|
773
|
-
x={x}
|
|
774
|
-
y={y}
|
|
775
|
-
width={Math.max(2, barWidth - 4)}
|
|
776
|
-
height={height}
|
|
777
|
-
fill={getChartPnlColor(midpoint)}
|
|
778
|
-
/>
|
|
779
|
-
);
|
|
780
|
-
})}
|
|
781
|
-
</svg>
|
|
782
|
-
<Flex justify="space-between" align="center" mt={2}>
|
|
783
|
-
<Text fontSize="xs" color="gray.500">
|
|
784
|
-
trade P&L buckets
|
|
785
|
-
</Text>
|
|
786
|
-
<Text
|
|
787
|
-
fontSize="sm"
|
|
788
|
-
color="gray.300"
|
|
789
|
-
fontFamily="mono"
|
|
790
|
-
fontWeight="bold"
|
|
791
|
-
>
|
|
792
|
-
{formatInteger(bins.reduce((sum, bin) => sum + bin.count, 0))}
|
|
793
|
-
</Text>
|
|
794
|
-
</Flex>
|
|
795
|
-
</Box>
|
|
796
|
-
);
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
const RollingPerformanceChart = ({
|
|
800
|
-
points,
|
|
801
|
-
}: {
|
|
802
|
-
points: RollingPerformancePoint[];
|
|
803
|
-
}) => {
|
|
804
|
-
if (points.length < 2) {
|
|
805
|
-
return <EmptyChart />;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const winRateLine = buildPolylinePoints(points.map((point) => point.winRate));
|
|
809
|
-
const pnlLine = buildPolylinePoints(points.map((point) => point.pnl));
|
|
810
|
-
const latest = points[points.length - 1];
|
|
811
|
-
|
|
812
|
-
return (
|
|
813
|
-
<Box>
|
|
814
|
-
<svg
|
|
815
|
-
width="100%"
|
|
816
|
-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
817
|
-
role="img"
|
|
818
|
-
aria-label="Rolling win rate and rolling P and L"
|
|
819
|
-
>
|
|
820
|
-
<line
|
|
821
|
-
x1={CHART_PADDING}
|
|
822
|
-
x2={CHART_WIDTH - CHART_PADDING}
|
|
823
|
-
y1={CHART_HEIGHT / 2}
|
|
824
|
-
y2={CHART_HEIGHT / 2}
|
|
825
|
-
stroke="#374151"
|
|
826
|
-
strokeDasharray="4 4"
|
|
827
|
-
/>
|
|
828
|
-
<polyline
|
|
829
|
-
points={pnlLine}
|
|
830
|
-
fill="none"
|
|
831
|
-
stroke={POSITIVE_CHART_COLOR}
|
|
832
|
-
strokeWidth="2"
|
|
833
|
-
/>
|
|
834
|
-
<polyline
|
|
835
|
-
points={winRateLine}
|
|
836
|
-
fill="none"
|
|
837
|
-
stroke="#fbbf24"
|
|
838
|
-
strokeWidth="2"
|
|
839
|
-
opacity="0.9"
|
|
840
|
-
/>
|
|
841
|
-
</svg>
|
|
842
|
-
<Flex justify="space-between" align="center" mt={2}>
|
|
843
|
-
<Text fontSize="xs" color="gray.500">
|
|
844
|
-
teal P&L, yellow win rate
|
|
845
|
-
</Text>
|
|
846
|
-
<Text
|
|
847
|
-
fontSize="sm"
|
|
848
|
-
color="gray.300"
|
|
849
|
-
fontFamily="mono"
|
|
850
|
-
fontWeight="bold"
|
|
851
|
-
>
|
|
852
|
-
{formatPercent(latest?.winRate)} /{' '}
|
|
853
|
-
{formatSignedNumber(latest?.pnl ?? null)}
|
|
854
|
-
</Text>
|
|
855
|
-
</Flex>
|
|
856
|
-
</Box>
|
|
857
|
-
);
|
|
858
|
-
};
|
|
859
|
-
|
|
860
|
-
const TimeOfDaySessionChart = ({
|
|
861
|
-
sessions,
|
|
862
|
-
hours,
|
|
863
|
-
}: {
|
|
864
|
-
sessions: SessionPnlStat[];
|
|
865
|
-
hours: HourlyPnlStat[];
|
|
866
|
-
}) => {
|
|
867
|
-
if (!sessions.some((session) => session.orders > 0)) {
|
|
868
|
-
return <EmptyChart />;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
const maxSessionAbsPnl = Math.max(
|
|
872
|
-
...sessions.map((session) => Math.abs(session.pnl)),
|
|
873
|
-
1,
|
|
874
|
-
);
|
|
875
|
-
const maxHourAbsPnl = Math.max(...hours.map((hour) => Math.abs(hour.pnl)), 1);
|
|
876
|
-
|
|
877
|
-
return (
|
|
878
|
-
<Flex direction="column" gap={4}>
|
|
879
|
-
<SimpleGrid columns={3} gap={3}>
|
|
880
|
-
{sessions.map((session) => {
|
|
881
|
-
const width = Math.max(
|
|
882
|
-
4,
|
|
883
|
-
(Math.abs(session.pnl) / maxSessionAbsPnl) * 100,
|
|
884
|
-
);
|
|
885
|
-
|
|
886
|
-
return (
|
|
887
|
-
<Box key={session.session}>
|
|
888
|
-
<Flex justify="space-between" align="center" mb={1}>
|
|
889
|
-
<Text fontSize="xs" color="gray.400" fontWeight="semibold">
|
|
890
|
-
{session.session}
|
|
891
|
-
</Text>
|
|
892
|
-
<Text
|
|
893
|
-
fontSize="xs"
|
|
894
|
-
color={getPnlColor(session.pnl)}
|
|
895
|
-
fontFamily="mono"
|
|
896
|
-
fontWeight="bold"
|
|
897
|
-
>
|
|
898
|
-
{formatSignedNumber(session.pnl)}
|
|
899
|
-
</Text>
|
|
900
|
-
</Flex>
|
|
901
|
-
<Box h="8px" bg="gray.800">
|
|
902
|
-
<Box
|
|
903
|
-
h="full"
|
|
904
|
-
w={`${width}%`}
|
|
905
|
-
bg={getChartPnlColor(session.pnl)}
|
|
906
|
-
/>
|
|
907
|
-
</Box>
|
|
908
|
-
<Text mt={1} fontSize="xs" color="gray.500" fontFamily="mono">
|
|
909
|
-
{formatInteger(session.orders)} orders
|
|
910
|
-
</Text>
|
|
911
|
-
</Box>
|
|
912
|
-
);
|
|
913
|
-
})}
|
|
914
|
-
</SimpleGrid>
|
|
915
|
-
|
|
916
|
-
<svg
|
|
917
|
-
width="100%"
|
|
918
|
-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
919
|
-
role="img"
|
|
920
|
-
aria-label="P and L by UTC hour"
|
|
921
|
-
>
|
|
922
|
-
<line
|
|
923
|
-
x1="0"
|
|
924
|
-
x2={CHART_WIDTH}
|
|
925
|
-
y1={CHART_HEIGHT / 2}
|
|
926
|
-
y2={CHART_HEIGHT / 2}
|
|
927
|
-
stroke="#374151"
|
|
928
|
-
/>
|
|
929
|
-
{hours.map((hour) => {
|
|
930
|
-
const barWidth = CHART_WIDTH / 24;
|
|
931
|
-
const height = Math.max(1, (Math.abs(hour.pnl) / maxHourAbsPnl) * 46);
|
|
932
|
-
const isPositive = hour.pnl >= 0;
|
|
933
|
-
const y = isPositive ? CHART_HEIGHT / 2 - height : CHART_HEIGHT / 2;
|
|
934
|
-
|
|
935
|
-
return (
|
|
936
|
-
<rect
|
|
937
|
-
key={hour.hour}
|
|
938
|
-
x={hour.hour * barWidth + 2}
|
|
939
|
-
y={y}
|
|
940
|
-
width={Math.max(2, barWidth - 4)}
|
|
941
|
-
height={height}
|
|
942
|
-
fill={getChartPnlColor(hour.pnl)}
|
|
943
|
-
opacity={hour.orders > 0 ? 0.95 : 0.2}
|
|
944
|
-
/>
|
|
945
|
-
);
|
|
946
|
-
})}
|
|
947
|
-
</svg>
|
|
948
|
-
<Text fontSize="xs" color="gray.500">
|
|
949
|
-
UTC hours, sessions: Asia 00-07, Europe 08-15, US 16-23
|
|
950
|
-
</Text>
|
|
951
|
-
</Flex>
|
|
952
|
-
);
|
|
953
|
-
};
|
|
954
|
-
|
|
955
256
|
const getAiExitReasonColor = (reason: string | null | undefined) => {
|
|
956
257
|
switch (reason) {
|
|
957
258
|
case 'take_profit':
|
|
@@ -1833,105 +1134,6 @@ const AiDiagnosticsPanel = ({ groups }: { groups: AiDiagnosticGroup[] }) => {
|
|
|
1833
1134
|
);
|
|
1834
1135
|
};
|
|
1835
1136
|
|
|
1836
|
-
const getMonthLabel = (monthIndex: number) =>
|
|
1837
|
-
new Date(Date.UTC(2026, monthIndex - 1, 1)).toLocaleString('en-US', {
|
|
1838
|
-
month: 'short',
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
const monthQuarters = [
|
|
1842
|
-
{ label: 'Q1', months: [1, 2, 3] },
|
|
1843
|
-
{ label: 'Q2', months: [4, 5, 6] },
|
|
1844
|
-
{ label: 'Q3', months: [7, 8, 9] },
|
|
1845
|
-
{ label: 'Q4', months: [10, 11, 12] },
|
|
1846
|
-
] as const;
|
|
1847
|
-
|
|
1848
|
-
const buildQuarterlyMonthlyStats = (
|
|
1849
|
-
months: MonthlyStat[],
|
|
1850
|
-
): QuarterlyMonthlyStats[] => {
|
|
1851
|
-
const byMonth = new Map(months.map((month) => [month.monthIndex, month]));
|
|
1852
|
-
|
|
1853
|
-
return monthQuarters
|
|
1854
|
-
.map((quarter) => {
|
|
1855
|
-
const quarterMonths = quarter.months.map(
|
|
1856
|
-
(monthIndex) => byMonth.get(monthIndex) ?? null,
|
|
1857
|
-
);
|
|
1858
|
-
|
|
1859
|
-
return {
|
|
1860
|
-
label: quarter.label,
|
|
1861
|
-
monthIndexes: quarter.months,
|
|
1862
|
-
months: quarterMonths,
|
|
1863
|
-
hasData: quarterMonths.some((month) => month != null),
|
|
1864
|
-
};
|
|
1865
|
-
})
|
|
1866
|
-
.filter((quarter) => quarter.hasData);
|
|
1867
|
-
};
|
|
1868
|
-
|
|
1869
|
-
const buildMonthlyStats = (
|
|
1870
|
-
orderLog: StrategyChartSnapshot['orderLog'],
|
|
1871
|
-
): YearlyMonthlyStats[] => {
|
|
1872
|
-
const grouped = new Map<string, MonthlyStat>();
|
|
1873
|
-
|
|
1874
|
-
for (let index = 1; index < orderLog.length; index += 1) {
|
|
1875
|
-
const current = orderLog[index];
|
|
1876
|
-
const previous = orderLog[index - 1];
|
|
1877
|
-
if (!current || !previous) {
|
|
1878
|
-
continue;
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
const [timestamp, amount] = current;
|
|
1882
|
-
const previousAmount = previous[1];
|
|
1883
|
-
if (
|
|
1884
|
-
typeof timestamp !== 'number' ||
|
|
1885
|
-
!Number.isFinite(timestamp) ||
|
|
1886
|
-
typeof amount !== 'number' ||
|
|
1887
|
-
!Number.isFinite(amount) ||
|
|
1888
|
-
typeof previousAmount !== 'number' ||
|
|
1889
|
-
!Number.isFinite(previousAmount)
|
|
1890
|
-
) {
|
|
1891
|
-
continue;
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
const date = new Date(timestamp);
|
|
1895
|
-
const year = date.getUTCFullYear();
|
|
1896
|
-
const monthIndex = date.getUTCMonth() + 1;
|
|
1897
|
-
const id = `${year}-${String(monthIndex).padStart(2, '0')}`;
|
|
1898
|
-
const pnl = amount - previousAmount;
|
|
1899
|
-
const existing = grouped.get(id) ?? {
|
|
1900
|
-
id,
|
|
1901
|
-
year,
|
|
1902
|
-
monthIndex,
|
|
1903
|
-
monthLabel: getMonthLabel(monthIndex),
|
|
1904
|
-
orders: 0,
|
|
1905
|
-
wins: 0,
|
|
1906
|
-
pnl: 0,
|
|
1907
|
-
};
|
|
1908
|
-
|
|
1909
|
-
existing.orders += 1;
|
|
1910
|
-
existing.wins += pnl > 0 ? 1 : 0;
|
|
1911
|
-
existing.pnl += pnl;
|
|
1912
|
-
grouped.set(id, existing);
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
const monthlyStats = [...grouped.values()].sort(
|
|
1916
|
-
(left, right) =>
|
|
1917
|
-
left.year - right.year || left.monthIndex - right.monthIndex,
|
|
1918
|
-
);
|
|
1919
|
-
const yearlyStats = new Map<number, MonthlyStat[]>();
|
|
1920
|
-
|
|
1921
|
-
for (const month of monthlyStats) {
|
|
1922
|
-
const months = yearlyStats.get(month.year) ?? [];
|
|
1923
|
-
months.push(month);
|
|
1924
|
-
yearlyStats.set(month.year, months);
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
return [...yearlyStats.entries()]
|
|
1928
|
-
.sort(([leftYear], [rightYear]) => leftYear - rightYear)
|
|
1929
|
-
.map(([year, months]) => ({
|
|
1930
|
-
year,
|
|
1931
|
-
months,
|
|
1932
|
-
}));
|
|
1933
|
-
};
|
|
1934
|
-
|
|
1935
1137
|
const buildSymbolPnlRanking = (
|
|
1936
1138
|
details: StrategyChartDetail[] | undefined,
|
|
1937
1139
|
): SymbolPnlRank[] => {
|
|
@@ -1989,110 +1191,14 @@ const buildSymbolPnlRanking = (
|
|
|
1989
1191
|
);
|
|
1990
1192
|
};
|
|
1991
1193
|
|
|
1992
|
-
export const
|
|
1993
|
-
snapshot,
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
snapshot: StrategyChartSnapshot;
|
|
2001
|
-
emptyText: string;
|
|
2002
|
-
mode: 'replay' | 'ai';
|
|
2003
|
-
onDeleted?: (cardId: string) => void;
|
|
2004
|
-
selected?: boolean;
|
|
2005
|
-
onToggleSelection?: (cardId: string, checked: boolean) => void;
|
|
2006
|
-
}) => {
|
|
2007
|
-
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
2008
|
-
const [ordersOpen, setOrdersOpen] = useState(false);
|
|
2009
|
-
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
2010
|
-
const [isDeleting, setIsDeleting] = useState(false);
|
|
2011
|
-
const snapshotOrders = useMemo(
|
|
2012
|
-
() => buildSnapshotOrders(snapshot, mode),
|
|
2013
|
-
[mode, snapshot],
|
|
2014
|
-
);
|
|
2015
|
-
const aiDiagnosticGroups = useMemo(
|
|
2016
|
-
() => buildAiDiagnosticGroups(snapshot.details),
|
|
2017
|
-
[snapshot.details],
|
|
2018
|
-
);
|
|
2019
|
-
const directionStatGroups = useMemo(
|
|
2020
|
-
() => buildDirectionStatGroups(snapshot.details),
|
|
2021
|
-
[snapshot.details],
|
|
2022
|
-
);
|
|
2023
|
-
const symbolPnlRanking = useMemo(
|
|
2024
|
-
() => buildSymbolPnlRanking(snapshot.details),
|
|
2025
|
-
[snapshot.details],
|
|
2026
|
-
);
|
|
2027
|
-
const topSymbolPnlRanking = useMemo(
|
|
2028
|
-
() =>
|
|
2029
|
-
[...symbolPnlRanking]
|
|
2030
|
-
.sort(
|
|
2031
|
-
(left, right) =>
|
|
2032
|
-
right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
|
|
2033
|
-
)
|
|
2034
|
-
.slice(0, 10),
|
|
2035
|
-
[symbolPnlRanking],
|
|
2036
|
-
);
|
|
2037
|
-
const worstSymbolPnlRanking = useMemo(
|
|
2038
|
-
() =>
|
|
2039
|
-
[...symbolPnlRanking]
|
|
2040
|
-
.sort(
|
|
2041
|
-
(left, right) =>
|
|
2042
|
-
left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
|
|
2043
|
-
)
|
|
2044
|
-
.slice(0, 10),
|
|
2045
|
-
[symbolPnlRanking],
|
|
2046
|
-
);
|
|
2047
|
-
const symbolRankingMaxAbsPnl = useMemo(
|
|
2048
|
-
() => Math.max(...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)), 1),
|
|
2049
|
-
[symbolPnlRanking],
|
|
2050
|
-
);
|
|
2051
|
-
const monthlyStats = useMemo(
|
|
2052
|
-
() => buildMonthlyStats(snapshot.orderLog),
|
|
2053
|
-
[snapshot.orderLog],
|
|
2054
|
-
);
|
|
2055
|
-
const snapshotTradePoints = useMemo(
|
|
2056
|
-
() => buildSnapshotTradePoints(snapshot.orderLog),
|
|
2057
|
-
[snapshot.orderLog],
|
|
2058
|
-
);
|
|
2059
|
-
const drawdownPoints = useMemo(
|
|
2060
|
-
() => buildDrawdownPoints(snapshot.orderLog),
|
|
2061
|
-
[snapshot.orderLog],
|
|
2062
|
-
);
|
|
2063
|
-
const rollingPerformancePoints = useMemo(
|
|
2064
|
-
() => buildRollingPerformance(snapshotTradePoints, 50),
|
|
2065
|
-
[snapshotTradePoints],
|
|
2066
|
-
);
|
|
2067
|
-
const pnlDistributionBins = useMemo(
|
|
2068
|
-
() => buildPnlDistribution(snapshotTradePoints),
|
|
2069
|
-
[snapshotTradePoints],
|
|
2070
|
-
);
|
|
2071
|
-
const sessionPnlStats = useMemo(
|
|
2072
|
-
() => buildSessionPnlStats(snapshotTradePoints),
|
|
2073
|
-
[snapshotTradePoints],
|
|
2074
|
-
);
|
|
2075
|
-
const hourlyPnlStats = useMemo(
|
|
2076
|
-
() => buildHourlyPnlStats(snapshotTradePoints),
|
|
2077
|
-
[snapshotTradePoints],
|
|
2078
|
-
);
|
|
2079
|
-
const symbolsLabel =
|
|
2080
|
-
snapshot.symbols.length > 3
|
|
2081
|
-
? `${snapshot.symbols.slice(0, 3).join(', ')} +${snapshot.symbols.length - 3}`
|
|
2082
|
-
: snapshot.symbols.join(', ') || 'n/a';
|
|
2083
|
-
const sourceLabel =
|
|
2084
|
-
mode === 'ai' && snapshot.datasetId ? 'dataset:' : 'symbols:';
|
|
2085
|
-
const sourceValue =
|
|
2086
|
-
mode === 'ai' && snapshot.datasetId ? snapshot.datasetId : symbolsLabel;
|
|
2087
|
-
const tagsLabel = snapshot.tags?.join(' · ') ?? '';
|
|
2088
|
-
const displaySubtitle =
|
|
2089
|
-
mode === 'ai'
|
|
2090
|
-
? snapshot.subtitle?.replace(/^q\d+\+\s*(?:·\s*)?/i, '').trim()
|
|
2091
|
-
: snapshot.subtitle;
|
|
2092
|
-
const metrics = useMemo(
|
|
2093
|
-
() => buildSnapshotSummaryMetrics(snapshot),
|
|
2094
|
-
[snapshot],
|
|
2095
|
-
);
|
|
1194
|
+
export const buildStrategySnapshotCardViewModel = (
|
|
1195
|
+
snapshot: StrategyChartSnapshot,
|
|
1196
|
+
mode: 'replay' | 'ai',
|
|
1197
|
+
) => {
|
|
1198
|
+
const snapshotOrders = buildSnapshotOrders(snapshot, mode);
|
|
1199
|
+
const symbolPnlRanking = buildSymbolPnlRanking(snapshot.details);
|
|
1200
|
+
const performance = buildStrategyPerformanceViewModel(snapshot.orderLog);
|
|
1201
|
+
const maxLossStreak = calculateMaxLossStreak(snapshot.orderLog);
|
|
2096
1202
|
const drawerBaseMetrics =
|
|
2097
1203
|
mode === 'ai'
|
|
2098
1204
|
? snapshot.metrics
|
|
@@ -2109,12 +1215,46 @@ export const StrategySnapshotCard = ({
|
|
|
2109
1215
|
: metric,
|
|
2110
1216
|
)
|
|
2111
1217
|
: snapshot.metrics;
|
|
2112
|
-
const
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
1218
|
+
const firstPoint = snapshot.orderLog[0];
|
|
1219
|
+
const lastPoint = snapshot.orderLog[snapshot.orderLog.length - 1];
|
|
1220
|
+
const symbolsLabel =
|
|
1221
|
+
snapshot.symbols.length > 3
|
|
1222
|
+
? `${snapshot.symbols.slice(0, 3).join(', ')} +${snapshot.symbols.length - 3}`
|
|
1223
|
+
: snapshot.symbols.join(', ') || 'n/a';
|
|
1224
|
+
|
|
1225
|
+
return {
|
|
1226
|
+
snapshotOrders,
|
|
1227
|
+
aiDiagnosticGroups: buildAiDiagnosticGroups(snapshot.details),
|
|
1228
|
+
directionStatGroups: buildDirectionStatGroups(snapshot.details),
|
|
1229
|
+
symbolPnlRanking,
|
|
1230
|
+
topSymbolPnlRanking: [...symbolPnlRanking]
|
|
1231
|
+
.sort(
|
|
1232
|
+
(left, right) =>
|
|
1233
|
+
right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
|
|
1234
|
+
)
|
|
1235
|
+
.slice(0, 10),
|
|
1236
|
+
worstSymbolPnlRanking: [...symbolPnlRanking]
|
|
1237
|
+
.sort(
|
|
1238
|
+
(left, right) =>
|
|
1239
|
+
left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
|
|
1240
|
+
)
|
|
1241
|
+
.slice(0, 10),
|
|
1242
|
+
symbolRankingMaxAbsPnl: Math.max(
|
|
1243
|
+
...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)),
|
|
1244
|
+
1,
|
|
1245
|
+
),
|
|
1246
|
+
performance,
|
|
1247
|
+
symbolsLabel,
|
|
1248
|
+
sourceLabel: mode === 'ai' && snapshot.datasetId ? 'dataset:' : 'symbols:',
|
|
1249
|
+
sourceValue:
|
|
1250
|
+
mode === 'ai' && snapshot.datasetId ? snapshot.datasetId : symbolsLabel,
|
|
1251
|
+
tagsLabel: snapshot.tags?.join(' · ') ?? '',
|
|
1252
|
+
displaySubtitle:
|
|
1253
|
+
mode === 'ai'
|
|
1254
|
+
? snapshot.subtitle?.replace(/^q\d+\+\s*(?:·\s*)?/i, '').trim()
|
|
1255
|
+
: snapshot.subtitle,
|
|
1256
|
+
metrics: buildSnapshotSummaryMetrics(snapshot),
|
|
1257
|
+
drawerMetrics:
|
|
2118
1258
|
mode === 'ai'
|
|
2119
1259
|
? sortAiDrawerMetrics([
|
|
2120
1260
|
...drawerBaseMetrics,
|
|
@@ -2127,21 +1267,66 @@ export const StrategySnapshotCard = ({
|
|
|
2127
1267
|
},
|
|
2128
1268
|
])
|
|
2129
1269
|
: drawerBaseMetrics,
|
|
2130
|
-
|
|
2131
|
-
);
|
|
2132
|
-
const advancedMetrics = useMemo(() => {
|
|
2133
|
-
const firstPoint = snapshot.orderLog[0];
|
|
2134
|
-
const lastPoint = snapshot.orderLog[snapshot.orderLog.length - 1];
|
|
2135
|
-
|
|
2136
|
-
return calculateAdvancedTradeMetrics({
|
|
1270
|
+
advancedMetrics: calculateAdvancedTradeMetrics({
|
|
2137
1271
|
trades: buildSnapshotAdvancedTrades(snapshot),
|
|
2138
1272
|
orderLog: snapshot.orderLog,
|
|
2139
1273
|
startTimestamp: firstPoint?.[0] ?? null,
|
|
2140
1274
|
endTimestamp: lastPoint?.[0] ?? null,
|
|
2141
|
-
})
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
1275
|
+
}),
|
|
1276
|
+
hasOrdersDrawer: snapshotOrders.length > 0,
|
|
1277
|
+
hasStatDrawer: mode === 'ai' || Boolean(snapshot.details?.length),
|
|
1278
|
+
};
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
export const StrategySnapshotCard = ({
|
|
1282
|
+
snapshot,
|
|
1283
|
+
emptyText,
|
|
1284
|
+
mode,
|
|
1285
|
+
onDeleted,
|
|
1286
|
+
selected = false,
|
|
1287
|
+
onToggleSelection,
|
|
1288
|
+
}: {
|
|
1289
|
+
snapshot: StrategyChartSnapshot;
|
|
1290
|
+
emptyText: string;
|
|
1291
|
+
mode: 'replay' | 'ai';
|
|
1292
|
+
onDeleted?: (cardId: string) => void;
|
|
1293
|
+
selected?: boolean;
|
|
1294
|
+
onToggleSelection?: (cardId: string, checked: boolean) => void;
|
|
1295
|
+
}) => {
|
|
1296
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
1297
|
+
const [ordersOpen, setOrdersOpen] = useState(false);
|
|
1298
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
1299
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
1300
|
+
const viewModel = useMemo(
|
|
1301
|
+
() => buildStrategySnapshotCardViewModel(snapshot, mode),
|
|
1302
|
+
[mode, snapshot],
|
|
1303
|
+
);
|
|
1304
|
+
const {
|
|
1305
|
+
snapshotOrders,
|
|
1306
|
+
aiDiagnosticGroups,
|
|
1307
|
+
directionStatGroups,
|
|
1308
|
+
topSymbolPnlRanking,
|
|
1309
|
+
worstSymbolPnlRanking,
|
|
1310
|
+
symbolRankingMaxAbsPnl,
|
|
1311
|
+
sourceLabel,
|
|
1312
|
+
sourceValue,
|
|
1313
|
+
tagsLabel,
|
|
1314
|
+
displaySubtitle,
|
|
1315
|
+
metrics,
|
|
1316
|
+
drawerMetrics,
|
|
1317
|
+
advancedMetrics,
|
|
1318
|
+
hasOrdersDrawer,
|
|
1319
|
+
hasStatDrawer,
|
|
1320
|
+
} = viewModel;
|
|
1321
|
+
const {
|
|
1322
|
+
monthlyStats,
|
|
1323
|
+
tradePoints: snapshotTradePoints,
|
|
1324
|
+
drawdownPoints,
|
|
1325
|
+
rollingPerformancePoints,
|
|
1326
|
+
pnlDistributionBins,
|
|
1327
|
+
sessionPnlStats,
|
|
1328
|
+
hourlyPnlStats,
|
|
1329
|
+
} = viewModel.performance;
|
|
2145
1330
|
|
|
2146
1331
|
const handleDelete = async () => {
|
|
2147
1332
|
if (isDeleting) {
|