@tradejs/app 2.0.19 → 2.0.21

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.19",
3
+ "version": "2.0.21",
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,13 +51,13 @@
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.19",
55
- "@tradejs/core": "^2.0.19",
56
- "@tradejs/indicators": "^2.0.19",
57
- "@tradejs/infra": "^2.0.19",
58
- "@tradejs/node": "^2.0.19",
59
- "@tradejs/strategies": "^2.0.19",
60
- "@tradejs/types": "^2.0.19",
54
+ "@tradejs/connectors": "^2.0.21",
55
+ "@tradejs/core": "^2.0.21",
56
+ "@tradejs/indicators": "^2.0.21",
57
+ "@tradejs/infra": "^2.0.21",
58
+ "@tradejs/node": "^2.0.21",
59
+ "@tradejs/strategies": "^2.0.21",
60
+ "@tradejs/types": "^2.0.21",
61
61
  "@types/bcryptjs": "2.4.6",
62
62
  "@types/lodash": "4.17.24",
63
63
  "@types/node": "24.13.3",
@@ -30,8 +30,9 @@ export const GET = async (
30
30
  );
31
31
  }
32
32
 
33
- const signal: Signal = await getData(
33
+ const signal: Signal | null = await getData(
34
34
  redisKeys.storeSignal(symbol, signalId),
35
+ null,
35
36
  );
36
37
 
37
38
  return NextResponse.json({ signal });
@@ -1405,9 +1405,7 @@ export const RuntimeStrategyCard = ({
1405
1405
  <RuntimeStrategyChart
1406
1406
  orderLog={strategy.orderLog}
1407
1407
  stat={strategy.stat}
1408
- aiGateObservedFrom={strategy.aiGateObservedFrom}
1409
- aiGateChanges={strategy.aiGateChanges}
1410
- maxLossValueTimeline={strategy.maxLossValueTimeline}
1408
+ evidenceTimeline={strategy.evidenceTimeline}
1411
1409
  startTimestamp={startTimestamp}
1412
1410
  endTimestamp={endTimestamp}
1413
1411
  />
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useMemo } from 'react';
3
+ import { useMemo, useState } from 'react';
4
4
  import { Box, Flex, Text } from '@chakra-ui/react';
5
5
  import { Chart, useChart } from '@chakra-ui/charts';
6
6
  import {
@@ -13,20 +13,24 @@ import {
13
13
  YAxis,
14
14
  } from 'recharts';
15
15
  import { getFormatted } from '@tradejs/core/backtest';
16
- import type { SimpleOrderLogData, TestStat } from '@tradejs/types';
17
16
  import type {
18
- RuntimeStrategyAiGateChange,
19
- RuntimeStrategyMaxLossValueTimeline,
20
- } from '#app/lib/runtimeStrategies';
17
+ SimpleOrderLogData,
18
+ StrategyEvidenceMarkerType,
19
+ StrategyEvidenceTimeline,
20
+ TestStat,
21
+ } from '@tradejs/types';
21
22
  import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
22
23
  import { TimeSeriesXAxis } from '#shared/Charts/TimeSeriesXAxis';
24
+ import {
25
+ filterStrategyEvidenceMarkers,
26
+ STRATEGY_EVIDENCE_MARKER_PRESENTATION,
27
+ StrategyEvidencePopover,
28
+ } from './StrategyEvidencePopover';
23
29
 
24
30
  interface RuntimeStrategyChartProps {
25
31
  orderLog: SimpleOrderLogData;
26
32
  stat: TestStat;
27
- aiGateObservedFrom: number | null;
28
- aiGateChanges: RuntimeStrategyAiGateChange[];
29
- maxLossValueTimeline: RuntimeStrategyMaxLossValueTimeline;
33
+ evidenceTimeline: StrategyEvidenceTimeline;
30
34
  startTimestamp: number;
31
35
  endTimestamp: number;
32
36
  height?: string | number;
@@ -35,13 +39,13 @@ interface RuntimeStrategyChartProps {
35
39
  export const RuntimeStrategyChart = ({
36
40
  orderLog,
37
41
  stat,
38
- aiGateObservedFrom,
39
- aiGateChanges,
40
- maxLossValueTimeline,
42
+ evidenceTimeline,
41
43
  startTimestamp,
42
44
  endTimestamp,
43
45
  height = '350px',
44
46
  }: RuntimeStrategyChartProps) => {
47
+ const [showParity, setShowParity] = useState(true);
48
+ const [showRecommendations, setShowRecommendations] = useState(true);
45
49
  const chartData = useMemo(
46
50
  () => ({
47
51
  data: orderLog.map(([timestamp, amount]) => ({
@@ -61,193 +65,120 @@ export const RuntimeStrategyChart = ({
61
65
  const chart = useChart(chartData as any);
62
66
  const { formatted: maxAmount } = getFormatted(stat, 'maxAmount');
63
67
  const { formatted: minAmount } = getFormatted(stat, 'minAmount');
64
- const gateChangeColor = chart.color('purple.400');
65
- const maxLossValueChangeColor = chart.color('orange.400');
66
-
67
- if (!orderLog.length) {
68
- return (
69
- <Box
70
- w="100%"
71
- minW="600px"
72
- h={height}
73
- display="flex"
74
- alignItems="center"
75
- justifyContent="center"
76
- >
77
- <Text color="gray.500">No runtime trades for the selected window.</Text>
78
- </Box>
79
- );
80
- }
68
+ const visibleEvidenceMarkers = filterStrategyEvidenceMarkers({
69
+ markers:
70
+ evidenceTimeline.status === 'verified' ? evidenceTimeline.markers : [],
71
+ showParity,
72
+ showRecommendations,
73
+ });
74
+ const markerColor = (type: StrategyEvidenceMarkerType) =>
75
+ chart.color(STRATEGY_EVIDENCE_MARKER_PRESENTATION[type].color);
76
+ const markerDash = (type: StrategyEvidenceMarkerType) => {
77
+ if (type === 'L') return '7 4';
78
+ if (type === 'P' || type === 'R') return '2 5';
79
+ return '3 5';
80
+ };
81
81
 
82
82
  return (
83
83
  <Box w="100%" minW="600px" h={height} display="flex" flexDirection="column">
84
- {aiGateObservedFrom != null ? (
85
- <Flex
86
- minH="24px"
87
- px={2}
88
- gap={4}
89
- alignItems="center"
90
- overflowX="auto"
91
- flexShrink={0}
92
- whiteSpace="nowrap"
93
- >
94
- <Text
95
- flexShrink={0}
96
- fontSize="xs"
97
- color="gray.500"
98
- title="AI-gate changes before this first runtime observation are unavailable"
99
- >
100
- AI-gate history from{' '}
101
- {formatTimeSeriesTooltipTimestamp(aiGateObservedFrom)}
102
- </Text>
103
- {aiGateChanges.map((change, index) => (
104
- <Text
105
- key={`${change.timestamp}:${change.fingerprint}:legend`}
106
- flexShrink={0}
107
- fontSize="xs"
108
- color="gray.400"
109
- title={`${change.previousFingerprint} → ${change.fingerprint}`}
110
- >
111
- <Text as="span" color={gateChangeColor} fontWeight="semibold">
112
- G{index + 1}
113
- </Text>{' '}
114
- {formatTimeSeriesTooltipTimestamp(change.timestamp)} ·{' '}
115
- {change.fingerprint.slice(0, 7)}
116
- </Text>
117
- ))}
118
- {maxLossValueTimeline.observedFrom != null &&
119
- maxLossValueTimeline.initialValue != null ? (
120
- <Text
121
- flexShrink={0}
122
- fontSize="xs"
123
- color="gray.500"
124
- title="MAX_LOSS_VALUE changes before this first runtime observation are unavailable"
125
- >
126
- MAX_LOSS_VALUE history from{' '}
127
- {formatTimeSeriesTooltipTimestamp(
128
- maxLossValueTimeline.observedFrom,
129
- )}{' '}
130
- · initial {maxLossValueTimeline.initialValue}$
131
- </Text>
132
- ) : null}
133
- {maxLossValueTimeline.changes.map((change, index) => (
134
- <Text
135
- key={`${change.timestamp}:${change.value}:max-loss-legend`}
136
- flexShrink={0}
137
- fontSize="xs"
138
- color="gray.400"
139
- >
140
- <Text
141
- as="span"
142
- color={maxLossValueChangeColor}
143
- fontWeight="semibold"
144
- >
145
- L{index + 1}
146
- </Text>{' '}
147
- {formatTimeSeriesTooltipTimestamp(change.timestamp)} ·{' '}
148
- {change.previousValue}$ → {change.value}$
149
- </Text>
150
- ))}
151
- </Flex>
152
- ) : null}
84
+ <Flex minH="32px" px={2} justify="flex-end" align="center" flexShrink={0}>
85
+ <StrategyEvidencePopover
86
+ timeline={evidenceTimeline}
87
+ showParity={showParity}
88
+ showRecommendations={showRecommendations}
89
+ onShowParityChange={setShowParity}
90
+ onShowRecommendationsChange={setShowRecommendations}
91
+ />
92
+ </Flex>
153
93
  <Box flex="1" minH={0} pr={2}>
154
- <ResponsiveContainer width="100%" height="100%">
155
- <Chart.Root maxH="md" chart={chart}>
156
- <LineChart data={chart.data}>
157
- <CartesianGrid stroke={chart.color('border')} vertical={false} />
158
- <ReferenceLine
159
- stroke={chart.color('gray.600')}
160
- strokeDasharray="5 5"
161
- y={stat.maxAmount}
162
- label={{
163
- value: `Max: ${maxAmount}`,
164
- offset: 10,
165
- fill: chart.color('gray.600'),
166
- position: 'top',
167
- }}
168
- />
169
- <ReferenceLine
170
- stroke={chart.color('gray.600')}
171
- strokeDasharray="8 8"
172
- y={100}
173
- />
174
- <ReferenceLine
175
- stroke={chart.color('gray.600')}
176
- strokeDasharray="5 5"
177
- y={stat.minAmount}
178
- label={{
179
- value: `Min: ${minAmount}`,
180
- offset: 10,
181
- fill: chart.color('gray.600'),
182
- position: 'bottom',
183
- }}
184
- />
185
- {aiGateChanges.map((change, index) => (
94
+ {orderLog.length ? (
95
+ <ResponsiveContainer width="100%" height="100%">
96
+ <Chart.Root maxH="md" chart={chart}>
97
+ <LineChart data={chart.data}>
98
+ <CartesianGrid
99
+ stroke={chart.color('border')}
100
+ vertical={false}
101
+ />
186
102
  <ReferenceLine
187
- key={`${change.timestamp}:${change.fingerprint}`}
188
- x={change.timestamp}
189
- stroke={gateChangeColor}
190
- strokeDasharray="3 5"
191
- strokeWidth={1.5}
103
+ stroke={chart.color('gray.600')}
104
+ strokeDasharray="5 5"
105
+ y={stat.maxAmount}
192
106
  label={{
193
- value: `G${index + 1}`,
194
- fill: gateChangeColor,
195
- fontSize: 10,
196
- fontWeight: 600,
197
- offset: 6,
198
- position:
199
- index % 2 === 0 ? 'insideTopRight' : 'insideTopLeft',
107
+ value: `Max: ${maxAmount}`,
108
+ offset: 10,
109
+ fill: chart.color('gray.600'),
110
+ position: 'top',
200
111
  }}
201
112
  />
202
- ))}
203
- {maxLossValueTimeline.changes.map((change, index) => (
204
113
  <ReferenceLine
205
- key={`${change.timestamp}:${change.value}:max-loss`}
206
- x={change.timestamp}
207
- stroke={maxLossValueChangeColor}
208
- strokeDasharray="7 4"
209
- strokeWidth={1.5}
114
+ stroke={chart.color('gray.600')}
115
+ strokeDasharray="8 8"
116
+ y={100}
117
+ />
118
+ <ReferenceLine
119
+ stroke={chart.color('gray.600')}
120
+ strokeDasharray="5 5"
121
+ y={stat.minAmount}
210
122
  label={{
211
- value: `L${index + 1}`,
212
- fill: maxLossValueChangeColor,
213
- fontSize: 10,
214
- fontWeight: 600,
215
- offset: 6,
216
- position:
217
- index % 2 === 0
218
- ? 'insideBottomRight'
219
- : 'insideBottomLeft',
123
+ value: `Min: ${minAmount}`,
124
+ offset: 10,
125
+ fill: chart.color('gray.600'),
126
+ position: 'bottom',
220
127
  }}
221
128
  />
222
- ))}
223
- <TimeSeriesXAxis
224
- startTimestamp={startTimestamp}
225
- endTimestamp={endTimestamp}
226
- />
227
- <YAxis tickCount={10} domain={[stat.minAmount - 10, 'auto']} />
228
- <Tooltip
229
- animationDuration={100}
230
- cursor={false}
231
- content={
232
- <Chart.Tooltip
233
- labelFormatter={formatTimeSeriesTooltipTimestamp}
129
+ {visibleEvidenceMarkers.map((marker, index) => (
130
+ <ReferenceLine
131
+ key={marker.id}
132
+ x={marker.timestamp}
133
+ stroke={markerColor(marker.type)}
134
+ strokeDasharray={markerDash(marker.type)}
135
+ strokeWidth={1.5}
136
+ label={{
137
+ value: marker.type,
138
+ fill: markerColor(marker.type),
139
+ fontSize: 10,
140
+ fontWeight: 600,
141
+ offset: 6,
142
+ position:
143
+ index % 2 === 0 ? 'insideTopRight' : 'insideTopLeft',
144
+ }}
234
145
  />
235
- }
236
- />
237
- {chart.series.map((item) => (
238
- <Line
239
- key={item.name as string}
240
- isAnimationActive={false}
241
- dataKey={chart.key(item.name) as string}
242
- stroke={chart.color(item.color)}
243
- strokeWidth={2}
244
- dot={false}
245
- activeDot={{ r: 5, strokeWidth: 2 }}
146
+ ))}
147
+ <TimeSeriesXAxis
148
+ startTimestamp={startTimestamp}
149
+ endTimestamp={endTimestamp}
246
150
  />
247
- ))}
248
- </LineChart>
249
- </Chart.Root>
250
- </ResponsiveContainer>
151
+ <YAxis tickCount={10} domain={[stat.minAmount - 10, 'auto']} />
152
+ <Tooltip
153
+ animationDuration={100}
154
+ cursor={false}
155
+ content={
156
+ <Chart.Tooltip
157
+ labelFormatter={formatTimeSeriesTooltipTimestamp}
158
+ />
159
+ }
160
+ />
161
+ {chart.series.map((item) => (
162
+ <Line
163
+ key={item.name as string}
164
+ isAnimationActive={false}
165
+ dataKey={chart.key(item.name) as string}
166
+ stroke={chart.color(item.color)}
167
+ strokeWidth={2}
168
+ dot={false}
169
+ activeDot={{ r: 5, strokeWidth: 2 }}
170
+ />
171
+ ))}
172
+ </LineChart>
173
+ </Chart.Root>
174
+ </ResponsiveContainer>
175
+ ) : (
176
+ <Flex h="100%" align="center" justify="center">
177
+ <Text color="gray.500">
178
+ No runtime trades for the selected window.
179
+ </Text>
180
+ </Flex>
181
+ )}
251
182
  </Box>
252
183
  </Box>
253
184
  );
@@ -0,0 +1,259 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Badge,
5
+ Box,
6
+ Button,
7
+ Checkbox,
8
+ Flex,
9
+ Popover,
10
+ Portal,
11
+ Text,
12
+ } from '@chakra-ui/react';
13
+ import type {
14
+ StrategyEvidenceMarker,
15
+ StrategyEvidenceMarkerType,
16
+ StrategyEvidenceTimeline,
17
+ } from '@tradejs/types';
18
+ import { formatTimeSeriesTooltipTimestamp } from '#app/lib/timeSeriesChart';
19
+
20
+ export const STRATEGY_EVIDENCE_MARKER_PRESENTATION: Record<
21
+ StrategyEvidenceMarkerType,
22
+ { name: string; color: string; optional: boolean }
23
+ > = {
24
+ G: { name: 'Composition / gate', color: 'purple.400', optional: false },
25
+ L: { name: 'MAX_LOSS_VALUE', color: 'orange.400', optional: false },
26
+ E: { name: 'Evidence boundary', color: 'teal.400', optional: false },
27
+ D: { name: 'Deployment', color: 'blue.400', optional: false },
28
+ P: { name: 'Runtime parity', color: 'cyan.400', optional: true },
29
+ R: { name: 'Recommendation', color: 'pink.400', optional: true },
30
+ };
31
+
32
+ const MARKER_TYPES = Object.keys(
33
+ STRATEGY_EVIDENCE_MARKER_PRESENTATION,
34
+ ) as StrategyEvidenceMarkerType[];
35
+
36
+ export const filterStrategyEvidenceMarkers = ({
37
+ markers,
38
+ showParity,
39
+ showRecommendations,
40
+ }: {
41
+ markers: StrategyEvidenceMarker[];
42
+ showParity: boolean;
43
+ showRecommendations: boolean;
44
+ }) =>
45
+ markers.filter(
46
+ (marker) =>
47
+ (marker.type !== 'P' || showParity) &&
48
+ (marker.type !== 'R' || showRecommendations),
49
+ );
50
+
51
+ const statusColor = (status: StrategyEvidenceTimeline['status']) => {
52
+ if (status === 'verified') return 'teal';
53
+ if (status === 'invalid') return 'red';
54
+ return 'orange';
55
+ };
56
+
57
+ const uniqueProvenance = (markers: StrategyEvidenceMarker[]) => {
58
+ const seen = new Set<string>();
59
+ return markers.filter((marker) => {
60
+ const key = `${marker.artifactId}:${marker.artifactSha256}`;
61
+ if (seen.has(key)) return false;
62
+ seen.add(key);
63
+ return true;
64
+ });
65
+ };
66
+
67
+ const FilterCheckbox = ({
68
+ checked,
69
+ label,
70
+ onChange,
71
+ }: {
72
+ checked: boolean;
73
+ label: string;
74
+ onChange: (checked: boolean) => void;
75
+ }) => (
76
+ <Checkbox.Root
77
+ size="sm"
78
+ checked={checked}
79
+ onCheckedChange={({ checked: nextChecked }) =>
80
+ onChange(nextChecked === true)
81
+ }
82
+ >
83
+ <Checkbox.HiddenInput />
84
+ <Checkbox.Control>
85
+ <Checkbox.Indicator />
86
+ </Checkbox.Control>
87
+ <Checkbox.Label>{label}</Checkbox.Label>
88
+ </Checkbox.Root>
89
+ );
90
+
91
+ export const StrategyEvidencePopover = ({
92
+ timeline,
93
+ showParity,
94
+ showRecommendations,
95
+ onShowParityChange,
96
+ onShowRecommendationsChange,
97
+ }: {
98
+ timeline: StrategyEvidenceTimeline;
99
+ showParity: boolean;
100
+ showRecommendations: boolean;
101
+ onShowParityChange: (checked: boolean) => void;
102
+ onShowRecommendationsChange: (checked: boolean) => void;
103
+ }) => {
104
+ const provenance =
105
+ timeline.status === 'verified' ? uniqueProvenance(timeline.markers) : [];
106
+
107
+ return (
108
+ <Popover.Root positioning={{ placement: 'bottom-end' }} lazyMount>
109
+ <Popover.Trigger asChild>
110
+ <Button
111
+ size="xs"
112
+ variant="outline"
113
+ aria-label={`Evidence: ${timeline.status}`}
114
+ >
115
+ Evidence
116
+ <Badge size="xs" colorPalette={statusColor(timeline.status)}>
117
+ {timeline.status === 'verified'
118
+ ? `${timeline.markers.length}`
119
+ : timeline.status}
120
+ </Badge>
121
+ </Button>
122
+ </Popover.Trigger>
123
+ <Portal>
124
+ <Popover.Positioner>
125
+ <Popover.Content width="min(430px, calc(100vw - 24px))">
126
+ <Popover.Arrow />
127
+ <Popover.Body>
128
+ <Flex direction="column" gap={3}>
129
+ <Box>
130
+ <Text fontWeight="semibold">Immutable evidence</Text>
131
+ <Text fontSize="xs" color="gray.500">
132
+ {timeline.status === 'verified'
133
+ ? timeline.observedFrom == null
134
+ ? 'Verified artifact; no observation boundary supplied.'
135
+ : `Verified from ${formatTimeSeriesTooltipTimestamp(
136
+ timeline.observedFrom,
137
+ )}.`
138
+ : timeline.status === 'invalid'
139
+ ? 'Evidence failed checksum, identity, or structure verification. No markers are rendered.'
140
+ : 'No checksum-verified evidence artifact is available. No mutable fallback is used.'}
141
+ </Text>
142
+ </Box>
143
+
144
+ <Box>
145
+ <Text fontSize="xs" fontWeight="semibold" mb={1}>
146
+ Legend
147
+ </Text>
148
+ <Flex wrap="wrap" gapX={3} gapY={1}>
149
+ {MARKER_TYPES.map((type) => (
150
+ <Flex key={type} align="center" gap={1}>
151
+ <Text
152
+ color={
153
+ STRATEGY_EVIDENCE_MARKER_PRESENTATION[type].color
154
+ }
155
+ fontWeight="bold"
156
+ fontSize="xs"
157
+ >
158
+ {type}
159
+ </Text>
160
+ <Text fontSize="xs" color="gray.500">
161
+ {STRATEGY_EVIDENCE_MARKER_PRESENTATION[type].name}
162
+ </Text>
163
+ </Flex>
164
+ ))}
165
+ </Flex>
166
+ </Box>
167
+
168
+ <Box>
169
+ <Text fontSize="xs" fontWeight="semibold" mb={1}>
170
+ Optional markers
171
+ </Text>
172
+ <Flex gap={4}>
173
+ <FilterCheckbox
174
+ checked={showParity}
175
+ label="Parity (P)"
176
+ onChange={onShowParityChange}
177
+ />
178
+ <FilterCheckbox
179
+ checked={showRecommendations}
180
+ label="Recommendations (R)"
181
+ onChange={onShowRecommendationsChange}
182
+ />
183
+ </Flex>
184
+ </Box>
185
+
186
+ <Box>
187
+ <Text fontSize="xs" fontWeight="semibold" mb={1}>
188
+ Events and provenance
189
+ </Text>
190
+ {timeline.status === 'verified' && timeline.markers.length ? (
191
+ <Flex
192
+ direction="column"
193
+ gap={2}
194
+ maxH="220px"
195
+ overflowY="auto"
196
+ >
197
+ {timeline.markers.map((marker) => (
198
+ <Box key={marker.id}>
199
+ <Flex align="baseline" gap={2}>
200
+ <Text
201
+ color={
202
+ STRATEGY_EVIDENCE_MARKER_PRESENTATION[
203
+ marker.type
204
+ ].color
205
+ }
206
+ fontWeight="bold"
207
+ fontSize="xs"
208
+ >
209
+ {marker.type}
210
+ </Text>
211
+ <Text fontSize="xs" fontWeight="semibold">
212
+ {marker.label}
213
+ </Text>
214
+ <Text fontSize="xs" color="gray.500">
215
+ {formatTimeSeriesTooltipTimestamp(
216
+ marker.timestamp,
217
+ )}
218
+ </Text>
219
+ </Flex>
220
+ <Text fontSize="xs" color="gray.500">
221
+ {marker.summary}
222
+ </Text>
223
+ </Box>
224
+ ))}
225
+ </Flex>
226
+ ) : (
227
+ <Text fontSize="xs" color="gray.500">
228
+ No verified events in the selected window.
229
+ </Text>
230
+ )}
231
+ </Box>
232
+
233
+ {provenance.length ? (
234
+ <Box>
235
+ <Text fontSize="xs" fontWeight="semibold" mb={1}>
236
+ Artifact provenance
237
+ </Text>
238
+ {provenance.map((marker) => (
239
+ <Text
240
+ key={`${marker.artifactId}:${marker.artifactSha256}`}
241
+ fontSize="xs"
242
+ color="gray.500"
243
+ fontFamily="mono"
244
+ title={marker.artifactSha256}
245
+ >
246
+ {marker.artifactId} ·{' '}
247
+ {marker.artifactSha256.slice(0, 12)}
248
+ </Text>
249
+ ))}
250
+ </Box>
251
+ ) : null}
252
+ </Flex>
253
+ </Popover.Body>
254
+ </Popover.Content>
255
+ </Popover.Positioner>
256
+ </Portal>
257
+ </Popover.Root>
258
+ );
259
+ };
@@ -1,5 +1,6 @@
1
1
  import { getRuntimeStorageDayKeys } from '@tradejs/core/time';
2
2
  import { logger } from '@tradejs/infra/logger';
3
+ import { strategyLogicConfigFingerprint } from '@tradejs/infra/strategyReleaseEvidence';
3
4
  import {
4
5
  listTradingAccounts,
5
6
  resolveTradingAccount,
@@ -35,13 +36,12 @@ import {
35
36
  } from '#app/lib/runtimeStrategies';
36
37
  import {
37
38
  assignLegacyRuntimeTradeAccountScopes,
38
- buildRuntimeStrategyAiGateChanges,
39
39
  buildRuntimeStrategyIdentityKey,
40
- buildRuntimeStrategyMaxLossValueTimeline,
41
- getRuntimeStrategyAiGateObservedFrom,
42
- isRuntimeStrategyLineageScope,
43
- type RuntimeStrategyLineageScope,
44
40
  } from '#app/lib/runtimeStrategyLineage';
41
+ import {
42
+ loadStrategyEvidenceTimelines,
43
+ strategyEvidenceTimelineSelectorKey,
44
+ } from '#app/lib/strategyEvidenceTimeline';
45
45
  import {
46
46
  isRuntimeTradeInConnectorScope,
47
47
  syncRuntimeTrades,
@@ -53,7 +53,6 @@ const MIN_HOURS = 6;
53
53
  const MAX_HOURS = 24 * 90;
54
54
  const BYBIT_MAX_TIME_RANGE_MS = 7 * 24 * 60 * 60 * 1000 - 1_000;
55
55
  const EXCHANGE_REQUEST_TIMEOUT_MS = 15_000;
56
- const RUNTIME_LINEAGE_HISTORY_MS = 30 * 24 * 60 * 60 * 1000;
57
56
 
58
57
  const coerceHours = (value: string | number | null | undefined) => {
59
58
  const parsed = Number(value ?? Number.NaN);
@@ -151,25 +150,6 @@ const loadRuntimeTrades = async (
151
150
  .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
152
151
  };
153
152
 
154
- const loadRuntimeLineageScopes = async (
155
- userName: string,
156
- endTime: number,
157
- ): Promise<RuntimeStrategyLineageScope[]> =>
158
- (
159
- await Promise.all(
160
- getRuntimeStorageDayKeys(
161
- Math.max(0, endTime - RUNTIME_LINEAGE_HISTORY_MS),
162
- endTime,
163
- ).map((dayKey) =>
164
- getHashJsonValues<RuntimeStrategyLineageScope>(
165
- redisKeys.runtimeLineageScopeBucket(userName, dayKey),
166
- ),
167
- ),
168
- )
169
- )
170
- .flat()
171
- .filter(isRuntimeStrategyLineageScope);
172
-
173
153
  const buildExchangeTimeRanges = (startTime: number, endTime: number) => {
174
154
  const ranges: Array<{ startTime: number; endTime: number }> = [];
175
155
  let cursor = startTime;
@@ -399,7 +379,6 @@ export const loadRuntimeDashboard = async ({
399
379
  openPositionsSnapshot,
400
380
  runtimeDeployments,
401
381
  tradingAccounts,
402
- runtimeLineageScopes,
403
382
  ] = await Promise.all([
404
383
  loadRuntimeStrategyConfigs(userName),
405
384
  loadConfiguredStrategyNames(projectRoot),
@@ -420,7 +399,6 @@ export const loadRuntimeDashboard = async ({
420
399
  loadOpenPositions(connector, exchangeErrors),
421
400
  listRuntimeDeployments(userName),
422
401
  listTradingAccounts(userName),
423
- loadRuntimeLineageScopes(userName, endTime),
424
402
  ]);
425
403
  const relevantTrades = selectTradesForWindow(
426
404
  runtimeTrades,
@@ -488,9 +466,15 @@ export const loadRuntimeDashboard = async ({
488
466
  accountLabel?: string;
489
467
  deploymentId?: string;
490
468
  policyProfileId?: string;
469
+ releaseCompositionId?: string;
491
470
  enabled?: boolean;
492
471
  config?: Record<string, unknown>;
493
472
  connected?: boolean;
473
+ gitSha?: string;
474
+ configFingerprint?: string;
475
+ gateFingerprint?: string;
476
+ contextFingerprint?: string;
477
+ maxLossValue?: number;
494
478
  }
495
479
  >();
496
480
  const runtimeConfigAccountScopes = new Array<{
@@ -518,9 +502,13 @@ export const loadRuntimeDashboard = async ({
518
502
  accountLabel: accountsById.get(deployment.accountId)?.label,
519
503
  deploymentId: deployment.id,
520
504
  policyProfileId: deploymentStrategy.policyProfileId,
505
+ releaseCompositionId: deploymentStrategy.releaseCompositionId,
521
506
  enabled: deployment.enabled && deploymentStrategy.enabled !== false,
522
507
  config: deploymentStrategy.config,
523
508
  connected: false,
509
+ configFingerprint: strategyLogicConfigFingerprint(
510
+ deploymentStrategy.config,
511
+ ),
524
512
  });
525
513
  }
526
514
  }
@@ -561,6 +549,7 @@ export const loadRuntimeDashboard = async ({
561
549
  enabled: isRuntimeStrategyConfigEnabled(runtimeConfig.config),
562
550
  config: runtimeConfig.config,
563
551
  connected: true,
552
+ configFingerprint: strategyLogicConfigFingerprint(runtimeConfig.config),
564
553
  });
565
554
  }
566
555
  const accountScopedTrades = assignLegacyRuntimeTradeAccountScopes(
@@ -569,6 +558,15 @@ export const loadRuntimeDashboard = async ({
569
558
  );
570
559
  for (const trade of accountScopedTrades) {
571
560
  const key = runtimeIdentityKey(trade);
561
+ const configuredCompositionId =
562
+ identityByKey.get(key)?.releaseCompositionId;
563
+ const observedCompositionId = trade.runtimeLineage?.compositionId;
564
+ const releaseCompositionId =
565
+ configuredCompositionId &&
566
+ observedCompositionId &&
567
+ configuredCompositionId !== observedCompositionId
568
+ ? undefined
569
+ : observedCompositionId ?? configuredCompositionId;
572
570
  identityByKey.set(key, {
573
571
  ...identityByKey.get(key),
574
572
  strategyName: trade.strategy,
@@ -581,9 +579,32 @@ export const loadRuntimeDashboard = async ({
581
579
  : undefined,
582
580
  deploymentId: trade.deploymentId,
583
581
  policyProfileId: trade.policyProfileId,
582
+ releaseCompositionId,
583
+ configFingerprint: trade.runtimeLineage?.configFingerprint,
584
+ gateFingerprint: trade.runtimeLineage?.gateFingerprint,
585
+ contextFingerprint: trade.runtimeLineage?.contextFingerprint,
586
+ gitSha: trade.runtimeLineage?.gitSha ?? undefined,
587
+ maxLossValue: trade.runtimeLineage?.maxLossValue ?? undefined,
584
588
  });
585
589
  }
586
590
 
591
+ const evidenceTimelines = await loadStrategyEvidenceTimelines({
592
+ projectRoot,
593
+ markerDir: process.env.STRATEGY_RELEASE_MARKER_DIR,
594
+ selectors: [...identityByKey.values()].map((identity) => ({
595
+ strategy: identity.strategyName,
596
+ compositionId: identity.releaseCompositionId,
597
+ configFingerprint: identity.configFingerprint,
598
+ gateFingerprint: identity.gateFingerprint,
599
+ contextFingerprint: identity.contextFingerprint,
600
+ gitSha: identity.gitSha,
601
+ maxLossValue: identity.maxLossValue,
602
+ requireCompleteLineage: true,
603
+ })),
604
+ startTime,
605
+ endTime,
606
+ });
607
+
587
608
  const strategies = await Promise.all(
588
609
  [...identityByKey.entries()].map(async ([runtimeKey, identity]) => {
589
610
  const { strategyName } = identity;
@@ -603,13 +624,6 @@ export const loadRuntimeDashboard = async ({
603
624
  startTime,
604
625
  endTime,
605
626
  });
606
- const maxLossValueTimeline = buildRuntimeStrategyMaxLossValueTimeline({
607
- scopes: runtimeLineageScopes,
608
- strategyName,
609
- configId: identity.configId,
610
- startTime,
611
- endTime,
612
- });
613
627
  const effectiveStrategyConfig = identity.config ?? null;
614
628
 
615
629
  return {
@@ -633,20 +647,22 @@ export const loadRuntimeDashboard = async ({
633
647
  stat: analytics.stat,
634
648
  summary: analytics.summary,
635
649
  orderLog: analytics.orderLog,
636
- aiGateObservedFrom: getRuntimeStrategyAiGateObservedFrom({
637
- scopes: runtimeLineageScopes,
638
- strategyName,
639
- configId: identity.configId,
640
- endTime,
641
- }),
642
- aiGateChanges: buildRuntimeStrategyAiGateChanges({
643
- scopes: runtimeLineageScopes,
644
- strategyName,
645
- configId: identity.configId,
646
- startTime,
647
- endTime,
648
- }),
649
- maxLossValueTimeline,
650
+ evidenceTimeline: evidenceTimelines.get(
651
+ strategyEvidenceTimelineSelectorKey({
652
+ strategy: strategyName,
653
+ compositionId: identity.releaseCompositionId,
654
+ configFingerprint: identity.configFingerprint,
655
+ gateFingerprint: identity.gateFingerprint,
656
+ contextFingerprint: identity.contextFingerprint,
657
+ gitSha: identity.gitSha,
658
+ maxLossValue: identity.maxLossValue,
659
+ requireCompleteLineage: true,
660
+ }),
661
+ ) ?? {
662
+ status: 'missing',
663
+ observedFrom: null,
664
+ markers: [],
665
+ },
650
666
  recentTrades: strategyTrades
651
667
  .slice(0, 8)
652
668
  .map((trade) => toRuntimeTradeView(trade, endTime)),
@@ -10,14 +10,11 @@ import type {
10
10
  RuntimeTradeRecord,
11
11
  SimpleOrderLogData,
12
12
  StrategyConfig,
13
+ StrategyEvidenceTimeline,
13
14
  TestStat,
14
15
  MarketUniverse,
15
16
  Interval,
16
17
  } from '@tradejs/types';
17
- import type {
18
- RuntimeStrategyAiGateChange,
19
- RuntimeStrategyMaxLossValueTimeline,
20
- } from './runtimeStrategyLineage';
21
18
  import {
22
19
  takeExactClosedPnlMatch,
23
20
  type ClosedPnlRecordWithOrderLinkId,
@@ -106,9 +103,7 @@ export interface RuntimeStrategyView {
106
103
  stat: TestStat;
107
104
  summary: RuntimeStrategyTradeSummary;
108
105
  orderLog: SimpleOrderLogData;
109
- aiGateObservedFrom: number | null;
110
- aiGateChanges: RuntimeStrategyAiGateChange[];
111
- maxLossValueTimeline: RuntimeStrategyMaxLossValueTimeline;
106
+ evidenceTimeline: StrategyEvidenceTimeline;
112
107
  recentTrades: RuntimeStrategyTradeView[];
113
108
  orders: RuntimeStrategyTradeView[];
114
109
  }
@@ -0,0 +1,298 @@
1
+ import type { Dirent } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ canonicalStrategyEvidenceJson,
6
+ safeStrategyEvidenceSegment,
7
+ verifyStrategyEvidenceMarkerEnvelope,
8
+ } from '@tradejs/infra/strategyReleaseEvidence';
9
+ import {
10
+ type StrategyEvidenceMarker,
11
+ type StrategyEvidenceMarkerEnvelope,
12
+ type StrategyEvidenceTimeline,
13
+ type StrategyEvidenceTimelineSelector,
14
+ } from '@tradejs/types';
15
+
16
+ const DEFAULT_MARKER_DIRECTORY = 'data/strategy-release/markers';
17
+ type JsonRecord = Record<string, unknown>;
18
+
19
+ const asRecord = (value: unknown): JsonRecord | null =>
20
+ value && typeof value === 'object' && !Array.isArray(value)
21
+ ? (value as JsonRecord)
22
+ : null;
23
+
24
+ const isNonEmptyString = (value: unknown): value is string =>
25
+ typeof value === 'string' && value.trim().length > 0;
26
+
27
+ export { canonicalStrategyEvidenceJson, verifyStrategyEvidenceMarkerEnvelope };
28
+
29
+ export const strategyEvidenceTimelineSelectorKey = (
30
+ selector: StrategyEvidenceTimelineSelector,
31
+ ) =>
32
+ [
33
+ selector.strategy,
34
+ selector.compositionId ?? '',
35
+ selector.gitSha ?? '',
36
+ selector.gateFingerprint ?? '',
37
+ selector.configFingerprint ?? '',
38
+ selector.contextFingerprint ?? '',
39
+ selector.requireCompleteLineage ? 'exact' : 'partial',
40
+ ].join(':');
41
+
42
+ const discoverJsonFiles = async (rootDir: string): Promise<string[]> => {
43
+ const files: string[] = [];
44
+
45
+ const visit = async (directory: string, depth: number): Promise<void> => {
46
+ if (depth > 12) return;
47
+
48
+ let entries: Dirent[];
49
+ try {
50
+ entries = await fs.readdir(directory, { withFileTypes: true });
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
53
+ throw error;
54
+ }
55
+
56
+ await Promise.all(
57
+ entries.map(async (entry) => {
58
+ if (entry.name.startsWith('.') || entry.name.includes('.tmp-')) return;
59
+ const entryPath = path.join(directory, entry.name);
60
+ if (entry.isDirectory()) {
61
+ await visit(entryPath, depth + 1);
62
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
63
+ files.push(entryPath);
64
+ }
65
+ }),
66
+ );
67
+ };
68
+
69
+ await visit(rootDir, 0);
70
+ return files.sort();
71
+ };
72
+
73
+ const inferMatchingStrategies = ({
74
+ filePath,
75
+ rootDir,
76
+ parsed,
77
+ strategies,
78
+ }: {
79
+ filePath: string;
80
+ rootDir: string;
81
+ parsed: unknown;
82
+ strategies: string[];
83
+ }) => {
84
+ const payload = asRecord(asRecord(parsed)?.payload);
85
+ const declaredStrategy = isNonEmptyString(payload?.strategy)
86
+ ? payload.strategy
87
+ : null;
88
+ const directoryStrategy = path.relative(rootDir, filePath).split(path.sep)[0];
89
+ return strategies.filter(
90
+ (strategy) =>
91
+ strategy === declaredStrategy ||
92
+ directoryStrategy === safeStrategyEvidenceSegment(strategy),
93
+ );
94
+ };
95
+
96
+ const missingTimeline = (): StrategyEvidenceTimeline => ({
97
+ status: 'missing',
98
+ observedFrom: null,
99
+ markers: [],
100
+ });
101
+
102
+ const invalidTimeline = (): StrategyEvidenceTimeline => ({
103
+ status: 'invalid',
104
+ observedFrom: null,
105
+ markers: [],
106
+ });
107
+
108
+ export const loadStrategyEvidenceTimelines = async ({
109
+ projectRoot,
110
+ markerDir,
111
+ selectors: requestedSelectors,
112
+ startTime,
113
+ endTime,
114
+ }: {
115
+ projectRoot: string;
116
+ markerDir?: string | null;
117
+ selectors: Iterable<StrategyEvidenceTimelineSelector>;
118
+ startTime: number;
119
+ endTime: number;
120
+ }): Promise<Map<string, StrategyEvidenceTimeline>> => {
121
+ const selectors = [...requestedSelectors]
122
+ .filter((selector) => selector.strategy.trim().length > 0)
123
+ .sort((left, right) =>
124
+ strategyEvidenceTimelineSelectorKey(left).localeCompare(
125
+ strategyEvidenceTimelineSelectorKey(right),
126
+ ),
127
+ );
128
+ const strategies = [...new Set(selectors.map(({ strategy }) => strategy))];
129
+ const timelines = new Map(
130
+ selectors.map((selector) => [
131
+ strategyEvidenceTimelineSelectorKey(selector),
132
+ missingTimeline(),
133
+ ]),
134
+ );
135
+ if (!selectors.length) return timelines;
136
+
137
+ const configuredDir = markerDir?.trim() || DEFAULT_MARKER_DIRECTORY;
138
+ const rootDir = path.isAbsolute(configuredDir)
139
+ ? configuredDir
140
+ : path.resolve(projectRoot, configuredDir);
141
+ let files: string[];
142
+ try {
143
+ files = await discoverJsonFiles(rootDir);
144
+ } catch {
145
+ for (const selector of selectors) {
146
+ timelines.set(
147
+ strategyEvidenceTimelineSelectorKey(selector),
148
+ invalidTimeline(),
149
+ );
150
+ }
151
+ return timelines;
152
+ }
153
+
154
+ const envelopesByStrategy = new Map<
155
+ string,
156
+ StrategyEvidenceMarkerEnvelope[]
157
+ >();
158
+ const invalidStrategies = new Set<string>();
159
+
160
+ for (const filePath of files) {
161
+ let parsed: unknown = null;
162
+ try {
163
+ parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
164
+ } catch {
165
+ for (const strategy of inferMatchingStrategies({
166
+ filePath,
167
+ rootDir,
168
+ parsed,
169
+ strategies,
170
+ })) {
171
+ invalidStrategies.add(strategy);
172
+ }
173
+ continue;
174
+ }
175
+
176
+ const matchingStrategies = inferMatchingStrategies({
177
+ filePath,
178
+ rootDir,
179
+ parsed,
180
+ strategies,
181
+ });
182
+ if (!matchingStrategies.length) continue;
183
+
184
+ try {
185
+ const envelope = verifyStrategyEvidenceMarkerEnvelope(parsed);
186
+ for (const strategy of matchingStrategies) {
187
+ if (strategy !== envelope.payload.strategy) {
188
+ invalidStrategies.add(strategy);
189
+ }
190
+ }
191
+ if (!strategies.includes(envelope.payload.strategy)) {
192
+ continue;
193
+ }
194
+ const envelopes =
195
+ envelopesByStrategy.get(envelope.payload.strategy) ?? [];
196
+ envelopes.push(envelope);
197
+ envelopesByStrategy.set(envelope.payload.strategy, envelopes);
198
+ } catch {
199
+ for (const strategy of matchingStrategies) {
200
+ invalidStrategies.add(strategy);
201
+ }
202
+ }
203
+ }
204
+
205
+ for (const selector of selectors) {
206
+ const strategy = selector.strategy;
207
+ const selectorKey = strategyEvidenceTimelineSelectorKey(selector);
208
+ if (invalidStrategies.has(strategy)) {
209
+ timelines.set(selectorKey, invalidTimeline());
210
+ continue;
211
+ }
212
+
213
+ const envelopes = envelopesByStrategy.get(strategy) ?? [];
214
+ if (!envelopes.length) continue;
215
+ const hasCompleteSelector =
216
+ Boolean(selector.compositionId) &&
217
+ Boolean(selector.gitSha) &&
218
+ Boolean(selector.gateFingerprint) &&
219
+ Boolean(selector.configFingerprint) &&
220
+ Boolean(selector.contextFingerprint);
221
+ if (selector.requireCompleteLineage && !hasCompleteSelector) continue;
222
+
223
+ const markersById = new Map<string, StrategyEvidenceMarker>();
224
+ let hasConflict = false;
225
+ for (const envelope of envelopes) {
226
+ for (const marker of envelope.payload.markers) {
227
+ const existing = markersById.get(marker.id);
228
+ if (
229
+ existing &&
230
+ canonicalStrategyEvidenceJson(existing) !==
231
+ canonicalStrategyEvidenceJson(marker)
232
+ ) {
233
+ hasConflict = true;
234
+ break;
235
+ }
236
+ markersById.set(marker.id, marker);
237
+ }
238
+ if (hasConflict) break;
239
+ }
240
+
241
+ if (hasConflict) {
242
+ timelines.set(selectorKey, invalidTimeline());
243
+ continue;
244
+ }
245
+
246
+ const matchingMarkers = [...markersById.values()]
247
+ .filter(
248
+ (marker) =>
249
+ (!selector.compositionId ||
250
+ marker.compositionId === selector.compositionId) &&
251
+ (!selector.gitSha || marker.gitSha === selector.gitSha) &&
252
+ (!selector.gateFingerprint ||
253
+ marker.gateFingerprint === selector.gateFingerprint) &&
254
+ (!selector.configFingerprint ||
255
+ marker.configFingerprint === selector.configFingerprint) &&
256
+ (!selector.contextFingerprint ||
257
+ marker.contextFingerprint === selector.contextFingerprint) &&
258
+ marker.timestamp >= startTime &&
259
+ marker.timestamp < endTime,
260
+ )
261
+ .sort(
262
+ (left, right) =>
263
+ left.timestamp - right.timestamp ||
264
+ left.type.localeCompare(right.type) ||
265
+ left.id.localeCompare(right.id),
266
+ );
267
+ let lastLossValue: number | null | undefined;
268
+ let hasLastLossValue = false;
269
+ const markers = matchingMarkers.filter((marker) => {
270
+ if (marker.type !== 'L') return true;
271
+ if (hasLastLossValue && marker.maxLossValue === lastLossValue) {
272
+ return false;
273
+ }
274
+ hasLastLossValue = true;
275
+ lastLossValue = marker.maxLossValue;
276
+ return true;
277
+ });
278
+ if (
279
+ !markers.length &&
280
+ (selector.compositionId ||
281
+ selector.gitSha ||
282
+ selector.gateFingerprint ||
283
+ selector.configFingerprint ||
284
+ selector.contextFingerprint)
285
+ ) {
286
+ continue;
287
+ }
288
+ timelines.set(selectorKey, {
289
+ status: 'verified',
290
+ observedFrom: markers.length
291
+ ? Math.min(...markers.map((marker) => marker.timestamp))
292
+ : Math.min(...envelopes.map((envelope) => envelope.payload.createdAt)),
293
+ markers,
294
+ });
295
+ }
296
+
297
+ return timelines;
298
+ };