@tradejs/app 2.0.10 → 2.0.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tradejs/app",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
5
5
  "keywords": [
6
6
  "tradejs",
@@ -51,12 +51,12 @@
51
51
  "@emotion/react": "^11.14.0",
52
52
  "@langchain/core": "^1.2.3",
53
53
  "@langchain/openai": "^1.5.5",
54
- "@tradejs/connectors": "^2.0.10",
55
- "@tradejs/core": "^2.0.10",
56
- "@tradejs/indicators": "^2.0.10",
57
- "@tradejs/infra": "^2.0.10",
58
- "@tradejs/node": "^2.0.10",
59
- "@tradejs/types": "^2.0.10",
54
+ "@tradejs/connectors": "^2.0.12",
55
+ "@tradejs/core": "^2.0.12",
56
+ "@tradejs/indicators": "^2.0.12",
57
+ "@tradejs/infra": "^2.0.12",
58
+ "@tradejs/node": "^2.0.12",
59
+ "@tradejs/types": "^2.0.12",
60
60
  "@types/bcryptjs": "2.4.6",
61
61
  "@types/lodash": "4.17.24",
62
62
  "@types/node": "24.13.3",
@@ -29,10 +29,13 @@ import { getCurrentUserName } from '#app/lib/currentUser';
29
29
  import {
30
30
  assignLegacyRuntimeTradeAccountScopes,
31
31
  buildRuntimeStrategyAnalytics,
32
+ buildRuntimeStrategyAiGateChanges,
32
33
  buildRuntimeStrategyIdentityKey,
33
34
  buildExchangeFallbackRuntimeTrades,
34
35
  isRuntimeTradeRecord,
36
+ isRuntimeStrategyLineageScope,
35
37
  resolveStrategyConfigIdentityByKey,
38
+ RuntimeStrategyLineageScope,
36
39
  RuntimeStrategiesResponse,
37
40
  selectTradesForWindow,
38
41
  toRuntimeTradeView,
@@ -52,6 +55,7 @@ const MIN_HOURS = 6;
52
55
  const MAX_HOURS = 24 * 90;
53
56
  const BYBIT_MAX_TIME_RANGE_MS = 7 * 24 * 60 * 60 * 1000 - 1_000;
54
57
  const EXCHANGE_REQUEST_TIMEOUT_MS = 15_000;
58
+ const RUNTIME_LINEAGE_BASELINE_MS = 7 * 24 * 60 * 60 * 1000;
55
59
 
56
60
  const coerceHours = (value: string | null) => {
57
61
  const parsed = Number(value ?? Number.NaN);
@@ -162,6 +166,26 @@ const loadRuntimeTrades = async (
162
166
  .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
163
167
  };
164
168
 
169
+ const loadRuntimeLineageScopes = async (
170
+ userName: string,
171
+ startTime: number,
172
+ endTime: number,
173
+ ): Promise<RuntimeStrategyLineageScope[]> =>
174
+ (
175
+ await Promise.all(
176
+ getRuntimeStorageDayKeys(
177
+ Math.max(0, startTime - RUNTIME_LINEAGE_BASELINE_MS),
178
+ endTime,
179
+ ).map((dayKey) =>
180
+ getHashJsonValues<RuntimeStrategyLineageScope>(
181
+ redisKeys.runtimeLineageScopeBucket(userName, dayKey),
182
+ ),
183
+ ),
184
+ )
185
+ )
186
+ .flat()
187
+ .filter(isRuntimeStrategyLineageScope);
188
+
165
189
  const buildExchangeTimeRanges = (startTime: number, endTime: number) => {
166
190
  const ranges: Array<{ startTime: number; endTime: number }> = [];
167
191
  let cursor = startTime;
@@ -380,6 +404,7 @@ export const GET = async (request: NextRequest) => {
380
404
  openPositionsSnapshot,
381
405
  runtimeDeployments,
382
406
  tradingAccounts,
407
+ runtimeLineageScopes,
383
408
  ] = await Promise.all([
384
409
  loadRuntimeStrategyConfigs(userName),
385
410
  loadConfiguredStrategyNames(),
@@ -400,6 +425,7 @@ export const GET = async (request: NextRequest) => {
400
425
  loadOpenPositions(connector, exchangeErrors),
401
426
  listRuntimeDeployments(userName),
402
427
  listTradingAccounts(userName),
428
+ loadRuntimeLineageScopes(userName, startTime, endTime),
403
429
  ]);
404
430
  const relevantTrades = selectTradesForWindow(
405
431
  runtimeTrades,
@@ -607,6 +633,13 @@ export const GET = async (request: NextRequest) => {
607
633
  stat: analytics.stat,
608
634
  summary: analytics.summary,
609
635
  orderLog: analytics.orderLog,
636
+ aiGateChanges: buildRuntimeStrategyAiGateChanges({
637
+ scopes: runtimeLineageScopes,
638
+ strategyName,
639
+ configId: identity.configId,
640
+ startTime,
641
+ endTime,
642
+ }),
610
643
  recentTrades: strategyTrades
611
644
  .slice(0, 8)
612
645
  .map((trade) => toRuntimeTradeView(trade, endTime)),
@@ -7,7 +7,6 @@ import {
7
7
  LineChart,
8
8
  CartesianGrid,
9
9
  Line,
10
- XAxis,
11
10
  YAxis,
12
11
  Tooltip,
13
12
  ReferenceLine,
@@ -18,6 +17,8 @@ import { useTestContext } from '../context';
18
17
  import { TestCompareList } from '@tradejs/types';
19
18
  import { mapOrderLogToChartData, getChartData } from './utils';
20
19
  import { getFormatted, getTimeline } from '@tradejs/core/backtest';
20
+ import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
21
+ import { TimeSeriesXAxis } from '#shared/Charts/TimeSeriesXAxis';
21
22
 
22
23
  interface TestCardChartProps {
23
24
  height?: string | number;
@@ -58,6 +59,9 @@ export const TestCardChart = ({ height = '350px' }: TestCardChartProps) => {
58
59
 
59
60
  const { formatted: maxAmount } = getFormatted(stat, 'maxAmount');
60
61
  const { formatted: minAmount } = getFormatted(stat, 'minAmount');
62
+ const startTimestamp = test.options.start ?? testResult.orderLog[0]?.[0] ?? 0;
63
+ const endTimestamp =
64
+ test.options.end ?? testResult.orderLog.at(-1)?.[0] ?? startTimestamp;
61
65
 
62
66
  return (
63
67
  <Box w="100%" minW="600px" h={height} pr={2}>
@@ -92,12 +96,19 @@ export const TestCardChart = ({ height = '350px' }: TestCardChartProps) => {
92
96
  position: 'bottom',
93
97
  }}
94
98
  />
95
- <XAxis dataKey="timestamp" />
99
+ <TimeSeriesXAxis
100
+ startTimestamp={startTimestamp}
101
+ endTimestamp={endTimestamp}
102
+ />
96
103
  <YAxis tickCount={10} domain={[stat.minAmount - 10, 'auto']} />
97
104
  <Tooltip
98
105
  animationDuration={100}
99
106
  cursor={false}
100
- content={<Chart.Tooltip />}
107
+ content={
108
+ <Chart.Tooltip
109
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
110
+ />
111
+ }
101
112
  />
102
113
 
103
114
  {chart.series.map((item) => (
@@ -108,6 +119,7 @@ export const TestCardChart = ({ height = '350px' }: TestCardChartProps) => {
108
119
  stroke={chart.color(item.color)}
109
120
  strokeWidth={2}
110
121
  dot={false}
122
+ activeDot={{ r: 5, strokeWidth: 2 }}
111
123
  />
112
124
  ))}
113
125
  </LineChart>
@@ -1,4 +1,3 @@
1
- import { format } from 'date-fns';
2
1
  import {
3
2
  SimpleOrderLogData,
4
3
  TestResult,
@@ -11,7 +10,7 @@ const getLineName = (testResult: TestResult) =>
11
10
  export const mapOrderLogToChartData = (testResult: TestResult) => {
12
11
  const data = testResult.orderLog.map(([timestamp, amount]) => ({
13
12
  [getLineName(testResult)]: amount,
14
- timestamp: format(timestamp, 'dd.MM'),
13
+ timestamp,
15
14
  }));
16
15
 
17
16
  const series = [
@@ -52,8 +51,6 @@ export const getChartData = (testList: TestCompareList, timeline: number[]) => {
52
51
  const values: Record<string, number> = {};
53
52
 
54
53
  const data = timeline.map((timestamp, ind) => {
55
- const formattedTimestamp = format(timestamp, 'dd.MM');
56
-
57
54
  testList.forEach(({ testResult }) => {
58
55
  values[getLineName(testResult)] = getAmountFromOrderLog(
59
56
  ind,
@@ -65,7 +62,7 @@ export const getChartData = (testList: TestCompareList, timeline: number[]) => {
65
62
 
66
63
  return {
67
64
  ...values,
68
- timestamp: formattedTimestamp,
65
+ timestamp,
69
66
  };
70
67
  });
71
68
 
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { XAxis } from 'recharts';
5
+ import {
6
+ buildTimeSeriesTicks,
7
+ formatTimeSeriesAxisTimestamp,
8
+ } from '#app/lib/timeSeriesChart';
9
+
10
+ export const TimeSeriesXAxis = ({
11
+ startTimestamp,
12
+ endTimestamp,
13
+ tickCount = 7,
14
+ minTickGap = 24,
15
+ includeTime,
16
+ }: {
17
+ startTimestamp: number;
18
+ endTimestamp: number;
19
+ tickCount?: number;
20
+ minTickGap?: number;
21
+ includeTime?: boolean;
22
+ }) => {
23
+ const start = Math.min(startTimestamp, endTimestamp);
24
+ const end = Math.max(startTimestamp, endTimestamp);
25
+ const ticks = useMemo(
26
+ () => buildTimeSeriesTicks(start, end, tickCount),
27
+ [end, start, tickCount],
28
+ );
29
+
30
+ return (
31
+ <XAxis
32
+ dataKey="timestamp"
33
+ type="number"
34
+ scale="time"
35
+ domain={[start, end]}
36
+ ticks={ticks}
37
+ tickFormatter={(timestamp) =>
38
+ formatTimeSeriesAxisTimestamp({
39
+ timestamp,
40
+ startTimestamp: start,
41
+ endTimestamp: end,
42
+ includeTime,
43
+ })
44
+ }
45
+ tickCount={tickCount}
46
+ interval="preserveStartEnd"
47
+ minTickGap={minTickGap}
48
+ allowDataOverflow
49
+ />
50
+ );
51
+ };
@@ -1434,10 +1434,14 @@ const mapRuntimeOrder = (order: RuntimeOrderView): OrdersDrawerOrder => {
1434
1434
  export const RuntimeStrategyCard = ({
1435
1435
  strategy,
1436
1436
  provider,
1437
+ startTimestamp,
1438
+ endTimestamp,
1437
1439
  onUpdated,
1438
1440
  }: {
1439
1441
  strategy: RuntimeStrategyView;
1440
1442
  provider: string;
1443
+ startTimestamp: number;
1444
+ endTimestamp: number;
1441
1445
  onUpdated: () => Promise<void> | void;
1442
1446
  }) => {
1443
1447
  const [configOpen, setConfigOpen] = useState(false);
@@ -2175,7 +2179,13 @@ export const RuntimeStrategyCard = ({
2175
2179
  </Portal>
2176
2180
  </Drawer.Root>
2177
2181
 
2178
- <RuntimeStrategyChart orderLog={strategy.orderLog} stat={strategy.stat} />
2182
+ <RuntimeStrategyChart
2183
+ orderLog={strategy.orderLog}
2184
+ stat={strategy.stat}
2185
+ aiGateChanges={strategy.aiGateChanges}
2186
+ startTimestamp={startTimestamp}
2187
+ endTimestamp={endTimestamp}
2188
+ />
2179
2189
 
2180
2190
  <SimpleGrid columns={{ base: 4, md: 8 }} p={4}>
2181
2191
  <StatItem stat={strategy.stat} id="netProfit" title="P&L" />
@@ -10,28 +10,35 @@ import {
10
10
  ReferenceLine,
11
11
  ResponsiveContainer,
12
12
  Tooltip,
13
- XAxis,
14
13
  YAxis,
15
14
  } from 'recharts';
16
- import { format } from 'date-fns';
17
15
  import { getFormatted } from '@tradejs/core/backtest';
18
16
  import type { SimpleOrderLogData, TestStat } from '@tradejs/types';
17
+ import type { RuntimeStrategyAiGateChange } from '#app/lib/runtimeStrategies';
18
+ import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
19
+ import { TimeSeriesXAxis } from '#shared/Charts/TimeSeriesXAxis';
19
20
 
20
21
  interface RuntimeStrategyChartProps {
21
22
  orderLog: SimpleOrderLogData;
22
23
  stat: TestStat;
24
+ aiGateChanges: RuntimeStrategyAiGateChange[];
25
+ startTimestamp: number;
26
+ endTimestamp: number;
23
27
  height?: string | number;
24
28
  }
25
29
 
26
30
  export const RuntimeStrategyChart = ({
27
31
  orderLog,
28
32
  stat,
33
+ aiGateChanges,
34
+ startTimestamp,
35
+ endTimestamp,
29
36
  height = '350px',
30
37
  }: RuntimeStrategyChartProps) => {
31
38
  const chartData = useMemo(
32
39
  () => ({
33
40
  data: orderLog.map(([timestamp, amount]) => ({
34
- timestamp: format(timestamp, 'dd.MM'),
41
+ timestamp,
35
42
  equity: amount,
36
43
  })),
37
44
  series: [
@@ -47,6 +54,7 @@ export const RuntimeStrategyChart = ({
47
54
  const chart = useChart(chartData as any);
48
55
  const { formatted: maxAmount } = getFormatted(stat, 'maxAmount');
49
56
  const { formatted: minAmount } = getFormatted(stat, 'minAmount');
57
+ const gateChangeColor = chart.color('purple.400');
50
58
 
51
59
  if (!orderLog.length) {
52
60
  return (
@@ -96,12 +104,35 @@ export const RuntimeStrategyChart = ({
96
104
  position: 'bottom',
97
105
  }}
98
106
  />
99
- <XAxis dataKey="timestamp" />
107
+ {aiGateChanges.map((change) => (
108
+ <ReferenceLine
109
+ key={`${change.timestamp}:${change.fingerprint}`}
110
+ x={change.timestamp}
111
+ stroke={gateChangeColor}
112
+ strokeDasharray="3 5"
113
+ strokeWidth={1.5}
114
+ label={{
115
+ value: `AI-gate ${change.fingerprint.slice(0, 7)}`,
116
+ fill: gateChangeColor,
117
+ fontSize: 10,
118
+ offset: 6,
119
+ position: 'insideTopRight',
120
+ }}
121
+ />
122
+ ))}
123
+ <TimeSeriesXAxis
124
+ startTimestamp={startTimestamp}
125
+ endTimestamp={endTimestamp}
126
+ />
100
127
  <YAxis tickCount={10} domain={[stat.minAmount - 10, 'auto']} />
101
128
  <Tooltip
102
129
  animationDuration={100}
103
130
  cursor={false}
104
- content={<Chart.Tooltip />}
131
+ content={
132
+ <Chart.Tooltip
133
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
134
+ />
135
+ }
105
136
  />
106
137
  {chart.series.map((item) => (
107
138
  <Line
@@ -111,6 +142,7 @@ export const RuntimeStrategyChart = ({
111
142
  stroke={chart.color(item.color)}
112
143
  strokeWidth={2}
113
144
  dot={false}
145
+ activeDot={{ r: 5, strokeWidth: 2 }}
114
146
  />
115
147
  ))}
116
148
  </LineChart>
@@ -9,11 +9,11 @@ import {
9
9
  LineChart,
10
10
  ResponsiveContainer,
11
11
  Tooltip,
12
- XAxis,
13
12
  YAxis,
14
13
  } from 'recharts';
15
- import { format } from 'date-fns';
16
14
  import type { SimpleOrderLogData } from '@tradejs/types';
15
+ import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
16
+ import { TimeSeriesXAxis } from '#shared/Charts/TimeSeriesXAxis';
17
17
 
18
18
  export const StrategySnapshotChart = ({
19
19
  orderLog,
@@ -27,7 +27,7 @@ export const StrategySnapshotChart = ({
27
27
  const chartData = useMemo(
28
28
  () => ({
29
29
  data: orderLog.map(([timestamp, amount]) => ({
30
- timestamp: format(timestamp, 'dd.MM'),
30
+ timestamp,
31
31
  equity: amount,
32
32
  })),
33
33
  series: [
@@ -59,6 +59,8 @@ export const StrategySnapshotChart = ({
59
59
 
60
60
  const values = orderLog.map(([, amount]) => amount);
61
61
  const minValue = Math.min(...values);
62
+ const startTimestamp = orderLog[0][0];
63
+ const endTimestamp = orderLog[orderLog.length - 1][0];
62
64
 
63
65
  return (
64
66
  <Box w="100%" minW="600px" h={height} pr={2}>
@@ -66,7 +68,10 @@ export const StrategySnapshotChart = ({
66
68
  <Chart.Root maxH="md" chart={chart}>
67
69
  <LineChart data={chart.data}>
68
70
  <CartesianGrid stroke={chart.color('border')} vertical={false} />
69
- <XAxis dataKey="timestamp" />
71
+ <TimeSeriesXAxis
72
+ startTimestamp={startTimestamp}
73
+ endTimestamp={endTimestamp}
74
+ />
70
75
  <YAxis
71
76
  tickCount={10}
72
77
  domain={[Math.min(minValue - 10, 0), 'auto']}
@@ -74,7 +79,11 @@ export const StrategySnapshotChart = ({
74
79
  <Tooltip
75
80
  animationDuration={100}
76
81
  cursor={false}
77
- content={<Chart.Tooltip />}
82
+ content={
83
+ <Chart.Tooltip
84
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
85
+ />
86
+ }
78
87
  />
79
88
  {chart.series.map((item) => (
80
89
  <Line
@@ -84,6 +93,7 @@ export const StrategySnapshotChart = ({
84
93
  stroke={chart.color(item.color)}
85
94
  strokeWidth={2}
86
95
  dot={false}
96
+ activeDot={{ r: 5, strokeWidth: 2 }}
87
97
  />
88
98
  ))}
89
99
  </LineChart>
@@ -9,6 +9,7 @@ import type {
9
9
  ExchangeEntryRecord,
10
10
  PositionPnlSnapshot,
11
11
  RuntimeTradeRecord,
12
+ RuntimeLineage,
12
13
  SimpleOrderLogData,
13
14
  StrategyConfig,
14
15
  TestStat,
@@ -72,6 +73,21 @@ export interface RuntimeStrategyTradeView {
72
73
  lastSyncedAt: number | null;
73
74
  }
74
75
 
76
+ export interface RuntimeStrategyLineageScope {
77
+ strategy: string;
78
+ symbol: string;
79
+ runtimeConfigId?: string;
80
+ lineage: RuntimeLineage;
81
+ firstTimestamp: number;
82
+ lastTimestamp: number;
83
+ }
84
+
85
+ export interface RuntimeStrategyAiGateChange {
86
+ timestamp: number;
87
+ previousFingerprint: string;
88
+ fingerprint: string;
89
+ }
90
+
75
91
  export interface RuntimeStrategyView {
76
92
  runtimeKey: string;
77
93
  strategyName: string;
@@ -89,6 +105,7 @@ export interface RuntimeStrategyView {
89
105
  stat: TestStat;
90
106
  summary: RuntimeStrategyTradeSummary;
91
107
  orderLog: SimpleOrderLogData;
108
+ aiGateChanges: RuntimeStrategyAiGateChange[];
92
109
  recentTrades: RuntimeStrategyTradeView[];
93
110
  orders: RuntimeStrategyTradeView[];
94
111
  }
@@ -164,6 +181,104 @@ export interface RuntimeStrategiesResponse {
164
181
  strategies: RuntimeStrategyView[];
165
182
  }
166
183
 
184
+ export const isRuntimeStrategyLineageScope = (
185
+ value: unknown,
186
+ ): value is RuntimeStrategyLineageScope => {
187
+ if (!value || typeof value !== 'object') {
188
+ return false;
189
+ }
190
+
191
+ const record = value as Record<string, unknown>;
192
+ const lineage = record.lineage as Record<string, unknown> | undefined;
193
+
194
+ return (
195
+ typeof record.strategy === 'string' &&
196
+ typeof record.symbol === 'string' &&
197
+ typeof record.firstTimestamp === 'number' &&
198
+ Number.isFinite(record.firstTimestamp) &&
199
+ typeof record.lastTimestamp === 'number' &&
200
+ Number.isFinite(record.lastTimestamp) &&
201
+ lineage != null &&
202
+ typeof lineage.gateFingerprint === 'string' &&
203
+ lineage.gateFingerprint.trim().length > 0
204
+ );
205
+ };
206
+
207
+ export const buildRuntimeStrategyAiGateChanges = ({
208
+ scopes,
209
+ strategyName,
210
+ configId,
211
+ startTime,
212
+ endTime,
213
+ }: {
214
+ scopes: RuntimeStrategyLineageScope[];
215
+ strategyName: string;
216
+ configId?: string;
217
+ startTime: number;
218
+ endTime: number;
219
+ }): RuntimeStrategyAiGateChange[] => {
220
+ const normalizedConfigId = configId ?? 'config';
221
+ const observationsByTimestamp = new Map<
222
+ number,
223
+ { fingerprint: string; lastTimestamp: number }
224
+ >();
225
+
226
+ for (const scope of scopes) {
227
+ if (
228
+ scope.strategy !== strategyName ||
229
+ (scope.runtimeConfigId ?? 'config') !== normalizedConfigId ||
230
+ scope.firstTimestamp > endTime
231
+ ) {
232
+ continue;
233
+ }
234
+
235
+ const fingerprint = scope.lineage.gateFingerprint.trim();
236
+ const existing = observationsByTimestamp.get(scope.firstTimestamp);
237
+
238
+ // Multiple symbols are evaluated for the same strategy timestamp. If a
239
+ // deploy happens during that cycle, prefer the lineage that kept running
240
+ // afterwards instead of making the marker order depend on the symbol name.
241
+ if (
242
+ !existing ||
243
+ scope.lastTimestamp > existing.lastTimestamp ||
244
+ (scope.lastTimestamp === existing.lastTimestamp &&
245
+ fingerprint > existing.fingerprint)
246
+ ) {
247
+ observationsByTimestamp.set(scope.firstTimestamp, {
248
+ fingerprint,
249
+ lastTimestamp: scope.lastTimestamp,
250
+ });
251
+ }
252
+ }
253
+
254
+ const observations = [...observationsByTimestamp.entries()].sort(
255
+ ([leftTimestamp], [rightTimestamp]) => leftTimestamp - rightTimestamp,
256
+ );
257
+ const changes: RuntimeStrategyAiGateChange[] = [];
258
+ let currentFingerprint: string | null = null;
259
+
260
+ for (const [timestamp, observation] of observations) {
261
+ if (currentFingerprint == null) {
262
+ currentFingerprint = observation.fingerprint;
263
+ continue;
264
+ }
265
+ if (observation.fingerprint === currentFingerprint) {
266
+ continue;
267
+ }
268
+
269
+ if (timestamp >= startTime) {
270
+ changes.push({
271
+ timestamp,
272
+ previousFingerprint: currentFingerprint,
273
+ fingerprint: observation.fingerprint,
274
+ });
275
+ }
276
+ currentFingerprint = observation.fingerprint;
277
+ }
278
+
279
+ return changes;
280
+ };
281
+
167
282
  const roundValue = (value: number, digits = 2) => {
168
283
  if (!Number.isFinite(value)) {
169
284
  return 0;
@@ -0,0 +1,100 @@
1
+ import { format } from 'date-fns';
2
+
3
+ const SHORT_TIME_RANGE_MS = 48 * 60 * 60 * 1000;
4
+
5
+ type TooltipPayloadEntry = {
6
+ payload?: unknown;
7
+ };
8
+
9
+ const getValidDate = (value: unknown) => {
10
+ if (
11
+ value === null ||
12
+ value === undefined ||
13
+ typeof value === 'boolean' ||
14
+ (typeof value === 'string' && value.trim() === '')
15
+ ) {
16
+ return null;
17
+ }
18
+
19
+ let timestamp: number;
20
+ try {
21
+ timestamp = Number(value);
22
+ } catch {
23
+ return null;
24
+ }
25
+
26
+ if (!Number.isFinite(timestamp)) {
27
+ return null;
28
+ }
29
+
30
+ const date = new Date(timestamp);
31
+ return Number.isNaN(date.getTime()) ? null : date;
32
+ };
33
+
34
+ export const buildTimeSeriesTicks = (
35
+ startTimestamp: number,
36
+ endTimestamp: number,
37
+ tickCount = 7,
38
+ ) => {
39
+ if (!getValidDate(startTimestamp) || !getValidDate(endTimestamp)) {
40
+ return [];
41
+ }
42
+
43
+ const start = Math.min(startTimestamp, endTimestamp);
44
+ const end = Math.max(startTimestamp, endTimestamp);
45
+ if (start === end) {
46
+ return [start];
47
+ }
48
+
49
+ const count = Math.max(2, Math.trunc(tickCount));
50
+ const ticks = Array.from({ length: count }, (_, index) =>
51
+ index === count - 1
52
+ ? end
53
+ : Math.round(start + ((end - start) * index) / (count - 1)),
54
+ );
55
+
56
+ return [...new Set(ticks)];
57
+ };
58
+
59
+ export const formatTimeSeriesAxisTimestamp = ({
60
+ timestamp,
61
+ startTimestamp,
62
+ endTimestamp,
63
+ includeTime,
64
+ }: {
65
+ timestamp: number;
66
+ startTimestamp: number;
67
+ endTimestamp: number;
68
+ includeTime?: boolean;
69
+ }) => {
70
+ const date = getValidDate(timestamp);
71
+ if (!date) {
72
+ return String(timestamp);
73
+ }
74
+
75
+ return format(
76
+ date,
77
+ includeTime ?? endTimestamp - startTimestamp <= SHORT_TIME_RANGE_MS
78
+ ? 'dd.MM HH:mm'
79
+ : 'dd.MM',
80
+ );
81
+ };
82
+
83
+ export const formatTimeSeriesTooltipTimestamp = (
84
+ value: unknown,
85
+ payload: ReadonlyArray<TooltipPayloadEntry> = [],
86
+ ) => {
87
+ const payloadTimestamp = payload.reduce<unknown>((timestamp, item) => {
88
+ if (timestamp !== undefined) {
89
+ return timestamp;
90
+ }
91
+
92
+ const row = item.payload;
93
+ return row && typeof row === 'object' && 'timestamp' in row
94
+ ? row.timestamp
95
+ : undefined;
96
+ }, undefined);
97
+ const date = getValidDate(value) ?? getValidDate(payloadTimestamp);
98
+
99
+ return date ? format(date, 'dd.MM.yyyy HH:mm') : String(value ?? '');
100
+ };
@@ -33,13 +33,14 @@ import {
33
33
  ReferenceLine,
34
34
  ResponsiveContainer,
35
35
  Tooltip,
36
- XAxis,
37
36
  YAxis,
38
37
  } from 'recharts';
39
38
  import { format } from 'date-fns';
40
39
  import { FiBarChart2 } from 'react-icons/fi';
41
40
  import { API } from '@tradejs/core/api';
42
41
  import { buildKlinePath } from '#app/lib/marketRoutes';
42
+ import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
43
+ import { TimeSeriesXAxis } from '#shared/Charts/TimeSeriesXAxis';
43
44
  import { EmptyState, Segment, Select, toaster } from '#ui';
44
45
 
45
46
  type SummaryItem = {
@@ -91,6 +92,11 @@ type PriceResponse = {
91
92
  data?: PriceRow[];
92
93
  };
93
94
 
95
+ type ChartWindow = {
96
+ startTimestamp: number;
97
+ endTimestamp: number;
98
+ };
99
+
94
100
  type BiasTone = 'teal' | 'green' | 'red' | 'orange' | 'gray';
95
101
 
96
102
  type SymbolMetrics = {
@@ -224,12 +230,6 @@ const getChartDomain = (
224
230
  return [min - basePadding, max + basePadding];
225
231
  };
226
232
 
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
233
  const formatFullTime = (value: string | null | undefined) => {
234
234
  if (!value) return 'n/a';
235
235
  const parsed = new Date(value);
@@ -572,7 +572,7 @@ const ChartCard = ({
572
572
 
573
573
  const mapRowsToChartRows = (rows: DetailRow[]) =>
574
574
  rows.map((row) => ({
575
- timestamp: formatTimeLabel(row.ts),
575
+ timestamp: new Date(row.ts).getTime(),
576
576
  openInterest: toFiniteNumber(row.open_interest) ?? 0,
577
577
  funding: (toFiniteNumber(row.funding_rate) ?? 0) * 10_000,
578
578
  longLiquidations: -(toFiniteNumber(row.liq_long) ?? 0),
@@ -582,15 +582,17 @@ const mapRowsToChartRows = (rows: DetailRow[]) =>
582
582
  const mapPriceRowsToChartRows = (rows: PriceRow[]) =>
583
583
  rows.map((row) => ({
584
584
  price: toFiniteNumber(row.close) ?? 0,
585
- timestamp: formatTimeLabel(new Date(row.timestamp).toISOString()),
585
+ timestamp: row.timestamp,
586
586
  }));
587
587
 
588
588
  const SymbolPriceCard = ({
589
589
  symbol,
590
590
  rows,
591
+ window,
591
592
  }: {
592
593
  symbol: string;
593
594
  rows: PriceRow[];
595
+ window: ChartWindow;
594
596
  }) => {
595
597
  const theme = SYMBOL_THEMES[symbol];
596
598
  const symbolLabel = getSymbolLabel(symbol);
@@ -644,9 +646,21 @@ const SymbolPriceCard = ({
644
646
  stroke={priceChart.color('border')}
645
647
  vertical={false}
646
648
  />
647
- <XAxis dataKey="timestamp" minTickGap={36} />
649
+ <TimeSeriesXAxis
650
+ startTimestamp={window.startTimestamp}
651
+ endTimestamp={window.endTimestamp}
652
+ tickCount={5}
653
+ minTickGap={36}
654
+ />
648
655
  <YAxis domain={priceDomain} tickFormatter={formatPrice} />
649
- <Tooltip cursor={false} content={<Chart.Tooltip />} />
656
+ <Tooltip
657
+ cursor={false}
658
+ content={
659
+ <Chart.Tooltip
660
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
661
+ />
662
+ }
663
+ />
650
664
  <Area
651
665
  type="monotone"
652
666
  dataKey={priceChart.key('price') as string}
@@ -667,9 +681,11 @@ const SymbolPriceCard = ({
667
681
  const SymbolOpenInterestCard = ({
668
682
  symbol,
669
683
  rows,
684
+ window,
670
685
  }: {
671
686
  symbol: string;
672
687
  rows: DetailRow[];
688
+ window: ChartWindow;
673
689
  }) => {
674
690
  const theme = SYMBOL_THEMES[symbol];
675
691
  const symbolLabel = getSymbolLabel(symbol);
@@ -722,9 +738,21 @@ const SymbolOpenInterestCard = ({
722
738
  stroke={oiChart.color('border')}
723
739
  vertical={false}
724
740
  />
725
- <XAxis dataKey="timestamp" minTickGap={36} />
741
+ <TimeSeriesXAxis
742
+ startTimestamp={window.startTimestamp}
743
+ endTimestamp={window.endTimestamp}
744
+ tickCount={5}
745
+ minTickGap={36}
746
+ />
726
747
  <YAxis domain={oiDomain} tickFormatter={formatAxisCompact} />
727
- <Tooltip cursor={false} content={<Chart.Tooltip />} />
748
+ <Tooltip
749
+ cursor={false}
750
+ content={
751
+ <Chart.Tooltip
752
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
753
+ />
754
+ }
755
+ />
728
756
  <Area
729
757
  type="monotone"
730
758
  dataKey={oiChart.key('openInterest') as string}
@@ -745,9 +773,11 @@ const SymbolOpenInterestCard = ({
745
773
  const SymbolFundingCard = ({
746
774
  symbol,
747
775
  rows,
776
+ window,
748
777
  }: {
749
778
  symbol: string;
750
779
  rows: DetailRow[];
780
+ window: ChartWindow;
751
781
  }) => {
752
782
  const theme = SYMBOL_THEMES[symbol];
753
783
  const symbolLabel = getSymbolLabel(symbol);
@@ -781,9 +811,21 @@ const SymbolFundingCard = ({
781
811
  stroke={fundingChart.color('gray.600')}
782
812
  strokeDasharray="4 4"
783
813
  />
784
- <XAxis dataKey="timestamp" minTickGap={36} />
814
+ <TimeSeriesXAxis
815
+ startTimestamp={window.startTimestamp}
816
+ endTimestamp={window.endTimestamp}
817
+ tickCount={5}
818
+ minTickGap={36}
819
+ />
785
820
  <YAxis tickFormatter={(value) => `${value} bps`} />
786
- <Tooltip cursor={false} content={<Chart.Tooltip />} />
821
+ <Tooltip
822
+ cursor={false}
823
+ content={
824
+ <Chart.Tooltip
825
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
826
+ />
827
+ }
828
+ />
787
829
  <Bar
788
830
  dataKey={fundingChart.key('funding') as string}
789
831
  isAnimationActive={false}
@@ -810,9 +852,11 @@ const SymbolFundingCard = ({
810
852
  const SymbolLiquidationCard = ({
811
853
  symbol,
812
854
  rows,
855
+ window,
813
856
  }: {
814
857
  symbol: string;
815
858
  rows: DetailRow[];
859
+ window: ChartWindow;
816
860
  }) => {
817
861
  const theme = SYMBOL_THEMES[symbol];
818
862
  const symbolLabel = getSymbolLabel(symbol);
@@ -849,9 +893,21 @@ const SymbolLiquidationCard = ({
849
893
  stroke={liquidationChart.color('gray.600')}
850
894
  strokeDasharray="4 4"
851
895
  />
852
- <XAxis dataKey="timestamp" minTickGap={28} />
896
+ <TimeSeriesXAxis
897
+ startTimestamp={window.startTimestamp}
898
+ endTimestamp={window.endTimestamp}
899
+ tickCount={5}
900
+ minTickGap={28}
901
+ />
853
902
  <YAxis tickFormatter={formatAxisCompact} />
854
- <Tooltip cursor={false} content={<Chart.Tooltip />} />
903
+ <Tooltip
904
+ cursor={false}
905
+ content={
906
+ <Chart.Tooltip
907
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
908
+ />
909
+ }
910
+ />
855
911
  <Legend />
856
912
  <Bar
857
913
  dataKey={liquidationChart.key('longLiquidations') as string}
@@ -883,6 +939,13 @@ const DerivativesPage = () => {
883
939
  const [pricesBySymbol, setPricesBySymbol] = useState<
884
940
  Record<string, PriceRow[]>
885
941
  >({});
942
+ const [chartWindow, setChartWindow] = useState<ChartWindow>(() => {
943
+ const endTimestamp = Date.now();
944
+ return {
945
+ startTimestamp: endTimestamp - 24 * 60 * 60 * 1000,
946
+ endTimestamp,
947
+ };
948
+ });
886
949
  const [summaryLoading, setSummaryLoading] = useState(false);
887
950
  const [detailLoading, setDetailLoading] = useState(false);
888
951
  const [summaryError, setSummaryError] = useState('');
@@ -959,6 +1022,7 @@ const DerivativesPage = () => {
959
1022
  responses.map(([symbol, payload]) => [symbol, payload.priceRows]),
960
1023
  ),
961
1024
  );
1025
+ setChartWindow({ startTimestamp: from, endTimestamp: now });
962
1026
  } catch (err) {
963
1027
  const message =
964
1028
  (err as Error)?.message ||
@@ -1101,6 +1165,7 @@ const DerivativesPage = () => {
1101
1165
  key={`${symbol}:price`}
1102
1166
  symbol={symbol}
1103
1167
  rows={pricesBySymbol[symbol] ?? []}
1168
+ window={chartWindow}
1104
1169
  />
1105
1170
  ))}
1106
1171
  </SimpleGrid>
@@ -1111,6 +1176,7 @@ const DerivativesPage = () => {
1111
1176
  key={`${symbol}:oi`}
1112
1177
  symbol={symbol}
1113
1178
  rows={detailsBySymbol[symbol] ?? []}
1179
+ window={chartWindow}
1114
1180
  />
1115
1181
  ))}
1116
1182
  </SimpleGrid>
@@ -1121,6 +1187,7 @@ const DerivativesPage = () => {
1121
1187
  key={`${symbol}:funding`}
1122
1188
  symbol={symbol}
1123
1189
  rows={detailsBySymbol[symbol] ?? []}
1190
+ window={chartWindow}
1124
1191
  />
1125
1192
  ))}
1126
1193
  </SimpleGrid>
@@ -1131,6 +1198,7 @@ const DerivativesPage = () => {
1131
1198
  key={`${symbol}:liq`}
1132
1199
  symbol={symbol}
1133
1200
  rows={detailsBySymbol[symbol] ?? []}
1201
+ window={chartWindow}
1134
1202
  />
1135
1203
  ))}
1136
1204
  </SimpleGrid>
@@ -89,6 +89,17 @@ const RuntimeStrategiesContent = () => {
89
89
  const isSnapshotMode = mode === 'replay' || mode === 'ai';
90
90
  const snapshotModeLabel = mode === 'replay' ? 'Replay' : 'AI';
91
91
  const snapshotModeLabelLower = snapshotModeLabel.toLowerCase();
92
+ const runtimeChartWindow = useMemo(() => {
93
+ if (!runtimeData) {
94
+ return null;
95
+ }
96
+
97
+ return {
98
+ startTimestamp:
99
+ runtimeData.generatedAt - runtimeData.hours * 60 * 60 * 1000,
100
+ endTimestamp: runtimeData.generatedAt,
101
+ };
102
+ }, [runtimeData]);
92
103
 
93
104
  useEffect(() => {
94
105
  setMode(routeMode);
@@ -557,11 +568,14 @@ const RuntimeStrategiesContent = () => {
557
568
 
558
569
  {!loading &&
559
570
  mode === 'runtime' &&
571
+ runtimeChartWindow &&
560
572
  filteredRuntimeStrategies.map((strategy) => (
561
573
  <RuntimeStrategyCard
562
574
  key={strategy.runtimeKey}
563
575
  strategy={strategy}
564
576
  provider={runtimeData?.provider || 'bybit'}
577
+ startTimestamp={runtimeChartWindow.startTimestamp}
578
+ endTimestamp={runtimeChartWindow.endTimestamp}
565
579
  onUpdated={load}
566
580
  />
567
581
  ))}