Skip to content

Commit e35b293

Browse files
unknownclaude
andcommitted
Replace single-path tracking with best track + uncertainty cone
Inspired by NHC tropical cyclone best tracks: shows the ensemble member that best tracked realized price as a glowing solid line, with a widening uncertainty cone from the top-7 nearest paths projected into the remaining forecast horizon. - FanChart computes best track internally using aggregated candles (fixes timeframe alignment issues) - Dashboard passes all scored hindcast candidates oldest-first; FanChart picks the first whose anchor fits visible candles - Mock hindcast now shares the same candle price walk as the prediction so best track aligns with context candles - Cone uses interpolated stacked area fill with dashed edges - Badge: "Best track: X.X pts RMSE | Xh Xm tracked" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2ff700e commit e35b293

File tree

3 files changed

+261
-171
lines changed

3 files changed

+261
-171
lines changed

src/api/mock.ts

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,44 @@ function seededRandom(seed: number): () => number {
2222
};
2323
}
2424

25-
export function generateMockPrediction(): PredictionResponse {
26-
// Use minute-of-day as seed so it changes every 5 minutes but is stable within
25+
/** Shared candle seed — ensures hindcast and prediction use the same price walk. */
26+
function getCandleSeed(): number {
2727
const now = new Date();
28-
const minuteSlot = Math.floor(
29-
(now.getHours() * 60 + now.getMinutes()) / 5,
30-
);
31-
const rand = seededRandom(minuteSlot * 1337 + now.getDate() * 7);
28+
const minuteSlot = Math.floor((now.getHours() * 60 + now.getMinutes()) / 5);
29+
return minuteSlot * 1337 + now.getDate() * 7;
30+
}
3231

33-
// Random walk for context candles (24 hours = 288 five-min bars)
34-
const contextCandles = [];
35-
let price = BASE_PRICE + (rand() - 0.5) * 80;
36-
const baseTime = Math.floor(now.getTime() / 1000) - 288 * 300;
32+
interface MockCandle { time: number; open: number; high: number; low: number; close: number; volume: number; }
3733

34+
/** Generate 288 five-minute candles using the shared seed. */
35+
function generateCandles(): MockCandle[] {
36+
const rand = seededRandom(getCandleSeed());
37+
const now = new Date();
38+
const baseTime = Math.floor(now.getTime() / 1000) - 288 * 300;
39+
const candles: MockCandle[] = [];
40+
let price = BASE_PRICE + (rand() - 0.5) * 80;
3841
for (let i = 0; i < 288; i++) {
3942
const ret = (rand() - 0.5) * 4;
4043
const open = round(price);
4144
const close = round(price + ret);
4245
const high = round(Math.max(open, close) + rand() * 3);
4346
const low = round(Math.min(open, close) - rand() * 3);
4447
const volume = Math.floor(5000 + rand() * 30000);
45-
contextCandles.push({
46-
time: baseTime + i * 300,
47-
open,
48-
high,
49-
low,
50-
close,
51-
volume,
52-
});
48+
candles.push({ time: baseTime + i * 300, open, high, low, close, volume });
5349
price = close;
5450
}
51+
return candles;
52+
}
5553

56-
const lastClose = price;
54+
export function generateMockPrediction(): PredictionResponse {
55+
const now = new Date();
56+
const contextCandles = generateCandles();
57+
const lastClose = contextCandles[contextCandles.length - 1].close;
58+
59+
// Use same seed for forecast portion (advanced past the candle generation)
60+
const rand = seededRandom(getCandleSeed());
61+
// Advance rand past the candle generation (1 for initial price + 4 per candle: ret, high, low, volume)
62+
for (let i = 0; i < 288 * 4 + 1; i++) rand();
5763

5864
// Forecast: slight drift + expanding uncertainty
5965
const drift = (rand() - 0.45) * 0.15; // slight long bias
@@ -168,14 +174,24 @@ export function generateMockPrediction(): PredictionResponse {
168174
}
169175

170176
export function generateMockHindcast(n = 6): HindcastResponse {
171-
const now = Date.now();
177+
const now = new Date();
172178
const rand = seededRandom(77);
173179
const predictions = [];
174180

181+
// Use the SAME candle series as the prediction so prices align
182+
const candles = generateCandles();
183+
175184
for (let i = n; i >= 1; i--) {
176-
const predTime = now - i * 300_000; // Each prediction 5 min apart
177-
const barsElapsed = i;
178-
let price = BASE_PRICE + (rand() - 0.5) * 40;
185+
const predTime = now.getTime() - i * 1_800_000; // 30 min apart
186+
const barsElapsed = i * 6;
187+
188+
// Anchor to actual candle close at prediction time
189+
const predTs = predTime / 1000;
190+
let anchorBar = 0;
191+
for (let ci = candles.length - 1; ci >= 0; ci--) {
192+
if (candles[ci].time <= predTs) { anchorBar = ci; break; }
193+
}
194+
const price = candles[anchorBar].close;
179195
const drift = (rand() - 0.45) * 0.12;
180196

181197
const horizons = [...Array.from({ length: 26 }, (_, j) => 1 + j * 3), 78]
@@ -207,12 +223,15 @@ export function generateMockHindcast(n = 6): HindcastResponse {
207223
samplePaths.push(path);
208224
}
209225

210-
// Realized prices: fill in for elapsed bars, null for future
226+
// Realized prices: use actual candle closes from the shared price walk
211227
const realizedPrices: (number | null)[] = horizons.map((h) => {
212228
if (h <= barsElapsed) {
213-
// Simulate realized price near median with some noise
214-
const mid = price + drift * h * 0.3;
215-
return round(mid + (rand() - 0.5) * 4);
229+
const realBar = anchorBar + h;
230+
if (realBar >= 0 && realBar < candles.length) {
231+
return candles[realBar].close;
232+
}
233+
// Fallback if beyond candle range
234+
return round(price + drift * h * 0.3 + (rand() - 0.5) * 4);
216235
}
217236
return null;
218237
});
@@ -224,11 +243,35 @@ export function generateMockHindcast(n = 6): HindcastResponse {
224243
const dirCorrect = rand() > 0.4;
225244
const coverageOuter = +(0.5 + rand() * 0.4).toFixed(4);
226245
const coverageInner = +(0.3 + rand() * 0.3).toFixed(4);
227-
const bestRmse = +(0.5 + rand() * 3).toFixed(2);
228246
const trackDur = Math.floor(3 + rand() * 10);
229247
const verdict = coverageOuter >= 0.7 && dirCorrect ? "PASS" as const
230248
: coverageOuter >= 0.5 || dirCorrect ? "PARTIAL" as const : "FAIL" as const;
231249

250+
// Compute top-10 best paths by RMSE against realized prices
251+
const pathScores = samplePaths.map((path, idx) => {
252+
let sumSq = 0;
253+
let count = 0;
254+
for (let hi = 0; hi < horizons.length; hi++) {
255+
const rp = realizedPrices[hi];
256+
if (rp != null) {
257+
sumSq += (path[hi] - (rp as number)) ** 2;
258+
count++;
259+
}
260+
}
261+
return { idx, rmse: count > 0 ? Math.sqrt(sumSq / count) : Infinity };
262+
}).sort((a, b) => a.rmse - b.rmse).slice(0, 10);
263+
264+
const bestPathsList = pathScores.map(({ idx, rmse }) => ({
265+
path_index: idx,
266+
path_values: samplePaths[idx],
267+
rmse_pts: +rmse.toFixed(2),
268+
tracking_duration_bars: trackDur,
269+
tracking_threshold_pts: 2.0,
270+
deviations: realizedPrices
271+
.filter((v): v is number => v != null)
272+
.map(() => +((rand() - 0.5) * 3).toFixed(2)),
273+
}));
274+
232275
predictions.push({
233276
timestamp: new Date(predTime).toISOString(),
234277
last_close: round(price),
@@ -242,16 +285,7 @@ export function generateMockHindcast(n = 6): HindcastResponse {
242285
coverage_p25_p75: coverageInner,
243286
direction_correct: dirCorrect,
244287
median_rmse_pts: +(1 + rand() * 5).toFixed(2),
245-
best_paths: [{
246-
path_index: Math.floor(rand() * 30),
247-
path_values: samplePaths[Math.floor(rand() * 30)],
248-
rmse_pts: bestRmse,
249-
tracking_duration_bars: trackDur,
250-
tracking_threshold_pts: 2.0,
251-
deviations: realizedPrices
252-
.filter((v): v is number => v != null)
253-
.map(() => +((rand() - 0.5) * 3).toFixed(2)),
254-
}],
288+
best_paths: bestPathsList,
255289
verdict,
256290
signal_direction: sigDir,
257291
expected_return_pts: +((rand() - 0.5) * 8).toFixed(2),

0 commit comments

Comments
 (0)