Skip to content

Commit 57794c3

Browse files
Merge pull request #1 from that-github-user/feature/frontend-dashboard
Redesign dashboard: analytics cards, scenario clusters, tabbed sidebar
2 parents 1e52abc + d51b33b commit 57794c3

File tree

10 files changed

+745
-62
lines changed

10 files changed

+745
-62
lines changed

src/api/mock.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export function generateMockPrediction(): PredictionResponse {
102102
? -(0.15 + rand() * 0.4)
103103
: (rand() - 0.5) * 0.2;
104104

105+
const regimeLabels = ["trending", "mean-reverting", "volatile", "quiet"] as const;
106+
105107
return {
106108
timestamp: now.toISOString(),
107109
instrument: "ES",
@@ -120,6 +122,31 @@ export function generateMockPrediction(): PredictionResponse {
120122
long_frac: +(0.5 + drift * 2).toFixed(4),
121123
},
122124
context_candles: contextCandles,
125+
// Analytics engine fields
126+
exhaustion_score: +(0.3 + rand() * 2.2).toFixed(2),
127+
regime: {
128+
label: regimeLabels[Math.floor(rand() * 4)],
129+
confidence: +(0.5 + rand() * 0.5).toFixed(2),
130+
},
131+
ensemble_agreement: +(0.3 + rand() * 0.7).toFixed(2),
132+
signal_percentile: Math.floor(rand() * 100),
133+
invalidation: {
134+
price_level: round(
135+
isLong ? lastClose - (4 + rand() * 8) : lastClose + (4 + rand() * 8),
136+
),
137+
price_direction: isLong ? "below" : isShort ? "above" : "either",
138+
description: isLong
139+
? "Close below support invalidates bullish thesis"
140+
: isShort
141+
? "Close above resistance invalidates bearish thesis"
142+
: "Breakout from range invalidates neutral stance",
143+
ensemble_contradiction: +(rand() * 0.4).toFixed(4),
144+
},
145+
regime_performance: {
146+
win_rate: +(0.4 + rand() * 0.25).toFixed(2),
147+
profit_factor: +(0.8 + rand() * 1.2).toFixed(2),
148+
n_trades: Math.floor(30 + rand() * 150),
149+
},
123150
};
124151
}
125152

src/api/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ export interface CandleData {
2727
volume: number;
2828
}
2929

