feat: pressure
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user