feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 00:31:30 +02:00
parent a8ee808937
commit d9e9d30889
4 changed files with 591 additions and 48 deletions

View File

@@ -43,6 +43,13 @@ class CalibrateBody(BaseModel):
ref_date: Optional[str] = None
class CalibrationOverrideBody(BaseModel):
absorption_days: int = 7
rise_days: int = 0
plateau_days: int = 0
decay_type: str = "exp"
@router.get("", response_model=List[Dict[str, Any]])
def list_instrument_models():
from services.database import get_conn
@@ -75,8 +82,11 @@ def list_instrument_models():
@router.get("/{instrument}/gauge-suggestions")
def get_gauge_suggestions(instrument: str) -> Dict[str, Any]:
"""Suggestions de valeurs depuis les derniers gauges de marché (macro_regime_history)."""
def get_gauge_suggestions(
instrument: str,
at_date: Optional[str] = Query(None, description="YYYY-MM-DD — snapshot le plus proche de cette date"),
) -> Dict[str, Any]:
"""Suggestions de valeurs depuis les gauges de marché (optionnellement à une date historique)."""
from services.database import get_conn
from services.gauge_sync import suggest_from_gauges, get_latest_gauges
from services.instrument_models import INSTRUMENT_MODELS
@@ -85,14 +95,14 @@ def get_gauge_suggestions(instrument: str) -> Dict[str, Any]:
inst = instrument.upper()
if inst not in INSTRUMENT_MODELS:
raise HTTPException(status_code=404, detail=f"Instrument {inst} non supporté")
gauges, snap_date = get_latest_gauges(conn)
gauges, snap_date = get_latest_gauges(conn, at_date=at_date)
if not gauges:
raise HTTPException(status_code=404, detail="Aucun snapshot de gauges disponible")
suggestions = suggest_from_gauges(inst, gauges)
# Enrich with node label/unit from graph if missing
return {
"instrument": inst,
"gauge_date": snap_date,
"requested_date": at_date,
"n_suggestions": len(suggestions),
"suggestions": suggestions,
"gauges_available": list(gauges.keys()),
@@ -219,6 +229,146 @@ def timeline_whatif(
conn.close()
@router.get("/{instrument}/calendar-events")
def get_calendar_events(
instrument: str,
days_back: int = Query(365, description="Jours en arrière depuis at_date"),
days_forward: int = Query(90, description="Jours en avant depuis at_date"),
at_date: Optional[str]= Query(None),
) -> List[Dict[str, Any]]:
"""Events calendrier analysés pour cet instrument — alimente le panneau What-if."""
from services.database import get_conn
from services.instrument_models import _lifecycle, init_instrument_model_tables
from datetime import datetime, date as date_type, timedelta
import json as _json
conn = get_conn()
try:
inst_upper = instrument.upper()
inst_lower = inst_upper.lower()
init_instrument_model_tables(conn)
try:
ref = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
except ValueError:
ref = datetime.utcnow().date()
date_from = ref - timedelta(days=days_back)
date_to = ref + timedelta(days=days_forward)
rows = conn.execute("""
SELECT a.id as analysis_id,
a.prediction_json,
e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
ORDER BY e.start_date DESC
""", (inst_upper, str(date_from), str(date_to))).fetchall()
result = []
for row in rows:
r = dict(row)
try:
preds = _json.loads(r["prediction_json"] or "{}")
calib = _json.loads(r["calibration_json"] or "{}")
except Exception:
continue
pips: float = 0.0
if inst_lower in preds:
pips = float(preds[inst_lower])
else:
for k, v in preds.items():
if inst_lower in k.lower():
try: pips = float(v); break
except (TypeError, ValueError): pass
try:
ev_date = date_type.fromisoformat(r["start_date"][:10])
except ValueError:
continue
days = (ref - ev_date).days
absorption = max(1, int(calib.get("absorption_days", 7)))
dtype = str(calib.get("decay_type", "exp"))
rise = max(0, int(calib.get("rise_days", 0)))
plateau = max(0, int(calib.get("plateau_days", 0)))
lf = _lifecycle(days, rise, plateau, absorption, dtype) if days >= 0 else 0.0
result.append({
"analysis_id": r["analysis_id"],
"event_id": r.get("event_id"),
"title": r.get("title") or r.get("sub_type", ""),
"start_date": r["start_date"][:10],
"category": r["category"],
"is_future": ev_date > ref,
"days_offset": -days, # positive = future, negative = past
"pip_prediction": round(pips, 1),
"lifecycle_factor": round(lf, 3),
"remaining_pips": round(pips * lf, 1),
"calibration": {
"absorption_days": absorption,
"rise_days": rise,
"plateau_days": plateau,
"decay_type": dtype,
},
})
return result
finally:
conn.close()
@router.put("/{instrument}/events/{analysis_id}/calibration")
def update_event_calibration(
instrument: str,
analysis_id: int,
body: CalibrationOverrideBody,
) -> Dict[str, Any]:
"""Écrase les paramètres de lifecycle d'une analyse event (override par-event)."""
from services.database import get_conn
import json as _json
conn = get_conn()
try:
row = conn.execute(
"SELECT id FROM causal_event_analyses WHERE id=? AND instrument=?",
(analysis_id, instrument.upper())
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Analysis not found")
calib = {
"absorption_days": body.absorption_days,
"rise_days": body.rise_days,
"plateau_days": body.plateau_days,
"decay_type": body.decay_type,
}
conn.execute("""
INSERT INTO event_calibration_overrides (analysis_id, calibration_json)
VALUES (?, ?)
ON CONFLICT(analysis_id) DO UPDATE SET calibration_json=excluded.calibration_json, updated_at=datetime('now')
""", (analysis_id, _json.dumps(calib)))
conn.commit()
return {"ok": True, "analysis_id": analysis_id, "calibration": calib}
finally:
conn.close()
@router.delete("/{instrument}/events/{analysis_id}/calibration")
def reset_event_calibration(instrument: str, analysis_id: int) -> Dict[str, Any]:
"""Supprime l'override de calibration — retour aux paramètres du template."""
from services.database import get_conn
conn = get_conn()
try:
conn.execute("DELETE FROM event_calibration_overrides WHERE analysis_id=?", (analysis_id,))
conn.commit()
return {"ok": True, "analysis_id": analysis_id}
finally:
conn.close()
@router.post("/{instrument}/calibrate")
def calibrate_intercept(
instrument: str,

View File

@@ -247,12 +247,21 @@ _SUGGEST_FN = {
# ── Public API ─────────────────────────────────────────────────────────────────
def get_latest_gauges(conn) -> tuple[dict, str]:
"""Retourne (gauges_dict, snapshot_date) depuis macro_regime_history."""
row = conn.execute(
"SELECT gauges_summary_json, timestamp FROM macro_regime_history "
"ORDER BY timestamp DESC LIMIT 1"
).fetchone()
def get_latest_gauges(conn, at_date: Optional[str] = None) -> tuple[dict, str]:
"""Retourne (gauges_dict, snapshot_date) depuis macro_regime_history.
Si at_date fourni, retourne le snapshot le plus proche <= at_date."""
if at_date:
row = conn.execute(
"SELECT gauges_summary_json, timestamp FROM macro_regime_history "
"WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
(at_date,)
).fetchone()
else:
row = conn.execute(
"SELECT gauges_summary_json, timestamp FROM macro_regime_history "
"ORDER BY timestamp DESC LIMIT 1"
).fetchone()
if row:
r = dict(row)
gauges = json.loads(r.get("gauges_summary_json") or "{}")
@@ -261,10 +270,17 @@ def get_latest_gauges(conn) -> tuple[dict, str]:
return gauges, date
# Fallback : macro_gauge_snapshots
row2 = conn.execute(
"SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots "
"ORDER BY snapshot_date DESC LIMIT 1"
).fetchone()
if at_date:
row2 = conn.execute(
"SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots "
"WHERE snapshot_date <= ? ORDER BY snapshot_date DESC LIMIT 1",
(at_date,)
).fetchone()
else:
row2 = conn.execute(
"SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots "
"ORDER BY snapshot_date DESC LIMIT 1"
).fetchone()
if row2:
return json.loads(row2["gauges_json"] or "{}"), str(row2["snapshot_date"])

View File

@@ -638,6 +638,11 @@ def init_instrument_model_tables(conn):
set_at TEXT DEFAULT (datetime('now')),
UNIQUE(instrument, node_id)
);
CREATE TABLE IF NOT EXISTS event_calibration_overrides (
analysis_id INTEGER PRIMARY KEY,
calibration_json TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
""")
conn.commit()
@@ -669,12 +674,15 @@ def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> di
extended_from = ref_date - timedelta(days=365)
rows = conn.execute("""
SELECT a.prediction_json,
e.start_date, e.end_date, e.sub_type,
t.category, t.calibration_json
SELECT a.id as analysis_id,
a.prediction_json,
e.start_date, e.end_date, e.sub_type, e.name as title,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
@@ -787,6 +795,98 @@ def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None)
# ── Public API ─────────────────────────────────────────────────────────────────
def get_active_event_details(conn, instrument: str, ref_date: date_type) -> dict:
"""
Détail des events actifs par catégorie — utilisé pour enrichir les nœuds event du DAG.
Retourne : {category: [{analysis_id, title, start_date, days_since,
pip_prediction, lifecycle_factor, remaining_pips, calibration}]}
"""
inst_upper = instrument.upper()
inst_lower = inst_upper.lower()
extended_from = ref_date - timedelta(days=365)
rows = conn.execute("""
SELECT a.id as analysis_id,
a.prediction_json,
e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
ORDER BY e.start_date DESC
""", (inst_upper, str(extended_from), str(ref_date))).fetchall()
by_cat: dict[str, list] = {}
for row in rows:
r = dict(row)
try:
preds = json.loads(r["prediction_json"] or "{}")
calib = json.loads(r["calibration_json"] or "{}")
except Exception:
continue
pips: Optional[float] = None
if inst_lower in preds:
pips = float(preds[inst_lower])
else:
for k, v in preds.items():
if inst_lower in k.lower():
try: pips = float(v); break
except (TypeError, ValueError): pass
if pips is None:
continue
absorption = max(1, int(calib.get("absorption_days", 7)))
dtype = str(calib.get("decay_type", "exp"))
rise = max(0, int(calib.get("rise_days", 0)))
plateau = max(0, int(calib.get("plateau_days", 0)))
ev_end = r.get("end_date")
if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
try:
meeting = date_type.fromisoformat(ev_end[:10])
ev_start = date_type.fromisoformat(r["start_date"][:10])
absorption = max(1, (meeting - ev_start).days)
dtype = "linear"; rise = 0; plateau = 0
except ValueError:
pass
try:
ev_date = date_type.fromisoformat(r["start_date"][:10])
except ValueError:
continue
days = (ref_date - ev_date).days
lf = _lifecycle(days, rise, plateau, absorption, dtype)
if lf < 0.01:
continue
cat = r["category"]
by_cat.setdefault(cat, []).append({
"analysis_id": r["analysis_id"],
"event_id": r.get("event_id"),
"title": r.get("title", ""),
"start_date": r["start_date"][:10],
"days_since": days,
"pip_prediction": round(pips, 1),
"lifecycle_factor": round(lf, 3),
"remaining_pips": round(pips * lf, 1),
"calibration": {
"absorption_days": absorption,
"rise_days": rise,
"plateau_days": plateau,
"decay_type": dtype,
},
})
return by_cat
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
"""
Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime.
@@ -889,6 +989,8 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
event_details = get_active_event_details(conn, inst_upper, ref_date)
return {
"instrument": inst_upper,
"name": graph_def["name"],
@@ -906,6 +1008,7 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
"nodes": nodes_out,
"output_node": output_id,
"regime": regime_info,
"event_details": event_details,
}
@@ -944,11 +1047,14 @@ def simulate_timeline(
inst_lower = inst_upper.lower()
extended = date_from - timedelta(days=365)
rows = conn.execute("""
SELECT a.prediction_json, e.start_date, e.end_date, e.sub_type,
t.category, t.calibration_json
SELECT a.id as analysis_id,
a.prediction_json, e.start_date, e.end_date, e.sub_type,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument=? AND e.start_date>=? AND e.start_date<=?
""", (inst_upper, str(extended), str(today))).fetchall()

View File

@@ -68,6 +68,7 @@ interface ModelState {
nodes: ModelNode[]
output_node: string
regime: RegimeInfo
event_details: Record<string, EventDetail[]>
}
interface TimelinePoint {
@@ -98,6 +99,42 @@ interface VirtualEventForm {
decay_type: string
}
interface EventDetail {
analysis_id: number
event_id: number
title: string
start_date: string
days_since: number
pip_prediction: number
lifecycle_factor: number
remaining_pips: number
calibration: {
absorption_days: number
rise_days: number
plateau_days: number
decay_type: string
}
}
interface CalendarEvent {
analysis_id: number
event_id: number
title: string
start_date: string
category: string
is_future: boolean
days_offset: number
pip_prediction: number
lifecycle_factor: number
remaining_pips: number
calibration: {
absorption_days: number
rise_days: number
plateau_days: number
decay_type: string
}
}
// ── Constants ─────────────────────────────────────────────────────────────────
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
@@ -361,7 +398,10 @@ function NodeEditModal({ node, instrument, onClose, onSaved }: {
const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => void }) {
function NodeCard({ node, onEdit, eventDetails }: {
node: ModelNode; onEdit: (n: ModelNode) => void
eventDetails?: Record<string, EventDetail[]>
}) {
const meta = NODE_TYPE_META[node.node_type]
const v = node.pip_contribution
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
@@ -403,7 +443,30 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
</span>
)}
</div>
) : (
) : node.node_type === 'input_event' ? (() => {
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const barW = Math.round(maxLf * 100)
return (
<div className="mt-1">
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
{evList.length > 0 && (
<span className="text-xs text-slate-600">{evList.length} ev</span>
)}
</div>
{evList.length > 0 && (
<div className="mt-0.5 flex items-center gap-1.5">
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width:`${barW}%`}}/>
</div>
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
</div>
)}
</div>
)
})() : (
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
{fmt(v)} pips
</div>
@@ -435,8 +498,9 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
)
}
function DagView({ nodes, instrument, onEdit }: {
function DagView({ nodes, instrument, onEdit, eventDetails }: {
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
eventDetails: Record<string, EventDetail[]>
}) {
const cols: ModelNode[][] = [[], [], [], []]
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
@@ -450,7 +514,7 @@ function DagView({ nodes, instrument, onEdit }: {
{lbl}
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
</div>
{cols[ci].map(n => <NodeCard key={n.id} node={n} onEdit={onEdit}/>)}
{cols[ci].map(n => <NodeCard key={n.id} node={n} onEdit={onEdit} eventDetails={eventDetails}/>)}
</div>
))}
</div>
@@ -677,6 +741,7 @@ function TimelineView({ instrument }: { instrument: string }) {
const [virtuals, setVirtuals] = useState<VirtualEventForm[]>([])
const [whatifLoading, setWhatifLoading] = useState(false)
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
const [importingCal, setImportingCal] = useState(false)
const canvasRef = useRef<HTMLCanvasElement>(null)
const activeData = whatifData ?? data
@@ -986,12 +1051,42 @@ function TimelineView({ instrument }: { instrument: string }) {
{/* Virtual Events Panel */}
{showVirtual && (
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
<Plus className="w-3 h-3"/> Ajouter
</button>
<div className="flex gap-1.5">
<button
disabled={importingCal}
onClick={async () => {
setImportingCal(true)
try {
const r = await api.get<CalendarEvent[]>(
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
)
const imported: VirtualEventForm[] = r.data.map(ev => ({
id: ev.analysis_id.toString(),
date: ev.start_date,
category: ev.category,
pips: ev.pip_prediction,
label: ev.title || ev.category,
absorption_days: ev.calibration.absorption_days,
rise_days: ev.calibration.rise_days,
plateau_days: ev.calibration.plateau_days,
decay_type: ev.calibration.decay_type,
}))
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))
return [...prev, ...imported.filter(x => !existingIds.has(x.id))]
})
} finally { setImportingCal(false) }
}}
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 border border-sky-700/40 rounded px-2 py-0.5 transition-colors disabled:opacity-40">
<Activity className="w-3 h-3"/> {importingCal ? '…' : 'Import calendrier'}
</button>
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
<Plus className="w-3 h-3"/> Ajouter
</button>
</div>
</div>
{virtuals.length === 0 && (
<div className="text-xs text-slate-500 text-center py-2">
@@ -1085,25 +1180,28 @@ const CONF_META: Record<string, { color: string; label: string }> = {
}
function SyncPanel({ instrument, onClose, onApplied }: {
instrument: string; onClose: () => void; onApplied: () => void
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
}) {
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [atDate, setAtDate] = useState<string>('')
useEffect(() => {
function fetchSuggestions(date?: string) {
setLoading(true)
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions`)
const qs = date ? `?at_date=${date}` : ''
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions${qs}`)
.then(r => {
setData(r.data)
// Pre-select HIGH confidence items
const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id))
setSelected(highs)
})
.catch(() => setData(null))
.finally(() => setLoading(false))
}, [instrument])
}
useEffect(() => { fetchSuggestions() }, [instrument])
function toggleAll(conf: string) {
if (!data) return
@@ -1122,9 +1220,9 @@ function SyncPanel({ instrument, onClose, onApplied }: {
try {
const overrides = data.suggestions
.filter(s => selected.has(s.node_id))
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto] ${s.note}` }))
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto${atDate ? ' @'+atDate : ''}] ${s.note}` }))
await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides })
onApplied()
onApplied(atDate || undefined)
onClose()
} finally { setApplying(false) }
}
@@ -1139,15 +1237,39 @@ function SyncPanel({ instrument, onClose, onApplied }: {
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
<div>
<div className="flex-1">
<div className="text-white font-semibold">Sync Marché {instrument}</div>
{data && (
<div className="text-xs text-slate-500 mt-0.5">
Gauges du {data.gauge_date} · {data.gauges_available.length} indicateurs disponibles
Snapshot: {data.gauge_date}
{(data as any).requested_date && (data as any).requested_date !== data.gauge_date && (
<span className="text-amber-500/70 ml-1">(demandé: {(data as any).requested_date})</span>
)}
· {data.gauges_available.length} indicateurs
</div>
)}
{/* Date picker to initialize at a historical date */}
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-slate-500">Date d'init :</label>
<input
type="date"
value={atDate}
onChange={e => setAtDate(e.target.value)}
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-blue-500/60"
/>
<button
onClick={() => fetchSuggestions(atDate || undefined)}
disabled={loading}
className="px-2 py-0.5 bg-blue-600/20 border border-blue-700/40 rounded text-xs text-blue-400 hover:bg-blue-600/30 transition-colors disabled:opacity-40">
{loading ? '' : 'Charger'}
</button>
{atDate && (
<button onClick={() => { setAtDate(''); fetchSuggestions() }}
className="text-xs text-slate-600 hover:text-slate-400">✕ Réinitialiser</button>
)}
</div>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white">
<button onClick={onClose} className="text-slate-500 hover:text-white ml-4">
<X className="w-4 h-4"/>
</button>
</div>
@@ -1254,9 +1376,153 @@ function SyncPanel({ instrument, onClose, onApplied }: {
)
}
// ── Calibration View ──────────────────────────────────────────────────────────
const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const
function CalibrationView({ instrument, eventDetails }: {
instrument: string
eventDetails: Record<string, EventDetail[]>
}) {
const [localCalib, setLocalCalib] = useState<Record<number, EventDetail['calibration']>>({})
const [saving, setSaving] = useState<Record<number, boolean>>({})
const [saved, setSaved] = useState<Record<number, boolean>>({})
const allEvents = Object.values(eventDetails).flat()
.sort((a, b) => b.lifecycle_factor - a.lifecycle_factor)
if (allEvents.length === 0) {
return (
<div className="text-center py-12 text-slate-500 text-sm">
Aucun event actif actuellement les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption.
</div>
)
}
function getCalib(ev: EventDetail) {
return localCalib[ev.analysis_id] ?? ev.calibration
}
function setField(id: number, field: keyof EventDetail['calibration'], val: string | number) {
setLocalCalib(prev => ({
...prev,
[id]: { ...(prev[id] ?? allEvents.find(e => e.analysis_id === id)!.calibration), [field]: val },
}))
setSaved(prev => ({ ...prev, [id]: false }))
}
async function saveCalib(ev: EventDetail) {
const calib = getCalib(ev)
setSaving(prev => ({ ...prev, [ev.analysis_id]: true }))
try {
await api.put(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`, calib)
setSaved(prev => ({ ...prev, [ev.analysis_id]: true }))
} finally {
setSaving(prev => ({ ...prev, [ev.analysis_id]: false }))
}
}
async function resetCalib(ev: EventDetail) {
await api.delete(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`)
setLocalCalib(prev => { const n = {...prev}; delete n[ev.analysis_id]; return n })
setSaved(prev => ({ ...prev, [ev.analysis_id]: false }))
}
return (
<div className="space-y-2">
<div className="text-xs text-slate-500 mb-3">
Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template.
</div>
{allEvents.map(ev => {
const cal = getCalib(ev)
const lf = ev.lifecycle_factor
const isSaving = saving[ev.analysis_id]
const isDirty = !!localCalib[ev.analysis_id]
const wasSaved = saved[ev.analysis_id]
return (
<div key={ev.analysis_id}
className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 space-y-2">
{/* Event header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 shrink-0">{ev.start_date}</span>
<span className="text-xs font-medium text-slate-300 truncate">{ev.title || ev.analysis_id}</span>
<span className="text-xs text-slate-600 bg-slate-800 rounded px-1.5 py-0.5 shrink-0">{ev.days_since}j</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className={clsx('text-xs font-bold font-mono', pipColor(ev.pip_prediction))}>
{fmt(ev.pip_prediction)}p
</span>
<span className={clsx('text-xs font-mono', pipColor(ev.remaining_pips))}>
→{fmt(ev.remaining_pips)}p
</span>
{/* Lifecycle bar */}
<div className="flex items-center gap-1">
<div className="w-20 h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', lf > 0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')}
style={{width:`${Math.round(lf*100)}%`}}/>
</div>
<span className="text-xs text-slate-500 font-mono">{Math.round(lf*100)}%</span>
</div>
</div>
</div>
{/* Calibration inputs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div>
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
<input type="number" min={1} max={365} value={cal.absorption_days}
onChange={e => setField(ev.analysis_id, 'absorption_days', parseInt(e.target.value)||1)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Rise (j)</label>
<input type="number" min={0} max={30} value={cal.rise_days}
onChange={e => setField(ev.analysis_id, 'rise_days', parseInt(e.target.value)||0)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Plateau (j)</label>
<input type="number" min={0} max={30} value={cal.plateau_days}
onChange={e => setField(ev.analysis_id, 'plateau_days', parseInt(e.target.value)||0)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Decay type</label>
<select value={cal.decay_type}
onChange={e => setField(ev.analysis_id, 'decay_type', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
{DECAY_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 justify-end">
{isDirty && !wasSaved && (
<button onClick={() => resetCalib(ev)}
className="text-xs text-slate-600 hover:text-slate-400 transition-colors">
↩ Réinitialiser template
</button>
)}
<button onClick={() => saveCalib(ev)} disabled={isSaving}
className={clsx('flex items-center gap-1 px-3 py-1 rounded text-xs font-medium transition-colors',
wasSaved ? 'bg-emerald-700/40 text-emerald-400 border border-emerald-700/40'
: isDirty ? 'bg-blue-600 hover:bg-blue-500 text-white'
: 'bg-dark-700 text-slate-500 border border-slate-700/30')}>
{isSaving ? 'Sauvegarde' : wasSaved ? ' Sauvegardé' : 'Sauvegarder'}
</button>
</div>
</div>
)
})}
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
type ViewMode = 'dag' | 'table' | 'timeline'
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration'
export default function InstrumentModels() {
const [instrument, setInstrument] = useState('EURUSD')
@@ -1293,9 +1559,10 @@ export default function InstrumentModels() {
}, [state])
const VIEW_BTNS: { key: ViewMode; Icon: typeof Network; label: string }[] = [
{ key: 'dag', Icon: Network, label: 'DAG' },
{ key: 'table', Icon: Table2, label: 'Tableau' },
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
{ key: 'dag', Icon: Network, label: 'DAG' },
{ key: 'table', Icon: Table2, label: 'Tableau' },
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
{ key: 'calibration', Icon: Activity, label: 'Calibration' },
]
return (
@@ -1437,9 +1704,10 @@ export default function InstrumentModels() {
{/* Main content */}
{state && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode}/>}
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
{view === 'timeline' && <TimelineView instrument={instrument}/>}
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}}/>}
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
{view === 'timeline' && <TimelineView instrument={instrument}/>}
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}
</div>
)}
@@ -1484,7 +1752,10 @@ export default function InstrumentModels() {
<SyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
onApplied={(atDate) => {
setShowSync(false)
setRefreshKey(k => k + 1)
}}
/>
)}
</div>