30+
export interface RegimeInfo {
31+
label: "trending" | "mean-reverting" | "volatile" | "quiet";
32+
confidence: number;
33+
}
34+
35+
export interface InvalidationInfo {
36+
price_level: number;
37+
price_direction: string;
38+
description: string;
39+
ensemble_contradiction: number;
40+
}
41+
42+
export interface RegimePerformance {
43+
win_rate: number;
44+
profit_factor: number;
45+
n_trades: number;
46+
}
47+
3048
export interface PredictionResponse {
3149
timestamp: string;
3250
instrument: string;
@@ -42,6 +60,13 @@ export interface PredictionResponse {
4260
sample_paths: number[][] | null;
4361
signal: SignalResponse;
4462
context_candles: CandleData[] | null;
63+
// Analytics engine fields
64+
exhaustion_score?: number | null;
65+
regime?: RegimeInfo | null;
66+
ensemble_agreement?: number | null;
67+
signal_percentile?: number | null;
68+
invalidation?: InvalidationInfo | null;
69+
regime_performance?: RegimePerformance | null;
4570
}
4671

4772
export interface HistoryEntry {

src/components/charts/FanChart.tsx

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ interface Props {
4444
timeframe?: Timeframe;
4545
hindcast?: HindcastPrediction[];
4646
showHindcast?: boolean;
47+
invalidationLevel?: number | null;
48+
highlightedPaths?: number[] | null;
4749
}
4850

4951
export function FanChart({
@@ -53,6 +55,8 @@ export function FanChart({
5355
timeframe = "5m",
5456
hindcast,
5557
showHindcast = false,
58+
invalidationLevel,
59+
highlightedPaths,
5660
}: Props) {
5761
const { last_close, signal } = prediction;
5862

@@ -399,16 +403,24 @@ export function FanChart({
399403
...(forecastStyle === "spaghetti" && sample_paths?.length
400404
? [
401405
// Individual sample trajectories
402-
...(sample_paths).map((path, si) => ({
403-
name: si === 0 ? "Sample" : "",
404-
type: "line" as const,
405-
data: [...ctxPad, ...path],
406-
lineStyle: { color: bandColor + "0.2)", width: 0.8 },
407-
symbol: "none" as const,
408-
smooth: 0.3,
409-
z: 1,
410-
silent: true,
411-
})),
406+
...(sample_paths).map((path, si) => {
407+
const isHighlighted = highlightedPaths?.includes(si);
408+
const hasSomeHighlighted = highlightedPaths != null && highlightedPaths.length > 0;
409+
const opacity = hasSomeHighlighted
410+
? (isHighlighted ? 0.7 : 0.08)
411+
: 0.2;
412+
const width = hasSomeHighlighted && isHighlighted ? 1.5 : 0.8;
413+
return {
414+
name: si === 0 ? "Sample" : "",
415+
type: "line" as const,
416+
data: [...ctxPad, ...path],
417+
lineStyle: { color: bandColor + `${opacity})`, width },
418+
symbol: "none" as const,
419+
smooth: 0.3,
420+
z: isHighlighted ? 4 : 1,
421+
silent: true,
422+
};
423+
}),
412424
// P50 median line on top of spaghetti
413425
{
414426
name: "Median",
@@ -529,6 +541,29 @@ export function FanChart({
529541
data: [],
530542
};
531543
})(),
544+
// ── Invalidation level markLine ──
545+
...(invalidationLevel != null
546+
? [
547+
{
548+
name: "Invalidation",
549+
type: "line" as const,
550+
markLine: {
551+
silent: true,
552+
symbol: "none",
553+
lineStyle: { color: "#ef4444", width: 1, type: "dashed" as const, opacity: 0.5 },
554+
data: [{ yAxis: invalidationLevel }],
555+
label: {
556+
formatter: `INVALIDATION ${invalidationLevel.toFixed(2)}`,
557+
color: "#ef4444",
558+
fontSize: 9,
559+
fontFamily: "Inter, sans-serif",
560+
position: "insideEndTop" as const,
561+
},
562+
},
563+
data: [],
564+
},
565+
]
566+
: []),
532567
// ── Hindcast: single ghost band from best past prediction ──
533568
// Shows ONE clean overlay behind context candles — the candles themselves
534569
// are the proof of accuracy. No multi-layer mess.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* ScenarioCluster — groups sample paths into meaningful scenario clusters.
3+
* Each cluster gets a card with label, count, inline SVG sparkline, and terminal return.
4+
*/
5+
6+
import { useMemo, useState } from "react";
7+
import type { PredictionResponse } from "../../api/types";
8+
9+
interface Cluster {
10+
label: string;
11+
indices: number[];
12+
meanReturn: number;
13+
meanPath: number[];
14+
color: string;
15+
}
16+
17+
interface Props {
18+
samplePaths: number[][] | null;
19+
horizons: number[];
20+
lastClose: number;
21+
percentiles: PredictionResponse["percentiles"];
22+
onClusterHighlight?: (indices: number[] | null) => void;
23+
}
24+
25+
export function ScenarioCluster({
26+
samplePaths,
27+
horizons,
28+
lastClose,
29+
onClusterHighlight,
30+
}: Props) {
31+
const [activeCluster, setActiveCluster] = useState<number | null>(null);
32+
33+
const clusters = useMemo(() => {
34+
if (!samplePaths?.length || !horizons.length) return [];
35+
36+
const lastIdx = horizons.length - 1;
37+
const midIdx = Math.floor(lastIdx / 2);
38+
39+
// Classify each path
40+
const classified: { label: string; index: number; termRet: number }[] = [];
41+
for (let i = 0; i < samplePaths.length; i++) {
42+
const path = samplePaths[i];
43+
const termRet = ((path[lastIdx] - lastClose) / lastClose) * 100;
44+
const midRet = ((path[midIdx] - lastClose) / lastClose) * 100;
45+
46+
// Check monotonicity
47+
let rising = true, falling = true;
48+
for (let j = 1; j < path.length; j++) {
49+
if (path[j] < path[j - 1]) rising = false;
50+
if (path[j] > path[j - 1]) falling = false;
51+
}
52+
53+
let label: string;
54+
if (termRet > 0.3 && rising) label = "Bullish Breakout";
55+
else if (termRet < -0.3 && falling) label = "Bearish Breakdown";
56+
else if (termRet > -0.15 && termRet < 0.15) label = "Range Bound";
57+
else if (midRet > 0 && termRet < midRet * 0.3) label = "Bullish Fade";
58+
else if (midRet < 0 && termRet > midRet * 0.3) label = "Bearish Fade";
59+
else if (termRet > 0) label = "Up Drift";
60+
else label = "Down Drift";
61+
62+
classified.push({ label, index: i, termRet });
63+
}
64+
65+
// Group by label
66+
const groups = new Map<string, typeof classified>();
67+
for (const c of classified) {
68+
const arr = groups.get(c.label) ?? [];
69+
arr.push(c);
70+
groups.set(c.label, arr);
71+
}
72+
73+
// Merge singletons into nearest cluster
74+
const merged = new Map<string, typeof classified>();
75+
for (const [label, members] of groups) {
76+
if (members.length >= 2) {
77+
merged.set(label, members);
78+
} else {
79+
// Find nearest cluster by terminal return
80+
let bestLabel = "";
81+
let bestDist = Infinity;
82+
for (const [l2, m2] of groups) {
83+
if (l2 === label || m2.length < 2) continue;
84+
const avgRet = m2.reduce((s, m) => s + m.termRet, 0) / m2.length;
85+
const dist = Math.abs(members[0].termRet - avgRet);
86+
if (dist < bestDist) { bestDist = dist; bestLabel = l2; }
87+
}
88+
if (bestLabel) {
89+
const target = merged.get(bestLabel) ?? groups.get(bestLabel) ?? [];
90+
target.push(...members);
91+
merged.set(bestLabel, target);
92+
}
93+
}
94+
}
95+
96+
// Build cluster objects
97+
const result: Cluster[] = [];
98+
const colors: Record<string, string> = {
99+
"Bullish Breakout": "#10b981",
100+
"Bearish Breakdown": "#ef4444",
101+
"Range Bound": "#3b82f6",
102+
"Bullish Fade": "#a3e635",
103+
"Bearish Fade": "#f97316",
104+
"Up Drift": "#34d399",
105+
"Down Drift": "#fb7185",
106+
};
107+
108+
for (const [label, members] of merged) {
109+
const indices = members.map((m) => m.index);
110+
const meanReturn = members.reduce((s, m) => s + m.termRet, 0) / members.length;
111+
112+
// Compute mean path
113+
const meanPath: number[] = [];
114+
for (let h = 0; h < horizons.length; h++) {
115+
let sum = 0;
116+
for (const m of members) sum += samplePaths[m.index][h];
117+
meanPath.push(sum / members.length);
118+
}
119+
120+
result.push({
121+
label,
122+
indices,
123+
meanReturn,
124+
meanPath,
125+
color: colors[label] ?? "#94a3b8",
126+
});
127+
}
128+
129+
// Sort by count descending
130+
result.sort((a, b) => b.indices.length - a.indices.length);
131+
return result;
132+
}, [samplePaths, horizons, lastClose]);
133+
134+
const handleClick = (idx: number) => {
135+
if (activeCluster === idx) {
136+
setActiveCluster(null);
137+
onClusterHighlight?.(null);
138+
} else {
139+
setActiveCluster(idx);
140+
onClusterHighlight?.(clusters[idx].indices);
141+
}
142+
};
143+
144+
if (!clusters.length) {
145+
return (
146+
<div className="scenario-cluster" style={{ color: "#64748b", fontSize: 12, padding: 16, textAlign: "center" }}>
147+
No sample paths available
148+
</div>
149+
);
150+
}
151+
152+
const totalPaths = samplePaths?.length ?? 0;
153+
154+
return (
155+
<div className="scenario-cluster">
156+
{clusters.map((c, i) => (
157+
<div
158+
key={c.label}
159+
className={`scenario-card${activeCluster === i ? " active" : ""}`}
160+
onClick={() => handleClick(i)}
161+
onMouseEnter={() => { if (activeCluster === null) onClusterHighlight?.(c.indices); }}
162+
onMouseLeave={() => { if (activeCluster === null) onClusterHighlight?.(null); }}
163+
>
164+
<div className="scenario-label" style={{ color: c.color }}>{c.label}</div>
165+
<div className="scenario-count">
166+
{c.indices.length}/{totalPaths}
167+
</div>
168+
<div className="scenario-sparkline">
169+
<Sparkline path={c.meanPath} color={c.color} lastClose={lastClose} />
170+
</div>
171+
<div
172+
className="scenario-return"
173+
style={{ color: c.meanReturn >= 0 ? "#10b981" : "#ef4444" }}
174+
>
175+
{c.meanReturn >= 0 ? "+" : ""}
176+
{(c.meanReturn * lastClose / 100).toFixed(1)} pts
177+
</div>
178+
</div>
179+
))}
180+
</div>
181+
);
182+
}
183+
184+
function Sparkline({ path, color, lastClose }: { path: number[]; color: string; lastClose: number }) {
185+
if (!path.length) return null;
186+
const w = 100, h = 24;
187+
const allVals = [...path, lastClose];
188+
const min = Math.min(...allVals);
189+
const max = Math.max(...allVals);
190+
const range = max - min || 1;
191+
const pad = 2;
192+
193+
const points = path.map((v, i) => {
194+
const x = (i / Math.max(path.length - 1, 1)) * (w - pad * 2) + pad;
195+
const y = h - pad - ((v - min) / range) * (h - pad * 2);
196+
return `${x},${y}`;
197+
});
198+
199+
// Reference line at lastClose
200+
const refY = h - pad - ((lastClose - min) / range) * (h - pad * 2);
201+
202+
return (
203+
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
204+
<line x1={pad} y1={refY} x2={w - pad} y2={refY} stroke="#334155" strokeWidth={0.5} strokeDasharray="2,2" />
205+
<polyline points={points.join(" ")} fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
206+
</svg>
207+
);
208+
}

0 commit comments

Comments
 (0)