feat: pressure

This commit is contained in:
OpenSquared
2026-07-02 18:07:21 +02:00
parent 01709e5edf
commit ae5865a156
3 changed files with 303 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ Exposes per-instrument snapshot (price, indicators, regime, trend, events) and A
"""
import json
import math
from datetime import datetime, timedelta
from datetime import datetime, timedelta, date as date_type
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
@@ -227,3 +227,166 @@ def get_theoretical_curve(
result.append({"date": d, **entry})
return result
# ── Libellés lisibles par catégorie ───────────────────────────────────────────
_CAT_LABELS: Dict[str, str] = {
"central_bank": "Banque Centrale",
"monetary_shock": "Surprise Macro",
"geopolitical": "Géopolitique",
"commodity": "Commodités",
"growth_shock": "Croissance",
"trade_policy": "Commerce / Tarifs",
"credit_stress": "Stress Crédit",
"sentiment": "Sentiment & Position.",
"technical": "Technique",
"positioning": "Flux Institutionnels",
"unclassified": "Non Classifié",
}
@router.get("/{instrument_id}/factor-state")
def get_factor_state(
instrument_id: str,
at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"),
) -> Dict[str, Any]:
"""
Pression nette actuelle sur l'instrument : somme de toutes les contributions
d'events actifs pondérées par leur courbe de dissipation.
Retourne une décomposition par catégorie causale (Banque Centrale, Surprise Macro…)
avec détail par event, ainsi que le NET en pips et la direction.
"""
from services.database import get_conn
try:
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
except ValueError:
ref_date = datetime.utcnow().date()
# Cherche les analyses pour cet instrument dans les 180 jours précédents
extended_from = ref_date - timedelta(days=180)
inst_upper = instrument_id.upper()
conn = get_conn()
try:
rows = conn.execute("""
SELECT a.prediction_json,
e.start_date AS event_date,
e.name AS event_name,
e.sub_type AS event_sub_type,
e.end_date AS event_end_date,
t.name AS template_name,
t.category AS category,
t.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
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()
finally:
conn.close()
inst_lower = inst_upper.lower()
by_category: Dict[str, Dict] = {}
seen_events: set = set() # évite les doublons (même event × multi-analyse)
for row in rows:
r = dict(row)
event_key = (r["event_name"], r["event_date"])
if event_key in seen_events:
continue
seen_events.add(event_key)
try:
predictions = json.loads(r["prediction_json"] or "{}")
calib = json.loads(r["calibration_json"] or "{}")
except Exception:
continue
# Pips prédits pour cet instrument (cherche node_id == inst_lower ou contenant)
pips_full: Optional[float] = None
if inst_lower in predictions:
pips_full = float(predictions[inst_lower])
else:
for k, v in predictions.items():
if inst_lower in k.lower():
try:
pips_full = float(v)
break
except (TypeError, ValueError):
pass
if pips_full is None or pips_full == 0:
continue
absorption_days = max(1, int(calib.get("absorption_days", 7)))
decay_type = str(calib.get("decay_type", "exp"))
try:
ev_date = date_type.fromisoformat(r["event_date"][:10])
except ValueError:
continue
# Pour les guidance events : end_date = meeting date → absorption dynamique
ev_end = r.get("event_end_date")
if ev_end and r.get("event_sub_type", "").startswith("rate_guidance"):
try:
meeting = date_type.fromisoformat(ev_end[:10])
absorption_days = max(1, (meeting - ev_date).days)
decay_type = "linear" # anticipation linéaire jusqu'à la réunion
except ValueError:
pass
days_elapsed = (ref_date - ev_date).days
df = _decay(days_elapsed, absorption_days, decay_type)
if df < 0.01:
continue
current_pips = round(pips_full * df, 1)
cat = r["category"]
if cat not in by_category:
by_category[cat] = {
"label": _CAT_LABELS.get(cat, cat),
"pips": 0.0,
"contributions": [],
}
by_category[cat]["pips"] += current_pips
by_category[cat]["contributions"].append({
"event_name": r["event_name"],
"event_date": r["event_date"][:10],
"template_name": r["template_name"],
"pips_full": round(pips_full, 1),
"days_elapsed": days_elapsed,
"absorption_days": absorption_days,
"decay_pct": round(df * 100),
"pips_current": current_pips,
})
# Arrondi + tri par |pips| décroissant
for v in by_category.values():
v["pips"] = round(v["pips"], 1)
v["contributions"].sort(key=lambda c: abs(c["pips_current"]), reverse=True)
categories = sorted(by_category.values(), key=lambda x: abs(x["pips"]), reverse=True)
net_pips = round(sum(v["pips"] for v in by_category.values()), 1)
direction = "neutral"
if net_pips > 5:
direction = "bullish"
elif net_pips < -5:
direction = "bearish"
return {
"instrument": inst_upper,
"at_date": str(ref_date),
"net_pips": net_pips,
"direction": direction,
"categories": categories,
"n_events": len(seen_events),
}

View File

