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 json
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, date as date_type
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
@@ -227,3 +227,166 @@ def get_theoretical_curve(
|
|||||||
result.append({"date": d, **entry})
|
result.append({"date": d, **entry})
|
||||||
|
|
||||||
return result
|
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):
|
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
|
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(
|
conn.execute(
|
||||||
"DELETE FROM causal_event_analyses WHERE market_event_id=?", (event_id,)
|
"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:
|
for inst in instruments:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO causal_event_analyses
|
INSERT INTO causal_event_analyses
|
||||||
(market_event_id, template_id, instrument, inputs_json, analyzed_at)
|
(market_event_id, template_id, instrument, inputs_json, prediction_json, analyzed_at)
|
||||||
VALUES (?, ?, ?, ?, datetime('now'))
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
""", (event_id, template_id, inst, inputs))
|
""", (event_id, template_id, inst, inputs_json, prediction_json))
|
||||||
|
|
||||||
|
|
||||||
def _build_title(currency: str, signal: float, meeting_date: str) -> str:
|
def _build_title(currency: str, signal: float, meeting_date: str) -> str:
|
||||||
|
|||||||
@@ -1213,6 +1213,117 @@ const PERIODS = [
|
|||||||
{ key: '5y', label: '5Y' },
|
{ 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 } = {}) {
|
export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) {
|
||||||
const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
|
const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -1600,6 +1711,14 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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) */}
|
{/* Frise des graphes causaux (inclut l'event) */}
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user