@tradejs/app 2.0.17 → 2.0.19
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 +14 -8
- package/src/app/actions/backtest.ts +2 -1
- package/src/app/actions/scanner.ts +2 -1
- package/src/app/api/backtest/files/route.ts +2 -1
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +1 -1
- package/src/app/api/derivatives/[symbol]/[interval]/route.ts +1 -1
- package/src/app/api/derivatives/summary/route.ts +1 -1
- package/src/app/api/spread/[symbol]/[interval]/route.ts +1 -1
- package/src/app/api/spread/summary/route.ts +1 -1
- package/src/app/api/strategies/runtime/route.ts +4 -674
- package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +1 -1
- package/src/app/api/user/runtime-deployments/route.ts +2 -2
- package/src/app/api/user/runtime-strategy-configs/route.ts +22 -212
- package/src/app/api/user/trading-accounts/[accountId]/route.ts +1 -1
- package/src/app/components/Backtest/TestList/index.tsx +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/circle.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/label.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/figures/star.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/index.tsx +2 -1
- package/src/app/components/Shared/Filters/Root/index.tsx +1 -1
- package/src/app/components/Shared/Filters/context.ts +1 -1
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +81 -858
- package/src/app/components/Strategies/StrategyPerformanceCharts.tsx +419 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +122 -937
- package/src/app/components/UI/Segment/index.tsx +1 -1
- package/src/app/components/UI/Select/index.tsx +1 -1
- package/src/app/components/UI/SelectWithSearch/index.tsx +1 -1
- package/src/app/lib/backtestJobContracts.ts +67 -0
- package/src/app/lib/backtestJobProgress.ts +28 -0
- package/src/app/lib/backtestJobRequest.ts +107 -0
- package/src/app/lib/backtestJobs.ts +25 -257
- package/src/app/lib/runtimeDashboard.ts +684 -0
- package/src/app/lib/runtimeStrategies.ts +24 -454
- package/src/app/lib/runtimeStrategyConfigService.ts +279 -0
- package/src/app/lib/runtimeStrategyLineage.ts +264 -0
- package/src/app/lib/runtimeTradeReconciliation.ts +113 -0
- package/src/app/lib/runtimeTradeSync.ts +1 -1
- package/src/app/lib/strategyPerformance.ts +387 -0
- package/src/app/routes/dashboard/Dashboard.tsx +2 -6
- package/src/app/routes/derivatives/derivativesViewModel.ts +253 -0
- package/src/app/routes/derivatives/page.tsx +70 -262
- package/src/app/store/filters.ts +2 -1
- package/src/app/store/indicators.ts +2 -1
- package/src/app/store/tests.ts +1 -1
- package/src/app/store/tickers.ts +2 -1
- package/src/app/types/ui.ts +20 -0
|
@@ -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
|
-
|
|
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,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
export type DerivativesInterval = '15m' | '1h';
|
|
2
|
+
|
|
3
|
+
export type SummaryItem = {
|
|
4
|
+
symbol: string;
|
|
5
|
+
interval: DerivativesInterval;
|
|
6
|
+
points: number;
|
|
7
|
+
last_ts: string;
|
|
8
|
+
first_ts: string;
|
|
9
|
+
latest_open_interest: number | null;
|
|
10
|
+
first_open_interest: number | null;
|
|
11
|
+
oi_change: number | null;
|
|
12
|
+
oi_change_pct: number | null;
|
|
13
|
+
latest_funding_rate: number | null;
|
|
14
|
+
first_funding_rate: number | null;
|
|
15
|
+
funding_change: number | null;
|
|
16
|
+
sum_liq_long: number | null;
|
|
17
|
+
sum_liq_short: number | null;
|
|
18
|
+
sum_liq_total: number | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SummaryResponse = {
|
|
22
|
+
hours: number;
|
|
23
|
+
items: SummaryItem[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type DetailRow = {
|
|
27
|
+
symbol: string;
|
|
28
|
+
interval: DerivativesInterval;
|
|
29
|
+
ts: string;
|
|
30
|
+
open_interest: number | null;
|
|
31
|
+
funding_rate: number | null;
|
|
32
|
+
liq_long: number | null;
|
|
33
|
+
liq_short: number | null;
|
|
34
|
+
liq_total: number | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DetailResponse = {
|
|
38
|
+
rows: DetailRow[];
|
|
39
|
+
symbol: string;
|
|
40
|
+
interval: DerivativesInterval;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PriceRow = {
|
|
44
|
+
close: number;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type PriceResponse = {
|
|
49
|
+
data?: PriceRow[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type SymbolMetrics = {
|
|
53
|
+
symbol: string;
|
|
54
|
+
lastTs: string | null;
|
|
55
|
+
currentOpenInterest: number | null;
|
|
56
|
+
oiChange: number | null;
|
|
57
|
+
oiChangePct: number | null;
|
|
58
|
+
currentFundingRate: number | null;
|
|
59
|
+
fundingChange: number | null;
|
|
60
|
+
sumLiqLong: number | null;
|
|
61
|
+
sumLiqShort: number | null;
|
|
62
|
+
sumLiqTotal: number | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type DerivativesChartRow = {
|
|
66
|
+
timestamp: number;
|
|
67
|
+
openInterest: number;
|
|
68
|
+
funding: number;
|
|
69
|
+
longLiquidations: number;
|
|
70
|
+
shortLiquidations: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type PriceChartRow = {
|
|
74
|
+
price: number;
|
|
75
|
+
timestamp: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type BiasTone = 'teal' | 'green' | 'red' | 'orange' | 'gray';
|
|
79
|
+
|
|
80
|
+
export type MarketBias = {
|
|
81
|
+
label: string;
|
|
82
|
+
tone: BiasTone;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const toFiniteNumber = (value: number | null | undefined) =>
|
|
86
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
87
|
+
|
|
88
|
+
export const mapRowsToChartRows = (rows: DetailRow[]): DerivativesChartRow[] =>
|
|
89
|
+
rows.map((row) => ({
|
|
90
|
+
timestamp: new Date(row.ts).getTime(),
|
|
91
|
+
openInterest: toFiniteNumber(row.open_interest) ?? 0,
|
|
92
|
+
funding: (toFiniteNumber(row.funding_rate) ?? 0) * 10_000,
|
|
93
|
+
longLiquidations: -(toFiniteNumber(row.liq_long) ?? 0),
|
|
94
|
+
shortLiquidations: toFiniteNumber(row.liq_short) ?? 0,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
export const mapPriceRowsToChartRows = (rows: PriceRow[]): PriceChartRow[] =>
|
|
98
|
+
rows.map((row) => ({
|
|
99
|
+
price: toFiniteNumber(row.close) ?? 0,
|
|
100
|
+
timestamp: row.timestamp,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
const getBias = (metrics: SymbolMetrics): MarketBias => {
|
|
104
|
+
const funding = toFiniteNumber(metrics.currentFundingRate) ?? 0;
|
|
105
|
+
const oiChangePct = toFiniteNumber(metrics.oiChangePct) ?? 0;
|
|
106
|
+
const longLiq = toFiniteNumber(metrics.sumLiqLong) ?? 0;
|
|
107
|
+
const shortLiq = toFiniteNumber(metrics.sumLiqShort) ?? 0;
|
|
108
|
+
|
|
109
|
+
if (shortLiq > longLiq * 1.35) {
|
|
110
|
+
return { label: 'Short squeeze', tone: 'green' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (longLiq > shortLiq * 1.35) {
|
|
114
|
+
return { label: 'Long flush', tone: 'red' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (oiChangePct > 1 && funding > 0) {
|
|
118
|
+
return { label: 'Crowded longs', tone: 'orange' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (oiChangePct > 1 && funding < 0) {
|
|
122
|
+
return { label: 'Crowded shorts', tone: 'teal' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { label: 'Balanced', tone: 'gray' };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const buildMetricsFromRows = (
|
|
129
|
+
symbol: string,
|
|
130
|
+
rows: DetailRow[],
|
|
131
|
+
summaryRow?: SummaryItem,
|
|
132
|
+
): SymbolMetrics => {
|
|
133
|
+
if (!rows.length) {
|
|
134
|
+
return {
|
|
135
|
+
symbol,
|
|
136
|
+
lastTs: summaryRow?.last_ts ?? null,
|
|
137
|
+
currentOpenInterest: summaryRow?.latest_open_interest ?? null,
|
|
138
|
+
oiChange: summaryRow?.oi_change ?? null,
|
|
139
|
+
oiChangePct: summaryRow?.oi_change_pct ?? null,
|
|
140
|
+
currentFundingRate: summaryRow?.latest_funding_rate ?? null,
|
|
141
|
+
fundingChange: summaryRow?.funding_change ?? null,
|
|
142
|
+
sumLiqLong: summaryRow?.sum_liq_long ?? null,
|
|
143
|
+
sumLiqShort: summaryRow?.sum_liq_short ?? null,
|
|
144
|
+
sumLiqTotal: summaryRow?.sum_liq_total ?? null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const first = rows[0];
|
|
149
|
+
const last = rows[rows.length - 1];
|
|
150
|
+
const firstOi = toFiniteNumber(first.open_interest);
|
|
151
|
+
const lastOi = toFiniteNumber(last.open_interest);
|
|
152
|
+
const oiChange = firstOi != null && lastOi != null ? lastOi - firstOi : null;
|
|
153
|
+
const oiChangePct =
|
|
154
|
+
oiChange != null && firstOi != null && Math.abs(firstOi) > 0
|
|
155
|
+
? (oiChange / Math.abs(firstOi)) * 100
|
|
156
|
+
: null;
|
|
157
|
+
const firstFunding = toFiniteNumber(first.funding_rate);
|
|
158
|
+
const lastFunding = toFiniteNumber(last.funding_rate);
|
|
159
|
+
const fundingChange =
|
|
160
|
+
firstFunding != null && lastFunding != null
|
|
161
|
+
? lastFunding - firstFunding
|
|
162
|
+
: null;
|
|
163
|
+
|
|
164
|
+
return rows.reduce<SymbolMetrics>(
|
|
165
|
+
(metrics, row) => ({
|
|
166
|
+
...metrics,
|
|
167
|
+
sumLiqLong: (metrics.sumLiqLong ?? 0) + Number(row.liq_long || 0),
|
|
168
|
+
sumLiqShort: (metrics.sumLiqShort ?? 0) + Number(row.liq_short || 0),
|
|
169
|
+
sumLiqTotal: (metrics.sumLiqTotal ?? 0) + Number(row.liq_total || 0),
|
|
170
|
+
}),
|
|
171
|
+
{
|
|
172
|
+
symbol,
|
|
173
|
+
lastTs: last.ts,
|
|
174
|
+
currentOpenInterest: lastOi,
|
|
175
|
+
oiChange,
|
|
176
|
+
oiChangePct,
|
|
177
|
+
currentFundingRate: lastFunding,
|
|
178
|
+
fundingChange,
|
|
179
|
+
sumLiqLong: 0,
|
|
180
|
+
sumLiqShort: 0,
|
|
181
|
+
sumLiqTotal: 0,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const buildDerivativesDashboardViewModel = ({
|
|
187
|
+
symbols,
|
|
188
|
+
selectedInterval,
|
|
189
|
+
summary,
|
|
190
|
+
detailsBySymbol,
|
|
191
|
+
pricesBySymbol,
|
|
192
|
+
summaryLoading,
|
|
193
|
+
detailLoading,
|
|
194
|
+
summaryError,
|
|
195
|
+
detailError,
|
|
196
|
+
}: {
|
|
197
|
+
symbols: readonly string[];
|
|
198
|
+
selectedInterval: DerivativesInterval;
|
|
199
|
+
summary: SummaryResponse | null;
|
|
200
|
+
detailsBySymbol: Record<string, DetailRow[]>;
|
|
201
|
+
pricesBySymbol: Record<string, PriceRow[]>;
|
|
202
|
+
summaryLoading: boolean;
|
|
203
|
+
detailLoading: boolean;
|
|
204
|
+
summaryError: string;
|
|
205
|
+
detailError: string;
|
|
206
|
+
}) => {
|
|
207
|
+
const filteredSummary = (summary?.items ?? []).filter(
|
|
208
|
+
(item) =>
|
|
209
|
+
item.interval === selectedInterval && symbols.includes(item.symbol),
|
|
210
|
+
);
|
|
211
|
+
const summaryBySymbol = Object.fromEntries(
|
|
212
|
+
filteredSummary.map((item) => [item.symbol, item]),
|
|
213
|
+
) as Record<string, SummaryItem | undefined>;
|
|
214
|
+
const metricsBySymbol = Object.fromEntries(
|
|
215
|
+
symbols.map((symbol) => [
|
|
216
|
+
symbol,
|
|
217
|
+
buildMetricsFromRows(
|
|
218
|
+
symbol,
|
|
219
|
+
detailsBySymbol[symbol] ?? [],
|
|
220
|
+
summaryBySymbol[symbol],
|
|
221
|
+
),
|
|
222
|
+
]),
|
|
223
|
+
) as Record<string, SymbolMetrics>;
|
|
224
|
+
const chartDataBySymbol = Object.fromEntries(
|
|
225
|
+
symbols.map((symbol) => [
|
|
226
|
+
symbol,
|
|
227
|
+
{
|
|
228
|
+
derivatives: mapRowsToChartRows(detailsBySymbol[symbol] ?? []),
|
|
229
|
+
prices: mapPriceRowsToChartRows(pricesBySymbol[symbol] ?? []),
|
|
230
|
+
},
|
|
231
|
+
]),
|
|
232
|
+
) as Record<
|
|
233
|
+
string,
|
|
234
|
+
{ derivatives: DerivativesChartRow[]; prices: PriceChartRow[] }
|
|
235
|
+
>;
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
metricsBySymbol,
|
|
239
|
+
chartDataBySymbol,
|
|
240
|
+
overviewRows: symbols.map((symbol) => ({
|
|
241
|
+
symbol,
|
|
242
|
+
metrics: metricsBySymbol[symbol],
|
|
243
|
+
bias: getBias(metricsBySymbol[symbol]),
|
|
244
|
+
})),
|
|
245
|
+
noSummaryData:
|
|
246
|
+
!summaryLoading && !summaryError && filteredSummary.length === 0,
|
|
247
|
+
noDetailData:
|
|
248
|
+
!detailLoading &&
|
|
249
|
+
!detailError &&
|
|
250
|
+
symbols.every((symbol) => (detailsBySymbol[symbol] ?? []).length === 0),
|
|
251
|
+
showSkeleton: (summaryLoading || detailLoading) && !summary,
|
|
252
|
+
};
|
|
253
|
+
};
|