@tradejs/app 1.0.9 → 1.0.11

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 (125) hide show
  1. package/README.md +16 -3
  2. package/bin/tradejs-app.mjs +146 -2
  3. package/next.config.mjs +0 -14
  4. package/package.json +35 -19
  5. package/src/app/actions/backtest.ts +54 -0
  6. package/src/app/actions/kline.ts +10 -5
  7. package/src/app/actions/scanner.ts +8 -3
  8. package/src/app/actions/strategies.ts +39 -0
  9. package/src/app/api/ai/route.ts +5 -4
  10. package/src/app/api/auth/[...nextauth]/route.ts +1 -1
  11. package/src/app/api/backtest/configs/route.ts +25 -0
  12. package/src/app/api/backtest/files/route.ts +5 -2
  13. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
  14. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
  15. package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
  16. package/src/app/api/backtest/runs/route.ts +45 -0
  17. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
  18. package/src/app/api/derivatives/summary/route.ts +8 -1
  19. package/src/app/api/install/route.ts +51 -0
  20. package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  21. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  22. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  23. package/src/app/api/scanner/[provider]/route.ts +30 -12
  24. package/src/app/api/scanner/route.ts +3 -3
  25. package/src/app/api/signal/[symbol]/[signalId]/route.ts +1 -1
  26. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  27. package/src/app/api/strategies/ai/route.ts +139 -0
  28. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  29. package/src/app/api/strategies/replay/route.ts +44 -0
  30. package/src/app/api/strategies/runtime/route.ts +831 -0
  31. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  32. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  33. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  34. package/src/app/api/user/settings/route.ts +18 -1
  35. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  36. package/src/app/api/user/trading-accounts/route.ts +134 -0
  37. package/src/app/auth.ts +1 -19
  38. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  39. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  40. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  41. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  42. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  43. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  44. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  45. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  46. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  47. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  48. package/src/app/components/Backtest/TestCard/Root/index.tsx +18 -4
  49. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  50. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  51. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  52. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  53. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  56. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  63. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  64. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  65. package/src/app/components/Dashboard/KlineChart/index.tsx +21 -7
  66. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  67. package/src/app/components/Shared/AppShell.tsx +2 -2
  68. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  69. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  70. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  71. package/src/app/components/Shared/Filters/Backtest/index.tsx +51 -51
  72. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  73. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  74. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  75. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  76. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  77. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  78. package/src/app/components/Shared/Filters/index.ts +2 -0
  79. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  80. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  81. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  82. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  83. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  84. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  85. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  86. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  87. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  88. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  89. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  90. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  91. package/src/app/components/UI/Segment/index.tsx +8 -1
  92. package/src/app/components/UI/Select/index.tsx +24 -13
  93. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  94. package/src/app/layout.tsx +1 -1
  95. package/src/app/lib/backtestJobs.ts +925 -0
  96. package/src/app/lib/cardPageLayout.ts +1 -0
  97. package/src/app/lib/connectorCreator.ts +28 -0
  98. package/src/app/lib/currentUser.ts +1 -1
  99. package/src/app/lib/errorMessage.ts +34 -0
  100. package/src/app/lib/installation.ts +83 -0
  101. package/src/app/lib/marketDefaults.ts +12 -0
  102. package/src/app/lib/marketRoutes.ts +58 -0
  103. package/src/app/lib/runtimeStrategies.ts +1376 -0
  104. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  105. package/src/app/provider.tsx +1 -1
  106. package/src/app/routes/backtest/page.tsx +1100 -302
  107. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +76 -35
  108. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  109. package/src/app/routes/dashboard/page.tsx +15 -3
  110. package/src/app/routes/derivatives/page.tsx +1222 -165
  111. package/src/app/routes/install/page.tsx +175 -0
  112. package/src/app/routes/signin/page.tsx +7 -0
  113. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  114. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  115. package/src/app/routes/strategies/page.tsx +7 -0
  116. package/src/app/store/ai.ts +1 -1
  117. package/src/app/store/data.ts +73 -17
  118. package/src/app/store/filters.ts +1 -0
  119. package/src/app/store/marketKlineStream.ts +87 -0
  120. package/src/app/store/tests.ts +83 -45
  121. package/src/app/store/tickers.ts +59 -22
  122. package/src/proxy.ts +4 -0
  123. package/tsconfig.json +17 -12
  124. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  125. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -0,0 +1,2204 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useMemo, useState } from 'react';
