@tradejs/app 1.0.8 → 1.0.10

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 (119) hide show
  1. package/README.md +7 -2
  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/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  20. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  21. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  22. package/src/app/api/scanner/[provider]/route.ts +30 -12
  23. package/src/app/api/scanner/route.ts +3 -3
  24. package/src/app/api/signal/[symbol]/[signalId]/route.ts +4 -7
  25. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  26. package/src/app/api/strategies/ai/route.ts +139 -0
  27. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  28. package/src/app/api/strategies/replay/route.ts +44 -0
  29. package/src/app/api/strategies/runtime/route.ts +831 -0
  30. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  31. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  32. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  33. package/src/app/api/user/settings/route.ts +18 -1
  34. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  35. package/src/app/api/user/trading-accounts/route.ts +134 -0
  36. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  37. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  38. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  39. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  40. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  41. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  42. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  43. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  44. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  45. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  46. package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
  47. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  48. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  49. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  50. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  51. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  52. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  53. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  56. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  63. package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
  64. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  65. package/src/app/components/Shared/AppShell.tsx +1 -1
  66. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  67. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  68. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  69. package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
  70. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  71. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  72. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  73. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  74. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  75. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  76. package/src/app/components/Shared/Filters/index.ts +2 -0
  77. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  78. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  79. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  80. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  81. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  82. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  83. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  84. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  85. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  86. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  87. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  88. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  89. package/src/app/components/UI/Segment/index.tsx +8 -1
  90. package/src/app/components/UI/Select/index.tsx +24 -13
  91. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/lib/backtestJobs.ts +792 -0
  94. package/src/app/lib/cardPageLayout.ts +1 -0
  95. package/src/app/lib/connectorCreator.ts +28 -0
  96. package/src/app/lib/currentUser.ts +1 -1
  97. package/src/app/lib/errorMessage.ts +34 -0
  98. package/src/app/lib/marketDefaults.ts +12 -0
  99. package/src/app/lib/marketRoutes.ts +58 -0
  100. package/src/app/lib/runtimeStrategies.ts +1376 -0
  101. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  102. package/src/app/provider.tsx +1 -1
  103. package/src/app/routes/backtest/page.tsx +1051 -304
  104. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
  105. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  106. package/src/app/routes/dashboard/page.tsx +15 -3
  107. package/src/app/routes/derivatives/page.tsx +1222 -165
  108. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  109. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  110. package/src/app/routes/strategies/page.tsx +7 -0
  111. package/src/app/store/ai.ts +1 -1
  112. package/src/app/store/data.ts +73 -17
  113. package/src/app/store/filters.ts +1 -0
  114. package/src/app/store/marketKlineStream.ts +87 -0
  115. package/src/app/store/tests.ts +1 -1
  116. package/src/app/store/tickers.ts +59 -22
  117. package/tsconfig.json +17 -12
  118. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  119. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -1,201 +1,1258 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useMemo, useState } from 'react';
