feat: instrument model

This commit is contained in:
OpenSquared
2026-07-02 22:46:55 +02:00
parent 7f7c343c9e
commit e2144d93e9
3 changed files with 411 additions and 66 deletions

View File

@@ -35,6 +35,19 @@ interface ModelNode {
source: 'manual' | 'events' | 'neutral' | 'computed'
override_note?: string
override_set_at?: string
// Phase 2
pip_linear?: number
pip_saturated?: number
saturation_pct?: number
regime_weight?: number
}
interface RegimeInfo {
regime: string
label: string
dominant_cat: string | null
scores: Record<string, number>
weights: Record<string, number>
}
interface ModelState {
@@ -46,11 +59,13 @@ interface ModelState {
direction: 'bullish' | 'bearish' | 'neutral'
nodes: ModelNode[]
output_node: string
regime: RegimeInfo
}
interface TimelinePoint {
date: string
net_pips: number
regime?: string
nodes: Record<string, number>
}
@@ -152,6 +167,46 @@ function DirectionBadge({ direction, pips }: { direction: string; pips: number }
)
}
// ── Regime Badge ──────────────────────────────────────────────────────────────
const REGIME_META: Record<string, { color: string; bg: string; icon: string }> = {
MONETARY_DOMINANCE: { color: 'text-blue-400', bg: 'bg-blue-900/30 border-blue-700/40', icon: '🏦' },
GEOPOLITICAL_RISK: { color: 'text-orange-400', bg: 'bg-orange-900/30 border-orange-700/40', icon: '⚠️' },
CREDIT_STRESS: { color: 'text-red-400', bg: 'bg-red-900/30 border-red-700/40', icon: '🔴' },
GROWTH_SCARE: { color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', icon: '📉' },
COMMODITY_SHOCK: { color: 'text-yellow-400', bg: 'bg-yellow-900/30 border-yellow-700/40','icon': '🛢️' },
BALANCED: { color: 'text-slate-400', bg: 'bg-slate-800/50 border-slate-700/40', icon: '⚖️' },
}
function RegimeBadge({ regime }: { regime: RegimeInfo }) {
const meta = REGIME_META[regime.regime] || REGIME_META.BALANCED
const topWeights = Object.entries(regime.weights)
.filter(([, w]) => w > 1.0)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
return (
<div className={clsx('inline-flex flex-col gap-1 px-3 py-2 rounded-lg border text-xs', meta.bg)}>
<div className="flex items-center gap-1.5">
<span>{meta.icon}</span>
<span className={clsx('font-semibold', meta.color)}>{regime.label}</span>
{regime.dominant_cat && (
<span className="text-slate-500">· {regime.dominant_cat}</span>
)}
</div>
{topWeights.length > 0 && (
<div className="flex gap-2 flex-wrap">
{topWeights.map(([layer, w]) => (
<span key={layer} className={clsx('font-mono', meta.color)}>
{layer.replace('layer_', '')} ×{w.toFixed(2)}
</span>
))}
</div>
)}
</div>
)
}
// ── Node Edit Modal ───────────────────────────────────────────────────────────
function NodeEditModal({ node, instrument, onClose, onSaved }: {
@@ -287,9 +342,15 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
</div>
)}
{node.node_type === 'intermediate' && node.formula && (
<div className="mt-1 text-slate-600 text-xs truncate">
{node.formula.split('+').length} sources
{node.node_type === 'intermediate' && (
<div className="mt-1 flex items-center gap-2">
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
<span className={clsx('text-xs font-mono font-bold',
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
×{node.regime_weight.toFixed(2)}
</span>
)}
</div>
)}
</div>
@@ -467,7 +528,16 @@ function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNod
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
<div className="text-slate-500">
Coeff : {node.coefficient_to_pips} pips/{node.unit}
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}${fmt(node.pip_contribution)} pips` : ''}
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}` : ''}
{node.pip_linear !== undefined && node.pip_saturated !== undefined && (
<span>
{' '} linéaire <span className="text-slate-400">{fmt(node.pip_linear)}</span>
{' '}/ saturé <span className={pipColor(node.pip_saturated)}>{fmt(node.pip_saturated)}</span>
{node.saturation_pct !== undefined && node.saturation_pct > 1 && (
<span className="text-amber-500/70"> (sat. {node.saturation_pct.toFixed(0)}%)</span>
)}
</span>
)}
</div>
)}
{node.node_type === 'intermediate' && node.formula && (
@@ -545,6 +615,28 @@ function TimelineView({ instrument }: { instrument: string }) {
const toX = (i: number) => mL + (i / Math.max(data.length - 1, 1)) * cW
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
// Regime background bands
const REGIME_CANVAS_COLORS: Record<string, string> = {
MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)',
GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)',
CREDIT_STRESS: 'rgba(239,68,68,0.07)',
GROWTH_SCARE: 'rgba(245,158,11,0.07)',
COMMODITY_SHOCK: 'rgba(234,179,8,0.07)',
BALANCED: 'rgba(0,0,0,0)',
}
let bandStart = 0, bandRegime = data[0]?.regime || 'BALANCED'
for (let i = 1; i <= data.length; i++) {
const r = i < data.length ? (data[i]?.regime || 'BALANCED') : null
if (r !== bandRegime || i === data.length) {
const color = REGIME_CANVAS_COLORS[bandRegime]
if (color !== 'rgba(0,0,0,0)') {
ctx.fillStyle = color
ctx.fillRect(toX(bandStart), mT, toX(i - 1) - toX(bandStart), cH)
}
bandStart = i; bandRegime = r || 'BALANCED'
}
}
// Grid
ctx.lineWidth = 1
const nG = 5
@@ -733,10 +825,13 @@ export default function InstrumentModels() {
</div>
)}
<div className="text-xs text-slate-600 space-y-0.5 self-start">
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
<div className="text-slate-700">{state.at_date}</div>
<div className="flex flex-col gap-2 self-start">
{state.regime && <RegimeBadge regime={state.regime}/>}
<div className="text-xs text-slate-600 space-y-0.5">
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
<div className="text-slate-700">{state.at_date}</div>
</div>
</div>
</div>
</div>