4
+ import {
5
+ Badge,
6
+ Box,
7
+ Button,
8
+ CloseButton,
9
+ Drawer,
10
+ Flex,
11
+ Grid,
12
+ Menu,
13
+ Portal,
14
+ SimpleGrid,
15
+ Stat,
16
+ Text,
17
+ } from '@chakra-ui/react';
18
+ import {
19
+ calculateAdvancedTradeMetrics,
20
+ getFormatted,
21
+ type AdvancedTradeInput,
22
+ } from '@tradejs/core/backtest';
23
+ import type { TestThresholdsKey, ThresholdLevel } from '@tradejs/types';
24
+ import {
25
+ formatCompactNumber,
26
+ formatDateTime,
27
+ formatFee,
28
+ formatInteger,
29
+ formatPercent,
30
+ formatPriceUsdt,
31
+ formatSignedNumber,
32
+ formatUsdt,
33
+ getPnlColor,
34
+ OrdersDrawerPanel,
35
+ type OrdersDrawerOrder,
36
+ type OrdersDrawerSummaryItem,
37
+ } from '#components/Shared/OrdersDrawer';
38
+ import type { RuntimeStrategyView } from '#app/lib/runtimeStrategies';
39
+ import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
40
+ import { RuntimeStrategyChart } from './RuntimeStrategyChart';
41
+ import { RuntimeStrategyConfigDrawer } from './RuntimeStrategyConfigDrawer';
42
+
43
+ type RuntimeOrderView = RuntimeStrategyView['orders'][number];
44
+ 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
+
115
+ interface RuntimeSymbolPnlRank {
116
+ symbol: string;
117
+ pnl: number;
118
+ orders: number;
119
+ winRate: number | null;
120
+ avgPnl: number | null;
121
+ }
122
+
123
+ interface RuntimeSymbolConcentrationRow extends RuntimeSymbolPnlRank {
124
+ absPnl: number;
125
+ absPnlShare: number;
126
+ orderShare: number;
127
+ }
128
+
129
+ interface RuntimeDirectionStats {
130
+ direction: RuntimeOrderView['direction'];
131
+ orders: number;
132
+ active: number;
133
+ closed: number;
134
+ wins: number;
135
+ pnl: number;
136
+ avgPnl: number | null;
137
+ }
138
+
139
+ interface RuntimeDrawerMetric {
140
+ id: string;
141
+ label: string;
142
+ value: string;
143
+ level: ThresholdLevel;
144
+ }
145
+
146
+ const getColorByLevel = (level: ThresholdLevel) => {
147
+ switch (level) {
148
+ case 'success':
149
+ return 'teal.500';
150
+ case 'warning':
151
+ return 'fg.warning';
152
+ case 'neutral':
153
+ return 'gray.300';
154
+ case 'error':
155
+ default:
156
+ return 'fg.error';
157
+ }
158
+ };
159
+
160
+ const getMetricColor = (level: ThresholdLevel) => getColorByLevel(level);
161
+
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
+ const getPnlBarColor = (value: number) => {
170
+ if (value > 0) {
171
+ return 'teal.500';
172
+ }
173
+ if (value < 0) {
174
+ return 'red.500';
175
+ }
176
+ return 'gray.500';
177
+ };
178
+
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
+ const StatItem = ({
492
+ stat,
493
+ id,
494
+ title,
495
+ }: {
496
+ stat: RuntimeStrategyView['stat'];
497
+ id: TestThresholdsKey;
498
+ title: string;
499
+ }) => {
500
+ const { formatted, level } = getFormatted(stat, id);
501
+
502
+ return (
503
+ <Stat.Root size="md">
504
+ <Stat.Label>{title}</Stat.Label>
505
+ <Stat.ValueText color={getColorByLevel(level)}>
506
+ {formatted}
507
+ </Stat.ValueText>
508
+ </Stat.Root>
509
+ );
510
+ };
511
+
512
+ const getOrderAccentColor = (order: RuntimeOrderView) => {
513
+ if (order.status === 'active') {
514
+ return 'orange.300';
515
+ }
516
+
517
+ if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
518
+ return 'gray.600';
519
+ }
520
+
521
+ return order.pnl >= 0 ? 'teal.300' : 'red.300';
522
+ };
523
+
524
+ const formatExitType = (order: RuntimeOrderView) => {
525
+ if (order.status === 'active') {
526
+ return 'active';
527
+ }
528
+
529
+ return order.exitType ? order.exitType.toUpperCase() : 'closed';
530
+ };
531
+
532
+ const formatOrderReference = (orderId: string) => {
533
+ const normalized = orderId.trim();
534
+ const separatorIndex = normalized.indexOf('--');
535
+ const suffix =
536
+ separatorIndex >= 0 ? normalized.slice(separatorIndex + 2) : normalized;
537
+
538
+ return suffix.length > 12 ? suffix.slice(-12) : suffix || 'n/a';
539
+ };
540
+
541
+ const RiskLevelsDetail = ({ order }: { order: RuntimeOrderView }) => (
542
+ <Box>
543
+ <Text>
544
+ TP {formatPriceUsdt(order.takeProfitPrice)} (
545
+ {formatPercent(order.takeProfitPercent, { signed: true })})
546
+ </Text>
547
+ <Text>
548
+ SL {formatPriceUsdt(order.stopLossPrice)} (
549
+ {formatPercent(order.stopLossPercent)})
550
+ </Text>
551
+ </Box>
552
+ );
553
+
554
+ const FeesDetail = ({ order }: { order: RuntimeOrderView }) => (
555
+ <Box>
556
+ <Text>open {formatFee(order.openFee)}</Text>
557
+ <Text>close {formatFee(order.closeFee)}</Text>
558
+ <Text>funding {formatFee(order.fundingFee)}</Text>
559
+ </Box>
560
+ );
561
+
562
+ const getRuntimeOrderNotional = (order: RuntimeOrderView) => {
563
+ const entryPrice = order.actualEntryPrice ?? order.entryPrice;
564
+
565
+ if (
566
+ typeof order.qty !== 'number' ||
567
+ !Number.isFinite(order.qty) ||
568
+ typeof entryPrice !== 'number' ||
569
+ !Number.isFinite(entryPrice)
570
+ ) {
571
+ return null;
572
+ }
573
+
574
+ return order.qty * entryPrice;
575
+ };
576
+
577
+ const getRuntimeOrderSlippageCost = (order: RuntimeOrderView) => {
578
+ const notional = getRuntimeOrderNotional(order);
579
+
580
+ if (notional == null) {
581
+ return null;
582
+ }
583
+
584
+ const entrySlippagePercent =
585
+ typeof order.entrySlippagePercent === 'number' &&
586
+ Number.isFinite(order.entrySlippagePercent)
587
+ ? Math.abs(order.entrySlippagePercent)
588
+ : 0;
589
+ const exitSlippagePercent =
590
+ typeof order.exitSlippagePercent === 'number' &&
591
+ Number.isFinite(order.exitSlippagePercent)
592
+ ? Math.abs(order.exitSlippagePercent)
593
+ : 0;
594
+ const totalSlippagePercent = entrySlippagePercent + exitSlippagePercent;
595
+
596
+ return totalSlippagePercent > 0
597
+ ? (notional * totalSlippagePercent) / 100
598
+ : null;
599
+ };
600
+
601
+ const buildRuntimeAdvancedTrades = (
602
+ orders: RuntimeOrderView[],
603
+ ): AdvancedTradeInput[] =>
604
+ orders.flatMap((order): AdvancedTradeInput[] => {
605
+ const timestamp = order.exitTimestamp ?? order.entryTimestamp;
606
+
607
+ if (
608
+ typeof timestamp !== 'number' ||
609
+ !Number.isFinite(timestamp) ||
610
+ typeof order.pnl !== 'number' ||
611
+ !Number.isFinite(order.pnl)
612
+ ) {
613
+ return [];
614
+ }
615
+
616
+ const slippageCost = getRuntimeOrderSlippageCost(order);
617
+
618
+ return [
619
+ {
620
+ id: order.orderId,
621
+ timestamp,
622
+ pnl: order.pnl,
623
+ symbol: order.symbol,
624
+ direction: order.direction,
625
+ exitReason: order.exitType ?? null,
626
+ slippageCost,
627
+ grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
628
+ approved: true,
629
+ blocked: false,
630
+ },
631
+ ];
632
+ });
633
+
634
+ const getOrdersSummary = (orders: RuntimeOrderView[]) => {
635
+ const closedOrders = orders.filter((order) => order.status === 'closed');
636
+ const winningOrders = closedOrders.filter(
637
+ (order) =>
638
+ typeof order.pnl === 'number' &&
639
+ Number.isFinite(order.pnl) &&
640
+ order.pnl > 0,
641
+ );
642
+
643
+ const sumPnl = (direction: RuntimeOrderView['direction']) =>
644
+ closedOrders.reduce((total, order) => {
645
+ if (
646
+ order.direction !== direction ||
647
+ typeof order.pnl !== 'number' ||
648
+ !Number.isFinite(order.pnl)
649
+ ) {
650
+ return total;
651
+ }
652
+
653
+ return total + order.pnl;
654
+ }, 0);
655
+
656
+ return {
657
+ closedOrders: closedOrders.length,
658
+ winRate:
659
+ closedOrders.length > 0
660
+ ? (winningOrders.length / closedOrders.length) * 100
661
+ : 0,
662
+ longPnl: sumPnl('LONG'),
663
+ shortPnl: sumPnl('SHORT'),
664
+ };
665
+ };
666
+
667
+ const getRuntimeOrdersSummaryItems = (
668
+ orders: RuntimeOrderView[],
669
+ ): OrdersDrawerSummaryItem[] => {
670
+ const summary = getOrdersSummary(orders);
671
+
672
+ return [
673
+ {
674
+ title: 'Total Closed Orders',
675
+ value: formatInteger(summary.closedOrders),
676
+ },
677
+ {
678
+ title: 'Win Rate',
679
+ value: formatPercent(summary.winRate, { signed: false }),
680
+ },
681
+ {
682
+ title: 'P&L of Closed Long Orders (USDT)',
683
+ value: formatSignedNumber(summary.longPnl),
684
+ color: getPnlColor(summary.longPnl),
685
+ },
686
+ {
687
+ title: 'P&L of Closed Short Orders (USDT)',
688
+ value: formatSignedNumber(summary.shortPnl),
689
+ color: getPnlColor(summary.shortPnl),
690
+ },
691
+ ];
692
+ };
693
+
694
+ const RuntimeOrdersSummaryBlock = ({
695
+ items,
696
+ }: {
697
+ items: OrdersDrawerSummaryItem[];
698
+ }) => (
699
+ <Box
700
+ p={4}
701
+ borderWidth="1px"
702
+ borderColor="gray.800"
703
+ borderRadius="md"
704
+ bg="gray.900"
705
+ >
706
+ <SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
707
+ {items.map((item) => (
708
+ <Box key={item.title}>
709
+ <Text fontSize="sm" color="gray.500">
710
+ {item.title}
711
+ </Text>
712
+ <Text
713
+ mt={1}
714
+ fontSize="2xl"
715
+ color={item.color ?? 'gray.100'}
716
+ fontWeight="bold"
717
+ fontFamily="mono"
718
+ lineHeight="1.1"
719
+ >
720
+ {item.value}
721
+ </Text>
722
+ </Box>
723
+ ))}
724
+ </SimpleGrid>
725
+ </Box>
726
+ );
727
+
728
+ const buildRuntimeDrawerMetrics = (
729
+ strategy: RuntimeStrategyView,
730
+ ): RuntimeDrawerMetric[] => {
731
+ const getStatMetric = (
732
+ id: TestThresholdsKey,
733
+ label: string,
734
+ ): RuntimeDrawerMetric => {
735
+ const { formatted, level } = getFormatted(strategy.stat, id);
736
+
737
+ return {
738
+ id,
739
+ label,
740
+ value: formatted,
741
+ level,
742
+ };
743
+ };
744
+
745
+ const maxLossStreak = calculateMaxLossStreak(strategy.orderLog);
746
+
747
+ return [
748
+ getStatMetric('netProfit', 'P&L'),
749
+ {
750
+ id: 'closedPnl',
751
+ label: 'Closed P&L',
752
+ value: formatSignedNumber(strategy.summary.closedPnl),
753
+ level:
754
+ strategy.summary.closedPnl > 0
755
+ ? 'success'
756
+ : strategy.summary.closedPnl < 0
757
+ ? 'error'
758
+ : 'neutral',
759
+ },
760
+ {
761
+ id: 'activePnl',
762
+ label: 'Active P&L',
763
+ value: formatSignedNumber(strategy.summary.activePnl),
764
+ level:
765
+ strategy.summary.activePnl > 0
766
+ ? 'success'
767
+ : strategy.summary.activePnl < 0
768
+ ? 'error'
769
+ : 'neutral',
770
+ },
771
+ getStatMetric('maxDrawdown', 'Max drawdown'),
772
+ {
773
+ id: 'maxLossStreak',
774
+ label: 'Max loss streak',
775
+ value: formatInteger(maxLossStreak),
776
+ level: maxLossStreak > 0 ? 'warning' : 'success',
777
+ },
778
+ {
779
+ id: 'totalTrades',
780
+ label: 'Trades',
781
+ value: formatInteger(strategy.summary.totalTrades),
782
+ level: 'neutral',
783
+ },
784
+ {
785
+ id: 'activeTrades',
786
+ label: 'Active',
787
+ value: formatInteger(strategy.summary.activeTrades),
788
+ level: strategy.summary.activeTrades > 0 ? 'warning' : 'neutral',
789
+ },
790
+ {
791
+ id: 'symbolTop1',
792
+ label: 'Symbol top 1',
793
+ value: formatPercent(strategy.summary.symbolConcentrationTop1),
794
+ level:
795
+ (strategy.summary.symbolConcentrationTop1 ?? 0) >= 60
796
+ ? 'warning'
797
+ : 'neutral',
798
+ },
799
+ {
800
+ id: 'symbolTop5',
801
+ label: 'Symbol top 5',
802
+ value: formatPercent(strategy.summary.symbolConcentrationTop5),
803
+ level: 'neutral',
804
+ },
805
+ getStatMetric('winRate', 'Win Rate'),
806
+ ];
807
+ };
808
+
809
+ const buildRuntimeSymbolPnlRanking = (
810
+ orders: RuntimeOrderView[],
811
+ ): RuntimeSymbolPnlRank[] => {
812
+ const grouped = new Map<
813
+ string,
814
+ { symbol: string; pnl: number; orders: number; wins: number }
815
+ >();
816
+
817
+ for (const order of orders) {
818
+ if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
819
+ continue;
820
+ }
821
+
822
+ const existing = grouped.get(order.symbol) ?? {
823
+ symbol: order.symbol,
824
+ pnl: 0,
825
+ orders: 0,
826
+ wins: 0,
827
+ };
828
+
829
+ existing.pnl += order.pnl;
830
+ existing.orders += 1;
831
+ existing.wins += order.pnl > 0 ? 1 : 0;
832
+ grouped.set(order.symbol, existing);
833
+ }
834
+
835
+ return [...grouped.values()]
836
+ .map((rank) => ({
837
+ symbol: rank.symbol,
838
+ pnl: rank.pnl,
839
+ orders: rank.orders,
840
+ winRate: rank.orders > 0 ? (rank.wins / rank.orders) * 100 : null,
841
+ avgPnl: rank.orders > 0 ? rank.pnl / rank.orders : null,
842
+ }))
843
+ .sort(
844
+ (left, right) =>
845
+ Math.abs(right.pnl) - Math.abs(left.pnl) ||
846
+ right.pnl - left.pnl ||
847
+ left.symbol.localeCompare(right.symbol),
848
+ );
849
+ };
850
+
851
+ const buildRuntimeSymbolConcentration = (
852
+ orders: RuntimeOrderView[],
853
+ ): RuntimeSymbolConcentrationRow[] => {
854
+ const ranking = buildRuntimeSymbolPnlRanking(orders).map((rank) => ({
855
+ ...rank,
856
+ absPnl: Math.abs(rank.pnl),
857
+ }));
858
+ const totalAbsPnl = ranking.reduce((sum, rank) => sum + rank.absPnl, 0);
859
+ const totalOrders = ranking.reduce((sum, rank) => sum + rank.orders, 0);
860
+
861
+ if (totalAbsPnl <= 0 || totalOrders <= 0) {
862
+ return [];
863
+ }
864
+
865
+ return ranking
866
+ .map((rank) => ({
867
+ ...rank,
868
+ absPnlShare: (rank.absPnl / totalAbsPnl) * 100,
869
+ orderShare: (rank.orders / totalOrders) * 100,
870
+ }))
871
+ .sort(
872
+ (left, right) =>
873
+ right.absPnlShare - left.absPnlShare ||
874
+ right.orderShare - left.orderShare ||
875
+ left.symbol.localeCompare(right.symbol),
876
+ );
877
+ };
878
+
879
+ const buildRuntimeDirectionStats = (
880
+ orders: RuntimeOrderView[],
881
+ ): RuntimeDirectionStats[] =>
882
+ (['LONG', 'SHORT'] as const).map((direction) => {
883
+ const directionOrders = orders.filter(
884
+ (order) => order.direction === direction,
885
+ );
886
+ const ordersWithPnl = directionOrders.filter(
887
+ (order) => typeof order.pnl === 'number' && Number.isFinite(order.pnl),
888
+ );
889
+ const pnl = ordersWithPnl.reduce((sum, order) => sum + (order.pnl ?? 0), 0);
890
+
891
+ return {
892
+ direction,
893
+ orders: directionOrders.length,
894
+ active: directionOrders.filter((order) => order.status === 'active')
895
+ .length,
896
+ closed: directionOrders.filter((order) => order.status === 'closed')
897
+ .length,
898
+ wins: ordersWithPnl.filter((order) => (order.pnl ?? 0) > 0).length,
899
+ pnl,
900
+ avgPnl: ordersWithPnl.length > 0 ? pnl / ordersWithPnl.length : null,
901
+ };
902
+ });
903
+
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
+ const renderPnlRanking = ({
1288
+ title,
1289
+ subtitle,
1290
+ ranking,
1291
+ maxAbsPnl,
1292
+ }: {
1293
+ title: string;
1294
+ subtitle: string;
1295
+ ranking: RuntimeSymbolPnlRank[];
1296
+ maxAbsPnl: number;
1297
+ }) => (
1298
+ <Box
1299
+ p={4}
1300
+ borderWidth="1px"
1301
+ borderColor="gray.800"
1302
+ borderRadius="md"
1303
+ bg="gray.900"
1304
+ >
1305
+ <Flex justify="space-between" align="baseline" gap={3} mb={4}>
1306
+ <Text fontSize="sm" color="gray.300" fontWeight="semibold">
1307
+ {title}
1308
+ </Text>
1309
+ <Text fontSize="xs" color="gray.500">
1310
+ {subtitle}
1311
+ </Text>
1312
+ </Flex>
1313
+
1314
+ {ranking.length ? (
1315
+ <Flex direction="column" gap={3}>
1316
+ {ranking.map((rank) => {
1317
+ const width = Math.max(4, (Math.abs(rank.pnl) / maxAbsPnl) * 100);
1318
+
1319
+ return (
1320
+ <Grid
1321
+ key={rank.symbol}
1322
+ templateColumns="1.1fr 2fr 0.8fr"
1323
+ alignItems="center"
1324
+ gap={3}
1325
+ >
1326
+ <Box minW={0}>
1327
+ <Text
1328
+ fontSize="sm"
1329
+ color="gray.200"
1330
+ fontWeight="bold"
1331
+ overflow="hidden"
1332
+ textOverflow="ellipsis"
1333
+ whiteSpace="nowrap"
1334
+ >
1335
+ {rank.symbol}
1336
+ </Text>
1337
+ <Text fontSize="xs" color="gray.500" fontFamily="mono">
1338
+ {formatInteger(rank.orders)} orders ·{' '}
1339
+ {formatPercent(rank.winRate)}
1340
+ </Text>
1341
+ <Text fontSize="xs" color="gray.600" fontFamily="mono">
1342
+ avg {formatSignedNumber(rank.avgPnl)}
1343
+ </Text>
1344
+ </Box>
1345
+ <Box h="10px" bg="gray.800">
1346
+ <Box h="full" w={`${width}%`} bg={getPnlBarColor(rank.pnl)} />
1347
+ </Box>
1348
+ <Text
1349
+ fontSize="sm"
1350
+ color={getPnlColor(rank.pnl)}
1351
+ fontWeight="bold"
1352
+ fontFamily="mono"
1353
+ textAlign="right"
1354
+ >
1355
+ {formatSignedNumber(rank.pnl)}
1356
+ </Text>
1357
+ </Grid>
1358
+ );
1359
+ })}
1360
+ </Flex>
1361
+ ) : (
1362
+ <Text fontSize="sm" color="gray.500">
1363
+ No symbol P&L data
1364
+ </Text>
1365
+ )}
1366
+ </Box>
1367
+ );
1368
+
1369
+ const mapRuntimeOrder = (order: RuntimeOrderView): OrdersDrawerOrder => {
1370
+ const displayEntryPrice = order.actualEntryPrice ?? order.entryPrice;
1371
+ const displayExitPrice =
1372
+ order.status === 'active'
1373
+ ? order.currentPrice
1374
+ : order.actualExitPrice ?? order.exitPrice;
1375
+ const notional =
1376
+ typeof order.qty === 'number' &&
1377
+ Number.isFinite(order.qty) &&
1378
+ typeof displayEntryPrice === 'number' &&
1379
+ Number.isFinite(displayEntryPrice)
1380
+ ? order.qty * displayEntryPrice
1381
+ : null;
1382
+
1383
+ return {
1384
+ id: order.orderId,
1385
+ title: order.symbol,
1386
+ reference: formatOrderReference(order.orderId),
1387
+ period: {
1388
+ start: order.entryTimestamp,
1389
+ end: order.status === 'active' ? null : order.exitTimestamp,
1390
+ durationHours: order.durationHours,
1391
+ },
1392
+ direction: order.direction,
1393
+ status: order.status,
1394
+ statusLabel: formatExitType(order).toUpperCase(),
1395
+ statusColor: order.status === 'active' ? 'orange' : 'gray',
1396
+ pnl: order.pnl,
1397
+ accentColor: getOrderAccentColor(order),
1398
+ metrics: [
1399
+ {
1400
+ title: 'Entry',
1401
+ value: formatPriceUsdt(displayEntryPrice),
1402
+ detail:
1403
+ order.actualEntryPrice == null
1404
+ ? 'actual n/a'
1405
+ : `plan ${formatPriceUsdt(order.entryPrice)} / slip ${formatPercent(order.entrySlippagePercent, { signed: true })}`,
1406
+ },
1407
+ {
1408
+ title: order.status === 'active' ? 'Current' : 'Exit',
1409
+ value: formatPriceUsdt(displayExitPrice),
1410
+ detail:
1411
+ order.status === 'active' ? (
1412
+ <RiskLevelsDetail order={order} />
1413
+ ) : (
1414
+ `slip ${formatPercent(order.exitSlippagePercent, { signed: true })}`
1415
+ ),
1416
+ },
1417
+ {
1418
+ title: 'Notional',
1419
+ value: formatUsdt(notional),
1420
+ },
1421
+ {
1422
+ title: 'Fees',
1423
+ value: formatFee(order.totalFee),
1424
+ detail: <FeesDetail order={order} />,
1425
+ },
1426
+ {
1427
+ title: 'Qty',
1428
+ value: formatCompactNumber(order.qty),
1429
+ },
1430
+ ],
1431
+ };
1432
+ };
1433
+
1434
+ export const RuntimeStrategyCard = ({
1435
+ strategy,
1436
+ provider,
1437
+ onUpdated,
1438
+ }: {
1439
+ strategy: RuntimeStrategyView;
1440
+ provider: string;
1441
+ onUpdated: () => Promise<void> | void;
1442
+ }) => {
1443
+ const [configOpen, setConfigOpen] = useState(false);
1444
+ const [ordersOpen, setOrdersOpen] = useState(false);
1445
+ const [statsOpen, setStatsOpen] = useState(false);
1446
+ const lastTrade = strategy.recentTrades[0];
1447
+ const runtimeOrders = useMemo(
1448
+ () => strategy.orders.map(mapRuntimeOrder),
1449
+ [strategy.orders],
1450
+ );
1451
+ const runtimeOrderSummaryItems = useMemo(
1452
+ () => getRuntimeOrdersSummaryItems(strategy.orders),
1453
+ [strategy.orders],
1454
+ );
1455
+ const drawerMetrics = useMemo(
1456
+ () => buildRuntimeDrawerMetrics(strategy),
1457
+ [strategy],
1458
+ );
1459
+ const monthlyStats = useMemo(
1460
+ () => buildMonthlyStats(strategy.orderLog),
1461
+ [strategy.orderLog],
1462
+ );
1463
+ const runtimeTradePoints = useMemo(
1464
+ () => buildRuntimeTradePoints(strategy.orderLog),
1465
+ [strategy.orderLog],
1466
+ );
1467
+ const drawdownPoints = useMemo(
1468
+ () => buildDrawdownPoints(strategy.orderLog),
1469
+ [strategy.orderLog],
1470
+ );
1471
+ const rollingPerformancePoints = useMemo(
1472
+ () => buildRollingPerformance(runtimeTradePoints, 50),
1473
+ [runtimeTradePoints],
1474
+ );
1475
+ const pnlDistributionBins = useMemo(
1476
+ () => buildPnlDistribution(runtimeTradePoints),
1477
+ [runtimeTradePoints],
1478
+ );
1479
+ const sessionPnlStats = useMemo(
1480
+ () => buildSessionPnlStats(runtimeTradePoints),
1481
+ [runtimeTradePoints],
1482
+ );
1483
+ const hourlyPnlStats = useMemo(
1484
+ () => buildHourlyPnlStats(runtimeTradePoints),
1485
+ [runtimeTradePoints],
1486
+ );
1487
+ const symbolPnlRanking = useMemo(
1488
+ () => buildRuntimeSymbolPnlRanking(strategy.orders),
1489
+ [strategy.orders],
1490
+ );
1491
+ const symbolConcentration = useMemo(
1492
+ () => buildRuntimeSymbolConcentration(strategy.orders).slice(0, 8),
1493
+ [strategy.orders],
1494
+ );
1495
+ const topSymbolPnlRanking = useMemo(
1496
+ () =>
1497
+ [...symbolPnlRanking]
1498
+ .sort(
1499
+ (left, right) =>
1500
+ right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
1501
+ )
1502
+ .slice(0, 10),
1503
+ [symbolPnlRanking],
1504
+ );
1505
+ const worstSymbolPnlRanking = useMemo(
1506
+ () =>
1507
+ [...symbolPnlRanking]
1508
+ .sort(
1509
+ (left, right) =>
1510
+ left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
1511
+ )
1512
+ .slice(0, 10),
1513
+ [symbolPnlRanking],
1514
+ );
1515
+ const symbolRankingMaxAbsPnl = useMemo(
1516
+ () => Math.max(...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)), 1),
1517
+ [symbolPnlRanking],
1518
+ );
1519
+ const directionStats = useMemo(
1520
+ () => buildRuntimeDirectionStats(strategy.orders),
1521
+ [strategy.orders],
1522
+ );
1523
+ const advancedMetrics = useMemo(() => {
1524
+ const firstPoint = strategy.orderLog[0];
1525
+ const lastPoint = strategy.orderLog[strategy.orderLog.length - 1];
1526
+
1527
+ return calculateAdvancedTradeMetrics({
1528
+ trades: buildRuntimeAdvancedTrades(strategy.orders),
1529
+ orderLog: strategy.orderLog,
1530
+ startTimestamp: firstPoint?.[0] ?? null,
1531
+ endTimestamp: lastPoint?.[0] ?? null,
1532
+ });
1533
+ }, [strategy.orderLog, strategy.orders]);
1534
+
1535
+ return (
1536
+ <Box
1537
+ p={2}
1538
+ mb={4}
1539
+ maxW="1400px"
1540
+ borderRadius="md"
1541
+ shadow="sm"
1542
+ borderWidth="1px"
1543
+ borderColor={strategy.connected ? 'gray.800' : 'orange.900'}
1544
+ overflowX="auto"
1545
+ >
1546
+ <Flex gap="4" p={4} mb={3} alignItems="center" wrap="wrap">
1547
+ <Text fontSize="lg" fontWeight="bold" color="gray.200">
1548
+ {strategy.strategyName}
1549
+ </Text>
1550
+ <Badge
1551
+ colorPalette={strategy.enabled ? 'teal' : 'gray'}
1552
+ variant="subtle"
1553
+ fontFamily="mono"
1554
+ letterSpacing="0"
1555
+ >
1556
+ {strategy.enabled ? 'enabled' : 'disabled'}
1557
+ </Badge>
1558
+ <Badge colorPalette="blue" variant="outline">
1559
+ {strategy.universe}
1560
+ </Badge>
1561
+ <Badge colorPalette="cyan" variant="outline">
1562
+ TF: {strategy.interval}m
1563
+ </Badge>
1564
+ <Badge colorPalette="gray" variant="outline">
1565
+ config: {strategy.configId}
1566
+ </Badge>
1567
+ {strategy.accountId ? (
1568
+ <Badge colorPalette="purple" variant="outline">
1569
+ account: {strategy.accountLabel ?? strategy.accountId}
1570
+ </Badge>
1571
+ ) : null}
1572
+ {strategy.deploymentId ? (
1573
+ <Badge colorPalette="orange" variant="outline">
1574
+ deployment: {strategy.deploymentId}
1575
+ </Badge>
1576
+ ) : null}
1577
+ {strategy.policyProfileId ? (
1578
+ <Badge colorPalette="cyan" variant="outline">
1579
+ policy: {strategy.policyProfileId}
1580
+ </Badge>
1581
+ ) : null}
1582
+
1583
+ <Flex gap="1">
1584
+ <Text fontSize="sm" fontWeight="bold" color="gray.400" mt={1}>
1585
+ connector:
1586
+ </Text>
1587
+ <Text fontSize="lg" fontWeight="bold" color="gray.200">
1588
+ {provider}
1589
+ </Text>
1590
+ </Flex>
1591
+
1592
+ <Flex gap="1">
1593
+ <Text fontSize="sm" fontWeight="bold" color="gray.400" mt={1}>
1594
+ trades:
1595
+ </Text>
1596
+ <Text fontSize="lg" fontWeight="bold" color="gray.200">
1597
+ {strategy.summary.totalTrades}
1598
+ </Text>
1599
+ </Flex>
1600
+
1601
+ <Flex gap="1">
1602
+ <Text fontSize="sm" fontWeight="bold" color="gray.400" mt={1}>
1603
+ active:
1604
+ </Text>
1605
+ <Text
1606
+ fontSize="lg"
1607
+ fontWeight="bold"
1608
+ color={
1609
+ strategy.summary.activeTrades > 0 ? 'orange.300' : 'gray.200'
1610
+ }
1611
+ >
1612
+ {strategy.summary.activeTrades}
1613
+ </Text>
1614
+ </Flex>
1615
+
1616
+ <Flex ml="auto" gap={3} alignItems="center">
1617
+ <Text fontSize="sm" color="gray.500">
1618
+ {lastTrade
1619
+ ? `last trade: ${lastTrade.symbol} ${formatDateTime(lastTrade.entryTimestamp)}`
1620
+ : strategy.connected
1621
+ ? 'connected, no runtime trades yet'
1622
+ : 'runtime trades only'}
1623
+ </Text>
1624
+
1625
+ <Menu.Root positioning={{ placement: 'bottom-end' }}>
1626
+ <Menu.Trigger asChild>
1627
+ <Button size="sm" variant="outline">
1628
+ Actions
1629
+ </Button>
1630
+ </Menu.Trigger>
1631
+ <Portal>
1632
+ <Menu.Positioner>
1633
+ <Menu.Content minW="160px">
1634
+ {strategy.connected ? (
1635
+ <Menu.Item value="edit" onClick={() => setConfigOpen(true)}>
1636
+ Edit
1637
+ </Menu.Item>
1638
+ ) : null}
1639
+ <Menu.Item value="orders" onClick={() => setOrdersOpen(true)}>
1640
+ Orders
1641
+ </Menu.Item>
1642
+ <Menu.Item value="stat" onClick={() => setStatsOpen(true)}>
1643
+ Stat
1644
+ </Menu.Item>
1645
+ </Menu.Content>
1646
+ </Menu.Positioner>
1647
+ </Portal>
1648
+ </Menu.Root>
1649
+ </Flex>
1650
+ </Flex>
1651
+
1652
+ <OrdersDrawerPanel
1653
+ title={`${strategy.strategyName} orders`}
1654
+ open={ordersOpen}
1655
+ orders={runtimeOrders}
1656
+ rowHeight={RUNTIME_ORDER_ROW_HEIGHT}
1657
+ onOpenChange={setOrdersOpen}
1658
+ />
1659
+
1660
+ <RuntimeStrategyConfigDrawer
1661
+ open={configOpen}
1662
+ strategy={strategy}
1663
+ onOpenChange={setConfigOpen}
1664
+ onSaved={onUpdated}
1665
+ />
1666
+
1667
+ <Drawer.Root
1668
+ size="xl"
1669
+ open={statsOpen}
1670
+ onOpenChange={(e) => setStatsOpen(e.open)}
1671
+ >
1672
+ <Portal>
1673
+ <Drawer.Backdrop />
1674
+ <Drawer.Positioner>
1675
+ <Drawer.Content
1676
+ display="flex"
1677
+ flexDirection="column"
1678
+ w="50vw"
1679
+ minW="640px"
1680
+ maxW="50vw"
1681
+ bg="gray.950"
1682
+ >
1683
+ <Drawer.Header>
1684
+ <Drawer.Title>{strategy.strategyName}</Drawer.Title>
1685
+ <Drawer.CloseTrigger asChild>
1686
+ <CloseButton size="sm" />
1687
+ </Drawer.CloseTrigger>
1688
+ </Drawer.Header>
1689
+
1690
+ <Drawer.Body
1691
+ display="flex"
1692
+ flexDirection="column"
1693
+ gap={4}
1694
+ overflowY="auto"
1695
+ flex="1"
1696
+ minH="0"
1697
+ w="full"
1698
+ >
1699
+ <RuntimeOrdersSummaryBlock items={runtimeOrderSummaryItems} />
1700
+
1701
+ <Box
1702
+ p={4}
1703
+ borderWidth="1px"
1704
+ borderColor="gray.800"
1705
+ borderRadius="md"
1706
+ bg="gray.900"
1707
+ >
1708
+ <Text fontSize="sm" color="gray.500" mb={3}>
1709
+ connector: {provider}
1710
+ </Text>
1711
+
1712
+ <SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
1713
+ {drawerMetrics.map((metric) => (
1714
+ <Box
1715
+ key={metric.id}
1716
+ p={3}
1717
+ borderWidth="1px"
1718
+ borderColor="gray.800"
1719
+ borderRadius="md"
1720
+ bg="blackAlpha.300"
1721
+ >
1722
+ <Text
1723
+ fontSize="xs"
1724
+ color="gray.400"
1725
+ fontWeight="semibold"
1726
+ textTransform="uppercase"
1727
+ >
1728
+ {metric.label}
1729
+ </Text>
1730
+ <Text
1731
+ mt={1}
1732
+ fontSize="xl"
1733
+ color={getMetricColor(metric.level)}
1734
+ fontWeight="bold"
1735
+ fontFamily="mono"
1736
+ lineHeight="1.2"
1737
+ >
1738
+ {metric.value}
1739
+ </Text>
1740
+ </Box>
1741
+ ))}
1742
+ </SimpleGrid>
1743
+ </Box>
1744
+
1745
+ <AdvancedMetricsPanel metrics={advancedMetrics} />
1746
+
1747
+ {monthlyStats.length ? (
1748
+ <Box
1749
+ p={4}
1750
+ borderWidth="1px"
1751
+ borderColor="gray.800"
1752
+ borderRadius="md"
1753
+ bg="gray.900"
1754
+ >
1755
+ <Text
1756
+ fontSize="sm"
1757
+ color="gray.300"
1758
+ fontWeight="semibold"
1759
+ mb={3}
1760
+ >
1761
+ Monthly Performance
1762
+ </Text>
1763
+
1764
+ <Flex direction="column" gap={4}>
1765
+ {monthlyStats.map((yearGroup) => (
1766
+ <Box key={yearGroup.year}>
1767
+ <Flex align="center" gap={3} mb={3}>
1768
+ <Text
1769
+ fontSize="lg"
1770
+ color="gray.100"
1771
+ fontWeight="bold"
1772
+ fontFamily="mono"
1773
+ >
1774
+ {yearGroup.year}
1775
+ </Text>
1776
+ <Box flex="1" h="1px" bg="gray.800" />
1777
+ </Flex>
1778
+
1779
+ <Flex direction="column" gap={3}>
1780
+ {buildQuarterlyMonthlyStats(yearGroup.months).map(
1781
+ (quarter) => (
1782
+ <Flex
1783
+ key={`${yearGroup.year}-${quarter.label}`}
1784
+ align="stretch"
1785
+ gap={3}
1786
+ >
1787
+ <Flex
1788
+ w="34px"
1789
+ flexShrink={0}
1790
+ align="center"
1791
+ justify="center"
1792
+ >
1793
+ <Text
1794
+ fontSize="xs"
1795
+ color="gray.500"
1796
+ fontFamily="mono"
1797
+ fontWeight="bold"
1798
+ >
1799
+ {quarter.label}
1800
+ </Text>
1801
+ </Flex>
1802
+
1803
+ <SimpleGrid columns={3} gap={3} flex="1">
1804
+ {quarter.months.map(
1805
+ (month, monthOffset) => {
1806
+ const monthIndex =
1807
+ quarter.monthIndexes[monthOffset] ??
1808
+ 0;
1809
+
1810
+ if (!month) {
1811
+ return (
1812
+ <Box
1813
+ key={`${quarter.label}-${monthIndex}-empty`}
1814
+ minH="116px"
1815
+ visibility="hidden"
1816
+ />
1817
+ );
1818
+ }
1819
+
1820
+ const winRate =
1821
+ month.orders > 0
1822
+ ? (month.wins / month.orders) * 100
1823
+ : null;
1824
+
1825
+ return (
1826
+ <Box
1827
+ key={month.id}
1828
+ p={3}
1829
+ minH="116px"
1830
+ borderWidth="1px"
1831
+ borderColor="gray.800"
1832
+ borderLeftWidth="3px"
1833
+ borderLeftColor={getPnlColor(
1834
+ month.pnl,
1835
+ )}
1836
+ borderRadius="md"
1837
+ bg="blackAlpha.300"
1838
+ >
1839
+ <Flex
1840
+ justify="space-between"
1841
+ align="baseline"
1842
+ gap={2}
1843
+ >
1844
+ <Text
1845
+ fontSize="sm"
1846
+ color="gray.200"
1847
+ fontWeight="bold"
1848
+ >
1849
+ {month.monthLabel}
1850
+ </Text>
1851
+ <Text
1852
+ fontSize="xs"
1853
+ color="gray.500"
1854
+ fontFamily="mono"
1855
+ >
1856
+ {String(
1857
+ month.monthIndex,
1858
+ ).padStart(2, '0')}
1859
+ </Text>
1860
+ </Flex>
1861
+ <Text
1862
+ mt={3}
1863
+ fontSize="xl"
1864
+ color={getPnlColor(month.pnl)}
1865
+ fontWeight="bold"
1866
+ fontFamily="mono"
1867
+ lineHeight="1.2"
1868
+ >
1869
+ {formatSignedNumber(month.pnl)}
1870
+ </Text>
1871
+ <Flex
1872
+ mt={3}
1873
+ justify="space-between"
1874
+ gap={3}
1875
+ >
1876
+ <Box>
1877
+ <Text
1878
+ fontSize="xs"
1879
+ color="gray.500"
1880
+ >
1881
+ Orders
1882
+ </Text>
1883
+ <Text
1884
+ fontSize="sm"
1885
+ color="gray.300"
1886
+ fontFamily="mono"
1887
+ fontWeight="semibold"
1888
+ >
1889
+ {formatInteger(month.orders)}
1890
+ </Text>
1891
+ </Box>
1892
+ <Box textAlign="right">
1893
+ <Text
1894
+ fontSize="xs"
1895
+ color="gray.500"
1896
+ >
1897
+ Win rate
1898
+ </Text>
1899
+ <Text
1900
+ fontSize="sm"
1901
+ color="gray.300"
1902
+ fontFamily="mono"
1903
+ fontWeight="semibold"
1904
+ >
1905
+ {formatPercent(winRate)}
1906
+ </Text>
1907
+ </Box>
1908
+ </Flex>
1909
+ </Box>
1910
+ );
1911
+ },
1912
+ )}
1913
+ </SimpleGrid>
1914
+ </Flex>
1915
+ ),
1916
+ )}
1917
+ </Flex>
1918
+ </Box>
1919
+ ))}
1920
+ </Flex>
1921
+ </Box>
1922
+ ) : null}
1923
+
1924
+ <Flex direction="column" gap={4}>
1925
+ <ChartPanel
1926
+ title="Drawdown Timeline"
1927
+ subtitle="equity peak to current equity"
1928
+ >
1929
+ <DrawdownTimelineChart points={drawdownPoints} />
1930
+ </ChartPanel>
1931
+
1932
+ <ChartPanel
1933
+ title="Rolling Performance"
1934
+ subtitle="last 50 trades"
1935
+ >
1936
+ <RollingPerformanceChart
1937
+ points={rollingPerformancePoints}
1938
+ />
1939
+ </ChartPanel>
1940
+
1941
+ <ChartPanel
1942
+ title="Win / Loss Streak Timeline"
1943
+ subtitle="trade sequence"
1944
+ >
1945
+ <WinLossStreakTimelineChart trades={runtimeTradePoints} />
1946
+ </ChartPanel>
1947
+
1948
+ <ChartPanel
1949
+ title="P&L Distribution"
1950
+ subtitle="trade result buckets"
1951
+ >
1952
+ <PnlDistributionChart bins={pnlDistributionBins} />
1953
+ </ChartPanel>
1954
+
1955
+ <ChartPanel
1956
+ title="P&L by Time of Day / Session"
1957
+ subtitle="UTC"
1958
+ >
1959
+ <TimeOfDaySessionChart
1960
+ sessions={sessionPnlStats}
1961
+ hours={hourlyPnlStats}
1962
+ />
1963
+ </ChartPanel>
1964
+ </Flex>
1965
+
1966
+ <Flex direction="column" gap={4}>
1967
+ {renderPnlRanking({
1968
+ title: 'P&L Ranking',
1969
+ subtitle: 'Top 10 contracts',
1970
+ ranking: topSymbolPnlRanking,
1971
+ maxAbsPnl: symbolRankingMaxAbsPnl,
1972
+ })}
1973
+ {renderPnlRanking({
1974
+ title: 'Worst Contracts',
1975
+ subtitle: 'Worst 10 contracts',
1976
+ ranking: worstSymbolPnlRanking,
1977
+ maxAbsPnl: symbolRankingMaxAbsPnl,
1978
+ })}
1979
+ </Flex>
1980
+
1981
+ {symbolConcentration.length ? (
1982
+ <Box
1983
+ p={4}
1984
+ borderWidth="1px"
1985
+ borderColor="gray.800"
1986
+ borderRadius="md"
1987
+ bg="gray.900"
1988
+ >
1989
+ <Flex justify="space-between" align="baseline" mb={3}>
1990
+ <Text
1991
+ fontSize="sm"
1992
+ color="gray.300"
1993
+ fontWeight="semibold"
1994
+ >
1995
+ Symbol Concentration
1996
+ </Text>
1997
+ <Text fontSize="xs" color="gray.600" textAlign="right">
1998
+ absolute P&L and order share
1999
+ </Text>
2000
+ </Flex>
2001
+
2002
+ <Flex direction="column" gap={2}>
2003
+ {symbolConcentration.map((row) => (
2004
+ <Box
2005
+ key={row.symbol}
2006
+ p={3}
2007
+ borderWidth="1px"
2008
+ borderColor="gray.800"
2009
+ borderRadius="md"
2010
+ bg="blackAlpha.300"
2011
+ >
2012
+ <Flex justify="space-between" align="center" gap={3}>
2013
+ <Box minW="0">
2014
+ <Text
2015
+ color="gray.100"
2016
+ fontSize="sm"
2017
+ fontWeight="bold"
2018
+ >
2019
+ {row.symbol}
2020
+ </Text>
2021
+ <Text
2022
+ mt={1}
2023
+ color="gray.500"
2024
+ fontSize="xs"
2025
+ fontFamily="mono"
2026
+ >
2027
+ {formatInteger(row.orders)} trades / order share{' '}
2028
+ {formatPercent(row.orderShare)}
2029
+ </Text>
2030
+ </Box>
2031
+ <Box textAlign="right" flexShrink={0}>
2032
+ <Text
2033
+ color={getPnlColor(row.pnl)}
2034
+ fontSize="sm"
2035
+ fontWeight="bold"
2036
+ fontFamily="mono"
2037
+ >
2038
+ {formatSignedNumber(row.pnl)}
2039
+ </Text>
2040
+ <Text
2041
+ mt={1}
2042
+ color="gray.400"
2043
+ fontSize="xs"
2044
+ fontFamily="mono"
2045
+ >
2046
+ abs {formatPercent(row.absPnlShare)}
2047
+ </Text>
2048
+ </Box>
2049
+ </Flex>
2050
+ </Box>
2051
+ ))}
2052
+ </Flex>
2053
+ </Box>
2054
+ ) : null}
2055
+
2056
+ <Box
2057
+ p={4}
2058
+ borderWidth="1px"
2059
+ borderColor="gray.800"
2060
+ borderRadius="md"
2061
+ bg="gray.900"
2062
+ >
2063
+ <Text
2064
+ fontSize="sm"
2065
+ color="gray.300"
2066
+ fontWeight="semibold"
2067
+ mb={3}
2068
+ >
2069
+ LONG / SHORT
2070
+ </Text>
2071
+ <SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
2072
+ {directionStats.map((group) => {
2073
+ const ordersWithPnl = group.closed + group.active;
2074
+ const winRate =
2075
+ ordersWithPnl > 0
2076
+ ? (group.wins / ordersWithPnl) * 100
2077
+ : null;
2078
+
2079
+ return (
2080
+ <Box
2081
+ key={group.direction}
2082
+ p={3}
2083
+ borderWidth="1px"
2084
+ borderColor="gray.800"
2085
+ borderRadius="md"
2086
+ bg="blackAlpha.300"
2087
+ >
2088
+ <Flex justify="space-between" align="center" mb={3}>
2089
+ <Text
2090
+ fontSize="sm"
2091
+ color={
2092
+ group.direction === 'LONG'
2093
+ ? 'teal.400'
2094
+ : 'pink.300'
2095
+ }
2096
+ fontWeight="bold"
2097
+ >
2098
+ {group.direction}
2099
+ </Text>
2100
+ {!group.orders ? (
2101
+ <Text fontSize="xs" color="gray.500">
2102
+ no data
2103
+ </Text>
2104
+ ) : null}
2105
+ </Flex>
2106
+
2107
+ <SimpleGrid columns={1} gap={2}>
2108
+ {[
2109
+ [
2110
+ 'Orders',
2111
+ formatInteger(group.orders),
2112
+ 'neutral',
2113
+ ],
2114
+ [
2115
+ 'Active',
2116
+ formatInteger(group.active),
2117
+ 'warning',
2118
+ ],
2119
+ [
2120
+ 'Closed',
2121
+ formatInteger(group.closed),
2122
+ 'neutral',
2123
+ ],
2124
+ ['Win rate', formatPercent(winRate), 'neutral'],
2125
+ [
2126
+ 'P&L',
2127
+ formatSignedNumber(group.pnl),
2128
+ group.pnl > 0
2129
+ ? 'success'
2130
+ : group.pnl < 0
2131
+ ? 'error'
2132
+ : 'neutral',
2133
+ ],
2134
+ [
2135
+ 'Avg Profit',
2136
+ formatSignedNumber(group.avgPnl),
2137
+ (group.avgPnl ?? 0) > 0
2138
+ ? 'success'
2139
+ : (group.avgPnl ?? 0) < 0
2140
+ ? 'error'
2141
+ : 'neutral',
2142
+ ],
2143
+ ].map(([label, value, level]) => (
2144
+ <Flex
2145
+ key={label}
2146
+ justify="space-between"
2147
+ align="baseline"
2148
+ gap={3}
2149
+ >
2150
+ <Text fontSize="xs" color="gray.500">
2151
+ {label}
2152
+ </Text>
2153
+ <Text
2154
+ fontSize="sm"
2155
+ color={getMetricColor(
2156
+ level as ThresholdLevel,
2157
+ )}
2158
+ fontFamily="mono"
2159
+ fontWeight="semibold"
2160
+ textAlign="right"
2161
+ >
2162
+ {value}
2163
+ </Text>
2164
+ </Flex>
2165
+ ))}
2166
+ </SimpleGrid>
2167
+ </Box>
2168
+ );
2169
+ })}
2170
+ </SimpleGrid>
2171
+ </Box>
2172
+ </Drawer.Body>
2173
+ </Drawer.Content>
2174
+ </Drawer.Positioner>
2175
+ </Portal>
2176
+ </Drawer.Root>
2177
+
2178
+ <RuntimeStrategyChart orderLog={strategy.orderLog} stat={strategy.stat} />
2179
+
2180
+ <SimpleGrid columns={{ base: 4, md: 8 }} p={4}>
2181
+ <StatItem stat={strategy.stat} id="netProfit" title="P&L" />
2182
+ <StatItem stat={strategy.stat} id="minAmount" title="Min Amount" />
2183
+ <StatItem stat={strategy.stat} id="maxDrawdown" title="Drawdown" />
2184
+ <StatItem stat={strategy.stat} id="orders" title="Orders" />
2185
+ <StatItem stat={strategy.stat} id="winRate" title="Win Rate" />
2186
+ <StatItem
2187
+ stat={strategy.stat}
2188
+ id="riskRewardRatio"
2189
+ title="Risk Ratio"
2190
+ />
2191
+ <StatItem
2192
+ stat={strategy.stat}
2193
+ id="maxConsecutiveWins"
2194
+ title="Max Gross Streak"
2195
+ />
2196
+ <StatItem
2197
+ stat={strategy.stat}
2198
+ id="maxConsecutiveLosses"
2199
+ title="Max Loss Streak"
2200
+ />
2201
+ </SimpleGrid>
2202
+ </Box>
2203
+ );
2204
+ };