@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
@@ -0,0 +1,298 @@
1
+ import type { Dirent } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ canonicalStrategyEvidenceJson,
6
+ safeStrategyEvidenceSegment,
7
+ verifyStrategyEvidenceMarkerEnvelope,
8
+ } from '@tradejs/infra/strategyReleaseEvidence';
9
+ import {
10
+ type StrategyEvidenceMarker,
11
+ type StrategyEvidenceMarkerEnvelope,
12
+ type StrategyEvidenceTimeline,
13
+ type StrategyEvidenceTimelineSelector,
14
+ } from '@tradejs/types';
15
+
16
+ const DEFAULT_MARKER_DIRECTORY = 'data/strategy-release/markers';
17
+ type JsonRecord = Record<string, unknown>;
18
+
19
+ const asRecord = (value: unknown): JsonRecord | null =>
20
+ value && typeof value === 'object' && !Array.isArray(value)
21
+ ? (value as JsonRecord)
22
+ : null;
23
+
24
+ const isNonEmptyString = (value: unknown): value is string =>
25
+ typeof value === 'string' && value.trim().length > 0;
26
+
27
+ export { canonicalStrategyEvidenceJson, verifyStrategyEvidenceMarkerEnvelope };
28
+
29
+ export const strategyEvidenceTimelineSelectorKey = (
30
+ selector: StrategyEvidenceTimelineSelector,
31
+ ) =>
32
+ [
33
+ selector.strategy,
34
+ selector.compositionId ?? '',
35
+ selector.gitSha ?? '',
36
+ selector.gateFingerprint ?? '',
37
+ selector.configFingerprint ?? '',
38
+ selector.contextFingerprint ?? '',
39
+ selector.requireCompleteLineage ? 'exact' : 'partial',
40
+ ].join(':');
41
+
42
+ const discoverJsonFiles = async (rootDir: string): Promise<string[]> => {
43
+ const files: string[] = [];
44
+
45
+ const visit = async (directory: string, depth: number): Promise<void> => {
46
+ if (depth > 12) return;
47
+
48
+ let entries: Dirent[];
49
+ try {
50
+ entries = await fs.readdir(directory, { withFileTypes: true });
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
53
+ throw error;
54
+ }
55
+
56
+ await Promise.all(
57
+ entries.map(async (entry) => {
58
+ if (entry.name.startsWith('.') || entry.name.includes('.tmp-')) return;
59
+ const entryPath = path.join(directory, entry.name);
60
+ if (entry.isDirectory()) {
61
+ await visit(entryPath, depth + 1);
62
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
63
+ files.push(entryPath);
64
+ }
65
+ }),
66
+ );
67
+ };
68
+
69
+ await visit(rootDir, 0);
70
+ return files.sort();
71
+ };
72
+
73
+ const inferMatchingStrategies = ({
74
+ filePath,
75
+ rootDir,
76
+ parsed,
77
+ strategies,
78
+ }: {
79
+ filePath: string;
80
+ rootDir: string;
81
+ parsed: unknown;
82
+ strategies: string[];
83
+ }) => {
84
+ const payload = asRecord(asRecord(parsed)?.payload);
85
+ const declaredStrategy = isNonEmptyString(payload?.strategy)
86
+ ? payload.strategy
87
+ : null;
88
+ const directoryStrategy = path.relative(rootDir, filePath).split(path.sep)[0];
89
+ return strategies.filter(
90
+ (strategy) =>
91
+ strategy === declaredStrategy ||
92
+ directoryStrategy === safeStrategyEvidenceSegment(strategy),
93
+ );
94
+ };
95
+
96
+ const missingTimeline = (): StrategyEvidenceTimeline => ({
97
+ status: 'missing',
98
+ observedFrom: null,
99
+ markers: [],
100
+ });
101
+
102
+ const invalidTimeline = (): StrategyEvidenceTimeline => ({
103
+ status: 'invalid',
104
+ observedFrom: null,
105
+ markers: [],
106
+ });
107
+
108
+ export const loadStrategyEvidenceTimelines = async ({
109
+ projectRoot,
110
+ markerDir,
111
+ selectors: requestedSelectors,
112
+ startTime,
113
+ endTime,
114
+ }: {
115
+ projectRoot: string;
116
+ markerDir?: string | null;
117
+ selectors: Iterable<StrategyEvidenceTimelineSelector>;
118
+ startTime: number;
119
+ endTime: number;
120
+ }): Promise<Map<string, StrategyEvidenceTimeline>> => {
121
+ const selectors = [...requestedSelectors]
122
+ .filter((selector) => selector.strategy.trim().length > 0)
123
+ .sort((left, right) =>
124
+ strategyEvidenceTimelineSelectorKey(left).localeCompare(
125
+ strategyEvidenceTimelineSelectorKey(right),
126
+ ),
127
+ );
128
+ const strategies = [...new Set(selectors.map(({ strategy }) => strategy))];
129
+ const timelines = new Map(
130
+ selectors.map((selector) => [
131
+ strategyEvidenceTimelineSelectorKey(selector),
132
+ missingTimeline(),
133
+ ]),
134
+ );
135
+ if (!selectors.length) return timelines;
136
+
137
+ const configuredDir = markerDir?.trim() || DEFAULT_MARKER_DIRECTORY;
138
+ const rootDir = path.isAbsolute(configuredDir)
139
+ ? configuredDir
140
+ : path.resolve(projectRoot, configuredDir);
141
+ let files: string[];
142
+ try {
143
+ files = await discoverJsonFiles(rootDir);
144
+ } catch {
145
+ for (const selector of selectors) {
146
+ timelines.set(
147
+ strategyEvidenceTimelineSelectorKey(selector),
148
+ invalidTimeline(),
149
+ );
150
+ }
151
+ return timelines;
152
+ }
153
+
154
+ const envelopesByStrategy = new Map<
155
+ string,
156
+ StrategyEvidenceMarkerEnvelope[]
157
+ >();
158
+ const invalidStrategies = new Set<string>();
159
+
160
+ for (const filePath of files) {
161
+ let parsed: unknown = null;
162
+ try {
163
+ parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
164
+ } catch {
165
+ for (const strategy of inferMatchingStrategies({
166
+ filePath,
167
+ rootDir,
168
+ parsed,
169
+ strategies,
170
+ })) {
171
+ invalidStrategies.add(strategy);
172
+ }
173
+ continue;
174
+ }
175
+
176
+ const matchingStrategies = inferMatchingStrategies({
177
+ filePath,
178
+ rootDir,
179
+ parsed,
180
+ strategies,
181
+ });
182
+ if (!matchingStrategies.length) continue;
183
+
184
+ try {
185
+ const envelope = verifyStrategyEvidenceMarkerEnvelope(parsed);
186
+ for (const strategy of matchingStrategies) {
187
+ if (strategy !== envelope.payload.strategy) {
188
+ invalidStrategies.add(strategy);
189
+ }
190
+ }
191
+ if (!strategies.includes(envelope.payload.strategy)) {
192
+ continue;
193
+ }
194
+ const envelopes =
195
+ envelopesByStrategy.get(envelope.payload.strategy) ?? [];
196
+ envelopes.push(envelope);
197
+ envelopesByStrategy.set(envelope.payload.strategy, envelopes);
198
+ } catch {
199
+ for (const strategy of matchingStrategies) {
200
+ invalidStrategies.add(strategy);
201
+ }
202
+ }
203
+ }
204
+
205
+ for (const selector of selectors) {
206
+ const strategy = selector.strategy;
207
+ const selectorKey = strategyEvidenceTimelineSelectorKey(selector);
208
+ if (invalidStrategies.has(strategy)) {
209
+ timelines.set(selectorKey, invalidTimeline());
210
+ continue;
211
+ }
212
+
213
+ const envelopes = envelopesByStrategy.get(strategy) ?? [];
214
+ if (!envelopes.length) continue;
215
+ const hasCompleteSelector =
216
+ Boolean(selector.compositionId) &&
217
+ Boolean(selector.gitSha) &&
218
+ Boolean(selector.gateFingerprint) &&
219
+ Boolean(selector.configFingerprint) &&
220
+ Boolean(selector.contextFingerprint);
221
+ if (selector.requireCompleteLineage && !hasCompleteSelector) continue;
222
+
223
+ const markersById = new Map<string, StrategyEvidenceMarker>();
224
+ let hasConflict = false;
225
+ for (const envelope of envelopes) {
226
+ for (const marker of envelope.payload.markers) {
227
+ const existing = markersById.get(marker.id);
228
+ if (
229
+ existing &&
230
+ canonicalStrategyEvidenceJson(existing) !==
231
+ canonicalStrategyEvidenceJson(marker)
232
+ ) {
233
+ hasConflict = true;
234
+ break;
235
+ }
236
+ markersById.set(marker.id, marker);
237
+ }
238
+ if (hasConflict) break;
239
+ }
240
+
241
+ if (hasConflict) {
242
+ timelines.set(selectorKey, invalidTimeline());
243
+ continue;
244
+ }
245
+
246
+ const matchingMarkers = [...markersById.values()]
247
+ .filter(
248
+ (marker) =>
249
+ (!selector.compositionId ||
250
+ marker.compositionId === selector.compositionId) &&
251
+ (!selector.gitSha || marker.gitSha === selector.gitSha) &&
252
+ (!selector.gateFingerprint ||
253
+ marker.gateFingerprint === selector.gateFingerprint) &&
254
+ (!selector.configFingerprint ||
255
+ marker.configFingerprint === selector.configFingerprint) &&
256
+ (!selector.contextFingerprint ||
257
+ marker.contextFingerprint === selector.contextFingerprint) &&
258
+ marker.timestamp >= startTime &&
259
+ marker.timestamp < endTime,
260
+ )
261
+ .sort(
262
+ (left, right) =>
263
+ left.timestamp - right.timestamp ||
264
+ left.type.localeCompare(right.type) ||
265
+ left.id.localeCompare(right.id),
266
+ );
267
+ let lastLossValue: number | null | undefined;
268
+ let hasLastLossValue = false;
269
+ const markers = matchingMarkers.filter((marker) => {
270
+ if (marker.type !== 'L') return true;
271
+ if (hasLastLossValue && marker.maxLossValue === lastLossValue) {
272
+ return false;
273
+ }
274
+ hasLastLossValue = true;
275
+ lastLossValue = marker.maxLossValue;
276
+ return true;
277
+ });
278
+ if (
279
+ !markers.length &&
280
+ (selector.compositionId ||
281
+ selector.gitSha ||
282
+ selector.gateFingerprint ||
283
+ selector.configFingerprint ||
284
+ selector.contextFingerprint)
285
+ ) {
286
+ continue;
287
+ }
288
+ timelines.set(selectorKey, {
289
+ status: 'verified',
290
+ observedFrom: markers.length
291
+ ? Math.min(...markers.map((marker) => marker.timestamp))
292
+ : Math.min(...envelopes.map((envelope) => envelope.payload.createdAt)),
293
+ markers,
294
+ });
295
+ }
296
+
297
+ return timelines;
298
+ };
@@ -0,0 +1,387 @@
1
+ export type EquityLog = ReadonlyArray<readonly [number, number]>;
2
+
3
+ export type TradingSession = 'Asia' | 'Europe' | 'US';
4
+
5
+ export interface StrategyTradePoint {
6
+ index: number;
7
+ timestamp: number;
8
+ pnl: number;
9
+ equity: number;
10
+ hour: number;
11
+ session: TradingSession;
12
+ }
13
+
14
+ export interface DrawdownPoint {
15
+ timestamp: number;
16
+ drawdownPercent: number;
17
+ }
18
+
19
+ export interface RollingPerformancePoint {
20
+ index: number;
21
+ winRate: number;
22
+ pnl: number;
23
+ }
24
+
25
+ export interface DistributionBin {
26
+ id: string;
27
+ min: number;
28
+ max: number;
29
+ count: number;
30
+ }
31
+
32
+ export interface SessionPnlStat {
33
+ session: TradingSession;
34
+ pnl: number;
35
+ orders: number;
36
+ }
37
+
38
+ export interface HourlyPnlStat {
39
+ hour: number;
40
+ pnl: number;
41
+ orders: number;
42
+ }
43
+
44
+ export interface MonthlyStat {
45
+ id: string;
46
+ year: number;
47
+ monthIndex: number;
48
+ monthLabel: string;
49
+ orders: number;
50
+ wins: number;
51
+ pnl: number;
52
+ }
53
+
54
+ export interface YearlyMonthlyStats {
55
+ year: number;
56
+ months: MonthlyStat[];
57
+ }
58
+
59
+ export interface QuarterlyMonthlyStats {
60
+ label: string;
61
+ monthIndexes: readonly number[];
62
+ months: (MonthlyStat | null)[];
63
+ hasData: boolean;
64
+ }
65
+
66
+ export interface StrategyPerformanceViewModel {
67
+ monthlyStats: YearlyMonthlyStats[];
68
+ tradePoints: StrategyTradePoint[];
69
+ drawdownPoints: DrawdownPoint[];
70
+ rollingPerformancePoints: RollingPerformancePoint[];
71
+ pnlDistributionBins: DistributionBin[];
72
+ sessionPnlStats: SessionPnlStat[];
73
+ hourlyPnlStats: HourlyPnlStat[];
74
+ }
75
+
76
+ const resolveTradingSession = (hour: number): TradingSession => {
77
+ if (hour < 8) return 'Asia';
78
+ if (hour < 16) return 'Europe';
79
+ return 'US';
80
+ };
81
+
82
+ export const getEquityStepPnl = (orderLog: EquityLog, index: number) => {
83
+ const current = orderLog[index];
84
+ const previous = orderLog[index - 1];
85
+ if (!current || !previous) return null;
86
+
87
+ const pnl = current[1] - previous[1];
88
+ return Number.isFinite(pnl) ? pnl : null;
89
+ };
90
+
91
+ const calculateMaxPnlStreak = (
92
+ orderLog: EquityLog,
93
+ isStreakPnl: (pnl: number) => boolean,
94
+ ) => {
95
+ let currentStreak = 0;
96
+ let maxStreak = 0;
97
+
98
+ for (let index = 1; index < orderLog.length; index += 1) {
99
+ const pnl = getEquityStepPnl(orderLog, index);
100
+ if (pnl == null) continue;
101
+
102
+ if (isStreakPnl(pnl)) {
103
+ currentStreak += 1;
104
+ maxStreak = Math.max(maxStreak, currentStreak);
105
+ } else {
106
+ currentStreak = 0;
107
+ }
108
+ }
109
+
110
+ return maxStreak;
111
+ };
112
+
113
+ export const calculateMaxGrossStreak = (orderLog: EquityLog) =>
114
+ calculateMaxPnlStreak(orderLog, (pnl) => pnl > 0);
115
+
116
+ export const calculateMaxLossStreak = (orderLog: EquityLog) =>
117
+ calculateMaxPnlStreak(orderLog, (pnl) => pnl < 0);
118
+
119
+ export const calculateMaxDrawdownValue = (orderLog: EquityLog) => {
120
+ if (!orderLog.length) return null;
121
+
122
+ let peak = orderLog[0]?.[1] ?? 0;
123
+ let maxDrawdownPercent = 0;
124
+
125
+ for (const [, amount] of orderLog) {
126
+ if (!Number.isFinite(amount)) continue;
127
+
128
+ peak = Math.max(peak, amount);
129
+ if (peak <= 0) continue;
130
+
131
+ maxDrawdownPercent = Math.max(
132
+ maxDrawdownPercent,
133
+ ((peak - amount) / peak) * 100,
134
+ );
135
+ }
136
+
137
+ return maxDrawdownPercent;
138
+ };
139
+
140
+ export const formatMaxDrawdownPercent = (orderLog: EquityLog) => {
141
+ const value = calculateMaxDrawdownValue(orderLog);
142
+ return value == null ? null : `${value.toFixed(1)}%`;
143
+ };
144
+
145
+ export const buildStrategyTradePoints = (
146
+ orderLog: EquityLog,
147
+ ): StrategyTradePoint[] => {
148
+ const points: StrategyTradePoint[] = [];
149
+
150
+ for (let index = 1; index < orderLog.length; index += 1) {
151
+ const current = orderLog[index];
152
+ const previous = orderLog[index - 1];
153
+ if (!current || !previous) continue;
154
+
155
+ const [timestamp, equity] = current;
156
+ const pnl = equity - previous[1];
157
+ if (
158
+ !Number.isFinite(timestamp) ||
159
+ !Number.isFinite(equity) ||
160
+ !Number.isFinite(pnl)
161
+ ) {
162
+ continue;
163
+ }
164
+
165
+ const hour = new Date(timestamp).getUTCHours();
166
+ points.push({
167
+ index,
168
+ timestamp,
169
+ pnl,
170
+ equity,
171
+ hour,
172
+ session: resolveTradingSession(hour),
173
+ });
174
+ }
175
+
176
+ return points;
177
+ };
178
+
179
+ export const buildDrawdownPoints = (orderLog: EquityLog): DrawdownPoint[] => {
180
+ let peak = orderLog[0]?.[1] ?? 0;
181
+
182
+ return orderLog
183
+ .map(([timestamp, equity]) => {
184
+ if (!Number.isFinite(timestamp) || !Number.isFinite(equity)) return null;
185
+
186
+ peak = Math.max(peak, equity);
187
+ return {
188
+ timestamp,
189
+ drawdownPercent: peak > 0 ? ((peak - equity) / peak) * 100 : 0,
190
+ };
191
+ })
192
+ .filter((point): point is DrawdownPoint => point != null);
193
+ };
194
+
195
+ export const buildRollingPerformance = (
196
+ trades: StrategyTradePoint[],
197
+ windowSize = 50,
198
+ ): RollingPerformancePoint[] =>
199
+ trades.map((trade, index) => {
200
+ const windowTrades = trades.slice(
201
+ Math.max(0, index - windowSize + 1),
202
+ index + 1,
203
+ );
204
+ const wins = windowTrades.filter((item) => item.pnl > 0).length;
205
+
206
+ return {
207
+ index: trade.index,
208
+ winRate: windowTrades.length > 0 ? (wins / windowTrades.length) * 100 : 0,
209
+ pnl: windowTrades.reduce((sum, item) => sum + item.pnl, 0),
210
+ };
211
+ });
212
+
213
+ export const buildPnlDistribution = (
214
+ trades: StrategyTradePoint[],
215
+ binCount = 12,
216
+ ): DistributionBin[] => {
217
+ if (!trades.length) return [];
218
+
219
+ const pnlValues = trades.map((trade) => trade.pnl);
220
+ const min = Math.min(...pnlValues);
221
+ const max = Math.max(...pnlValues);
222
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
223
+
224
+ if (min === max) {
225
+ return [{ id: `${min}:${max}`, min, max, count: trades.length }];
226
+ }
227
+
228
+ const step = (max - min) / binCount;
229
+ const bins = Array.from({ length: binCount }, (_, index) => ({
230
+ id: String(index),
231
+ min: min + step * index,
232
+ max: index === binCount - 1 ? max : min + step * (index + 1),
233
+ count: 0,
234
+ }));
235
+
236
+ for (const pnl of pnlValues) {
237
+ const rawIndex = Math.floor((pnl - min) / step);
238
+ const bin = bins[Math.max(0, Math.min(binCount - 1, rawIndex))];
239
+ if (bin) bin.count += 1;
240
+ }
241
+
242
+ return bins;
243
+ };
244
+
245
+ export const buildSessionPnlStats = (
246
+ trades: StrategyTradePoint[],
247
+ ): SessionPnlStat[] => {
248
+ const stats = new Map<TradingSession, SessionPnlStat>(
249
+ (['Asia', 'Europe', 'US'] as const).map((session) => [
250
+ session,
251
+ { session, pnl: 0, orders: 0 },
252
+ ]),
253
+ );
254
+
255
+ for (const trade of trades) {
256
+ const stat = stats.get(trade.session);
257
+ if (!stat) continue;
258
+ stat.pnl += trade.pnl;
259
+ stat.orders += 1;
260
+ }
261
+
262
+ return [...stats.values()];
263
+ };
264
+
265
+ export const buildHourlyPnlStats = (
266
+ trades: StrategyTradePoint[],
267
+ ): HourlyPnlStat[] => {
268
+ const stats = Array.from({ length: 24 }, (_, hour) => ({
269
+ hour,
270
+ pnl: 0,
271
+ orders: 0,
272
+ }));
273
+
274
+ for (const trade of trades) {
275
+ const stat = stats[trade.hour];
276
+ if (!stat) continue;
277
+ stat.pnl += trade.pnl;
278
+ stat.orders += 1;
279
+ }
280
+
281
+ return stats;
282
+ };
283
+
284
+ const getMonthLabel = (monthIndex: number) =>
285
+ new Date(Date.UTC(2026, monthIndex - 1, 1)).toLocaleString('en-US', {
286
+ month: 'short',
287
+ });
288
+
289
+ const monthQuarters = [
290
+ { label: 'Q1', months: [1, 2, 3] },
291
+ { label: 'Q2', months: [4, 5, 6] },
292
+ { label: 'Q3', months: [7, 8, 9] },
293
+ { label: 'Q4', months: [10, 11, 12] },
294
+ ] as const;
295
+
296
+ export const buildQuarterlyMonthlyStats = (
297
+ months: MonthlyStat[],
298
+ ): QuarterlyMonthlyStats[] => {
299
+ const byMonth = new Map(months.map((month) => [month.monthIndex, month]));
300
+
301
+ return monthQuarters
302
+ .map((quarter) => {
303
+ const quarterMonths = quarter.months.map(
304
+ (monthIndex) => byMonth.get(monthIndex) ?? null,
305
+ );
306
+
307
+ return {
308
+ label: quarter.label,
309
+ monthIndexes: quarter.months,
310
+ months: quarterMonths,
311
+ hasData: quarterMonths.some((month) => month != null),
312
+ };
313
+ })
314
+ .filter((quarter) => quarter.hasData);
315
+ };
316
+
317
+ export const buildMonthlyStats = (
318
+ orderLog: EquityLog,
319
+ ): YearlyMonthlyStats[] => {
320
+ const grouped = new Map<string, MonthlyStat>();
321
+
322
+ for (let index = 1; index < orderLog.length; index += 1) {
323
+ const current = orderLog[index];
324
+ const previous = orderLog[index - 1];
325
+ if (!current || !previous) continue;
326
+
327
+ const [timestamp, amount] = current;
328
+ const previousAmount = previous[1];
329
+ if (
330
+ !Number.isFinite(timestamp) ||
331
+ !Number.isFinite(amount) ||
332
+ !Number.isFinite(previousAmount)
333
+ ) {
334
+ continue;
335
+ }
336
+
337
+ const date = new Date(timestamp);
338
+ const year = date.getUTCFullYear();
339
+ const monthIndex = date.getUTCMonth() + 1;
340
+ const id = `${year}-${String(monthIndex).padStart(2, '0')}`;
341
+ const pnl = amount - previousAmount;
342
+ const existing = grouped.get(id) ?? {
343
+ id,
344
+ year,
345
+ monthIndex,
346
+ monthLabel: getMonthLabel(monthIndex),
347
+ orders: 0,
348
+ wins: 0,
349
+ pnl: 0,
350
+ };
351
+
352
+ existing.orders += 1;
353
+ existing.wins += pnl > 0 ? 1 : 0;
354
+ existing.pnl += pnl;
355
+ grouped.set(id, existing);
356
+ }
357
+
358
+ const yearlyStats = new Map<number, MonthlyStat[]>();
359
+ for (const month of [...grouped.values()].sort(
360
+ (left, right) =>
361
+ left.year - right.year || left.monthIndex - right.monthIndex,
362
+ )) {
363
+ const months = yearlyStats.get(month.year) ?? [];
364
+ months.push(month);
365
+ yearlyStats.set(month.year, months);
366
+ }
367
+
368
+ return [...yearlyStats.entries()]
369
+ .sort(([leftYear], [rightYear]) => leftYear - rightYear)
370
+ .map(([year, months]) => ({ year, months }));
371
+ };
372
+
373
+ export const buildStrategyPerformanceViewModel = (
374
+ orderLog: EquityLog,
375
+ ): StrategyPerformanceViewModel => {
376
+ const tradePoints = buildStrategyTradePoints(orderLog);
377
+
378
+ return {
379
+ monthlyStats: buildMonthlyStats(orderLog),
380
+ tradePoints,
381
+ drawdownPoints: buildDrawdownPoints(orderLog),
382
+ rollingPerformancePoints: buildRollingPerformance(tradePoints, 50),
383
+ pnlDistributionBins: buildPnlDistribution(tradePoints),
384
+ sessionPnlStats: buildSessionPnlStats(tradePoints),
385
+ hourlyPnlStats: buildHourlyPnlStats(tradePoints),
386
+ };
387
+ };
@@ -7,12 +7,8 @@ import { Box, Button, Flex, ClientOnly } from '@chakra-ui/react';
7
7
  import { useFilters, useTickers, useTestList } from '#store';
8
8
  import { Filters } from '#shared/Filters';
9
9
  import { MainChart } from '#app/components/Dashboard/MainChart';
10
- import {
11
- Interval,
12
- MarketUniverse,
13
- OnChangeFilters,
14
- Provider,
15
- } from '@tradejs/types';
10
+ import { Interval, MarketUniverse, Provider } from '@tradejs/types';
11
+ import type { OnChangeFilters } from '#app/types/ui';
16
12
  import {
17
13
  buildDashboardPath,
18
14
  parseDashboardPath as parseMarketDashboardPath,