@tradejs/app 2.0.19 → 2.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +2 -1
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +1 -3
- package/src/app/components/Strategies/RuntimeStrategyChart.tsx +115 -184
- package/src/app/components/Strategies/StrategyEvidencePopover.tsx +259 -0
- package/src/app/lib/runtimeDashboard.ts +64 -48
- package/src/app/lib/runtimeStrategies.ts +2 -7
- package/src/app/lib/strategyEvidenceTimeline.ts +298 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tradejs/app",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.20",
|
|
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.
|
|
55
|
-
"@tradejs/core": "^2.0.
|
|
56
|
-
"@tradejs/indicators": "^2.0.
|
|
57
|
-
"@tradejs/infra": "^2.0.
|
|
58
|
-
"@tradejs/node": "^2.0.
|
|
59
|
-
"@tradejs/strategies": "^2.0.
|
|
60
|
-
"@tradejs/types": "^2.0.
|
|
54
|
+
"@tradejs/connectors": "^2.0.20",
|
|
55
|
+
"@tradejs/core": "^2.0.20",
|
|
56
|
+
"@tradejs/indicators": "^2.0.20",
|
|
57
|
+
"@tradejs/infra": "^2.0.20",
|
|
58
|
+
"@tradejs/node": "^2.0.20",
|
|
59
|
+
"@tradejs/strategies": "^2.0.20",
|
|
60
|
+
"@tradejs/types": "^2.0.20",
|
|
61
61
|
"@types/bcryptjs": "2.4.6",
|
|
62
62
|
"@types/lodash": "4.17.24",
|
|
63
63
|
"@types/node": "24.13.3",
|
|
@@ -1405,9 +1405,7 @@ export const RuntimeStrategyCard = ({
|
|
|
1405
1405
|
<RuntimeStrategyChart
|
|
1406
1406
|
orderLog={strategy.orderLog}
|
|
1407
1407
|
stat={strategy.stat}
|
|
1408
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
{
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
155
|
-
<
|
|
156
|
-
<
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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: `
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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: `
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 { strategyEvidenceFingerprint } 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: strategyEvidenceFingerprint(
|
|
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: strategyEvidenceFingerprint(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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
+
};
|