@tradejs/app 2.0.21 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/app/actions/backtest.ts +1 -1
- package/src/app/actions/strategies.ts +1 -1
- package/src/app/api/ai/route.ts +13 -6
- package/src/app/api/user/settings/route.ts +48 -28
- package/src/app/components/Shared/OrdersDrawer.tsx +4 -2
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +3 -3
- package/src/app/components/Strategies/RuntimeStrategyCard.presenter.ts +536 -0
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +16 -1207
- package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +1 -1
- package/src/app/components/Strategies/RuntimeStrategyStatsDrawer.tsx +677 -0
- package/src/app/components/Strategies/StrategySnapshotCard.details.presenter.ts +38 -0
- package/src/app/components/Strategies/StrategySnapshotCard.diagnostics.presenter.ts +263 -0
- package/src/app/components/Strategies/StrategySnapshotCard.orders.presenter.ts +680 -0
- package/src/app/components/Strategies/StrategySnapshotCard.presenter.ts +119 -0
- package/src/app/components/Strategies/StrategySnapshotCard.ranking.presenter.ts +76 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +13 -1793
- package/src/app/components/Strategies/StrategySnapshotCardDetailsDrawer.tsx +682 -0
- package/src/app/lib/runtimeStrategies.ts +7 -80
- package/src/app/lib/runtimeStrategyContracts.ts +85 -0
- package/src/app/routes/backtest/BacktestJobItem.tsx +275 -0
- package/src/app/routes/backtest/BacktestRunForm.tsx +502 -0
- package/src/app/routes/backtest/page.tsx +16 -1099
- package/src/app/routes/backtest/useBacktestRunsController.ts +441 -0
- package/src/app/routes/strategies/StrategiesPageClient.tsx +1 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { getFormatted, type AdvancedTradeInput } from '@tradejs/core/backtest';
|
|
2
|
+
import type {
|
|
3
|
+
StrategyChartOrder,
|
|
4
|
+
StrategyChartSnapshot,
|
|
5
|
+
TestStat,
|
|
6
|
+
TestThresholdsKey,
|
|
7
|
+
} from '@tradejs/types';
|
|
8
|
+
import {
|
|
9
|
+
formatCompactNumber,
|
|
10
|
+
formatFee,
|
|
11
|
+
formatPriceUsdt,
|
|
12
|
+
formatUsdt,
|
|
13
|
+
type OrdersDrawerOrder,
|
|
14
|
+
} from '#components/Shared/OrdersDrawer';
|
|
15
|
+
import {
|
|
16
|
+
calculateMaxDrawdownValue,
|
|
17
|
+
calculateMaxGrossStreak,
|
|
18
|
+
calculateMaxLossStreak,
|
|
19
|
+
getEquityStepPnl as getSnapshotStepPnl,
|
|
20
|
+
} from '#app/lib/strategyPerformance';
|
|
21
|
+
|
|
22
|
+
const MS_IN_HOUR = 60 * 60 * 1000;
|
|
23
|
+
export const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
|
|
24
|
+
const SNAPSHOT_SUMMARY_METRICS: {
|
|
25
|
+
id: TestThresholdsKey;
|
|
26
|
+
label: string;
|
|
27
|
+
}[] = [
|
|
28
|
+
{ id: 'netProfit', label: 'P&L' },
|
|
29
|
+
{ id: 'minAmount', label: 'Min Amount' },
|
|
30
|
+
{ id: 'maxDrawdown', label: 'Drawdown' },
|
|
31
|
+
{ id: 'orders', label: 'Orders' },
|
|
32
|
+
{ id: 'winRate', label: 'Win Rate' },
|
|
33
|
+
{ id: 'riskRewardRatio', label: 'Risk Ratio' },
|
|
34
|
+
{ id: 'maxConsecutiveWins', label: 'Max Gross Streak' },
|
|
35
|
+
{ id: 'maxConsecutiveLosses', label: 'Max Loss Streak' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const asFiniteNumber = (value: unknown) =>
|
|
39
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
40
|
+
|
|
41
|
+
const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
|
|
42
|
+
const orderPnls = snapshot.orders
|
|
43
|
+
.map((order) => asFiniteNumber(order.pnl))
|
|
44
|
+
.filter((pnl): pnl is number => pnl != null);
|
|
45
|
+
|
|
46
|
+
if (orderPnls.length) {
|
|
47
|
+
return orderPnls;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return snapshot.orderLog
|
|
51
|
+
.map((_, index) =>
|
|
52
|
+
index === 0
|
|
53
|
+
? null
|
|
54
|
+
: asFiniteNumber(getSnapshotStepPnl(snapshot.orderLog, index)),
|
|
55
|
+
)
|
|
56
|
+
.filter((pnl): pnl is number => pnl != null);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const calculateSnapshotRiskRewardRatio = (pnls: number[]) => {
|
|
60
|
+
const wins = pnls.filter((pnl) => pnl > 0);
|
|
61
|
+
const losses = pnls.filter((pnl) => pnl < 0);
|
|
62
|
+
|
|
63
|
+
if (!wins.length || !losses.length) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const avgWin = wins.reduce((sum, pnl) => sum + pnl, 0) / wins.length;
|
|
68
|
+
const avgLossAbs = Math.abs(
|
|
69
|
+
losses.reduce((sum, pnl) => sum + pnl, 0) / losses.length,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return avgLossAbs > 0 ? avgWin / avgLossAbs : null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const buildSnapshotSummaryStat = (
|
|
76
|
+
snapshot: StrategyChartSnapshot,
|
|
77
|
+
): Partial<TestStat> => {
|
|
78
|
+
const amounts = snapshot.orderLog
|
|
79
|
+
.map(([, amount]) => asFiniteNumber(amount))
|
|
80
|
+
.filter((amount): amount is number => amount != null);
|
|
81
|
+
const firstAmount = amounts[0] ?? null;
|
|
82
|
+
const lastAmount = amounts.at(-1) ?? null;
|
|
83
|
+
const pnls = getSnapshotTradePnls(snapshot);
|
|
84
|
+
const wins = pnls.filter((pnl) => pnl > 0).length;
|
|
85
|
+
const orders =
|
|
86
|
+
asFiniteNumber(snapshot.stat?.orders) ??
|
|
87
|
+
(snapshot.orders.length || pnls.length);
|
|
88
|
+
const netProfit =
|
|
89
|
+
asFiniteNumber(snapshot.stat?.netProfit) ??
|
|
90
|
+
(firstAmount != null && lastAmount != null
|
|
91
|
+
? lastAmount - firstAmount
|
|
92
|
+
: pnls.reduce((sum, pnl) => sum + pnl, 0));
|
|
93
|
+
const minAmount =
|
|
94
|
+
asFiniteNumber(snapshot.stat?.minAmount) ??
|
|
95
|
+
(amounts.length ? Math.min(...amounts) : null);
|
|
96
|
+
const maxDrawdown =
|
|
97
|
+
asFiniteNumber(snapshot.stat?.maxDrawdown) ??
|
|
98
|
+
calculateMaxDrawdownValue(snapshot.orderLog);
|
|
99
|
+
const winRate =
|
|
100
|
+
asFiniteNumber(snapshot.stat?.winRate) ??
|
|
101
|
+
(orders > 0 ? (wins / orders) * 100 : 0);
|
|
102
|
+
const riskRewardRatio =
|
|
103
|
+
asFiniteNumber(snapshot.stat?.riskRewardRatio) ??
|
|
104
|
+
calculateSnapshotRiskRewardRatio(pnls);
|
|
105
|
+
const maxConsecutiveWins =
|
|
106
|
+
asFiniteNumber(snapshot.stat?.maxConsecutiveWins) ??
|
|
107
|
+
calculateMaxGrossStreak(snapshot.orderLog);
|
|
108
|
+
const maxConsecutiveLosses =
|
|
109
|
+
asFiniteNumber(snapshot.stat?.maxConsecutiveLosses) ??
|
|
110
|
+
calculateMaxLossStreak(snapshot.orderLog);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
netProfit,
|
|
114
|
+
minAmount: minAmount ?? undefined,
|
|
115
|
+
maxDrawdown: maxDrawdown ?? undefined,
|
|
116
|
+
orders,
|
|
117
|
+
winRate,
|
|
118
|
+
riskRewardRatio,
|
|
119
|
+
maxConsecutiveWins,
|
|
120
|
+
maxConsecutiveLosses,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const buildSnapshotSummaryMetrics = (
|
|
125
|
+
snapshot: StrategyChartSnapshot,
|
|
126
|
+
) => {
|
|
127
|
+
const stat = buildSnapshotSummaryStat(snapshot);
|
|
128
|
+
|
|
129
|
+
return SNAPSHOT_SUMMARY_METRICS.map(({ id, label }) => {
|
|
130
|
+
const { formatted, level } = getFormatted(stat, id);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id,
|
|
134
|
+
label,
|
|
135
|
+
value: formatted,
|
|
136
|
+
tone: level,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const formatPrice = (value: number | null | undefined) =>
|
|
142
|
+
formatPriceUsdt(value);
|
|
143
|
+
|
|
144
|
+
const formatBps = (value: number | null | undefined) => {
|
|
145
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
146
|
+
return 'n/a';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return `${formatCompactNumber(value, {
|
|
150
|
+
maximumFractionDigits: 2,
|
|
151
|
+
minimumFractionDigits: 2,
|
|
152
|
+
})} bps`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const formatAiExitReason = (reason: string | null | undefined) =>
|
|
156
|
+
reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
|
|
157
|
+
|
|
158
|
+
const getAiExitReasonColor = (reason: string | null | undefined) => {
|
|
159
|
+
switch (reason) {
|
|
160
|
+
case 'take_profit':
|
|
161
|
+
return 'teal';
|
|
162
|
+
case 'stop_loss':
|
|
163
|
+
return 'red';
|
|
164
|
+
default:
|
|
165
|
+
return 'gray';
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const buildSlippageDetail = ({
|
|
170
|
+
requestedPrice,
|
|
171
|
+
slippageBps,
|
|
172
|
+
}: {
|
|
173
|
+
requestedPrice?: number | null;
|
|
174
|
+
slippageBps?: number | null;
|
|
175
|
+
}) => [`plan ${formatPrice(requestedPrice)}`, `slip ${formatBps(slippageBps)}`];
|
|
176
|
+
|
|
177
|
+
const buildAiFeesDetail = (order: StrategyChartOrder) => [
|
|
178
|
+
`open ${formatFee(order.openFee)}`,
|
|
179
|
+
`close ${formatFee(order.closeFee)}`,
|
|
180
|
+
`funding ${formatFee(order.fundingFee)}`,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const normalizeSnapshotDirection = (
|
|
184
|
+
direction: StrategyChartOrder['direction'],
|
|
185
|
+
): OrdersDrawerOrder['direction'] =>
|
|
186
|
+
direction === 'LONG' || direction === 'SHORT' ? direction : null;
|
|
187
|
+
|
|
188
|
+
const getSnapshotOrderTimestamp = (order: StrategyChartOrder) => {
|
|
189
|
+
const timestamp =
|
|
190
|
+
order.timestamp ?? order.entryTimestamp ?? order.exitTimestamp;
|
|
191
|
+
return typeof timestamp === 'number' && Number.isFinite(timestamp)
|
|
192
|
+
? timestamp
|
|
193
|
+
: null;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const getReplayOrderStatus = (type: string | null | undefined) => {
|
|
197
|
+
if (type?.startsWith('OPEN')) {
|
|
198
|
+
return { label: 'ACTIVE', color: 'orange', status: 'active' as const };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (type?.startsWith('TAKE_PROFIT')) {
|
|
202
|
+
return { label: 'TAKE PROFIT', color: 'teal', status: 'closed' as const };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (type?.startsWith('STOP_LOSS')) {
|
|
206
|
+
return { label: 'STOP LOSS', color: 'red', status: 'closed' as const };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (type?.startsWith('CLOSE')) {
|
|
210
|
+
return { label: 'CLOSE', color: 'gray', status: 'closed' as const };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { label: 'REPLAY', color: 'gray', status: 'closed' as const };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const isReplayOpenOrder = (order: StrategyChartOrder) =>
|
|
217
|
+
order.exitReason?.startsWith('OPEN') === true;
|
|
218
|
+
|
|
219
|
+
const getReplayPairKey = (order: StrategyChartOrder) => {
|
|
220
|
+
const direction = normalizeSnapshotDirection(order.direction);
|
|
221
|
+
if (!order.symbol || !direction) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return `${order.symbol}:${direction}`;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getFiniteNumber = (value: number | null | undefined) =>
|
|
229
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
230
|
+
|
|
231
|
+
const sumFiniteNumbers = (
|
|
232
|
+
...values: Array<number | null | undefined>
|
|
233
|
+
): number | null => {
|
|
234
|
+
let total = 0;
|
|
235
|
+
let hasValue = false;
|
|
236
|
+
|
|
237
|
+
for (const value of values) {
|
|
238
|
+
const finiteValue = getFiniteNumber(value);
|
|
239
|
+
if (finiteValue == null) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
total += finiteValue;
|
|
244
|
+
hasValue = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return hasValue ? total : null;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const multiplyFiniteNumber = (
|
|
251
|
+
value: number | null | undefined,
|
|
252
|
+
multiplier: number,
|
|
253
|
+
) => {
|
|
254
|
+
const finiteValue = getFiniteNumber(value);
|
|
255
|
+
return finiteValue == null ? null : finiteValue * multiplier;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const buildReplayFeesDetail = ({
|
|
259
|
+
openFee,
|
|
260
|
+
closeFee,
|
|
261
|
+
fundingFee,
|
|
262
|
+
}: {
|
|
263
|
+
openFee?: number | null;
|
|
264
|
+
closeFee?: number | null;
|
|
265
|
+
fundingFee?: number | null;
|
|
266
|
+
}) => [
|
|
267
|
+
`open ${formatFee(openFee)}`,
|
|
268
|
+
`close ${formatFee(closeFee)}`,
|
|
269
|
+
`funding ${formatFee(fundingFee)}`,
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
type ReplayOpenPosition = {
|
|
273
|
+
order: StrategyChartOrder;
|
|
274
|
+
remainingQty: number | null;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const getReplayExitShare = (
|
|
278
|
+
entry: ReplayOpenPosition,
|
|
279
|
+
exitOrder: StrategyChartOrder,
|
|
280
|
+
) => {
|
|
281
|
+
const entryQty = getFiniteNumber(entry.order.qty);
|
|
282
|
+
const exitQty = getFiniteNumber(exitOrder.qty);
|
|
283
|
+
|
|
284
|
+
if (entryQty == null || entryQty <= 0 || exitQty == null || exitQty <= 0) {
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const remainingQty = entry.remainingQty ?? entryQty;
|
|
289
|
+
return Math.min(exitQty, remainingQty) / entryQty;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const consumeReplayEntryQty = (
|
|
293
|
+
entry: ReplayOpenPosition,
|
|
294
|
+
exitOrder: StrategyChartOrder,
|
|
295
|
+
) => {
|
|
296
|
+
const exitQty = getFiniteNumber(exitOrder.qty);
|
|
297
|
+
if (entry.remainingQty == null || exitQty == null) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
entry.remainingQty = Math.max(0, entry.remainingQty - exitQty);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const isReplayEntryConsumed = (entry: ReplayOpenPosition) =>
|
|
305
|
+
entry.remainingQty != null && entry.remainingQty <= 0.00000001;
|
|
306
|
+
|
|
307
|
+
const buildReplayTradeCard = ({
|
|
308
|
+
snapshot,
|
|
309
|
+
entryOrder,
|
|
310
|
+
exitOrder,
|
|
311
|
+
tradeIndex,
|
|
312
|
+
entryShare = 1,
|
|
313
|
+
}: {
|
|
314
|
+
snapshot: StrategyChartSnapshot;
|
|
315
|
+
entryOrder: StrategyChartOrder;
|
|
316
|
+
exitOrder?: StrategyChartOrder;
|
|
317
|
+
tradeIndex: number;
|
|
318
|
+
entryShare?: number;
|
|
319
|
+
}): OrdersDrawerOrder => {
|
|
320
|
+
const orderStatus = getReplayOrderStatus(
|
|
321
|
+
exitOrder?.exitReason ?? entryOrder.exitReason,
|
|
322
|
+
);
|
|
323
|
+
const entryTimestamp = getSnapshotOrderTimestamp(entryOrder);
|
|
324
|
+
const exitTimestamp = exitOrder ? getSnapshotOrderTimestamp(exitOrder) : null;
|
|
325
|
+
const durationHours =
|
|
326
|
+
entryTimestamp != null && exitTimestamp != null
|
|
327
|
+
? (exitTimestamp - entryTimestamp) / MS_IN_HOUR
|
|
328
|
+
: null;
|
|
329
|
+
const openFee = multiplyFiniteNumber(entryOrder.openFee, entryShare);
|
|
330
|
+
const openPnl = multiplyFiniteNumber(entryOrder.pnl, entryShare);
|
|
331
|
+
const closeFee = exitOrder?.closeFee ?? exitOrder?.totalFee ?? null;
|
|
332
|
+
const fundingFee = sumFiniteNumbers(
|
|
333
|
+
multiplyFiniteNumber(entryOrder.fundingFee, entryShare),
|
|
334
|
+
exitOrder?.fundingFee,
|
|
335
|
+
);
|
|
336
|
+
const totalFee = sumFiniteNumbers(openFee, closeFee, fundingFee);
|
|
337
|
+
const pnl = sumFiniteNumbers(openPnl, exitOrder?.pnl);
|
|
338
|
+
const qty = exitOrder?.qty ?? entryOrder.qty;
|
|
339
|
+
const notional =
|
|
340
|
+
entryOrder.notional != null
|
|
341
|
+
? multiplyFiniteNumber(entryOrder.notional, entryShare)
|
|
342
|
+
: exitOrder?.notional;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
id: `${snapshot.cardId}:replay-trade:${tradeIndex}:${entryOrder.id}:${exitOrder?.id ?? 'active'}`,
|
|
346
|
+
title: entryOrder.symbol
|
|
347
|
+
? `${entryOrder.symbol} · Replay #${tradeIndex}`
|
|
348
|
+
: `Replay #${tradeIndex}`,
|
|
349
|
+
period: {
|
|
350
|
+
start: entryTimestamp,
|
|
351
|
+
end: exitTimestamp,
|
|
352
|
+
durationHours,
|
|
353
|
+
},
|
|
354
|
+
direction: normalizeSnapshotDirection(entryOrder.direction),
|
|
355
|
+
status: orderStatus.status,
|
|
356
|
+
statusLabel: orderStatus.label,
|
|
357
|
+
statusColor: orderStatus.color,
|
|
358
|
+
pnl,
|
|
359
|
+
metrics: [
|
|
360
|
+
{
|
|
361
|
+
title: 'Entry',
|
|
362
|
+
value: formatPrice(entryOrder.entryPrice),
|
|
363
|
+
detailLines: buildSlippageDetail({
|
|
364
|
+
requestedPrice: entryOrder.requestedEntryPrice,
|
|
365
|
+
slippageBps: entryOrder.entrySlippageBps,
|
|
366
|
+
}),
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
title: 'Exit',
|
|
370
|
+
value: formatPrice(exitOrder?.exitPrice),
|
|
371
|
+
detailLines: buildSlippageDetail({
|
|
372
|
+
requestedPrice: exitOrder?.requestedExitPrice,
|
|
373
|
+
slippageBps: exitOrder?.exitSlippageBps,
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
title: 'Notional',
|
|
378
|
+
value: formatUsdt(notional),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
title: 'Fees',
|
|
382
|
+
value: formatFee(totalFee),
|
|
383
|
+
detailLines: buildReplayFeesDetail({ openFee, closeFee, fundingFee }),
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
title: 'Qty',
|
|
387
|
+
value: formatCompactNumber(qty, {
|
|
388
|
+
maximumFractionDigits: 8,
|
|
389
|
+
minimumFractionDigits: 0,
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
title: 'Equity',
|
|
394
|
+
value: formatUsdt(exitOrder?.equityAfter ?? entryOrder.equityAfter),
|
|
395
|
+
detail: `prev ${formatUsdt(entryOrder.equityBefore)}`,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const buildReplaySnapshotOrders = (
|
|
402
|
+
snapshot: StrategyChartSnapshot,
|
|
403
|
+
): OrdersDrawerOrder[] => {
|
|
404
|
+
const persistedOrders = snapshot.orders
|
|
405
|
+
.map((order, index) => ({ order, index }))
|
|
406
|
+
.sort((left, right) => {
|
|
407
|
+
const leftTimestamp =
|
|
408
|
+
getSnapshotOrderTimestamp(left.order) ?? Number.NEGATIVE_INFINITY;
|
|
409
|
+
const rightTimestamp =
|
|
410
|
+
getSnapshotOrderTimestamp(right.order) ?? Number.NEGATIVE_INFINITY;
|
|
411
|
+
|
|
412
|
+
return leftTimestamp - rightTimestamp || left.index - right.index;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (persistedOrders.length) {
|
|
416
|
+
const openPositions = new Map<string, ReplayOpenPosition[]>();
|
|
417
|
+
const tradeCards: OrdersDrawerOrder[] = [];
|
|
418
|
+
|
|
419
|
+
for (const { order } of persistedOrders) {
|
|
420
|
+
const pairKey = getReplayPairKey(order);
|
|
421
|
+
if (!pairKey) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (isReplayOpenOrder(order)) {
|
|
426
|
+
const bucket = openPositions.get(pairKey) ?? [];
|
|
427
|
+
bucket.push({
|
|
428
|
+
order,
|
|
429
|
+
remainingQty: getFiniteNumber(order.qty),
|
|
430
|
+
});
|
|
431
|
+
openPositions.set(pairKey, bucket);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const bucket = openPositions.get(pairKey);
|
|
436
|
+
const entry = bucket?.[0];
|
|
437
|
+
if (!entry) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const entryShare = getReplayExitShare(entry, order);
|
|
442
|
+
tradeCards.push(
|
|
443
|
+
buildReplayTradeCard({
|
|
444
|
+
snapshot,
|
|
445
|
+
entryOrder: entry.order,
|
|
446
|
+
exitOrder: order,
|
|
447
|
+
tradeIndex: tradeCards.length + 1,
|
|
448
|
+
entryShare,
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
consumeReplayEntryQty(entry, order);
|
|
452
|
+
|
|
453
|
+
if (entry.remainingQty == null || isReplayEntryConsumed(entry)) {
|
|
454
|
+
bucket.shift();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const bucket of openPositions.values()) {
|
|
459
|
+
for (const entry of bucket) {
|
|
460
|
+
if (isReplayEntryConsumed(entry)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
tradeCards.push(
|
|
465
|
+
buildReplayTradeCard({
|
|
466
|
+
snapshot,
|
|
467
|
+
entryOrder: entry.order,
|
|
468
|
+
tradeIndex: tradeCards.length + 1,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return tradeCards.reverse();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return snapshot.orderLog
|
|
478
|
+
.slice(1)
|
|
479
|
+
.map<OrdersDrawerOrder | null>((current, index) => {
|
|
480
|
+
const previous = snapshot.orderLog[index];
|
|
481
|
+
if (!previous) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const [timestamp, equityAfter] = current;
|
|
486
|
+
const [, equityBefore] = previous;
|
|
487
|
+
const pnl = equityAfter - equityBefore;
|
|
488
|
+
if (
|
|
489
|
+
!Number.isFinite(timestamp) ||
|
|
490
|
+
!Number.isFinite(equityBefore) ||
|
|
491
|
+
!Number.isFinite(equityAfter) ||
|
|
492
|
+
!Number.isFinite(pnl)
|
|
493
|
+
) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const orderIndex = index + 1;
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
id: `${snapshot.cardId}:replay:${orderIndex}:${timestamp}`,
|
|
501
|
+
title: `Replay #${orderIndex}`,
|
|
502
|
+
period: {
|
|
503
|
+
start: timestamp,
|
|
504
|
+
},
|
|
505
|
+
status: 'closed',
|
|
506
|
+
statusLabel: 'REPLAY',
|
|
507
|
+
statusColor: 'gray',
|
|
508
|
+
pnl,
|
|
509
|
+
metrics: [
|
|
510
|
+
{
|
|
511
|
+
title: 'Entry',
|
|
512
|
+
value: 'n/a',
|
|
513
|
+
detailLines: buildSlippageDetail({}),
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
title: 'Exit',
|
|
517
|
+
value: 'n/a',
|
|
518
|
+
detailLines: buildSlippageDetail({}),
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
title: 'Notional',
|
|
522
|
+
value: formatUsdt(null),
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
title: 'Fees',
|
|
526
|
+
value: formatFee(null),
|
|
527
|
+
detailLines: buildReplayFeesDetail({}),
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
title: 'Qty',
|
|
531
|
+
value: formatCompactNumber(null),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
title: 'Equity',
|
|
535
|
+
value: formatUsdt(equityAfter),
|
|
536
|
+
detail: `prev ${formatUsdt(equityBefore)}`,
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
};
|
|
540
|
+
})
|
|
541
|
+
.filter((order): order is OrdersDrawerOrder => order != null)
|
|
542
|
+
.reverse();
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const buildAiSnapshotOrders = (
|
|
546
|
+
snapshot: StrategyChartSnapshot,
|
|
547
|
+
): OrdersDrawerOrder[] =>
|
|
548
|
+
snapshot.orders
|
|
549
|
+
.map((order, index) => ({ order, index }))
|
|
550
|
+
.sort((left, right) => {
|
|
551
|
+
const leftEntry =
|
|
552
|
+
typeof left.order.entryTimestamp === 'number' &&
|
|
553
|
+
Number.isFinite(left.order.entryTimestamp)
|
|
554
|
+
? left.order.entryTimestamp
|
|
555
|
+
: Number.NEGATIVE_INFINITY;
|
|
556
|
+
const rightEntry =
|
|
557
|
+
typeof right.order.entryTimestamp === 'number' &&
|
|
558
|
+
Number.isFinite(right.order.entryTimestamp)
|
|
559
|
+
? right.order.entryTimestamp
|
|
560
|
+
: Number.NEGATIVE_INFINITY;
|
|
561
|
+
|
|
562
|
+
return rightEntry - leftEntry || left.index - right.index;
|
|
563
|
+
})
|
|
564
|
+
.map(({ order, index }) => {
|
|
565
|
+
const orderIndex = order.sequence ?? index + 1;
|
|
566
|
+
const durationHours =
|
|
567
|
+
typeof order.entryTimestamp === 'number' &&
|
|
568
|
+
Number.isFinite(order.entryTimestamp) &&
|
|
569
|
+
typeof order.exitTimestamp === 'number' &&
|
|
570
|
+
Number.isFinite(order.exitTimestamp)
|
|
571
|
+
? (order.exitTimestamp - order.entryTimestamp) / MS_IN_HOUR
|
|
572
|
+
: null;
|
|
573
|
+
const title = order.symbol
|
|
574
|
+
? `${order.symbol} · AI step #${orderIndex}`
|
|
575
|
+
: `AI step #${orderIndex}`;
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
id: `${snapshot.cardId}:${order.id}`,
|
|
579
|
+
title,
|
|
580
|
+
period: {
|
|
581
|
+
start: order.entryTimestamp,
|
|
582
|
+
end: order.exitTimestamp,
|
|
583
|
+
durationHours,
|
|
584
|
+
},
|
|
585
|
+
direction: normalizeSnapshotDirection(order.direction),
|
|
586
|
+
status:
|
|
587
|
+
typeof order.exitTimestamp === 'number' &&
|
|
588
|
+
Number.isFinite(order.exitTimestamp)
|
|
589
|
+
? 'closed'
|
|
590
|
+
: 'active',
|
|
591
|
+
statusLabel: formatAiExitReason(order.exitReason),
|
|
592
|
+
statusColor: getAiExitReasonColor(order.exitReason),
|
|
593
|
+
pnl: order.pnl,
|
|
594
|
+
metrics: [
|
|
595
|
+
{
|
|
596
|
+
title: 'Entry',
|
|
597
|
+
value: formatPrice(order.entryPrice),
|
|
598
|
+
detailLines: buildSlippageDetail({
|
|
599
|
+
requestedPrice: order.requestedEntryPrice,
|
|
600
|
+
slippageBps: order.entrySlippageBps,
|
|
601
|
+
}),
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
title: 'Exit',
|
|
605
|
+
value: formatPrice(order.exitPrice),
|
|
606
|
+
detailLines: buildSlippageDetail({
|
|
607
|
+
requestedPrice: order.requestedExitPrice,
|
|
608
|
+
slippageBps: order.exitSlippageBps,
|
|
609
|
+
}),
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
title: 'Notional',
|
|
613
|
+
value: formatUsdt(order.notional),
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
title: 'Fees',
|
|
617
|
+
value: formatFee(order.totalFee),
|
|
618
|
+
detailLines: buildAiFeesDetail(order),
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
title: 'Qty',
|
|
622
|
+
value: formatCompactNumber(order.qty, {
|
|
623
|
+
maximumFractionDigits: 8,
|
|
624
|
+
minimumFractionDigits: 0,
|
|
625
|
+
}),
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
title: 'Equity',
|
|
629
|
+
value: formatUsdt(order.equityAfter),
|
|
630
|
+
detail: `prev ${formatUsdt(order.equityBefore)}`,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
export const buildSnapshotOrders = (
|
|
637
|
+
snapshot: StrategyChartSnapshot,
|
|
638
|
+
mode: 'replay' | 'ai',
|
|
639
|
+
): OrdersDrawerOrder[] =>
|
|
640
|
+
mode === 'replay'
|
|
641
|
+
? buildReplaySnapshotOrders(snapshot)
|
|
642
|
+
: buildAiSnapshotOrders(snapshot);
|
|
643
|
+
|
|
644
|
+
export const buildSnapshotAdvancedTrades = (
|
|
645
|
+
snapshot: StrategyChartSnapshot,
|
|
646
|
+
): AdvancedTradeInput[] =>
|
|
647
|
+
snapshot.orders.flatMap((order): AdvancedTradeInput[] => {
|
|
648
|
+
const timestamp =
|
|
649
|
+
order.exitTimestamp ?? order.timestamp ?? order.entryTimestamp;
|
|
650
|
+
|
|
651
|
+
if (
|
|
652
|
+
typeof timestamp !== 'number' ||
|
|
653
|
+
!Number.isFinite(timestamp) ||
|
|
654
|
+
typeof order.pnl !== 'number' ||
|
|
655
|
+
!Number.isFinite(order.pnl)
|
|
656
|
+
) {
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const slippageCost =
|
|
661
|
+
typeof order.totalSlippageCost === 'number' &&
|
|
662
|
+
Number.isFinite(order.totalSlippageCost)
|
|
663
|
+
? Math.abs(order.totalSlippageCost)
|
|
664
|
+
: null;
|
|
665
|
+
|
|
666
|
+
return [
|
|
667
|
+
{
|
|
668
|
+
id: order.id,
|
|
669
|
+
timestamp,
|
|
670
|
+
pnl: order.pnl,
|
|
671
|
+
symbol: order.symbol ?? null,
|
|
672
|
+
direction: order.direction ?? null,
|
|
673
|
+
exitReason: order.exitReason ?? null,
|
|
674
|
+
slippageCost,
|
|
675
|
+
grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
|
|
676
|
+
approved: true,
|
|
677
|
+
blocked: false,
|
|
678
|
+
},
|
|
679
|
+
];
|
|
680
|
+
});
|