@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,831 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { TTL_1M } from '@tradejs/core/constants';
3
+ import { getRuntimeStorageDayKeys } from '@tradejs/core/time';
4
+ import { logger } from '@tradejs/infra/logger';
5
+ import {
6
+ listRuntimeDeployments,
7
+ listTradingAccounts,
8
+ resolveTradingAccount,
9
+ } from '@tradejs/infra/tradingAccounts';
10
+ import { strategyEntries } from '@tradejs/strategies';
11
+ import {
12
+ delKey,
13
+ getData,
14
+ getHashJsonValues,
15
+ getKeys,
16
+ redisKeys,
17
+ setData,
18
+ } from '@tradejs/infra/redis';
19
+ import type {
20
+ Connector,
21
+ ConnectorCreator,
22
+ PositionPnlSnapshot,
23
+ RuntimeTradeRecord,
24
+ Interval,
25
+ StrategyConfig,
26
+ } from '@tradejs/types';
27
+ import { getAvailableStrategyNames } from '@tradejs/node/strategies';
28
+ import {
29
+ DEFAULT_CONNECTOR_PROVIDER,
30
+ resolveConnectorCreatorByProvider,
31
+ } from '#app/lib/connectorCreator';
32
+ import { getCurrentUserName } from '#app/lib/currentUser';
33
+ import {
34
+ assignLegacyRuntimeTradeAccountScopes,
35
+ buildRuntimeStrategyAnalytics,
36
+ buildRuntimeStrategyIdentityKey,
37
+ buildExchangeFallbackRuntimeTrades,
38
+ isRuntimeTradeRecord,
39
+ resolveStrategyConfigIdentityByKey,
40
+ RuntimeStrategiesResponse,
41
+ selectTradesForWindow,
42
+ takeClosedPnlMatch,
43
+ toRuntimeTradeView,
44
+ } from '#app/lib/runtimeStrategies';
45
+
46
+ type ClosedPnlRecordWithOrderLinkId = Awaited<
47
+ ReturnType<NonNullable<Connector['getClosedPnl']>>
48
+ >[number] & {
49
+ orderLinkId?: string;
50
+ };
51
+
52
+ export const dynamic = 'force-dynamic';
53
+
54
+ const projectRoot =
55
+ String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
56
+ const DEFAULT_PROVIDER = DEFAULT_CONNECTOR_PROVIDER;
57
+ const DEFAULT_HOURS = 168;
58
+ const MIN_HOURS = 6;
59
+ const MAX_HOURS = 24 * 90;
60
+ const BYBIT_MAX_TIME_RANGE_MS = 7 * 24 * 60 * 60 * 1000 - 1_000;
61
+ const EXCHANGE_REQUEST_TIMEOUT_MS = 15_000;
62
+
63
+ const coerceHours = (value: string | null) => {
64
+ const parsed = Number(value ?? Number.NaN);
65
+ if (!Number.isFinite(parsed)) {
66
+ return DEFAULT_HOURS;
67
+ }
68
+
69
+ return Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.trunc(parsed)));
70
+ };
71
+
72
+ const isRuntimeStrategyConfigEnabled = (config: StrategyConfig | null) => {
73
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
74
+ return false;
75
+ }
76
+
77
+ return (config as Record<string, unknown>).ENABLE !== false;
78
+ };
79
+
80
+ const loadRuntimeStrategyConfigs = async (userName: string) => {
81
+ const keys = await getKeys(`${redisKeys.strategies(userName)}:`);
82
+ const entries = await Promise.all(
83
+ keys.map(async (key) => {
84
+ const identity = resolveStrategyConfigIdentityByKey(userName, key);
85
+ if (!identity) {
86
+ return null;
87
+ }
88
+
89
+ const config = (await getData(key, null)) as StrategyConfig | null;
90
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
91
+ return null;
92
+ }
93
+
94
+ return { ...identity, key, config };
95
+ }),
96
+ );
97
+ return entries.filter(
98
+ (entry): entry is NonNullable<typeof entry> => entry != null,
99
+ );
100
+ };
101
+
102
+ const loadConfiguredStrategyNames = async () => {
103
+ try {
104
+ const names = await getAvailableStrategyNames(projectRoot);
105
+ const builtInNames = strategyEntries
106
+ .map((entry) => entry.manifest?.name)
107
+ .filter((value): value is string => Boolean(value));
108
+
109
+ return [...new Set([...names, ...builtInNames])].sort((left, right) =>
110
+ left.localeCompare(right),
111
+ );
112
+ } catch (error) {
113
+ logger.warn(
114
+ 'strategies runtime: failed to load configured strategies: %s',
115
+ (error as Error)?.message || String(error),
116
+ );
117
+ return strategyEntries
118
+ .map((entry) => entry.manifest?.name)
119
+ .filter((value): value is string => Boolean(value))
120
+ .sort((left, right) => left.localeCompare(right));
121
+ }
122
+ };
123
+
124
+ const loadRuntimeTrades = async (
125
+ userName: string,
126
+ {
127
+ startTime,
128
+ endTime,
129
+ }: {
130
+ startTime: number;
131
+ endTime: number;
132
+ },
133
+ ): Promise<RuntimeTradeRecord[]> => {
134
+ const filterByWindow = (trade: RuntimeTradeRecord) =>
135
+ trade.entryTimestamp >= startTime ||
136
+ (typeof trade.exitTimestamp === 'number' &&
137
+ trade.exitTimestamp >= startTime);
138
+ const dayKeys = getRuntimeStorageDayKeys(startTime, endTime);
139
+ const bucketTrades = (
140
+ await Promise.all(
141
+ dayKeys.map((dayKey) =>
142
+ getHashJsonValues<RuntimeTradeRecord>(
143
+ redisKeys.runtimeTradeBucket(userName, dayKey),
144
+ ),
145
+ ),
146
+ )
147
+ ).flat();
148
+ const dedupedBucketTrades = new Map<string, RuntimeTradeRecord>();
149
+
150
+ for (const trade of bucketTrades) {
151
+ if (!isRuntimeTradeRecord(trade)) {
152
+ continue;
153
+ }
154
+ dedupedBucketTrades.set(trade.orderId, trade);
155
+ }
156
+
157
+ if (dedupedBucketTrades.size > 0 || dayKeys.length === 0) {
158
+ return [...dedupedBucketTrades.values()]
159
+ .filter(filterByWindow)
160
+ .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
161
+ }
162
+
163
+ const keys = await getKeys(redisKeys.runtimeTrades(userName));
164
+ const trades = await Promise.all(keys.map((key) => getData(key, null)));
165
+
166
+ return trades
167
+ .filter(isRuntimeTradeRecord)
168
+ .filter(filterByWindow)
169
+ .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
170
+ };
171
+
172
+ const buildExchangeTimeRanges = (startTime: number, endTime: number) => {
173
+ const ranges: Array<{ startTime: number; endTime: number }> = [];
174
+ let cursor = startTime;
175
+
176
+ while (cursor < endTime) {
177
+ const rangeEnd = Math.min(endTime, cursor + BYBIT_MAX_TIME_RANGE_MS);
178
+ ranges.push({ startTime: cursor, endTime: rangeEnd });
179
+ cursor = rangeEnd + 1;
180
+ }
181
+
182
+ return ranges;
183
+ };
184
+
185
+ const loadExchangeRange = async <T>({
186
+ label,
187
+ startTime,
188
+ endTime,
189
+ load,
190
+ errors,
191
+ }: {
192
+ label: string;
193
+ startTime: number;
194
+ endTime: number;
195
+ load: () => Promise<T[]>;
196
+ errors?: string[];
197
+ }) => {
198
+ try {
199
+ return await Promise.race([
200
+ load(),
201
+ new Promise<T[]>((_, reject) => {
202
+ setTimeout(
203
+ () =>
204
+ reject(
205
+ new Error(
206
+ `${label} timed out for ${new Date(startTime).toISOString()} - ${new Date(endTime).toISOString()}`,
207
+ ),
208
+ ),
209
+ EXCHANGE_REQUEST_TIMEOUT_MS,
210
+ );
211
+ }),
212
+ ]);
213
+ } catch (error) {
214
+ const message = (error as Error)?.message || String(error);
215
+ errors?.push(`${label}: ${message}`);
216
+ logger.warn('strategies runtime: %s failed: %s', label, message);
217
+ return [];
218
+ }
219
+ };
220
+
221
+ const loadActiveRuntimeOrderIds = async (userName: string) => {
222
+ const keys = await getKeys(redisKeys.runtimeActiveTrades(userName));
223
+ const refs = await Promise.all(keys.map((key) => getData(key, null)));
224
+
225
+ return new Set(
226
+ refs
227
+ .map((ref) =>
228
+ typeof ref?.orderId === 'string' && ref.orderId.trim()
229
+ ? ref.orderId.trim()
230
+ : null,
231
+ )
232
+ .filter((value): value is string => Boolean(value)),
233
+ );
234
+ };
235
+
236
+ const loadClosedPnlRows = async ({
237
+ connector,
238
+ startTime,
239
+ endTime,
240
+ errors,
241
+ }: {
242
+ connector: Connector;
243
+ startTime: number;
244
+ endTime: number;
245
+ errors?: string[];
246
+ }) => {
247
+ if (typeof connector.getClosedPnl !== 'function') {
248
+ return [];
249
+ }
250
+
251
+ try {
252
+ const rows = (
253
+ await Promise.all(
254
+ buildExchangeTimeRanges(startTime, endTime).map((range) =>
255
+ loadExchangeRange({
256
+ label: 'getClosedPnl',
257
+ ...range,
258
+ errors,
259
+ load: () =>
260
+ connector.getClosedPnl?.({
261
+ ...range,
262
+ limit: 100,
263
+ }) ?? Promise.resolve([]),
264
+ }),
265
+ ),
266
+ )
267
+ ).flatMap((items) => items ?? []);
268
+
269
+ return rows.sort((left, right) => left.closedAt - right.closedAt);
270
+ } catch (error) {
271
+ const message = (error as Error)?.message || String(error);
272
+ errors?.push(`getClosedPnl: ${message}`);
273
+ logger.warn('strategies runtime: getClosedPnl failed: %s', message);
274
+ return [];
275
+ }
276
+ };
277
+
278
+ const loadExchangeEntryRows = async ({
279
+ connector,
280
+ startTime,
281
+ endTime,
282
+ errors,
283
+ }: {
284
+ connector: Connector;
285
+ startTime: number;
286
+ endTime: number;
287
+ errors?: string[];
288
+ }) => {
289
+ if (typeof connector.getEntryExecutions !== 'function') {
290
+ return [];
291
+ }
292
+
293
+ try {
294
+ const rows = (
295
+ await Promise.all(
296
+ buildExchangeTimeRanges(startTime, endTime).map((range) =>
297
+ loadExchangeRange({
298
+ label: 'getEntryExecutions',
299
+ ...range,
300
+ errors,
301
+ load: () =>
302
+ connector.getEntryExecutions?.({
303
+ ...range,
304
+ limit: 100,
305
+ }) ?? Promise.resolve([]),
306
+ }),
307
+ ),
308
+ )
309
+ ).flatMap((items) => items ?? []);
310
+
311
+ return rows.sort(
312
+ (left, right) => left.entryTimestamp - right.entryTimestamp,
313
+ );
314
+ } catch (error) {
315
+ const message = (error as Error)?.message || String(error);
316
+ errors?.push(`getEntryExecutions: ${message}`);
317
+ logger.warn('strategies runtime: getEntryExecutions failed: %s', message);
318
+ return [];
319
+ }
320
+ };
321
+
322
+ const loadOpenPositions = async (
323
+ connector: Connector,
324
+ errors?: string[],
325
+ ): Promise<PositionPnlSnapshot[]> => {
326
+ if (typeof connector.getOpenPositionPnl !== 'function') {
327
+ return [];
328
+ }
329
+
330
+ try {
331
+ return await connector.getOpenPositionPnl();
332
+ } catch (error) {
333
+ const message = (error as Error)?.message || String(error);
334
+ errors?.push(`getOpenPositionPnl: ${message}`);
335
+ logger.warn('strategies runtime: getOpenPositionPnl failed: %s', message);
336
+ return [];
337
+ }
338
+ };
339
+
340
+ const buildRiskLevelsAnalysis = (position: PositionPnlSnapshot) => {
341
+ const takeProfitPrice =
342
+ typeof position.takeProfitPrice === 'number' &&
343
+ Number.isFinite(position.takeProfitPrice)
344
+ ? position.takeProfitPrice
345
+ : null;
346
+ const stopLossPrice =
347
+ typeof position.stopLossPrice === 'number' &&
348
+ Number.isFinite(position.stopLossPrice)
349
+ ? position.stopLossPrice
350
+ : null;
351
+
352
+ if (takeProfitPrice == null && stopLossPrice == null) {
353
+ return null;
354
+ }
355
+
356
+ return {
357
+ ...(takeProfitPrice != null ? { takeProfitPrice } : {}),
358
+ ...(stopLossPrice != null ? { stopLossPrice } : {}),
359
+ };
360
+ };
361
+
362
+ const syncRuntimeTrades = async ({
363
+ userName,
364
+ trades,
365
+ endTime,
366
+ openPositions,
367
+ closedPnlRows,
368
+ }: {
369
+ userName: string;
370
+ trades: RuntimeTradeRecord[];
371
+ endTime: number;
372
+ openPositions: PositionPnlSnapshot[];
373
+ closedPnlRows: ClosedPnlRecordWithOrderLinkId[];
374
+ }) => {
375
+ const openPositionsBySymbol = new Map(
376
+ openPositions.map((position) => [position.symbol, position]),
377
+ );
378
+ const activeOrderIdBySymbol = new Map<string, string | null>();
379
+ const symbols = [...new Set(trades.map((trade) => trade.symbol))];
380
+
381
+ await Promise.all(
382
+ symbols.map(async (symbol) => {
383
+ const activeRef = (await getData(
384
+ redisKeys.runtimeActiveTrade(userName, symbol),
385
+ null,
386
+ )) as { orderId?: string } | null;
387
+ activeOrderIdBySymbol.set(
388
+ symbol,
389
+ typeof activeRef?.orderId === 'string' ? activeRef.orderId : null,
390
+ );
391
+ }),
392
+ );
393
+
394
+ const closedPnlRowsWithOrderLinkId =
395
+ closedPnlRows as ClosedPnlRecordWithOrderLinkId[];
396
+ const exactByOrderLinkId = new Map(
397
+ closedPnlRowsWithOrderLinkId
398
+ .filter(
399
+ (row): row is typeof row & { orderLinkId: string } =>
400
+ typeof row.orderLinkId === 'string' && row.orderLinkId.length > 0,
401
+ )
402
+ .map((row) => [row.orderLinkId, row]),
403
+ );
404
+ const exactByOrderId = new Map(
405
+ closedPnlRowsWithOrderLinkId
406
+ .filter(
407
+ (row): row is typeof row & { orderId: string } =>
408
+ typeof row.orderId === 'string' && row.orderId.length > 0,
409
+ )
410
+ .map((row) => [row.orderId, row]),
411
+ );
412
+ const symbolBuckets = new Map<string, ClosedPnlRecordWithOrderLinkId[]>();
413
+
414
+ for (const row of closedPnlRowsWithOrderLinkId) {
415
+ const bucket = symbolBuckets.get(row.symbol) ?? [];
416
+ bucket.push(row);
417
+ symbolBuckets.set(row.symbol, bucket);
418
+ }
419
+
420
+ const syncedTrades: RuntimeTradeRecord[] = [];
421
+
422
+ for (const trade of trades) {
423
+ const closedTradeHasExchangeDetails =
424
+ trade.status === 'closed' &&
425
+ typeof trade.exitPrice === 'number' &&
426
+ Number.isFinite(trade.exitPrice) &&
427
+ typeof trade.actualExitPrice === 'number' &&
428
+ Number.isFinite(trade.actualExitPrice) &&
429
+ typeof trade.closedPnl === 'number' &&
430
+ Number.isFinite(trade.closedPnl) &&
431
+ typeof trade.openFee === 'number' &&
432
+ Number.isFinite(trade.openFee) &&
433
+ typeof trade.closeFee === 'number' &&
434
+ Number.isFinite(trade.closeFee);
435
+
436
+ if (trade.status !== 'active' && closedTradeHasExchangeDetails) {
437
+ syncedTrades.push(trade);
438
+ continue;
439
+ }
440
+
441
+ const openPosition = openPositionsBySymbol.get(trade.symbol);
442
+ const activeOrderId = activeOrderIdBySymbol.get(trade.symbol);
443
+ const isCurrentActiveTrade = activeOrderId === trade.orderId;
444
+
445
+ if (
446
+ isCurrentActiveTrade &&
447
+ openPosition &&
448
+ openPosition.direction === trade.direction
449
+ ) {
450
+ const riskLevelsAnalysis = buildRiskLevelsAnalysis(openPosition);
451
+ const nextTrade: RuntimeTradeRecord = {
452
+ ...trade,
453
+ status: 'active',
454
+ currentPrice: openPosition.currentPrice,
455
+ currentPnl: openPosition.unrealizedPnl,
456
+ aiAnalysis: riskLevelsAnalysis
457
+ ? { ...(trade.aiAnalysis ?? {}), ...riskLevelsAnalysis }
458
+ : trade.aiAnalysis,
459
+ lastSyncedAt: endTime,
460
+ };
461
+
462
+ await setData(
463
+ redisKeys.runtimeTrade(userName, trade.orderId),
464
+ nextTrade,
465
+ {
466
+ expire: 0,
467
+ },
468
+ );
469
+ syncedTrades.push(nextTrade);
470
+ continue;
471
+ }
472
+
473
+ const matchedClosedPnl = takeClosedPnlMatch({
474
+ exactByOrderLinkId,
475
+ exactByOrderId,
476
+ symbolBuckets,
477
+ trade,
478
+ });
479
+
480
+ if (trade.status === 'closed' && !matchedClosedPnl) {
481
+ syncedTrades.push(trade);
482
+ continue;
483
+ }
484
+
485
+ const nextTrade: RuntimeTradeRecord = {
486
+ ...trade,
487
+ status: 'closed',
488
+ currentPrice: matchedClosedPnl?.exitPrice ?? trade.currentPrice ?? null,
489
+ currentPnl:
490
+ matchedClosedPnl?.closedPnl ??
491
+ trade.closedPnl ??
492
+ trade.currentPnl ??
493
+ null,
494
+ closedPnl:
495
+ matchedClosedPnl?.closedPnl ??
496
+ trade.closedPnl ??
497
+ trade.currentPnl ??
498
+ null,
499
+ actualEntryPrice:
500
+ matchedClosedPnl?.entryPrice ?? trade.actualEntryPrice ?? null,
501
+ exitPrice: matchedClosedPnl?.exitPrice ?? trade.exitPrice ?? null,
502
+ actualExitPrice:
503
+ matchedClosedPnl?.exitPrice ?? trade.actualExitPrice ?? null,
504
+ exitTimestamp:
505
+ matchedClosedPnl?.closedAt ?? trade.exitTimestamp ?? endTime,
506
+ exitType: trade.exitType ?? null,
507
+ openFee: matchedClosedPnl?.openFee ?? trade.openFee ?? null,
508
+ closeFee: matchedClosedPnl?.closeFee ?? trade.closeFee ?? null,
509
+ fundingFee: matchedClosedPnl?.fundingFee ?? trade.fundingFee ?? null,
510
+ totalFee: matchedClosedPnl?.totalFee ?? trade.totalFee ?? null,
511
+ lastSyncedAt: endTime,
512
+ };
513
+
514
+ await Promise.all([
515
+ setData(redisKeys.runtimeTrade(userName, trade.orderId), nextTrade, {
516
+ expire: TTL_1M,
517
+ }),
518
+ ...(isCurrentActiveTrade
519
+ ? [delKey(redisKeys.runtimeActiveTrade(userName, trade.symbol))]
520
+ : []),
521
+ ]);
522
+ syncedTrades.push(nextTrade);
523
+ }
524
+
525
+ return syncedTrades;
526
+ };
527
+
528
+ export const GET = async (request: NextRequest) => {
529
+ try {
530
+ const userName = await getCurrentUserName();
531
+ if (!userName) {
532
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
533
+ }
534
+
535
+ const provider =
536
+ request.nextUrl.searchParams.get('provider')?.trim() || DEFAULT_PROVIDER;
537
+ const hours = coerceHours(request.nextUrl.searchParams.get('hours'));
538
+ const endTime = Date.now();
539
+ const startTime = endTime - hours * 60 * 60 * 1000;
540
+ const exchangeErrors: string[] = [];
541
+ const connectorCreator = await resolveConnectorCreatorByProvider(
542
+ provider,
543
+ projectRoot,
544
+ DEFAULT_PROVIDER,
545
+ );
546
+
547
+ if (!connectorCreator) {
548
+ throw new Error(`No connector available for provider "${provider}"`);
549
+ }
550
+
551
+ const connector = await (connectorCreator as ConnectorCreator)({
552
+ userName,
553
+ });
554
+
555
+ const [
556
+ runtimeStrategyConfigs,
557
+ configuredStrategyNames,
558
+ runtimeTrades,
559
+ activeOrderIds,
560
+ closedPnlRows,
561
+ entryRows,
562
+ openPositions,
563
+ runtimeDeployments,
564
+ tradingAccounts,
565
+ ] = await Promise.all([
566
+ loadRuntimeStrategyConfigs(userName),
567
+ loadConfiguredStrategyNames(),
568
+ loadRuntimeTrades(userName, { startTime, endTime }),
569
+ loadActiveRuntimeOrderIds(userName),
570
+ loadClosedPnlRows({
571
+ connector,
572
+ startTime,
573
+ endTime,
574
+ errors: exchangeErrors,
575
+ }),
576
+ loadExchangeEntryRows({
577
+ connector,
578
+ startTime,
579
+ endTime,
580
+ errors: exchangeErrors,
581
+ }),
582
+ loadOpenPositions(connector, exchangeErrors),
583
+ listRuntimeDeployments(userName),
584
+ listTradingAccounts(userName),
585
+ ]);
586
+ const relevantTrades = selectTradesForWindow(
587
+ runtimeTrades,
588
+ startTime,
589
+ activeOrderIds,
590
+ );
591
+ const scopedTrades = relevantTrades.filter((trade) =>
592
+ Boolean(trade.accountId || trade.deploymentId),
593
+ );
594
+ const defaultAccountTrades = relevantTrades.filter(
595
+ (trade) => !trade.accountId && !trade.deploymentId,
596
+ );
597
+ const syncedDefaultAccountTrades = await syncRuntimeTrades({
598
+ userName,
599
+ trades: defaultAccountTrades,
600
+ endTime,
601
+ openPositions,
602
+ closedPnlRows,
603
+ });
604
+ const syncedTrades = [...scopedTrades, ...syncedDefaultAccountTrades];
605
+ const fallbackStrategyNames = [
606
+ ...new Set([
607
+ ...runtimeStrategyConfigs.map(({ strategyName }) => strategyName),
608
+ ...configuredStrategyNames,
609
+ ]),
610
+ ];
611
+ const fallbackTrades = buildExchangeFallbackRuntimeTrades({
612
+ entryRows,
613
+ closedPnlRows,
614
+ openPositions,
615
+ strategyNames: fallbackStrategyNames,
616
+ existingTrades: syncedTrades,
617
+ endTime,
618
+ });
619
+ const allTrades = [...syncedTrades, ...fallbackTrades].filter(
620
+ isRuntimeTradeRecord,
621
+ );
622
+ const connectedSet = new Set(
623
+ runtimeStrategyConfigs.map(
624
+ ({ strategyName, configId }) => `${strategyName}:${configId}`,
625
+ ),
626
+ );
627
+ const accountsById = new Map(
628
+ tradingAccounts.map((account) => [account.id, account]),
629
+ );
630
+ const runtimeIdentityKey = (trade: RuntimeTradeRecord) =>
631
+ buildRuntimeStrategyIdentityKey({
632
+ strategyName: trade.strategy,
633
+ configId: trade.runtimeConfigId,
634
+ universe: trade.universe,
635
+ accountId: trade.accountId,
636
+ deploymentId: trade.deploymentId,
637
+ policyProfileId: trade.policyProfileId,
638
+ });
639
+ const identityByKey = new Map<
640
+ string,
641
+ {
642
+ strategyName: string;
643
+ configId: string;
644
+ interval: Interval;
645
+ universe: 'crypto' | 'tradfi';
646
+ accountId?: string;
647
+ accountLabel?: string;
648
+ deploymentId?: string;
649
+ policyProfileId?: string;
650
+ enabled?: boolean;
651
+ config?: Record<string, unknown>;
652
+ connected?: boolean;
653
+ }
654
+ >();
655
+ const runtimeConfigAccountScopes = new Array<{
656
+ strategyName: string;
657
+ configId: string;
658
+ universe: 'crypto' | 'tradfi';
659
+ accountId?: string;
660
+ }>();
661
+ for (const deployment of runtimeDeployments) {
662
+ for (const deploymentStrategy of deployment.strategies) {
663
+ const runtimeKey = buildRuntimeStrategyIdentityKey({
664
+ strategyName: deploymentStrategy.strategyName,
665
+ configId: `deployment-${deployment.id}`,
666
+ universe: deployment.universe,
667
+ accountId: deployment.accountId,
668
+ deploymentId: deployment.id,
669
+ policyProfileId: deploymentStrategy.policyProfileId,
670
+ });
671
+ identityByKey.set(runtimeKey, {
672
+ strategyName: deploymentStrategy.strategyName,
673
+ configId: `deployment-${deployment.id}`,
674
+ interval: String(deployment.interval) as Interval,
675
+ universe: deployment.universe,
676
+ accountId: deployment.accountId,
677
+ accountLabel: accountsById.get(deployment.accountId)?.label,
678
+ deploymentId: deployment.id,
679
+ policyProfileId: deploymentStrategy.policyProfileId,
680
+ enabled: deployment.enabled && deploymentStrategy.enabled !== false,
681
+ config: deploymentStrategy.config,
682
+ connected: false,
683
+ });
684
+ }
685
+ }
686
+ for (const runtimeConfig of runtimeStrategyConfigs) {
687
+ const universe =
688
+ runtimeConfig.config.UNIVERSE === 'tradfi' ? 'tradfi' : 'crypto';
689
+ const configuredAccountId =
690
+ typeof runtimeConfig.config.ACCOUNT_ID === 'string' &&
691
+ runtimeConfig.config.ACCOUNT_ID.trim()
692
+ ? runtimeConfig.config.ACCOUNT_ID.trim()
693
+ : undefined;
694
+ const resolvedAccount = await resolveTradingAccount({
695
+ userName,
696
+ accountId: configuredAccountId,
697
+ provider,
698
+ universe,
699
+ }).catch(() => null);
700
+ const accountId = resolvedAccount?.id ?? configuredAccountId;
701
+ runtimeConfigAccountScopes.push({
702
+ strategyName: runtimeConfig.strategyName,
703
+ configId: runtimeConfig.configId,
704
+ universe,
705
+ accountId,
706
+ });
707
+ const runtimeKey = buildRuntimeStrategyIdentityKey({
708
+ strategyName: runtimeConfig.strategyName,
709
+ configId: runtimeConfig.configId,
710
+ universe,
711
+ accountId,
712
+ });
713
+ identityByKey.set(runtimeKey, {
714
+ strategyName: runtimeConfig.strategyName,
715
+ configId: runtimeConfig.configId,
716
+ interval: String(runtimeConfig.config.INTERVAL ?? '15') as Interval,
717
+ universe,
718
+ accountId,
719
+ accountLabel: accountId
720
+ ? accountsById.get(accountId)?.label
721
+ : undefined,
722
+ enabled: isRuntimeStrategyConfigEnabled(runtimeConfig.config),
723
+ config: runtimeConfig.config,
724
+ connected: true,
725
+ });
726
+ }
727
+ const accountScopedTrades = assignLegacyRuntimeTradeAccountScopes(
728
+ allTrades,
729
+ runtimeConfigAccountScopes,
730
+ );
731
+ for (const trade of accountScopedTrades) {
732
+ const key = runtimeIdentityKey(trade);
733
+ identityByKey.set(key, {
734
+ ...identityByKey.get(key),
735
+ strategyName: trade.strategy,
736
+ configId: trade.runtimeConfigId ?? 'config',
737
+ interval: String(trade.interval ?? '15') as Interval,
738
+ universe: trade.universe ?? 'crypto',
739
+ accountId: trade.accountId,
740
+ accountLabel: trade.accountId
741
+ ? accountsById.get(trade.accountId)?.label
742
+ : undefined,
743
+ deploymentId: trade.deploymentId,
744
+ policyProfileId: trade.policyProfileId,
745
+ });
746
+ }
747
+
748
+ const strategies = await Promise.all(
749
+ [...identityByKey.entries()].map(async ([runtimeKey, identity]) => {
750
+ const { strategyName } = identity;
751
+ const strategyTrades = accountScopedTrades
752
+ .filter((trade) => runtimeIdentityKey(trade) === runtimeKey)
753
+ .sort((left, right) => right.entryTimestamp - left.entryTimestamp);
754
+ const orders = strategyTrades
755
+ .sort((left, right) => {
756
+ const leftDate = left.exitTimestamp ?? left.entryTimestamp;
757
+ const rightDate = right.exitTimestamp ?? right.entryTimestamp;
758
+
759
+ return rightDate - leftDate;
760
+ })
761
+ .map((trade) => toRuntimeTradeView(trade, endTime));
762
+ const analytics = buildRuntimeStrategyAnalytics({
763
+ trades: strategyTrades,
764
+ startTime,
765
+ endTime,
766
+ });
767
+ const effectiveStrategyConfig = identity.config ?? null;
768
+
769
+ return {
770
+ runtimeKey,
771
+ strategyName,
772
+ configId: identity.configId,
773
+ interval: identity.interval,
774
+ universe: identity.universe,
775
+ accountId: identity.accountId,
776
+ accountLabel: identity.accountLabel,
777
+ deploymentId: identity.deploymentId,
778
+ policyProfileId: identity.policyProfileId,
779
+ connected:
780
+ identity.connected ??
781
+ connectedSet.has(`${strategyName}:${identity.configId}`),
782
+ enabled:
783
+ identity.enabled ??
784
+ isRuntimeStrategyConfigEnabled(effectiveStrategyConfig),
785
+ config: effectiveStrategyConfig,
786
+ symbols: [...new Set(strategyTrades.map((trade) => trade.symbol))],
787
+ stat: analytics.stat,
788
+ summary: analytics.summary,
789
+ orderLog: analytics.orderLog,
790
+ recentTrades: strategyTrades
791
+ .slice(0, 8)
792
+ .map((trade) => toRuntimeTradeView(trade, endTime)),
793
+ orders,
794
+ };
795
+ }),
796
+ );
797
+
798
+ strategies.sort((left, right) => {
799
+ if (left.stat.netProfit !== right.stat.netProfit) {
800
+ return right.stat.netProfit - left.stat.netProfit;
801
+ }
802
+ if (left.summary.totalPnl !== right.summary.totalPnl) {
803
+ return right.summary.totalPnl - left.summary.totalPnl;
804
+ }
805
+ if (left.connected !== right.connected) {
806
+ return left.connected ? -1 : 1;
807
+ }
808
+ return left.strategyName.localeCompare(right.strategyName);
809
+ });
810
+
811
+ const response: RuntimeStrategiesResponse = {
812
+ provider,
813
+ hours,
814
+ generatedAt: endTime,
815
+ dataSources: {
816
+ localTrades: syncedTrades.length,
817
+ exchangeFallbackTrades: fallbackTrades.length,
818
+ exchangeErrors: [...new Set(exchangeErrors)].sort(),
819
+ },
820
+ strategies,
821
+ };
822
+
823
+ return NextResponse.json(response);
824
+ } catch (error) {
825
+ logger.error('strategies runtime route failed: %o', error);
826
+ return NextResponse.json(
827
+ { error: 'Internal Server Error' },
828
+ { status: 500 },
829
+ );
830
+ }
831
+ };