feat: instrument model
This commit is contained in:
@@ -148,6 +148,43 @@ def apply_gauge_suggestions(
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}/macro-sync")
|
||||
def get_macro_sync(instrument: str) -> Dict[str, Any]:
|
||||
"""Retourne les valeurs actuelles + forecasts FF Calendar pour tous les nœuds macro-liés."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import get_macro_sync_items
|
||||
conn = get_conn()
|
||||
try:
|
||||
items = get_macro_sync_items(conn, instrument)
|
||||
return {"instrument": instrument.upper(), "items": items, "count": len(items)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class MacroSyncApplyBody(BaseModel):
|
||||
items: list[dict] # [{node_id, value, note}]
|
||||
|
||||
@router.post("/{instrument}/macro-sync/apply")
|
||||
def apply_macro_sync(instrument: str, body: MacroSyncApplyBody) -> Dict[str, Any]:
|
||||
"""Applique les valeurs macro (actuelles ou forecasts) comme node overrides."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import set_node_override
|
||||
conn = get_conn()
|
||||
try:
|
||||
inst = instrument.upper()
|
||||
saved = []
|
||||
for item in body.items:
|
||||
nid = item.get("node_id", "")
|
||||
value = item.get("value")
|
||||
note = item.get("note", "[Sync Marché FF Calendar]")
|
||||
if nid and value is not None:
|
||||
set_node_override(conn, inst, nid, float(value), note)
|
||||
saved.append(nid)
|
||||
return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/{instrument}/overrides")
|
||||
def clear_all_overrides(instrument: str) -> Dict[str, Any]:
|
||||
"""Supprime TOUTES les overrides d'un instrument (reset à zéro)."""
|
||||
|
||||
@@ -315,6 +315,132 @@ def build_node_combined_timeline(
|
||||
return result
|
||||
|
||||
|
||||
# ── Macro sync — lecture directe ff_calendar → overrides ──────────────────────
|
||||
|
||||
def get_macro_sync_items(conn, instrument: str) -> list[dict]:
|
||||
"""
|
||||
Pour chaque nœud avec macro_key dans le graphe, retourne :
|
||||
- actual_value : dernière valeur publiée (passé)
|
||||
- actual_date : date de cette publication
|
||||
- forecast_value: forecast du prochain événement (futur)
|
||||
- forecast_date / next_event_name / days_until
|
||||
- current_override : valeur déjà settée en DB (ou None)
|
||||
Utilisé par le bouton "Sync Marché" pour pré-remplir le graphe.
|
||||
"""
|
||||
inst_upper = instrument.upper()
|
||||
row = conn.execute(
|
||||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
graph_def = json.loads(row["graph_json"])
|
||||
today = date_type.today()
|
||||
today_str = str(today)
|
||||
q_from = str(today - timedelta(days=730))
|
||||
q_to = str(today + timedelta(days=180))
|
||||
|
||||
overrides = {r["node_id"]: float(r["value"]) for r in conn.execute(
|
||||
"SELECT node_id, value FROM instrument_node_overrides WHERE instrument=?", (inst_upper,)
|
||||
).fetchall()}
|
||||
|
||||
items = []
|
||||
for node in graph_def.get("nodes", []):
|
||||
mk = node.get("macro_key")
|
||||
if not mk or node.get("node_type") != "input_manual":
|
||||
continue
|
||||
mk_info = FF_MACRO_KEYS.get(mk)
|
||||
if not mk_info:
|
||||
continue
|
||||
|
||||
currency = mk_info["currency"]
|
||||
patterns = mk_info["names"]
|
||||
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT event_date, event_name, actual_value, forecast_value, previous_value
|
||||
FROM ff_calendar
|
||||
WHERE currency=? AND event_date>=? AND event_date<=?
|
||||
ORDER BY event_date ASC""",
|
||||
(currency, q_from, q_to)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
matched = [
|
||||
dict(r) for r in rows
|
||||
if any(p in str(r["event_name"]).lower() for p in patterns)
|
||||
]
|
||||
|
||||
# Deduplicate by date
|
||||
seen: set[str] = set()
|
||||
deduped = []
|
||||
for ev in matched:
|
||||
if ev["event_date"] not in seen:
|
||||
seen.add(ev["event_date"])
|
||||
deduped.append(ev)
|
||||
|
||||
# Latest actual (most recent past event with actual_value)
|
||||
past = [e for e in deduped if e["event_date"] <= today_str]
|
||||
actual_val: Optional[float] = None
|
||||
actual_date: Optional[str] = None
|
||||
for ev in reversed(past):
|
||||
v = _parse_ff_num(ev.get("actual_value"))
|
||||
if v is not None:
|
||||
actual_val = v
|
||||
actual_date = ev["event_date"]
|
||||
break
|
||||
|
||||
# Next forecast (first future event)
|
||||
future = [e for e in deduped if e["event_date"] > today_str]
|
||||
forecast_val: Optional[float] = None
|
||||
forecast_date: Optional[str] = None
|
||||
next_event_name: Optional[str] = None
|
||||
days_until: Optional[int] = None
|
||||
if future:
|
||||
nxt = future[0]
|
||||
forecast_val = (
|
||||
_parse_ff_num(nxt.get("forecast_value"))
|
||||
or _parse_ff_num(nxt.get("previous_value"))
|
||||
)
|
||||
forecast_date = nxt["event_date"]
|
||||
next_event_name = nxt["event_name"]
|
||||
try:
|
||||
days_until = (date_type.fromisoformat(forecast_date) - today).days
|
||||
except Exception:
|
||||
days_until = None
|
||||
|
||||
# Unit-aware post-conversion
|
||||
# The FF Calendar stores NFP in absolute (e.g. 228000), node expects K (228)
|
||||
# PMI stored as absolute value (54.7), node expects deviation from 50 (4.7)
|
||||
unit = node.get("unit", "")
|
||||
def _convert(v: Optional[float]) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
if unit == "K" and abs(v) >= 1_000:
|
||||
return round(v / 1_000, 1)
|
||||
if unit == "pts" and v > 10: # PMI > 10 → is absolute, subtract 50
|
||||
return round(v - 50.0, 1)
|
||||
return round(v, 4)
|
||||
|
||||
items.append({
|
||||
"node_id": node["id"],
|
||||
"node_label": node["label"],
|
||||
"macro_key": mk,
|
||||
"unit": unit,
|
||||
"coefficient_to_pips": node.get("coefficient_to_pips", 1),
|
||||
"actual_value": _convert(actual_val),
|
||||
"actual_date": actual_date,
|
||||
"forecast_value": _convert(forecast_val),
|
||||
"forecast_date": forecast_date,
|
||||
"next_event_name": next_event_name,
|
||||
"days_until_forecast": days_until,
|
||||
"current_override": overrides.get(node["id"]),
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# ── Scenario CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]:
|
||||
|
||||
@@ -1705,7 +1705,7 @@ const CONF_META: Record<string, { color: string; label: string }> = {
|
||||
}
|
||||
|
||||
function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
|
||||
instrument: string; onClose: () => void; onApplied: () => void
|
||||
}) {
|
||||
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -1747,7 +1747,7 @@ function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
.filter(s => selected.has(s.node_id))
|
||||
.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(atDate || undefined)
|
||||
onApplied()
|
||||
onClose()
|
||||
} finally { setApplying(false) }
|
||||
}
|
||||
@@ -1901,6 +1901,235 @@ function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Macro Sync Panel (FF Calendar → node overrides) ───────────────────────────
|
||||
|
||||
interface MacroSyncItem {
|
||||
node_id: string; node_label: string; macro_key: string; unit: string
|
||||
coefficient_to_pips: number
|
||||
actual_value: number | null; actual_date: string | null
|
||||
forecast_value: number | null; forecast_date: string | null
|
||||
next_event_name: string | null; days_until_forecast: number | null
|
||||
current_override: number | null
|
||||
}
|
||||
|
||||
function MacroSyncPanel({ instrument, onClose, onApplied }: {
|
||||
instrument: string; onClose: () => void; onApplied: () => void
|
||||
}) {
|
||||
const [items, setItems] = useState<MacroSyncItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// per-node choice: 'actual' | 'forecast' | null (skip)
|
||||
const [choices, setChoices] = useState<Record<string, 'actual' | 'forecast' | null>>({})
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.get(`/instrument-models/${instrument}/macro-sync`)
|
||||
.then(r => {
|
||||
const its: MacroSyncItem[] = r.data.items
|
||||
setItems(its)
|
||||
// Default: select 'actual' when available
|
||||
const init: Record<string, 'actual' | 'forecast' | null> = {}
|
||||
for (const it of its) {
|
||||
init[it.node_id] = it.actual_value != null ? 'actual' : it.forecast_value != null ? 'forecast' : null
|
||||
}
|
||||
setChoices(init)
|
||||
})
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [instrument])
|
||||
|
||||
async function apply() {
|
||||
setApplying(true)
|
||||
try {
|
||||
const payload = items.flatMap(it => {
|
||||
const choice = choices[it.node_id]
|
||||
if (!choice) return []
|
||||
const value = choice === 'actual' ? it.actual_value : it.forecast_value
|
||||
if (value == null) return []
|
||||
const source = choice === 'actual'
|
||||
? `Actual ${it.actual_date ?? ''}`
|
||||
: `Forecast ${it.forecast_date ?? ''} — ${it.next_event_name ?? ''}`
|
||||
return [{ node_id: it.node_id, value, note: `[FF Calendar] ${source}` }]
|
||||
})
|
||||
await api.post(`/instrument-models/${instrument}/macro-sync/apply`, { items: payload })
|
||||
onApplied()
|
||||
onClose()
|
||||
} finally { setApplying(false) }
|
||||
}
|
||||
|
||||
const applyCount = Object.values(choices).filter(v => v != null).length
|
||||
|
||||
function setAllActual() {
|
||||
const n = { ...choices }
|
||||
for (const it of items) {
|
||||
if (it.actual_value != null) n[it.node_id] = 'actual'
|
||||
}
|
||||
setChoices(n)
|
||||
}
|
||||
function setAllForecast() {
|
||||
const n = { ...choices }
|
||||
for (const it of items) {
|
||||
if (it.forecast_value != null) n[it.node_id] = 'forecast'
|
||||
}
|
||||
setChoices(n)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}>
|
||||
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-3xl mx-4 max-h-[85vh] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
|
||||
<div>
|
||||
<div className="text-white font-semibold">Sync Marché — {instrument} (FF Calendar)</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Synchronise les nœuds macro avec les dernières valeurs publiées + forecasts prochains événements.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-white">
|
||||
<X className="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<div className="text-center py-10 text-slate-500 text-sm">Lecture FF Calendar…</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="text-center py-10 text-slate-500 text-sm">
|
||||
Aucun nœud macro-lié trouvé, ou aucune donnée FF Calendar disponible.
|
||||
</div>
|
||||
)}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<span className="text-xs text-slate-500">Sélectionner tout :</span>
|
||||
<button onClick={setAllActual}
|
||||
className="px-2.5 py-1 rounded border border-violet-700/40 bg-violet-900/20 text-violet-300 text-xs hover:bg-violet-800/30 transition-colors">
|
||||
Valeurs actuelles
|
||||
</button>
|
||||
<button onClick={setAllForecast}
|
||||
className="px-2.5 py-1 rounded border border-amber-700/40 bg-amber-900/20 text-amber-300 text-xs hover:bg-amber-800/30 transition-colors">
|
||||
Forecasts
|
||||
</button>
|
||||
<button onClick={() => setChoices(Object.fromEntries(items.map(i => [i.node_id, null])))}
|
||||
className="px-2.5 py-1 rounded border border-slate-700/30 text-slate-500 text-xs hover:text-slate-300 transition-colors">
|
||||
Désélectionner
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-slate-500">{applyCount} sélectionné{applyCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Node rows */}
|
||||
<div className="rounded-lg border border-slate-700/40 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-dark-700/60 border-b border-slate-700/40 text-slate-500">
|
||||
<th className="px-3 py-2 text-left">Variable</th>
|
||||
<th className="px-3 py-2 text-center w-28">
|
||||
<span className="text-violet-400">Actuel</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center w-40">
|
||||
<span className="text-amber-400">Prochain forecast</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center w-28">Override actuel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => {
|
||||
const ch = choices[it.node_id]
|
||||
return (
|
||||
<tr key={it.node_id}
|
||||
className="border-b border-slate-700/20 hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="font-medium text-white">{it.node_label}</div>
|
||||
<div className="text-slate-600">{it.macro_key}</div>
|
||||
</td>
|
||||
|
||||
{/* Actual value cell */}
|
||||
<td className="px-2 py-2.5 text-center">
|
||||
{it.actual_value != null ? (
|
||||
<button
|
||||
onClick={() => setChoices(p => ({ ...p, [it.node_id]: ch === 'actual' ? null : 'actual' }))}
|
||||
className={clsx(
|
||||
'px-2 py-1.5 rounded border text-xs font-mono w-full transition-colors',
|
||||
ch === 'actual'
|
||||
? 'border-violet-500 bg-violet-900/40 text-violet-200 font-bold'
|
||||
: 'border-slate-700/40 text-slate-300 hover:border-violet-700/50',
|
||||
)}>
|
||||
{it.actual_value.toFixed(2)} {it.unit}
|
||||
<div className="text-slate-600 font-normal text-xs">{it.actual_date}</div>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-700">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Forecast cell */}
|
||||
<td className="px-2 py-2.5 text-center">
|
||||
{it.forecast_value != null ? (
|
||||
<button
|
||||
onClick={() => setChoices(p => ({ ...p, [it.node_id]: ch === 'forecast' ? null : 'forecast' }))}
|
||||
className={clsx(
|
||||
'px-2 py-1.5 rounded border text-xs font-mono w-full transition-colors',
|
||||
ch === 'forecast'
|
||||
? 'border-amber-500 bg-amber-900/30 text-amber-200 font-bold'
|
||||
: 'border-slate-700/40 text-slate-300 hover:border-amber-700/50',
|
||||
)}>
|
||||
{it.forecast_value.toFixed(2)} {it.unit}
|
||||
<div className="text-slate-600 font-normal text-xs">
|
||||
{it.forecast_date} ({it.days_until_forecast}j)
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-700">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Current override */}
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{it.current_override != null ? (
|
||||
<span className="font-mono text-violet-400">
|
||||
{it.current_override.toFixed(2)} {it.unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-700">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-600">
|
||||
<span className="text-violet-400">Actuel</span> = dernière valeur publiée dans FF Calendar.
|
||||
<span className="text-amber-400">Forecast</span> = estimation du prochain événement.
|
||||
Cliquer sur une cellule pour la sélectionner / désélectionner.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 p-4 border-t border-slate-700/40">
|
||||
<button onClick={apply} disabled={applying || applyCount === 0}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 font-medium transition-colors">
|
||||
{applying ? 'Import…' : `Appliquer ${applyCount} valeur${applyCount > 1 ? 's' : ''}`}
|
||||
</button>
|
||||
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded-lg px-4 py-2 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Calibration View ──────────────────────────────────────────────────────────
|
||||
|
||||
const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const
|
||||
@@ -2530,14 +2759,17 @@ export default function InstrumentModels() {
|
||||
)}
|
||||
|
||||
{showSync && (
|
||||
<SyncPanel
|
||||
instrument={instrument}
|
||||
onClose={() => setShowSync(false)}
|
||||
onApplied={(atDate) => {
|
||||
setShowSync(false)
|
||||
setRefreshKey(k => k + 1)
|
||||
}}
|
||||
/>
|
||||
state?.nodes.some(n => n.macro_key)
|
||||
? <MacroSyncPanel
|
||||
instrument={instrument}
|
||||
onClose={() => setShowSync(false)}
|
||||
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
: <SyncPanel
|
||||
instrument={instrument}
|
||||
onClose={() => setShowSync(false)}
|
||||
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user