@tradejs/app 2.0.18 → 2.0.20

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.
Files changed (52) hide show
  1. package/package.json +14 -8
  2. package/src/app/actions/backtest.ts +2 -1
  3. package/src/app/actions/scanner.ts +2 -1
  4. package/src/app/api/backtest/files/route.ts +2 -1
  5. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +1 -1
  6. package/src/app/api/derivatives/[symbol]/[interval]/route.ts +1 -1
  7. package/src/app/api/derivatives/summary/route.ts +1 -1
  8. package/src/app/api/signal/[symbol]/[signalId]/route.ts +2 -1
  9. package/src/app/api/spread/[symbol]/[interval]/route.ts +1 -1
  10. package/src/app/api/spread/summary/route.ts +1 -1
  11. package/src/app/api/strategies/runtime/route.ts +4 -674
  12. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +1 -1
  13. package/src/app/api/user/runtime-deployments/route.ts +2 -2
  14. package/src/app/api/user/runtime-strategy-configs/route.ts +22 -212
  15. package/src/app/api/user/trading-accounts/[accountId]/route.ts +1 -1
  16. package/src/app/components/Backtest/TestList/index.tsx +1 -1
  17. package/src/app/components/Dashboard/KlineChart/figures/circle.ts +1 -1
  18. package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +1 -1
  19. package/src/app/components/Dashboard/KlineChart/figures/label.ts +1 -1
  20. package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +1 -1
  21. package/src/app/components/Dashboard/KlineChart/figures/star.ts +1 -1
  22. package/src/app/components/Dashboard/KlineChart/index.tsx +2 -1
  23. package/src/app/components/Shared/Filters/Root/index.tsx +1 -1
  24. package/src/app/components/Shared/Filters/context.ts +1 -1
  25. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +82 -861
  26. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +115 -184
  27. package/src/app/components/Strategies/StrategyEvidencePopover.tsx +259 -0
  28. package/src/app/components/Strategies/StrategyPerformanceCharts.tsx +419 -0
  29. package/src/app/components/Strategies/StrategySnapshotCard.tsx +122 -937
  30. package/src/app/components/UI/Segment/index.tsx +1 -1
  31. package/src/app/components/UI/Select/index.tsx +1 -1
  32. package/src/app/components/UI/SelectWithSearch/index.tsx +1 -1
  33. package/src/app/lib/backtestJobContracts.ts +67 -0
  34. package/src/app/lib/backtestJobProgress.ts +28 -0
  35. package/src/app/lib/backtestJobRequest.ts +107 -0
  36. package/src/app/lib/backtestJobs.ts +25 -257
  37. package/src/app/lib/runtimeDashboard.ts +700 -0
  38. package/src/app/lib/runtimeStrategies.ts +22 -457
  39. package/src/app/lib/runtimeStrategyConfigService.ts +279 -0
  40. package/src/app/lib/runtimeStrategyLineage.ts +264 -0
  41. package/src/app/lib/runtimeTradeReconciliation.ts +113 -0
  42. package/src/app/lib/runtimeTradeSync.ts +1 -1
  43. package/src/app/lib/strategyEvidenceTimeline.ts +298 -0
  44. package/src/app/lib/strategyPerformance.ts +387 -0
  45. package/src/app/routes/dashboard/Dashboard.tsx +2 -6
  46. package/src/app/routes/derivatives/derivativesViewModel.ts +253 -0
  47. package/src/app/routes/derivatives/page.tsx +70 -262
  48. package/src/app/store/filters.ts +2 -1
  49. package/src/app/store/indicators.ts +2 -1
  50. package/src/app/store/tests.ts +1 -1
  51. package/src/app/store/tickers.ts +2 -1
  52. package/src/app/types/ui.ts +20 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useMemo, useState } from 'react';
