feat: instrument model
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user