4
3
  import {
4
+ type ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from 'react';
10
+ import {
11
+ Badge,
5
12
  Box,
13
+ Card,
14
+ ClientOnly,
15
+ Flex,
6
16
  Heading,
7
- Text,
17
+ SimpleGrid,
18
+ Skeleton,
19
+ SkeletonText,
20
+ Stat,
8
21
  Table,
9
- HStack,
10
- Spinner,
11
- Button,
12
- Input,
22
+ Text,
13
23
  } from '@chakra-ui/react';
24
+ import { Chart, useChart } from '@chakra-ui/charts';
25
+ import {
26
+ Area,
27
+ AreaChart,
28
+ Bar,
29
+ BarChart,
30
+ CartesianGrid,
31
+ Cell,
32
+ Legend,
33
+ ReferenceLine,
34
+ ResponsiveContainer,
35
+ Tooltip,
36
+ XAxis,
37
+ YAxis,
38
+ } from 'recharts';
39
+ import { format } from 'date-fns';
40
+ import { FiBarChart2 } from 'react-icons/fi';
14
41
  import { API } from '@tradejs/core/api';
42
+ import { buildKlinePath } from '#app/lib/marketRoutes';
43
+ import { EmptyState, Segment, Select, toaster } from '#ui';
44
+
45
+ type SummaryItem = {
46
+ symbol: string;
47
+ interval: '15m' | '1h';
48
+ points: number;
49
+ last_ts: string;
50
+ first_ts: string;
51
+ latest_open_interest: number | null;
52
+ first_open_interest: number | null;
53
+ oi_change: number | null;
54
+ oi_change_pct: number | null;
55
+ latest_funding_rate: number | null;
56
+ first_funding_rate: number | null;
57
+ funding_change: number | null;
58
+ sum_liq_long: number | null;
59
+ sum_liq_short: number | null;
60
+ sum_liq_total: number | null;
61
+ };
15
62
 
16
63
  type SummaryResponse = {
17
- rows: Array<{
18
- symbol: string;
19
- interval: string;
20
- ts: string;
21
- open_interest: number | null;
22
- funding_rate: number | null;
23
- liq_long: number | null;
24
- liq_short: number | null;
25
- liq_total: number | null;
26
- }>;
27
- aggregates: Array<{
28
- symbol: string;
29
- interval: string;
30
- points: number;
31
- last_ts: string;
32
- avg_open_interest: number | null;
33
- avg_funding_rate: number | null;
34
- sum_liq_total: number | null;
35
- }>;
36
64
  hours: number;
65
+ items: SummaryItem[];
66
+ };
67
+
68
+ type DetailRow = {
69
+ symbol: string;
70
+ interval: '15m' | '1h';
71
+ ts: string;
72
+ open_interest: number | null;
73
+ funding_rate: number | null;
74
+ liq_long: number | null;
75
+ liq_short: number | null;
76
+ liq_total: number | null;
77
+ };
78
+
79
+ type DetailResponse = {
80
+ rows: DetailRow[];
81
+ symbol: string;
82
+ interval: '15m' | '1h';
83
+ };
84
+
85
+ type PriceRow = {
86
+ close: number;
87
+ timestamp: number;
88
+ };
89
+
90
+ type PriceResponse = {
91
+ data?: PriceRow[];
92
+ };
93
+
94
+ type BiasTone = 'teal' | 'green' | 'red' | 'orange' | 'gray';
95
+
96
+ type SymbolMetrics = {
97
+ symbol: string;
98
+ lastTs: string | null;
99
+ currentOpenInterest: number | null;
100
+ oiChange: number | null;
101
+ oiChangePct: number | null;
102
+ currentFundingRate: number | null;
103
+ fundingChange: number | null;
104
+ sumLiqLong: number | null;
105
+ sumLiqShort: number | null;
106
+ sumLiqTotal: number | null;
107
+ };
108
+
109
+ type SymbolChartTheme = {
110
+ primary: string;
111
+ primaryNegative: string;
112
+ secondary: string;
113
+ };
114
+
115
+ const HOURS_OPTIONS = [
116
+ { label: 'Last 24h', value: '24' },
117
+ { label: 'Last 7d', value: '168' },
118
+ { label: 'Last 30d', value: '720' },
119
+ { label: 'Last 60d', value: '1440' },
120
+ { label: 'Last 90d', value: '2160' },
121
+ ];
122
+
123
+ const INTERVAL_OPTIONS = ['15m', '1h'] as const;
124
+
125
+ const FIXED_SYMBOLS = ['BTCUSDT', 'ETHUSDT'] as const;
126
+
127
+ const DERIVATIVES_TO_KLINE_INTERVAL = {
128
+ '15m': '15',
129
+ '1h': '60',
130
+ } as const;
131
+
132
+ const SYMBOL_THEMES: Record<string, SymbolChartTheme> = {
133
+ BTCUSDT: {
134
+ primary: 'orange.solid',
135
+ primaryNegative: 'red.solid',
136
+ secondary: 'orange.solid',
137
+ },
138
+ ETHUSDT: {
139
+ primary: 'teal.solid',
140
+ primaryNegative: 'pink.solid',
141
+ secondary: 'teal.solid',
142
+ },
143
+ };
144
+
145
+ const compactFormatter = new Intl.NumberFormat('en-US', {
146
+ notation: 'compact',
147
+ maximumFractionDigits: 2,
148
+ });
149
+
150
+ const compactSignedFormatter = new Intl.NumberFormat('en-US', {
151
+ notation: 'compact',
152
+ maximumFractionDigits: 2,
153
+ signDisplay: 'always',
154
+ });
155
+
156
+ const toFiniteNumber = (value: number | null | undefined) =>
157
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
158
+
159
+ const formatCompact = (value: number | null | undefined) => {
160
+ const parsed = toFiniteNumber(value);
161
+ if (parsed == null) return 'n/a';
162
+ return compactFormatter.format(parsed);
163
+ };
164
+
165
+ const formatSignedCompact = (value: number | null | undefined) => {
166
+ const parsed = toFiniteNumber(value);
167
+ if (parsed == null) return 'n/a';
168
+ return compactSignedFormatter.format(parsed);
169
+ };
170
+
171
+ const formatPercent = (value: number | null | undefined) => {
172
+ const parsed = toFiniteNumber(value);
173
+ if (parsed == null) return 'n/a';
174
+ return `${parsed >= 0 ? '+' : ''}${parsed.toFixed(2)}%`;
175
+ };
176
+
177
+ const formatFunding = (value: number | null | undefined) => {
178
+ const parsed = toFiniteNumber(value);
179
+ if (parsed == null) return 'n/a';
180
+ const basisPoints = parsed * 10_000;
181
+ return `${basisPoints >= 0 ? '+' : ''}${basisPoints.toFixed(2)} bps`;
182
+ };
183
+
184
+ const formatAxisCompact = (value: number) =>
185
+ compactFormatter.format(Math.abs(value));
186
+
187
+ const formatPrice = (value: number | null | undefined) => {
188
+ const parsed = toFiniteNumber(value);
189
+ if (parsed == null) return 'n/a';
190
+
191
+ const digits = parsed >= 1000 ? 2 : parsed >= 1 ? 3 : 6;
192
+ return parsed.toLocaleString('en-US', {
193
+ maximumFractionDigits: digits,
194
+ minimumFractionDigits: 0,
195
+ });
196
+ };
197
+
198
+ const getChartDomain = (
199
+ values: Array<number | null | undefined>,
200
+ options?: { includeZero?: boolean; minPaddingPct?: number },
201
+ ): [number, number] | undefined => {
202
+ const finite = values.filter(
203
+ (value): value is number =>
204
+ typeof value === 'number' && Number.isFinite(value),
205
+ );
206
+
207
+ if (!finite.length) return undefined;
208
+
209
+ let min = Math.min(...finite);
210
+ let max = Math.max(...finite);
211
+
212
+ if (options?.includeZero) {
213
+ min = Math.min(min, 0);
214
+ max = Math.max(max, 0);
215
+ }
216
+
217
+ const span = max - min;
218
+ const paddingPct = options?.minPaddingPct ?? 0.06;
219
+ const basePadding =
220
+ span > 0
221
+ ? span * paddingPct
222
+ : Math.max(Math.abs(max || min || 1) * paddingPct, 1e-6);
223
+
224
+ return [min - basePadding, max + basePadding];
225
+ };
226
+
227
+ const formatTimeLabel = (value: string) => {
228
+ const parsed = new Date(value);
229
+ if (Number.isNaN(parsed.getTime())) return value;
230
+ return format(parsed, 'dd.MM HH:mm');
231
+ };
232
+
233
+ const formatFullTime = (value: string | null | undefined) => {
234
+ if (!value) return 'n/a';
235
+ const parsed = new Date(value);
236
+ if (Number.isNaN(parsed.getTime())) return value;
237
+ return format(parsed, 'dd.MM.yyyy HH:mm');
238
+ };
239
+
240
+ const getValueColor = (value: number | null | undefined) => {
241
+ const parsed = toFiniteNumber(value);
242
+ if (parsed == null || parsed === 0) return 'gray.200';
243
+ return parsed > 0 ? 'teal.300' : 'red.300';
244
+ };
245
+
246
+ const getFundingColor = (value: number | null | undefined) => {
247
+ const parsed = toFiniteNumber(value);
248
+ if (parsed == null || parsed === 0) return 'gray.200';
249
+ return parsed > 0 ? 'orange.300' : 'teal.300';
250
+ };
251
+
252
+ const getSymbolLabel = (symbol: string) =>
253
+ symbol === 'BTCUSDT' ? 'BTC' : symbol === 'ETHUSDT' ? 'ETH' : symbol;
254
+
255
+ const getBias = (item: {
256
+ latest_funding_rate: number | null;
257
+ oi_change_pct: number | null;
258
+ sum_liq_long: number | null;
259
+ sum_liq_short: number | null;
260
+ }) => {
261
+ const funding = toFiniteNumber(item.latest_funding_rate) ?? 0;
262
+ const oiChangePct = toFiniteNumber(item.oi_change_pct) ?? 0;
263
+ const longLiq = toFiniteNumber(item.sum_liq_long) ?? 0;
264
+ const shortLiq = toFiniteNumber(item.sum_liq_short) ?? 0;
265
+
266
+ if (shortLiq > longLiq * 1.35) {
267
+ return { label: 'Short squeeze', tone: 'green' as BiasTone };
268
+ }
269
+
270
+ if (longLiq > shortLiq * 1.35) {
271
+ return { label: 'Long flush', tone: 'red' as BiasTone };
272
+ }
273
+
274
+ if (oiChangePct > 1 && funding > 0) {
275
+ return { label: 'Crowded longs', tone: 'orange' as BiasTone };
276
+ }
277
+
278
+ if (oiChangePct > 1 && funding < 0) {
279
+ return { label: 'Crowded shorts', tone: 'teal' as BiasTone };
280
+ }
281
+
282
+ return { label: 'Balanced', tone: 'gray' as BiasTone };
283
+ };
284
+
285
+ const buildMetricsFromRows = (
286
+ symbol: string,
287
+ rows: DetailRow[],
288
+ summaryRow?: SummaryItem,
289
+ ): SymbolMetrics => {
290
+ if (!rows.length) {
291
+ return {
292
+ symbol,
293
+ lastTs: summaryRow?.last_ts ?? null,
294
+ currentOpenInterest: summaryRow?.latest_open_interest ?? null,
295
+ oiChange: summaryRow?.oi_change ?? null,
296
+ oiChangePct: summaryRow?.oi_change_pct ?? null,
297
+ currentFundingRate: summaryRow?.latest_funding_rate ?? null,
298
+ fundingChange: summaryRow?.funding_change ?? null,
299
+ sumLiqLong: summaryRow?.sum_liq_long ?? null,
300
+ sumLiqShort: summaryRow?.sum_liq_short ?? null,
301
+ sumLiqTotal: summaryRow?.sum_liq_total ?? null,
302
+ };
303
+ }
304
+
305
+ const first = rows[0];
306
+ const last = rows[rows.length - 1];
307
+ const firstOi = toFiniteNumber(first.open_interest);
308
+ const lastOi = toFiniteNumber(last.open_interest);
309
+ const oiChange = firstOi != null && lastOi != null ? lastOi - firstOi : null;
310
+ const oiChangePct =
311
+ oiChange != null && firstOi != null && Math.abs(firstOi) > 0
312
+ ? (oiChange / Math.abs(firstOi)) * 100
313
+ : null;
314
+ const firstFunding = toFiniteNumber(first.funding_rate);
315
+ const lastFunding = toFiniteNumber(last.funding_rate);
316
+ const fundingChange =
317
+ firstFunding != null && lastFunding != null
318
+ ? lastFunding - firstFunding
319
+ : null;
320
+
321
+ return rows.reduce<SymbolMetrics>(
322
+ (acc, row) => {
323
+ acc.sumLiqLong = (acc.sumLiqLong ?? 0) + Number(row.liq_long || 0);
324
+ acc.sumLiqShort = (acc.sumLiqShort ?? 0) + Number(row.liq_short || 0);
325
+ acc.sumLiqTotal = (acc.sumLiqTotal ?? 0) + Number(row.liq_total || 0);
326
+ return acc;
327
+ },
328
+ {
329
+ symbol,
330
+ lastTs: last.ts,
331
+ currentOpenInterest: lastOi,
332
+ oiChange,
333
+ oiChangePct,
334
+ currentFundingRate: lastFunding,
335
+ fundingChange,
336
+ sumLiqLong: 0,
337
+ sumLiqShort: 0,
338
+ sumLiqTotal: 0,
339
+ },
340
+ );
341
+ };
342
+
343
+ const DashboardSkeleton = () => (
344
+ <>
345
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
346
+ {FIXED_SYMBOLS.map((symbol) => (
347
+ <Card.Root
348
+ key={symbol}
349
+ bg="gray.900"
350
+ borderColor="gray.800"
351
+ borderWidth="1px"
352
+ size="sm"
353
+ >
354
+ <Card.Header>
355
+ <Skeleton height="24px" width="120px" />
356
+ </Card.Header>
357
+ <Card.Body>
358
+ <SimpleGrid columns={{ base: 2, xl: 4 }} gap={4}>
359
+ {Array.from({ length: 4 }).map((_, index) => (
360
+ <Box key={`${symbol}:${index}`}>
361
+ <SkeletonText noOfLines={2} gap="3" mb={2} />
362
+ <Skeleton height="18px" width="70%" />
363
+ </Box>
364
+ ))}
365
+ </SimpleGrid>
366
+ </Card.Body>
367
+ </Card.Root>
368
+ ))}
369
+ </SimpleGrid>
370
+
371
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
372
+ <Card.Root
373
+ bg="gray.900"
374
+ borderColor="gray.800"
375
+ borderWidth="1px"
376
+ size="sm"
377
+ >
378
+ <Card.Header>
379
+ <Skeleton height="24px" width="220px" />
380
+ </Card.Header>
381
+ <Card.Body>
382
+ <Skeleton height="280px" />
383
+ </Card.Body>
384
+ </Card.Root>
385
+ <Card.Root
386
+ bg="gray.900"
387
+ borderColor="gray.800"
388
+ borderWidth="1px"
389
+ size="sm"
390
+ >
391
+ <Card.Header>
392
+ <Skeleton height="24px" width="220px" />
393
+ </Card.Header>
394
+ <Card.Body>
395
+ <Skeleton height="280px" />
396
+ </Card.Body>
397
+ </Card.Root>
398
+ </SimpleGrid>
399
+
400
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
401
+ <Card.Root
402
+ bg="gray.900"
403
+ borderColor="gray.800"
404
+ borderWidth="1px"
405
+ size="sm"
406
+ >
407
+ <Card.Header>
408
+ <Skeleton height="24px" width="220px" />
409
+ </Card.Header>
410
+ <Card.Body>
411
+ <Skeleton height="280px" />
412
+ </Card.Body>
413
+ </Card.Root>
414
+ <Card.Root
415
+ bg="gray.900"
416
+ borderColor="gray.800"
417
+ borderWidth="1px"
418
+ size="sm"
419
+ >
420
+ <Card.Header>
421
+ <Skeleton height="24px" width="220px" />
422
+ </Card.Header>
423
+ <Card.Body>
424
+ <Skeleton height="280px" />
425
+ </Card.Body>
426
+ </Card.Root>
427
+ </SimpleGrid>
428
+
429
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
430
+ <Card.Root
431
+ bg="gray.900"
432
+ borderColor="gray.800"
433
+ borderWidth="1px"
434
+ size="sm"
435
+ >
436
+ <Card.Header>
437
+ <Skeleton height="24px" width="220px" />
438
+ </Card.Header>
439
+ <Card.Body>
440
+ <Skeleton height="280px" />
441
+ </Card.Body>
442
+ </Card.Root>
443
+ <Card.Root
444
+ bg="gray.900"
445
+ borderColor="gray.800"
446
+ borderWidth="1px"
447
+ size="sm"
448
+ >
449
+ <Card.Header>
450
+ <Skeleton height="24px" width="220px" />
451
+ </Card.Header>
452
+ <Card.Body>
453
+ <Skeleton height="280px" />
454
+ </Card.Body>
455
+ </Card.Root>
456
+ </SimpleGrid>
457
+
458
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
459
+ <Card.Root
460
+ bg="gray.900"
461
+ borderColor="gray.800"
462
+ borderWidth="1px"
463
+ size="sm"
464
+ >
465
+ <Card.Header>
466
+ <Skeleton height="24px" width="220px" />
467
+ </Card.Header>
468
+ <Card.Body>
469
+ <Skeleton height="320px" />
470
+ </Card.Body>
471
+ </Card.Root>
472
+ <Card.Root
473
+ bg="gray.900"
474
+ borderColor="gray.800"
475
+ borderWidth="1px"
476
+ size="sm"
477
+ >
478
+ <Card.Header>
479
+ <Skeleton height="24px" width="260px" />
480
+ </Card.Header>
481
+ <Card.Body>
482
+ <Skeleton height="320px" />
483
+ </Card.Body>
484
+ </Card.Root>
485
+ </SimpleGrid>
486
+
487
+ <Card.Root bg="gray.900" borderColor="gray.800" borderWidth="1px" size="sm">
488
+ <Card.Header>
489
+ <Skeleton height="24px" width="180px" />
490
+ </Card.Header>
491
+ <Card.Body>
492
+ <SkeletonText noOfLines={8} gap="5" />
493
+ </Card.Body>
494
+ </Card.Root>
495
+ </>
496
+ );
497
+
498
+ const SymbolMetricsCard = ({
499
+ title,
500
+ metrics,
501
+ }: {
502
+ title: string;
503
+ metrics: SymbolMetrics;
504
+ }) => (
505
+ <Card.Root bg="gray.900" borderColor="gray.800" borderWidth="1px" size="sm">
506
+ <Card.Header pb={0}>
507
+ <Heading size="md">{title}</Heading>
508
+ <Text mt={1} color="gray.500" fontSize="sm">
509
+ Updated {formatFullTime(metrics.lastTs)}
510
+ </Text>
511
+ </Card.Header>
512
+ <Card.Body>
513
+ <SimpleGrid columns={{ base: 2, xl: 4 }} gap={4}>
514
+ <Stat.Root>
515
+ <Stat.Label color="gray.400">Open Interest</Stat.Label>
516
+ <Stat.ValueText color={getValueColor(metrics.oiChange)}>
517
+ {formatCompact(metrics.currentOpenInterest)}
518
+ </Stat.ValueText>
519
+ </Stat.Root>
520
+ <Stat.Root>
521
+ <Stat.Label color="gray.400">OI Change</Stat.Label>
522
+ <Stat.ValueText color={getValueColor(metrics.oiChangePct)}>
523
+ {formatPercent(metrics.oiChangePct)}
524
+ </Stat.ValueText>
525
+ <Stat.HelpText color="gray.500">
526
+ {formatSignedCompact(metrics.oiChange)}
527
+ </Stat.HelpText>
528
+ </Stat.Root>
529
+ <Stat.Root>
530
+ <Stat.Label color="gray.400">Funding</Stat.Label>
531
+ <Stat.ValueText color={getFundingColor(metrics.currentFundingRate)}>
532
+ {formatFunding(metrics.currentFundingRate)}
533
+ </Stat.ValueText>
534
+ <Stat.HelpText color="gray.500">
535
+ Delta {formatFunding(metrics.fundingChange)}
536
+ </Stat.HelpText>
537
+ </Stat.Root>
538
+ <Stat.Root>
539
+ <Stat.Label color="gray.400">Liquidations</Stat.Label>
540
+ <Stat.ValueText color="gray.200">
541
+ {formatCompact(metrics.sumLiqTotal)}
542
+ </Stat.ValueText>
543
+ <Stat.HelpText color="gray.500">
544
+ Long {formatCompact(metrics.sumLiqLong)} / Short{' '}
545
+ {formatCompact(metrics.sumLiqShort)}
546
+ </Stat.HelpText>
547
+ </Stat.Root>
548
+ </SimpleGrid>
549
+ </Card.Body>
550
+ </Card.Root>
551
+ );
552
+
553
+ const ChartCard = ({
554
+ title,
555
+ description,
556
+ children,
557
+ }: {
558
+ title: string;
559
+ description: string;
560
+ children: ReactNode;
561
+ }) => (
562
+ <Card.Root bg="gray.900" borderColor="gray.800" borderWidth="1px" size="sm">
563
+ <Card.Header pb={0}>
564
+ <Heading size="md">{title}</Heading>
565
+ <Text mt={1} color="gray.500" fontSize="sm">
566
+ {description}
567
+ </Text>
568
+ </Card.Header>
569
+ <Card.Body>{children}</Card.Body>
570
+ </Card.Root>
571
+ );
572
+
573
+ const mapRowsToChartRows = (rows: DetailRow[]) =>
574
+ rows.map((row) => ({
575
+ timestamp: formatTimeLabel(row.ts),
576
+ openInterest: toFiniteNumber(row.open_interest) ?? 0,
577
+ funding: (toFiniteNumber(row.funding_rate) ?? 0) * 10_000,
578
+ longLiquidations: -(toFiniteNumber(row.liq_long) ?? 0),
579
+ shortLiquidations: toFiniteNumber(row.liq_short) ?? 0,
580
+ }));
581
+
582
+ const mapPriceRowsToChartRows = (rows: PriceRow[]) =>
583
+ rows.map((row) => ({
584
+ price: toFiniteNumber(row.close) ?? 0,
585
+ timestamp: formatTimeLabel(new Date(row.timestamp).toISOString()),
586
+ }));
587
+
588
+ const SymbolPriceCard = ({
589
+ symbol,
590
+ rows,
591
+ }: {
592
+ symbol: string;
593
+ rows: PriceRow[];
594
+ }) => {
595
+ const theme = SYMBOL_THEMES[symbol];
596
+ const symbolLabel = getSymbolLabel(symbol);
597
+ const chartRows = useMemo(() => mapPriceRowsToChartRows(rows), [rows]);
598
+
599
+ const priceChartConfig = useMemo(
600
+ () => ({
601
+ data: chartRows,
602
+ series: [{ name: 'price', color: theme.primary }],
603
+ }),
604
+ [chartRows, theme.primary],
605
+ );
606
+
607
+ const priceChart = useChart(priceChartConfig as never);
608
+ const latestPrice = rows[rows.length - 1]?.close ?? null;
609
+ const priceDomain = useMemo(
610
+ () => getChartDomain(chartRows.map((row) => row.price)),
611
+ [chartRows],
612
+ );
613
+
614
+ return (
615
+ <ChartCard
616
+ title={`${symbolLabel} Price`}
617
+ description={`Last close ${formatPrice(latestPrice)}`}
618
+ >
619
+ <Box h="280px">
620
+ <ResponsiveContainer width="100%" height="100%">
621
+ <Chart.Root chart={priceChart}>
622
+ <AreaChart data={priceChart.data}>
623
+ <defs>
624
+ <linearGradient
625
+ id={`${symbol}-price-fill`}
626
+ x1="0"
627
+ y1="0"
628
+ x2="0"
629
+ y2="1"
630
+ >
631
+ <stop
632
+ offset="5%"
633
+ stopColor={priceChart.color(theme.primary)}
634
+ stopOpacity={0.28}
635
+ />
636
+ <stop
637
+ offset="95%"
638
+ stopColor={priceChart.color(theme.primary)}
639
+ stopOpacity={0}
640
+ />
641
+ </linearGradient>
642
+ </defs>
643
+ <CartesianGrid
644
+ stroke={priceChart.color('border')}
645
+ vertical={false}
646
+ />
647
+ <XAxis dataKey="timestamp" minTickGap={36} />
648
+ <YAxis domain={priceDomain} tickFormatter={formatPrice} />
649
+ <Tooltip cursor={false} content={<Chart.Tooltip />} />
650
+ <Area
651
+ type="monotone"
652
+ dataKey={priceChart.key('price') as string}
653
+ stroke={priceChart.color(theme.primary)}
654
+ fill={`url(#${symbol}-price-fill)`}
655
+ strokeWidth={2}
656
+ dot={false}
657
+ isAnimationActive={false}
658
+ />
659
+ </AreaChart>
660
+ </Chart.Root>
661
+ </ResponsiveContainer>
662
+ </Box>
663
+ </ChartCard>
664
+ );
665
+ };
666
+
667
+ const SymbolOpenInterestCard = ({
668
+ symbol,
669
+ rows,
670
+ }: {
671
+ symbol: string;
672
+ rows: DetailRow[];
673
+ }) => {
674
+ const theme = SYMBOL_THEMES[symbol];
675
+ const symbolLabel = getSymbolLabel(symbol);
676
+ const chartRows = useMemo(() => mapRowsToChartRows(rows), [rows]);
677
+
678
+ const oiChartConfig = useMemo(
679
+ () => ({
680
+ data: chartRows,
681
+ series: [{ name: 'openInterest', color: theme.primary }],
682
+ }),
683
+ [chartRows, theme.primary],
684
+ );
685
+
686
+ const oiChart = useChart(oiChartConfig as never);
687
+ const oiDomain = useMemo(
688
+ () => getChartDomain(chartRows.map((row) => row.openInterest)),
689
+ [chartRows],
690
+ );
691
+
692
+ return (
693
+ <ChartCard
694
+ title={`${symbolLabel} Open Interest`}
695
+ description="Position size through the selected window."
696
+ >
697
+ <Box h="280px">
698
+ <ResponsiveContainer width="100%" height="100%">
699
+ <Chart.Root chart={oiChart}>
700
+ <AreaChart data={oiChart.data}>
701
+ <defs>
702
+ <linearGradient
703
+ id={`${symbol}-oi-fill`}
704
+ x1="0"
705
+ y1="0"
706
+ x2="0"
707
+ y2="1"
708
+ >
709
+ <stop
710
+ offset="5%"
711
+ stopColor={oiChart.color(theme.primary)}
712
+ stopOpacity={0.3}
713
+ />
714
+ <stop
715
+ offset="95%"
716
+ stopColor={oiChart.color(theme.primary)}
717
+ stopOpacity={0}
718
+ />
719
+ </linearGradient>
720
+ </defs>
721
+ <CartesianGrid
722
+ stroke={oiChart.color('border')}
723
+ vertical={false}
724
+ />
725
+ <XAxis dataKey="timestamp" minTickGap={36} />
726
+ <YAxis domain={oiDomain} tickFormatter={formatAxisCompact} />
727
+ <Tooltip cursor={false} content={<Chart.Tooltip />} />
728
+ <Area
729
+ type="monotone"
730
+ dataKey={oiChart.key('openInterest') as string}
731
+ stroke={oiChart.color(theme.primary)}
732
+ fill={`url(#${symbol}-oi-fill)`}
733
+ strokeWidth={2}
734
+ dot={false}
735
+ isAnimationActive={false}
736
+ />
737
+ </AreaChart>
738
+ </Chart.Root>
739
+ </ResponsiveContainer>
740
+ </Box>
741
+ </ChartCard>
742
+ );
37
743
  };
38
744
 
39
- const fmt = (value: number | null | undefined, digits = 4) => {
40
- if (value == null || !Number.isFinite(Number(value))) return 'n/a';
41
- return Number(value).toFixed(digits);
745
+ const SymbolFundingCard = ({
746
+ symbol,
747
+ rows,
748
+ }: {
749
+ symbol: string;
750
+ rows: DetailRow[];
751
+ }) => {
752
+ const theme = SYMBOL_THEMES[symbol];
753
+ const symbolLabel = getSymbolLabel(symbol);
754
+ const chartRows = useMemo(() => mapRowsToChartRows(rows), [rows]);
755
+
756
+ const fundingChartConfig = useMemo(
757
+ () => ({
758
+ data: chartRows,
759
+ series: [{ name: 'funding', color: theme.primary }],
760
+ }),
761
+ [chartRows, theme.primary],
762
+ );
763
+
764
+ const fundingChart = useChart(fundingChartConfig as never);
765
+
766
+ return (
767
+ <ChartCard
768
+ title={`${symbolLabel} Funding`}
769
+ description="Positive values mean longs pay shorts."
770
+ >
771
+ <Box h="280px">
772
+ <ResponsiveContainer width="100%" height="100%">
773
+ <Chart.Root chart={fundingChart}>
774
+ <BarChart data={fundingChart.data}>
775
+ <CartesianGrid
776
+ stroke={fundingChart.color('border')}
777
+ vertical={false}
778
+ />
779
+ <ReferenceLine
780
+ y={0}
781
+ stroke={fundingChart.color('gray.600')}
782
+ strokeDasharray="4 4"
783
+ />
784
+ <XAxis dataKey="timestamp" minTickGap={36} />
785
+ <YAxis tickFormatter={(value) => `${value} bps`} />
786
+ <Tooltip cursor={false} content={<Chart.Tooltip />} />
787
+ <Bar
788
+ dataKey={fundingChart.key('funding') as string}
789
+ isAnimationActive={false}
790
+ >
791
+ {fundingChart.data.map((entry, idx) => (
792
+ <Cell
793
+ key={`${symbol}:${entry.timestamp}:${idx}`}
794
+ fill={
795
+ entry.funding >= 0
796
+ ? fundingChart.color(theme.primary)
797
+ : fundingChart.color(theme.primaryNegative)
798
+ }
799
+ />
800
+ ))}
801
+ </Bar>
802
+ </BarChart>
803
+ </Chart.Root>
804
+ </ResponsiveContainer>
805
+ </Box>
806
+ </ChartCard>
807
+ );
808
+ };
809
+
810
+ const SymbolLiquidationCard = ({
811
+ symbol,
812
+ rows,
813
+ }: {
814
+ symbol: string;
815
+ rows: DetailRow[];
816
+ }) => {
817
+ const theme = SYMBOL_THEMES[symbol];
818
+ const symbolLabel = getSymbolLabel(symbol);
819
+ const chartRows = useMemo(() => mapRowsToChartRows(rows), [rows]);
820
+
821
+ const liquidationChartConfig = useMemo(
822
+ () => ({
823
+ data: chartRows,
824
+ series: [
825
+ { name: 'longLiquidations', color: theme.primaryNegative },
826
+ { name: 'shortLiquidations', color: theme.secondary },
827
+ ],
828
+ }),
829
+ [chartRows, theme.primaryNegative, theme.secondary],
830
+ );
831
+
832
+ const liquidationChart = useChart(liquidationChartConfig as never);
833
+
834
+ return (
835
+ <ChartCard
836
+ title={`${symbolLabel} Liquidation Pressure`}
837
+ description="Long liquidations are below zero, short liquidations are above zero."
838
+ >
839
+ <Box h="320px">
840
+ <ResponsiveContainer width="100%" height="100%">
841
+ <Chart.Root chart={liquidationChart}>
842
+ <BarChart data={liquidationChart.data}>
843
+ <CartesianGrid
844
+ stroke={liquidationChart.color('border')}
845
+ vertical={false}
846
+ />
847
+ <ReferenceLine
848
+ y={0}
849
+ stroke={liquidationChart.color('gray.600')}
850
+ strokeDasharray="4 4"
851
+ />
852
+ <XAxis dataKey="timestamp" minTickGap={28} />
853
+ <YAxis tickFormatter={formatAxisCompact} />
854
+ <Tooltip cursor={false} content={<Chart.Tooltip />} />
855
+ <Legend />
856
+ <Bar
857
+ dataKey={liquidationChart.key('longLiquidations') as string}
858
+ name={`${symbolLabel} long liq`}
859
+ fill={liquidationChart.color(theme.primaryNegative)}
860
+ isAnimationActive={false}
861
+ />
862
+ <Bar
863
+ dataKey={liquidationChart.key('shortLiquidations') as string}
864
+ name={`${symbolLabel} short liq`}
865
+ fill={liquidationChart.color(theme.secondary)}
866
+ isAnimationActive={false}
867
+ />
868
+ </BarChart>
869
+ </Chart.Root>
870
+ </ResponsiveContainer>
871
+ </Box>
872
+ </ChartCard>
873
+ );
42
874
  };
43
875
 
44
876
  const DerivativesPage = () => {
45
877
  const [hours, setHours] = useState('24');
46
- const [limit, setLimit] = useState('500');
47
- const [loading, setLoading] = useState(false);
48
- const [data, setData] = useState<SummaryResponse | null>(null);
49
- const [error, setError] = useState<string>('');
50
-
51
- const load = useCallback(async () => {
52
- setLoading(true);
53
- setError('');
878
+ const [selectedInterval, setSelectedInterval] = useState<'15m' | '1h'>('1h');
879
+ const [summary, setSummary] = useState<SummaryResponse | null>(null);
880
+ const [detailsBySymbol, setDetailsBySymbol] = useState<
881
+ Record<string, DetailRow[]>
882
+ >({});
883
+ const [pricesBySymbol, setPricesBySymbol] = useState<
884
+ Record<string, PriceRow[]>
885
+ >({});
886
+ const [summaryLoading, setSummaryLoading] = useState(false);
887
+ const [detailLoading, setDetailLoading] = useState(false);
888
+ const [summaryError, setSummaryError] = useState('');
889
+ const [detailError, setDetailError] = useState('');
890
+
891
+ const loadSummary = useCallback(async () => {
892
+ setSummaryLoading(true);
893
+ setSummaryError('');
894
+
54
895
  try {
896
+ const symbolsParam = FIXED_SYMBOLS.join(',');
55
897
  const response = await API.get<SummaryResponse>(
56
- `/api/derivatives/summary?hours=${hours}&limit=${limit}`,
898
+ `/api/derivatives/summary?hours=${hours}&limit=200&symbols=${symbolsParam}`,
899
+ );
900
+ setSummary(response);
901
+ } catch (err) {
902
+ const message = (err as Error)?.message || 'Failed to load derivatives';
903
+ setSummaryError(message);
904
+ toaster.error({
905
+ title: 'Failed to load derivatives',
906
+ description: message,
907
+ });
908
+ } finally {
909
+ setSummaryLoading(false);
910
+ }
911
+ }, [hours]);
912
+
913
+ const loadDetails = useCallback(async () => {
914
+ setDetailLoading(true);
915
+ setDetailError('');
916
+
917
+ const now = Date.now();
918
+ const from = now - Number(hours) * 60 * 60 * 1000;
919
+ const klineInterval = DERIVATIVES_TO_KLINE_INTERVAL[selectedInterval];
920
+
921
+ try {
922
+ const responses = await Promise.all(
923
+ FIXED_SYMBOLS.map(async (symbol) => {
924
+ const [derivativesResponse, priceResponse] = await Promise.all([
925
+ API.get<DetailResponse>(
926
+ `/api/derivatives/${symbol}/${selectedInterval}?from=${from}&to=${now}`,
927
+ ),
928
+ API.post<PriceResponse>(
929
+ buildKlinePath({
930
+ provider: 'bybit',
931
+ universe: 'crypto',
932
+ symbol,
933
+ interval: klineInterval,
934
+ }),
935
+ {
936
+ start: from,
937
+ end: now,
938
+ },
939
+ ),
940
+ ]);
941
+
942
+ return [
943
+ symbol,
944
+ {
945
+ detailRows: derivativesResponse.rows,
946
+ priceRows: priceResponse.data ?? [],
947
+ },
948
+ ] as const;
949
+ }),
950
+ );
951
+
952
+ setDetailsBySymbol(
953
+ Object.fromEntries(
954
+ responses.map(([symbol, payload]) => [symbol, payload.detailRows]),
955
+ ),
956
+ );
957
+ setPricesBySymbol(
958
+ Object.fromEntries(
959
+ responses.map(([symbol, payload]) => [symbol, payload.priceRows]),
960
+ ),
57
961
  );
58
- setData(response);
59
962
  } catch (err) {
60
- setError((err as Error)?.message || 'Failed to load derivatives');
963
+ const message =
964
+ (err as Error)?.message ||
965
+ 'Failed to load symbol derivatives and price data';
966
+ setDetailError(message);
967
+ toaster.error({
968
+ title: 'Failed to load derivative details',
969
+ description: message,
970
+ });
61
971
  } finally {
62
- setLoading(false);
972
+ setDetailLoading(false);
63
973
  }
64
- }, [hours, limit]);
974
+ }, [hours, selectedInterval]);
65
975
 
66
976
  useEffect(() => {
67
- void load();
68
- }, [load]);
977
+ void loadSummary();
978
+ }, [loadSummary]);
69
979
 
70
- const totals = useMemo(() => {
71
- const aggregates = data?.aggregates ?? [];
72
- return {
73
- pairs: aggregates.length,
74
- points: aggregates.reduce(
75
- (acc, item) => acc + Number(item.points || 0),
76
- 0,
77
- ),
78
- liq: aggregates.reduce(
79
- (acc, item) => acc + Number(item.sum_liq_total || 0),
80
- 0,
980
+ useEffect(() => {
981
+ void loadDetails();
982
+ }, [loadDetails]);
983
+
984
+ const filteredSummary = useMemo(
985
+ () =>
986
+ (summary?.items ?? []).filter(
987
+ (item) =>
988
+ item.interval === selectedInterval &&
989
+ FIXED_SYMBOLS.includes(item.symbol as (typeof FIXED_SYMBOLS)[number]),
81
990
  ),
82
- };
83
- }, [data]);
991
+ [selectedInterval, summary?.items],
992
+ );
993
+
994
+ const summaryBySymbol = useMemo(
995
+ () =>
996
+ Object.fromEntries(
997
+ filteredSummary.map((item) => [item.symbol, item]),
998
+ ) as Record<string, SummaryItem | undefined>,
999
+ [filteredSummary],
1000
+ );
1001
+
1002
+ const metricsBySymbol = useMemo(
1003
+ () =>
1004
+ Object.fromEntries(
1005
+ FIXED_SYMBOLS.map((symbol) => [
1006
+ symbol,
1007
+ buildMetricsFromRows(
1008
+ symbol,
1009
+ detailsBySymbol[symbol] ?? [],
1010
+ summaryBySymbol[symbol],
1011
+ ),
1012
+ ]),
1013
+ ) as Record<string, SymbolMetrics>,
1014
+ [detailsBySymbol, summaryBySymbol],
1015
+ );
1016
+
1017
+ const noSummaryData =
1018
+ !summaryLoading && !summaryError && filteredSummary.length === 0;
1019
+ const noDetailData =
1020
+ !detailLoading &&
1021
+ !detailError &&
1022
+ FIXED_SYMBOLS.every(
1023
+ (symbol) => (detailsBySymbol[symbol] ?? []).length === 0,
1024
+ );
1025
+ const showSkeleton = (summaryLoading || detailLoading) && !summary;
84
1026
 
85
1027
  return (
86
- <Box p={6}>
87
- <Heading size="lg" mb={2}>
88
- Derivatives Dashboard
89
- </Heading>
90
- <Text color="gray.500" mb={4}>
91
- OI / Funding / Liquidations from Timescale (TF 15m, 1h)
92
- </Text>
1028
+ <ClientOnly>
1029
+ <Box minH="100vh" bg="gray.950">
1030
+ <Box as="main" maxW="1600px" mx="auto" px={6} py={6}>
1031
+ <Flex
1032
+ mb={6}
1033
+ gap={4}
1034
+ alignItems={{ base: 'flex-start', lg: 'center' }}
1035
+ justifyContent="space-between"
1036
+ wrap="wrap"
1037
+ >
1038
+ <Box>
1039
+ <Heading size="lg">Derivatives Dashboard</Heading>
1040
+ </Box>
93
1041
 
94
- <HStack mb={4}>
95
- <select
96
- value={hours}
97
- onChange={(e) => setHours(e.target.value)}
98
- style={{
99
- width: '160px',
100
- border: '1px solid #d1d5db',
101
- borderRadius: '8px',
102
- padding: '8px',
103
- }}
104
- >
105
- <option value="6">Last 6h</option>
106
- <option value="24">Last 24h</option>
107
- <option value="72">Last 72h</option>
108
- <option value="168">Last 7d</option>
109
- </select>
110
- <Input
111
- value={limit}
112
- onChange={(e) => setLimit(e.target.value)}
113
- width="160px"
114
- placeholder="Row limit"
115
- />
116
- <Button onClick={load}>Refresh</Button>
117
- </HStack>
118
-
119
- {loading && <Spinner />}
120
- {error && (
121
- <Text color="red.400" mb={4}>
122
- {error}
123
- </Text>
124
- )}
125
-
126
- <Text mb={4}>
127
- Symbols/TF: {totals.pairs} | Points: {totals.points} | Sum liquidation:{' '}
128
- {fmt(totals.liq, 2)}
129
- </Text>
1042
+ <Flex gap={3} wrap="wrap" alignItems="center">
1043
+ <Select
1044
+ placeholder="Window"
1045
+ defaultValue={[hours]}
1046
+ value={[hours]}
1047
+ onChange={(value) => setHours(value[0] || '24')}
1048
+ items={HOURS_OPTIONS}
1049
+ width="180px"
1050
+ />
130
1051
 
131
- <Heading size="md" mb={2}>
132
- Aggregates
133
- </Heading>
134
- <Box overflowX="auto" mb={8}>
135
- <Table.Root size="sm">
136
- <Table.Header>
137
- <Table.Row>
138
- <Table.ColumnHeader>symbol</Table.ColumnHeader>
139
- <Table.ColumnHeader>tf</Table.ColumnHeader>
140
- <Table.ColumnHeader>points</Table.ColumnHeader>
141
- <Table.ColumnHeader>last_ts</Table.ColumnHeader>
142
- <Table.ColumnHeader>avg_oi</Table.ColumnHeader>
143
- <Table.ColumnHeader>avg_funding</Table.ColumnHeader>
144
- <Table.ColumnHeader>sum_liq</Table.ColumnHeader>
145
- </Table.Row>
146
- </Table.Header>
147
- <Table.Body>
148
- {(data?.aggregates ?? []).map((row) => (
149
- <Table.Row key={`${row.symbol}:${row.interval}`}>
150
- <Table.Cell>{row.symbol}</Table.Cell>
151
- <Table.Cell>{row.interval}</Table.Cell>
152
- <Table.Cell>{row.points}</Table.Cell>
153
- <Table.Cell>
154
- {row.last_ts ? new Date(row.last_ts).toISOString() : 'n/a'}
155
- </Table.Cell>
156
- <Table.Cell>{fmt(row.avg_open_interest, 2)}</Table.Cell>
157
- <Table.Cell>{fmt(row.avg_funding_rate, 6)}</Table.Cell>
158
- <Table.Cell>{fmt(row.sum_liq_total, 2)}</Table.Cell>
159
- </Table.Row>
160
- ))}
161
- </Table.Body>
162
- </Table.Root>
163
- </Box>
1052
+ <Segment
1053
+ defaultValue={selectedInterval}
1054
+ value={selectedInterval}
1055
+ onChange={(value) =>
1056
+ setSelectedInterval((value as '15m' | '1h' | null) ?? '1h')
1057
+ }
1058
+ items={INTERVAL_OPTIONS.map((value) => ({
1059
+ label: value,
1060
+ value,
1061
+ }))}
1062
+ />
1063
+ </Flex>
1064
+ </Flex>
1065
+
1066
+ {showSkeleton ? <DashboardSkeleton /> : null}
1067
+
1068
+ {noSummaryData ? (
1069
+ <EmptyState
1070
+ icon={FiBarChart2}
1071
+ title="No derivatives data found"
1072
+ description="There are no BTC or ETH derivatives rows for the selected time window and interval."
1073
+ />
1074
+ ) : null}
1075
+
1076
+ {!showSkeleton && !noSummaryData && (
1077
+ <>
1078
+ {noDetailData ? (
1079
+ <EmptyState
1080
+ icon={FiBarChart2}
1081
+ title="No chart data for BTC and ETH"
1082
+ description="Try another interval or a wider time window."
1083
+ />
1084
+ ) : null}
1085
+
1086
+ {!noDetailData && (
1087
+ <>
1088
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
1089
+ {FIXED_SYMBOLS.map((symbol) => (
1090
+ <SymbolMetricsCard
1091
+ key={symbol}
1092
+ title={`${getSymbolLabel(symbol)} Snapshot`}
1093
+ metrics={metricsBySymbol[symbol]}
1094
+ />
1095
+ ))}
1096
+ </SimpleGrid>
1097
+
1098
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
1099
+ {FIXED_SYMBOLS.map((symbol) => (
1100
+ <SymbolPriceCard
1101
+ key={`${symbol}:price`}
1102
+ symbol={symbol}
1103
+ rows={pricesBySymbol[symbol] ?? []}
1104
+ />
1105
+ ))}
1106
+ </SimpleGrid>
1107
+
1108
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
1109
+ {FIXED_SYMBOLS.map((symbol) => (
1110
+ <SymbolOpenInterestCard
1111
+ key={`${symbol}:oi`}
1112
+ symbol={symbol}
1113
+ rows={detailsBySymbol[symbol] ?? []}
1114
+ />
1115
+ ))}
1116
+ </SimpleGrid>
1117
+
1118
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
1119
+ {FIXED_SYMBOLS.map((symbol) => (
1120
+ <SymbolFundingCard
1121
+ key={`${symbol}:funding`}
1122
+ symbol={symbol}
1123
+ rows={detailsBySymbol[symbol] ?? []}
1124
+ />
1125
+ ))}
1126
+ </SimpleGrid>
1127
+
1128
+ <SimpleGrid columns={{ base: 1, lg: 2 }} gap={4} mb={6}>
1129
+ {FIXED_SYMBOLS.map((symbol) => (
1130
+ <SymbolLiquidationCard
1131
+ key={`${symbol}:liq`}
1132
+ symbol={symbol}
1133
+ rows={detailsBySymbol[symbol] ?? []}
1134
+ />
1135
+ ))}
1136
+ </SimpleGrid>
1137
+ </>
1138
+ )}
1139
+
1140
+ <Card.Root
1141
+ bg="gray.900"
1142
+ borderColor="gray.800"
1143
+ borderWidth="1px"
1144
+ size="sm"
1145
+ >
1146
+ <Card.Header>
1147
+ <Heading size="md">BTC / ETH Overview</Heading>
1148
+ <Text mt={1} color="gray.500" fontSize="sm">
1149
+ One row per symbol for the selected interval and window.
1150
+ </Text>
1151
+ </Card.Header>
1152
+ <Card.Body>
1153
+ <Box overflowX="auto">
1154
+ <Table.Root size="sm">
1155
+ <Table.Header>
1156
+ <Table.Row>
1157
+ <Table.ColumnHeader>Symbol</Table.ColumnHeader>
1158
+ <Table.ColumnHeader textAlign="right">
1159
+ OI
1160
+ </Table.ColumnHeader>
1161
+ <Table.ColumnHeader textAlign="right">
1162
+ OI Δ
1163
+ </Table.ColumnHeader>
1164
+ <Table.ColumnHeader textAlign="right">
1165
+ Funding
1166
+ </Table.ColumnHeader>
1167
+ <Table.ColumnHeader textAlign="right">
1168
+ Long Liq
1169
+ </Table.ColumnHeader>
1170
+ <Table.ColumnHeader textAlign="right">
1171
+ Short Liq
1172
+ </Table.ColumnHeader>
1173
+ <Table.ColumnHeader>Pressure</Table.ColumnHeader>
1174
+ <Table.ColumnHeader>Updated</Table.ColumnHeader>
1175
+ </Table.Row>
1176
+ </Table.Header>
1177
+ <Table.Body>
1178
+ {FIXED_SYMBOLS.map((symbol) => {
1179
+ const row = summaryBySymbol[symbol];
1180
+ const bias = getBias({
1181
+ latest_funding_rate:
1182
+ metricsBySymbol[symbol].currentFundingRate,
1183
+ oi_change_pct: metricsBySymbol[symbol].oiChangePct,
1184
+ sum_liq_long: metricsBySymbol[symbol].sumLiqLong,
1185
+ sum_liq_short: metricsBySymbol[symbol].sumLiqShort,
1186
+ });
164
1187
 
165
- <Heading size="md" mb={2}>
166
- Latest Points
167
- </Heading>
168
- <Box overflowX="auto">
169
- <Table.Root size="sm">
170
- <Table.Header>
171
- <Table.Row>
172
- <Table.ColumnHeader>ts</Table.ColumnHeader>
173
- <Table.ColumnHeader>symbol</Table.ColumnHeader>
174
- <Table.ColumnHeader>tf</Table.ColumnHeader>
175
- <Table.ColumnHeader>oi</Table.ColumnHeader>
176
- <Table.ColumnHeader>funding</Table.ColumnHeader>
177
- <Table.ColumnHeader>liq_long</Table.ColumnHeader>
178
- <Table.ColumnHeader>liq_short</Table.ColumnHeader>
179
- <Table.ColumnHeader>liq_total</Table.ColumnHeader>
180
- </Table.Row>
181
- </Table.Header>
182
- <Table.Body>
183
- {(data?.rows ?? []).slice(0, 200).map((row, idx) => (
184
- <Table.Row key={`${row.symbol}:${row.interval}:${row.ts}:${idx}`}>
185
- <Table.Cell>{new Date(row.ts).toISOString()}</Table.Cell>
186
- <Table.Cell>{row.symbol}</Table.Cell>
187
- <Table.Cell>{row.interval}</Table.Cell>
188
- <Table.Cell>{fmt(row.open_interest, 2)}</Table.Cell>
189
- <Table.Cell>{fmt(row.funding_rate, 6)}</Table.Cell>
190
- <Table.Cell>{fmt(row.liq_long, 2)}</Table.Cell>
191
- <Table.Cell>{fmt(row.liq_short, 2)}</Table.Cell>
192
- <Table.Cell>{fmt(row.liq_total, 2)}</Table.Cell>
193
- </Table.Row>
194
- ))}
195
- </Table.Body>
196
- </Table.Root>
1188
+ return (
1189
+ <Table.Row key={symbol}>
1190
+ <Table.Cell>
1191
+ <Text fontWeight="semibold">
1192
+ {getSymbolLabel(symbol)}
1193
+ </Text>
1194
+ </Table.Cell>
1195
+ <Table.Cell textAlign="right">
1196
+ {formatCompact(
1197
+ metricsBySymbol[symbol].currentOpenInterest,
1198
+ )}
1199
+ </Table.Cell>
1200
+ <Table.Cell textAlign="right">
1201
+ <Text
1202
+ color={getValueColor(
1203
+ metricsBySymbol[symbol].oiChangePct,
1204
+ )}
1205
+ >
1206
+ {formatPercent(
1207
+ metricsBySymbol[symbol].oiChangePct,
1208
+ )}
1209
+ </Text>
1210
+ </Table.Cell>
1211
+ <Table.Cell textAlign="right">
1212
+ <Text
1213
+ color={getFundingColor(
1214
+ metricsBySymbol[symbol].currentFundingRate,
1215
+ )}
1216
+ >
1217
+ {formatFunding(
1218
+ metricsBySymbol[symbol].currentFundingRate,
1219
+ )}
1220
+ </Text>
1221
+ </Table.Cell>
1222
+ <Table.Cell textAlign="right">
1223
+ {formatCompact(
1224
+ metricsBySymbol[symbol].sumLiqLong,
1225
+ )}
1226
+ </Table.Cell>
1227
+ <Table.Cell textAlign="right">
1228
+ {formatCompact(
1229
+ metricsBySymbol[symbol].sumLiqShort,
1230
+ )}
1231
+ </Table.Cell>
1232
+ <Table.Cell>
1233
+ <Badge colorPalette={bias.tone}>
1234
+ {bias.label}
1235
+ </Badge>
1236
+ </Table.Cell>
1237
+ <Table.Cell>
1238
+ {formatFullTime(
1239
+ metricsBySymbol[symbol].lastTs ??
1240
+ row?.last_ts,
1241
+ )}
1242
+ </Table.Cell>
1243
+ </Table.Row>
1244
+ );
1245
+ })}
1246
+ </Table.Body>
1247
+ </Table.Root>
1248
+ </Box>
1249
+ </Card.Body>
1250
+ </Card.Root>
1251
+ </>
1252
+ )}
1253
+ </Box>
197
1254
  </Box>
198
- </Box>
1255
+ </ClientOnly>
199
1256
  );
200
1257
  };
201
1258