3
+ import { useMemo, useState } from 'react';
4
4
  import {
5
5
  Badge,
6
6
  Box,
@@ -36,81 +36,25 @@ import {
36
36
  type OrdersDrawerSummaryItem,
37
37
  } from '#components/Shared/OrdersDrawer';
38
38
  import type { RuntimeStrategyView } from '#app/lib/runtimeStrategies';
39
+ import {
40
+ buildQuarterlyMonthlyStats,
41
+ buildStrategyPerformanceViewModel,
42
+ calculateMaxLossStreak,
43
+ } from '#app/lib/strategyPerformance';
39
44
  import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
40
45
  import { RuntimeStrategyChart } from './RuntimeStrategyChart';
46
+ import {
47
+ ChartPanel,
48
+ DrawdownTimelineChart,
49
+ PnlDistributionChart,
50
+ RollingPerformanceChart,
51
+ TimeOfDaySessionChart,
52
+ WinLossStreakTimelineChart,
53
+ } from './StrategyPerformanceCharts';
41
54
  import { RuntimeStrategyConfigDrawer } from './RuntimeStrategyConfigDrawer';
42
55
 
43
56
  type RuntimeOrderView = RuntimeStrategyView['orders'][number];
44
57
  const RUNTIME_ORDER_ROW_HEIGHT = 306;
45
- const CHART_WIDTH = 640;
46
- const CHART_HEIGHT = 120;
47
- const CHART_PADDING = 10;
48
- const POSITIVE_CHART_COLOR = '#5eead4';
49
- const NEGATIVE_CHART_COLOR = '#f87171';
50
- const NEUTRAL_CHART_COLOR = '#6b7280';
51
-
52
- type TradingSession = 'Asia' | 'Europe' | 'US';
53
-
54
- interface RuntimeTradePoint {
55
- index: number;
56
- timestamp: number;
57
- pnl: number;
58
- equity: number;
59
- hour: number;
60
- session: TradingSession;
61
- }
62
-
63
- interface DrawdownPoint {
64
- timestamp: number;
65
- drawdownPercent: number;
66
- }
67
-
68
- interface RollingPerformancePoint {
69
- index: number;
70
- winRate: number;
71
- pnl: number;
72
- }
73
-
74
- interface DistributionBin {
75
- id: string;
76
- min: number;
77
- max: number;
78
- count: number;
79
- }
80
-
81
- interface MonthlyStat {
82
- id: string;
83
- year: number;
84
- monthIndex: number;
85
- monthLabel: string;
86
- orders: number;
87
- wins: number;
88
- pnl: number;
89
- }
90
-
91
- interface YearlyMonthlyStats {
92
- year: number;
93
- months: MonthlyStat[];
94
- }
95
-
96
- interface QuarterlyMonthlyStats {
97
- label: string;
98
- monthIndexes: readonly number[];
99
- months: (MonthlyStat | null)[];
100
- hasData: boolean;
101
- }
102
-
103
- interface SessionPnlStat {
104
- session: TradingSession;
105
- pnl: number;
106
- orders: number;
107
- }
108
-
109
- interface HourlyPnlStat {
110
- hour: number;
111
- pnl: number;
112
- orders: number;
113
- }
114
58
 
115
59
  interface RuntimeSymbolPnlRank {
116
60
  symbol: string;
@@ -159,13 +103,6 @@ const getColorByLevel = (level: ThresholdLevel) => {
159
103
 
160
104
  const getMetricColor = (level: ThresholdLevel) => getColorByLevel(level);
161
105
 
162
- const getChartPnlColor = (value: number) =>
163
- value > 0
164
- ? POSITIVE_CHART_COLOR
165
- : value < 0
166
- ? NEGATIVE_CHART_COLOR
167
- : NEUTRAL_CHART_COLOR;
168
-
169
106
  const getPnlBarColor = (value: number) => {
170
107
  if (value > 0) {
171
108
  return 'teal.500';
@@ -176,318 +113,6 @@ const getPnlBarColor = (value: number) => {
176
113
  return 'gray.500';
177
114
  };
178
115
 
179
- const resolveTradingSession = (hour: number): TradingSession => {
180
- if (hour < 8) {
181
- return 'Asia';
182
- }
183
-
184
- if (hour < 16) {
185
- return 'Europe';
186
- }
187
-
188
- return 'US';
189
- };
190
-
191
- const getStepPnl = (
192
- orderLog: RuntimeStrategyView['orderLog'],
193
- index: number,
194
- ) => {
195
- const current = orderLog[index];
196
- const previous = orderLog[index - 1];
197
-
198
- if (!current || !previous) {
199
- return null;
200
- }
201
-
202
- const pnl = current[1] - previous[1];
203
- return Number.isFinite(pnl) ? pnl : null;
204
- };
205
-
206
- const calculateMaxLossStreak = (orderLog: RuntimeStrategyView['orderLog']) => {
207
- let currentStreak = 0;
208
- let maxStreak = 0;
209
-
210
- for (let index = 1; index < orderLog.length; index += 1) {
211
- const pnl = getStepPnl(orderLog, index);
212
- if (typeof pnl !== 'number' || !Number.isFinite(pnl)) {
213
- continue;
214
- }
215
-
216
- if (pnl < 0) {
217
- currentStreak += 1;
218
- maxStreak = Math.max(maxStreak, currentStreak);
219
- continue;
220
- }
221
-
222
- currentStreak = 0;
223
- }
224
-
225
- return maxStreak;
226
- };
227
-
228
- const buildRuntimeTradePoints = (
229
- orderLog: RuntimeStrategyView['orderLog'],
230
- ): RuntimeTradePoint[] => {
231
- const points: RuntimeTradePoint[] = [];
232
-
233
- for (let index = 1; index < orderLog.length; index += 1) {
234
- const current = orderLog[index];
235
- const previous = orderLog[index - 1];
236
- if (!current || !previous) {
237
- continue;
238
- }
239
-
240
- const [timestamp, equity] = current;
241
- const pnl = equity - previous[1];
242
- if (
243
- !Number.isFinite(timestamp) ||
244
- !Number.isFinite(equity) ||
245
- !Number.isFinite(pnl)
246
- ) {
247
- continue;
248
- }
249
-
250
- const hour = new Date(timestamp).getUTCHours();
251
- points.push({
252
- index,
253
- timestamp,
254
- pnl,
255
- equity,
256
- hour,
257
- session: resolveTradingSession(hour),
258
- });
259
- }
260
-
261
- return points;
262
- };
263
-
264
- const buildDrawdownPoints = (
265
- orderLog: RuntimeStrategyView['orderLog'],
266
- ): DrawdownPoint[] => {
267
- let peak = orderLog[0]?.[1] ?? 0;
268
-
269
- return orderLog
270
- .map(([timestamp, equity]) => {
271
- if (!Number.isFinite(timestamp) || !Number.isFinite(equity)) {
272
- return null;
273
- }
274
-
275
- peak = Math.max(peak, equity);
276
- const drawdownPercent = peak > 0 ? ((peak - equity) / peak) * 100 : 0;
277
-
278
- return {
279
- timestamp,
280
- drawdownPercent,
281
- };
282
- })
283
- .filter((point): point is DrawdownPoint => point != null);
284
- };
285
-
286
- const buildRollingPerformance = (
287
- trades: RuntimeTradePoint[],
288
- windowSize = 50,
289
- ): RollingPerformancePoint[] =>
290
- trades.map((trade, index) => {
291
- const windowTrades = trades.slice(
292
- Math.max(0, index - windowSize + 1),
293
- index + 1,
294
- );
295
- const wins = windowTrades.filter((item) => item.pnl > 0).length;
296
- const pnl = windowTrades.reduce((sum, item) => sum + item.pnl, 0);
297
-
298
- return {
299
- index: trade.index,
300
- winRate: windowTrades.length > 0 ? (wins / windowTrades.length) * 100 : 0,
301
- pnl,
302
- };
303
- });
304
-
305
- const buildPnlDistribution = (
306
- trades: RuntimeTradePoint[],
307
- binCount = 12,
308
- ): DistributionBin[] => {
309
- if (!trades.length) {
310
- return [];
311
- }
312
-
313
- const pnlValues = trades.map((trade) => trade.pnl);
314
- const min = Math.min(...pnlValues);
315
- const max = Math.max(...pnlValues);
316
-
317
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
318
- return [];
319
- }
320
-
321
- if (min === max) {
322
- return [
323
- {
324
- id: `${min}:${max}`,
325
- min,
326
- max,
327
- count: trades.length,
328
- },
329
- ];
330
- }
331
-
332
- const step = (max - min) / binCount;
333
- const bins = Array.from({ length: binCount }, (_, index) => ({
334
- id: String(index),
335
- min: min + step * index,
336
- max: index === binCount - 1 ? max : min + step * (index + 1),
337
- count: 0,
338
- }));
339
-
340
- for (const pnl of pnlValues) {
341
- const rawIndex = Math.floor((pnl - min) / step);
342
- const index = Math.max(0, Math.min(binCount - 1, rawIndex));
343
- const bin = bins[index];
344
- if (bin) {
345
- bin.count += 1;
346
- }
347
- }
348
-
349
- return bins;
350
- };
351
-
352
- const buildSessionPnlStats = (
353
- trades: RuntimeTradePoint[],
354
- ): SessionPnlStat[] => {
355
- const stats = new Map<TradingSession, SessionPnlStat>(
356
- (['Asia', 'Europe', 'US'] as const).map((session) => [
357
- session,
358
- { session, pnl: 0, orders: 0 },
359
- ]),
360
- );
361
-
362
- for (const trade of trades) {
363
- const stat = stats.get(trade.session);
364
- if (!stat) {
365
- continue;
366
- }
367
-
368
- stat.pnl += trade.pnl;
369
- stat.orders += 1;
370
- }
371
-
372
- return [...stats.values()];
373
- };
374
-
375
- const buildHourlyPnlStats = (trades: RuntimeTradePoint[]): HourlyPnlStat[] => {
376
- const stats = Array.from({ length: 24 }, (_, hour) => ({
377
- hour,
378
- pnl: 0,
379
- orders: 0,
380
- }));
381
-
382
- for (const trade of trades) {
383
- const stat = stats[trade.hour];
384
- if (!stat) {
385
- continue;
386
- }
387
-
388
- stat.pnl += trade.pnl;
389
- stat.orders += 1;
390
- }
391
-
392
- return stats;
393
- };
394
-
395
- const getMonthLabel = (monthIndex: number) =>
396
- new Date(Date.UTC(2026, monthIndex - 1, 1)).toLocaleString('en-US', {
397
- month: 'short',
398
- });
399
-
400
- const monthQuarters = [
401
- { label: 'Q1', months: [1, 2, 3] },
402
- { label: 'Q2', months: [4, 5, 6] },
403
- { label: 'Q3', months: [7, 8, 9] },
404
- { label: 'Q4', months: [10, 11, 12] },
405
- ] as const;
406
-
407
- const buildQuarterlyMonthlyStats = (
408
- months: MonthlyStat[],
409
- ): QuarterlyMonthlyStats[] => {
410
- const byMonth = new Map(months.map((month) => [month.monthIndex, month]));
411
-
412
- return monthQuarters
413
- .map((quarter) => {
414
- const quarterMonths = quarter.months.map(
415
- (monthIndex) => byMonth.get(monthIndex) ?? null,
416
- );
417
-
418
- return {
419
- label: quarter.label,
420
- monthIndexes: quarter.months,
421
- months: quarterMonths,
422
- hasData: quarterMonths.some((month) => month != null),
423
- };
424
- })
425
- .filter((quarter) => quarter.hasData);
426
- };
427
-
428
- const buildMonthlyStats = (
429
- orderLog: RuntimeStrategyView['orderLog'],
430
- ): YearlyMonthlyStats[] => {
431
- const grouped = new Map<string, MonthlyStat>();
432
-
433
- for (let index = 1; index < orderLog.length; index += 1) {
434
- const current = orderLog[index];
435
- const previous = orderLog[index - 1];
436
- if (!current || !previous) {
437
- continue;
438
- }
439
-
440
- const [timestamp, amount] = current;
441
- const previousAmount = previous[1];
442
- if (
443
- !Number.isFinite(timestamp) ||
444
- !Number.isFinite(amount) ||
445
- !Number.isFinite(previousAmount)
446
- ) {
447
- continue;
448
- }
449
-
450
- const date = new Date(timestamp);
451
- const year = date.getUTCFullYear();
452
- const monthIndex = date.getUTCMonth() + 1;
453
- const id = `${year}-${String(monthIndex).padStart(2, '0')}`;
454
- const pnl = amount - previousAmount;
455
- const existing = grouped.get(id) ?? {
456
- id,
457
- year,
458
- monthIndex,
459
- monthLabel: getMonthLabel(monthIndex),
460
- orders: 0,
461
- wins: 0,
462
- pnl: 0,
463
- };
464
-
465
- existing.orders += 1;
466
- existing.wins += pnl > 0 ? 1 : 0;
467
- existing.pnl += pnl;
468
- grouped.set(id, existing);
469
- }
470
-
471
- const monthlyStats = [...grouped.values()].sort(
472
- (left, right) =>
473
- left.year - right.year || left.monthIndex - right.monthIndex,
474
- );
475
- const yearlyStats = new Map<number, MonthlyStat[]>();
476
-
477
- for (const month of monthlyStats) {
478
- const months = yearlyStats.get(month.year) ?? [];
479
- months.push(month);
480
- yearlyStats.set(month.year, months);
481
- }
482
-
483
- return [...yearlyStats.entries()]
484
- .sort(([leftYear], [rightYear]) => leftYear - rightYear)
485
- .map(([year, months]) => ({
486
- year,
487
- months,
488
- }));
489
- };
490
-
491
116
  const StatItem = ({
492
117
  stat,
493
118
  id,
@@ -901,389 +526,6 @@ const buildRuntimeDirectionStats = (
901
526
  };
902
527
  });
903
528
 
904
- const buildPolylinePoints = (values: number[]) => {
905
- if (!values.length) {
906
- return '';
907
- }
908
-
909
- const min = Math.min(...values);
910
- const max = Math.max(...values);
911
- const range = max - min || 1;
912
- const drawableWidth = CHART_WIDTH - CHART_PADDING * 2;
913
- const drawableHeight = CHART_HEIGHT - CHART_PADDING * 2;
914
-
915
- return values
916
- .map((value, index) => {
917
- const x =
918
- CHART_PADDING +
919
- (values.length === 1
920
- ? 0
921
- : (index / (values.length - 1)) * drawableWidth);
922
- const y = CHART_PADDING + ((max - value) / range) * drawableHeight;
923
-
924
- return `${x.toFixed(2)},${y.toFixed(2)}`;
925
- })
926
- .join(' ');
927
- };
928
-
929
- const ChartPanel = ({
930
- title,
931
- subtitle,
932
- children,
933
- }: {
934
- title: string;
935
- subtitle?: string;
936
- children: ReactNode;
937
- }) => (
938
- <Box
939
- p={4}
940
- borderWidth="1px"
941
- borderColor="gray.800"
942
- borderRadius="md"
943
- bg="gray.900"
944
- minH="210px"
945
- >
946
- <Flex justify="space-between" align="baseline" gap={3} mb={3}>
947
- <Text fontSize="sm" color="gray.300" fontWeight="semibold">
948
- {title}
949
- </Text>
950
- {subtitle ? (
951
- <Text fontSize="xs" color="gray.500" textAlign="right">
952
- {subtitle}
953
- </Text>
954
- ) : null}
955
- </Flex>
956
- {children}
957
- </Box>
958
- );
959
-
960
- const EmptyChart = () => (
961
- <Flex h="140px" align="center" justify="center">
962
- <Text fontSize="sm" color="gray.500">
963
- No trade data
964
- </Text>
965
- </Flex>
966
- );
967
-
968
- const DrawdownTimelineChart = ({ points }: { points: DrawdownPoint[] }) => {
969
- if (points.length < 2) {
970
- return <EmptyChart />;
971
- }
972
-
973
- const values = points.map((point) => -point.drawdownPercent);
974
- const maxDrawdown = Math.max(...points.map((point) => point.drawdownPercent));
975
- const linePoints = buildPolylinePoints(values);
976
-
977
- return (
978
- <Box>
979
- <svg
980
- width="100%"
981
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
982
- role="img"
983
- aria-label="Drawdown timeline"
984
- >
985
- <line
986
- x1={CHART_PADDING}
987
- x2={CHART_WIDTH - CHART_PADDING}
988
- y1={CHART_PADDING}
989
- y2={CHART_PADDING}
990
- stroke="#374151"
991
- strokeDasharray="4 4"
992
- />
993
- <polyline
994
- points={linePoints}
995
- fill="none"
996
- stroke={NEGATIVE_CHART_COLOR}
997
- strokeWidth="2"
998
- />
999
- </svg>
1000
- <Flex justify="space-between" align="center" mt={2}>
1001
- <Text fontSize="xs" color="gray.500">
1002
- max drawdown
1003
- </Text>
1004
- <Text
1005
- fontSize="sm"
1006
- color="orange.300"
1007
- fontFamily="mono"
1008
- fontWeight="bold"
1009
- >
1010
- {formatPercent(maxDrawdown)}
1011
- </Text>
1012
- </Flex>
1013
- </Box>
1014
- );
1015
- };
1016
-
1017
- const WinLossStreakTimelineChart = ({
1018
- trades,
1019
- }: {
1020
- trades: RuntimeTradePoint[];
1021
- }) => {
1022
- if (!trades.length) {
1023
- return <EmptyChart />;
1024
- }
1025
-
1026
- const maxAbsPnl = Math.max(...trades.map((trade) => Math.abs(trade.pnl)), 1);
1027
- const centerY = CHART_HEIGHT / 2;
1028
- const barWidth = CHART_WIDTH / trades.length;
1029
-
1030
- return (
1031
- <Box>
1032
- <svg
1033
- width="100%"
1034
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
1035
- role="img"
1036
- aria-label="Win loss streak timeline"
1037
- >
1038
- <line
1039
- x1="0"
1040
- x2={CHART_WIDTH}
1041
- y1={centerY}
1042
- y2={centerY}
1043
- stroke="#374151"
1044
- />
1045
- {trades.map((trade, index) => {
1046
- const height = Math.max(2, (Math.abs(trade.pnl) / maxAbsPnl) * 48);
1047
- const isWin = trade.pnl > 0;
1048
- const y = isWin ? centerY - height : centerY;
1049
-
1050
- return (
1051
- <rect
1052
- key={`${trade.timestamp}-${index}`}
1053
- x={index * barWidth}
1054
- y={y}
1055
- width={Math.max(1, barWidth - 0.4)}
1056
- height={height}
1057
- fill={getChartPnlColor(trade.pnl)}
1058
- opacity="0.9"
1059
- />
1060
- );
1061
- })}
1062
- </svg>
1063
- <Flex justify="space-between" align="center" mt={2}>
1064
- <Text fontSize="xs" color="gray.500">
1065
- wins above line, losses below
1066
- </Text>
1067
- <Text
1068
- fontSize="sm"
1069
- color="gray.300"
1070
- fontFamily="mono"
1071
- fontWeight="bold"
1072
- >
1073
- {formatInteger(trades.length)} trades
1074
- </Text>
1075
- </Flex>
1076
- </Box>
1077
- );
1078
- };
1079
-
1080
- const PnlDistributionChart = ({ bins }: { bins: DistributionBin[] }) => {
1081
- if (!bins.length) {
1082
- return <EmptyChart />;
1083
- }
1084
-
1085
- const maxCount = Math.max(...bins.map((bin) => bin.count), 1);
1086
- const barWidth = CHART_WIDTH / bins.length;
1087
-
1088
- return (
1089
- <Box>
1090
- <svg
1091
- width="100%"
1092
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
1093
- role="img"
1094
- aria-label="P and L distribution"
1095
- >
1096
- {bins.map((bin, index) => {
1097
- const height = Math.max(2, (bin.count / maxCount) * 92);
1098
- const x = index * barWidth + 2;
1099
- const y = CHART_HEIGHT - height - CHART_PADDING;
1100
- const midpoint = (bin.min + bin.max) / 2;
1101
-
1102
- return (
1103
- <rect
1104
- key={bin.id}
1105
- x={x}
1106
- y={y}
1107
- width={Math.max(2, barWidth - 4)}
1108
- height={height}
1109
- fill={getChartPnlColor(midpoint)}
1110
- />
1111
- );
1112
- })}
1113
- </svg>
1114
- <Flex justify="space-between" align="center" mt={2}>
1115
- <Text fontSize="xs" color="gray.500">
1116
- trade P&L buckets
1117
- </Text>
1118
- <Text
1119
- fontSize="sm"
1120
- color="gray.300"
1121
- fontFamily="mono"
1122
- fontWeight="bold"
1123
- >
1124
- {formatInteger(bins.reduce((sum, bin) => sum + bin.count, 0))}
1125
- </Text>
1126
- </Flex>
1127
- </Box>
1128
- );
1129
- };
1130
-
1131
- const RollingPerformanceChart = ({
1132
- points,
1133
- }: {
1134
- points: RollingPerformancePoint[];
1135
- }) => {
1136
- if (points.length < 2) {
1137
- return <EmptyChart />;
1138
- }
1139
-
1140
- const winRateLine = buildPolylinePoints(points.map((point) => point.winRate));
1141
- const pnlLine = buildPolylinePoints(points.map((point) => point.pnl));
1142
- const latest = points[points.length - 1];
1143
-
1144
- return (
1145
- <Box>
1146
- <svg
1147
- width="100%"
1148
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
1149
- role="img"
1150
- aria-label="Rolling win rate and rolling P and L"
1151
- >
1152
- <line
1153
- x1={CHART_PADDING}
1154
- x2={CHART_WIDTH - CHART_PADDING}
1155
- y1={CHART_HEIGHT / 2}
1156
- y2={CHART_HEIGHT / 2}
1157
- stroke="#374151"
1158
- strokeDasharray="4 4"
1159
- />
1160
- <polyline
1161
- points={pnlLine}
1162
- fill="none"
1163
- stroke={POSITIVE_CHART_COLOR}
1164
- strokeWidth="2"
1165
- />
1166
- <polyline
1167
- points={winRateLine}
1168
- fill="none"
1169
- stroke="#fbbf24"
1170
- strokeWidth="2"
1171
- opacity="0.9"
1172
- />
1173
- </svg>
1174
- <Flex justify="space-between" align="center" mt={2}>
1175
- <Text fontSize="xs" color="gray.500">
1176
- teal P&L, yellow win rate
1177
- </Text>
1178
- <Text
1179
- fontSize="sm"
1180
- color="gray.300"
1181
- fontFamily="mono"
1182
- fontWeight="bold"
1183
- >
1184
- {formatPercent(latest?.winRate)} /{' '}
1185
- {formatSignedNumber(latest?.pnl ?? null)}
1186
- </Text>
1187
- </Flex>
1188
- </Box>
1189
- );
1190
- };
1191
-
1192
- const TimeOfDaySessionChart = ({
1193
- sessions,
1194
- hours,
1195
- }: {
1196
- sessions: SessionPnlStat[];
1197
- hours: HourlyPnlStat[];
1198
- }) => {
1199
- if (!sessions.some((session) => session.orders > 0)) {
1200
- return <EmptyChart />;
1201
- }
1202
-
1203
- const maxSessionAbsPnl = Math.max(
1204
- ...sessions.map((session) => Math.abs(session.pnl)),
1205
- 1,
1206
- );
1207
- const maxHourAbsPnl = Math.max(...hours.map((hour) => Math.abs(hour.pnl)), 1);
1208
-
1209
- return (
1210
- <Flex direction="column" gap={4}>
1211
- <SimpleGrid columns={3} gap={3}>
1212
- {sessions.map((session) => {
1213
- const width = Math.max(
1214
- 4,
1215
- (Math.abs(session.pnl) / maxSessionAbsPnl) * 100,
1216
- );
1217
-
1218
- return (
1219
- <Box key={session.session}>
1220
- <Flex justify="space-between" align="center" mb={1}>
1221
- <Text fontSize="xs" color="gray.400" fontWeight="semibold">
1222
- {session.session}
1223
- </Text>
1224
- <Text
1225
- fontSize="xs"
1226
- color={getPnlColor(session.pnl)}
1227
- fontFamily="mono"
1228
- fontWeight="bold"
1229
- >
1230
- {formatSignedNumber(session.pnl)}
1231
- </Text>
1232
- </Flex>
1233
- <Box h="8px" bg="gray.800">
1234
- <Box
1235
- h="full"
1236
- w={`${width}%`}
1237
- bg={getChartPnlColor(session.pnl)}
1238
- />
1239
- </Box>
1240
- <Text mt={1} fontSize="xs" color="gray.500" fontFamily="mono">
1241
- {formatInteger(session.orders)} orders
1242
- </Text>
1243
- </Box>
1244
- );
1245
- })}
1246
- </SimpleGrid>
1247
-
1248
- <svg
1249
- width="100%"
1250
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
1251
- role="img"
1252
- aria-label="P and L by UTC hour"
1253
- >
1254
- <line
1255
- x1="0"
1256
- x2={CHART_WIDTH}
1257
- y1={CHART_HEIGHT / 2}
1258
- y2={CHART_HEIGHT / 2}
1259
- stroke="#374151"
1260
- />
1261
- {hours.map((hour) => {
1262
- const barWidth = CHART_WIDTH / 24;
1263
- const height = Math.max(1, (Math.abs(hour.pnl) / maxHourAbsPnl) * 46);
1264
- const isPositive = hour.pnl >= 0;
1265
- const y = isPositive ? CHART_HEIGHT / 2 - height : CHART_HEIGHT / 2;
1266
-
1267
- return (
1268
- <rect
1269
- key={hour.hour}
1270
- x={hour.hour * barWidth + 2}
1271
- y={y}
1272
- width={Math.max(2, barWidth - 4)}
1273
- height={height}
1274
- fill={getChartPnlColor(hour.pnl)}
1275
- opacity={hour.orders > 0 ? 0.95 : 0.2}
1276
- />
1277
- );
1278
- })}
1279
- </svg>
1280
- <Text fontSize="xs" color="gray.500">
1281
- UTC hours, sessions: Asia 00-07, Europe 08-15, US 16-23
1282
- </Text>
1283
- </Flex>
1284
- );
1285
- };
1286
-
1287
529
  const renderPnlRanking = ({
1288
530
  title,
1289
531
  subtitle,
@@ -1431,6 +673,50 @@ const mapRuntimeOrder = (order: RuntimeOrderView): OrdersDrawerOrder => {
1431
673
  };
1432
674
  };
1433
675
 
676
+ export const buildRuntimeStrategyCardViewModel = (
677
+ strategy: RuntimeStrategyView,
678
+ ) => {
679
+ const symbolPnlRanking = buildRuntimeSymbolPnlRanking(strategy.orders);
680
+ const firstPoint = strategy.orderLog[0];
681
+ const lastPoint = strategy.orderLog[strategy.orderLog.length - 1];
682
+
683
+ return {
684
+ lastTrade: strategy.recentTrades[0],
685
+ runtimeOrders: strategy.orders.map(mapRuntimeOrder),
686
+ runtimeOrderSummaryItems: getRuntimeOrdersSummaryItems(strategy.orders),
687
+ drawerMetrics: buildRuntimeDrawerMetrics(strategy),
688
+ performance: buildStrategyPerformanceViewModel(strategy.orderLog),
689
+ symbolPnlRanking,
690
+ symbolConcentration: buildRuntimeSymbolConcentration(strategy.orders).slice(
691
+ 0,
692
+ 8,
693
+ ),
694
+ topSymbolPnlRanking: [...symbolPnlRanking]
695
+ .sort(
696
+ (left, right) =>
697
+ right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
698
+ )
699
+ .slice(0, 10),
700
+ worstSymbolPnlRanking: [...symbolPnlRanking]
701
+ .sort(
702
+ (left, right) =>
703
+ left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
704
+ )
705
+ .slice(0, 10),
706
+ symbolRankingMaxAbsPnl: Math.max(
707
+ ...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)),
708
+ 1,
709
+ ),
710
+ directionStats: buildRuntimeDirectionStats(strategy.orders),
711
+ advancedMetrics: calculateAdvancedTradeMetrics({
712
+ trades: buildRuntimeAdvancedTrades(strategy.orders),
713
+ orderLog: strategy.orderLog,
714
+ startTimestamp: firstPoint?.[0] ?? null,
715
+ endTimestamp: lastPoint?.[0] ?? null,
716
+ }),
717
+ };
718
+ };
719
+
1434
720
  export const RuntimeStrategyCard = ({
1435
721
  strategy,
1436
722
  provider,
@@ -1447,94 +733,31 @@ export const RuntimeStrategyCard = ({
1447
733
  const [configOpen, setConfigOpen] = useState(false);
1448
734
  const [ordersOpen, setOrdersOpen] = useState(false);
1449
735
  const [statsOpen, setStatsOpen] = useState(false);
1450
- const lastTrade = strategy.recentTrades[0];
1451
- const runtimeOrders = useMemo(
1452
- () => strategy.orders.map(mapRuntimeOrder),
1453
- [strategy.orders],
1454
- );
1455
- const runtimeOrderSummaryItems = useMemo(
1456
- () => getRuntimeOrdersSummaryItems(strategy.orders),
1457
- [strategy.orders],
1458
- );
1459
- const drawerMetrics = useMemo(
1460
- () => buildRuntimeDrawerMetrics(strategy),
736
+ const viewModel = useMemo(
737
+ () => buildRuntimeStrategyCardViewModel(strategy),
1461
738
  [strategy],
1462
739
  );
1463
- const monthlyStats = useMemo(
1464
- () => buildMonthlyStats(strategy.orderLog),
1465
- [strategy.orderLog],
1466
- );
1467
- const runtimeTradePoints = useMemo(
1468
- () => buildRuntimeTradePoints(strategy.orderLog),
1469
- [strategy.orderLog],
1470
- );
1471
- const drawdownPoints = useMemo(
1472
- () => buildDrawdownPoints(strategy.orderLog),
1473
- [strategy.orderLog],
1474
- );
1475
- const rollingPerformancePoints = useMemo(
1476
- () => buildRollingPerformance(runtimeTradePoints, 50),
1477
- [runtimeTradePoints],
1478
- );
1479
- const pnlDistributionBins = useMemo(
1480
- () => buildPnlDistribution(runtimeTradePoints),
1481
- [runtimeTradePoints],
1482
- );
1483
- const sessionPnlStats = useMemo(
1484
- () => buildSessionPnlStats(runtimeTradePoints),
1485
- [runtimeTradePoints],
1486
- );
1487
- const hourlyPnlStats = useMemo(
1488
- () => buildHourlyPnlStats(runtimeTradePoints),
1489
- [runtimeTradePoints],
1490
- );
1491
- const symbolPnlRanking = useMemo(
1492
- () => buildRuntimeSymbolPnlRanking(strategy.orders),
1493
- [strategy.orders],
1494
- );
1495
- const symbolConcentration = useMemo(
1496
- () => buildRuntimeSymbolConcentration(strategy.orders).slice(0, 8),
1497
- [strategy.orders],
1498
- );
1499
- const topSymbolPnlRanking = useMemo(
1500
- () =>
1501
- [...symbolPnlRanking]
1502
- .sort(
1503
- (left, right) =>
1504
- right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
1505
- )
1506
- .slice(0, 10),
1507
- [symbolPnlRanking],
1508
- );
1509
- const worstSymbolPnlRanking = useMemo(
1510
- () =>
1511
- [...symbolPnlRanking]
1512
- .sort(
1513
- (left, right) =>
1514
- left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
1515
- )
1516
- .slice(0, 10),
1517
- [symbolPnlRanking],
1518
- );
1519
- const symbolRankingMaxAbsPnl = useMemo(
1520
- () => Math.max(...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)), 1),
1521
- [symbolPnlRanking],
1522
- );
1523
- const directionStats = useMemo(
1524
- () => buildRuntimeDirectionStats(strategy.orders),
1525
- [strategy.orders],
1526
- );
1527
- const advancedMetrics = useMemo(() => {
1528
- const firstPoint = strategy.orderLog[0];
1529
- const lastPoint = strategy.orderLog[strategy.orderLog.length - 1];
1530
-
1531
- return calculateAdvancedTradeMetrics({
1532
- trades: buildRuntimeAdvancedTrades(strategy.orders),
1533
- orderLog: strategy.orderLog,
1534
- startTimestamp: firstPoint?.[0] ?? null,
1535
- endTimestamp: lastPoint?.[0] ?? null,
1536
- });
1537
- }, [strategy.orderLog, strategy.orders]);
740
+ const {
741
+ lastTrade,
742
+ runtimeOrders,
743
+ runtimeOrderSummaryItems,
744
+ drawerMetrics,
745
+ symbolConcentration,
746
+ topSymbolPnlRanking,
747
+ worstSymbolPnlRanking,
748
+ symbolRankingMaxAbsPnl,
749
+ directionStats,
750
+ advancedMetrics,
751
+ } = viewModel;
752
+ const {
753
+ monthlyStats,
754
+ tradePoints: runtimeTradePoints,
755
+ drawdownPoints,
756
+ rollingPerformancePoints,
757
+ pnlDistributionBins,
758
+ sessionPnlStats,
759
+ hourlyPnlStats,
760
+ } = viewModel.performance;
1538
761
 
1539
762
  return (
1540
763
  <Box
@@ -2182,9 +1405,7 @@ export const RuntimeStrategyCard = ({
2182
1405
  <RuntimeStrategyChart
2183
1406
  orderLog={strategy.orderLog}
2184
1407
  stat={strategy.stat}
2185
- aiGateObservedFrom={strategy.aiGateObservedFrom}
2186
- aiGateChanges={strategy.aiGateChanges}
2187
- maxLossValueTimeline={strategy.maxLossValueTimeline}
1408
+ evidenceTimeline={strategy.evidenceTimeline}
2188
1409
  startTimestamp={startTimestamp}
2189
1410
  endTimestamp={endTimestamp}
2190
1411
  />