@@ -96,18 +96,33 @@ def _find_existing(conn, currency: str, meeting_date: str) -> Optional[dict]:
def _upsert_analyses(conn, event_id: int, template_id: int, instruments: list[str], signal: float):
"""
Crée/recrée les causal_event_analyses pour que l'event apparaisse
dans la Frise de chaque instrument concerné.
dans la Frise et dans la theoretical-curve de chaque instrument.
Calcule prediction_json via evaluate_graph pour alimenter le factor engine.
"""
conn.execute(
"DELETE FROM causal_event_analyses WHERE market_event_id=?", (event_id,)
)
inputs = json.dumps({"guidance_signal": signal})
inputs_dict = {"guidance_signal": signal}
inputs_json = json.dumps(inputs_dict)
# Évalue le graphe pour obtenir les pips prédits par instrument
node_values: dict = {}
try:
from services.causal_graphs import evaluate_graph, get_template
tmpl = get_template(conn, template_id)
if tmpl:
node_values = evaluate_graph(tmpl["graph_json"], inputs_dict)
except Exception as e:
logger.warning(f"[guidance_sync] evaluate_graph failed: {e}")
prediction_json = json.dumps(node_values) if node_values else None
for inst in instruments:
conn.execute("""
INSERT INTO causal_event_analyses
(market_event_id, template_id, instrument, inputs_json, analyzed_at)
VALUES (?, ?, ?, ?, datetime('now'))
""", (event_id, template_id, inst, inputs))
(market_event_id, template_id, instrument, inputs_json, prediction_json, analyzed_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
""", (event_id, template_id, inst, inputs_json, prediction_json))
def _build_title(currency: str, signal: float, meeting_date: str) -> str:

View File

@@ -1213,6 +1213,117 @@ const PERIODS = [
{ key: '5y', label: '5Y' },
]
// ── Types factor-state ────────────────────────────────────────────────────────
interface FactorContrib {
event_name: string; event_date: string; template_name: string
pips_full: number; days_elapsed: number; absorption_days: number
decay_pct: number; pips_current: number
}
interface FactorCategory {
label: string; pips: number; contributions: FactorContrib[]
}
interface FactorState {
instrument: string; at_date: string; net_pips: number
direction: 'bullish' | 'bearish' | 'neutral'
categories: FactorCategory[]; n_events: number
}
// ── PressureCockpit ───────────────────────────────────────────────────────────
function PressureCockpit({ instrumentId, refreshKey }: { instrumentId: string; refreshKey: number }) {
const [state, setState] = useState<FactorState | null>(null)
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
useEffect(() => {
if (!instrumentId) return
setLoading(true)
api.get(`/instruments/${instrumentId}/factor-state`)
.then(r => setState(r.data))
.catch(() => setState(null))
.finally(() => setLoading(false))
}, [instrumentId, refreshKey])
if (loading) return (
<div className="h-16 flex items-center justify-center text-xs text-slate-600 italic">
Calcul pression en cours
</div>
)
if (!state) return null
const { net_pips, direction, categories } = state
const maxAbs = Math.max(...categories.map(c => Math.abs(c.pips)), 1)
const netCls = direction === 'bullish' ? 'text-emerald-400' : direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
const netLabel = direction === 'bullish' ? '▲ HAUSSIER' : direction === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE'
return (
<div className="space-y-2">
{/* NET */}
<div className="flex items-center gap-3 px-1">
<span className="text-xs text-slate-500 uppercase tracking-wide">Pression nette</span>
<span className={clsx('font-mono font-bold text-base', netCls)}>
{net_pips >= 0 ? '+' : ''}{net_pips} pips
</span>
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded border', netCls,
direction === 'bullish' ? 'border-emerald-700/40 bg-emerald-900/20'
: direction === 'bearish' ? 'border-red-700/40 bg-red-900/20'
: 'border-slate-700/40 bg-slate-800/20')}>
{netLabel}
</span>
<span className="ml-auto text-[10px] text-slate-600">{state.n_events} event{state.n_events > 1 ? 's' : ''} actifs</span>
</div>
{/* Barres par catégorie */}
{categories.length === 0 ? (
<p className="text-xs text-slate-600 italic px-1">Aucun event actif avec prédiction pour cet instrument.</p>
) : (
<div className="space-y-1.5">
{categories.map(cat => {
const barW = Math.round(Math.abs(cat.pips) / maxAbs * 100)
const isPos = cat.pips >= 0
const isOpen = expanded === cat.label
return (
<div key={cat.label}>
<button
className="w-full flex items-center gap-2 text-xs hover:bg-slate-800/30 rounded px-1 py-0.5 transition-colors"
onClick={() => setExpanded(isOpen ? null : cat.label)}
>
<span className="w-32 text-left text-slate-400 shrink-0 truncate">{cat.label}</span>
{/* Barre */}
<div className="flex-1 h-3 bg-slate-800/60 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', isPos ? 'bg-emerald-600/70' : 'bg-red-600/70')}
style={{ width: `${barW}%` }}
/>
</div>
<span className={clsx('font-mono font-semibold w-16 text-right shrink-0', isPos ? 'text-emerald-400' : 'text-red-400')}>
{isPos ? '+' : ''}{cat.pips}
</span>
<span className="text-slate-600 text-[10px] shrink-0">{isOpen ? '▲' : '▼'}</span>
</button>
{/* Détail events */}
{isOpen && (
<div className="ml-2 mt-1 space-y-1 border-l border-slate-700/40 pl-3">
{cat.contributions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-[10px] text-slate-500">
<span className="flex-1 truncate">{c.event_name}</span>
<span className="text-slate-600 shrink-0">{c.event_date} · {c.decay_pct}% actif ({c.days_elapsed}j)</span>
<span className={clsx('font-mono shrink-0 w-12 text-right', c.pips_current >= 0 ? 'text-emerald-500' : 'text-red-500')}>
{c.pips_current >= 0 ? '+' : ''}{c.pips_current}
</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) {
const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -1600,6 +1711,14 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
: null
return (
<div className="space-y-3">
{/* Pression nette actuelle */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Pression Nette</span>
</div>
<PressureCockpit instrumentId={instrumentId} refreshKey={chartReady} />
</div>
{/* Frise des graphes causaux (inclut l'event) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">