feat: Sentiment desk — gauge threshold alerts (Option B)
Each macro gauge selected in the Sentiment desk can now be independently configured with three alert criteria: - Seuil bas (≤): alert when value crosses below - Seuil haut (≥): alert when value crosses above - Variation % (Δ%): alert when N-day % change exceeds threshold Frontend: GaugeThresholdConfig component — one row per selected gauge, compact grid layout with enable toggle + 3 numeric inputs. Stored in config.gauge_thresholds[gauge_id]. Backend: _check_sentiment() extended — after CBOE signals, reads macro_gauge_snapshots history, checks each enabled gauge threshold, emits sentiment market_events with options_note for each breach. Gauge → affected_assets mapping covers all 32 gauge keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -972,6 +972,123 @@ def _check_sentiment(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
["VXX","SPY"],
|
["VXX","SPY"],
|
||||||
"Skew bas → acheter protection bon marché (puts OTM relativement peu chers).")
|
"Skew bas → acheter protection bon marché (puts OTM relativement peu chers).")
|
||||||
|
|
||||||
|
# ── Custom gauge threshold alerts ────────────────────────────────────────────
|
||||||
|
gauge_thresholds = desk_cfg.get("gauge_thresholds", {})
|
||||||
|
selected_gauges = desk_cfg.get("_instruments") or []
|
||||||
|
|
||||||
|
if gauge_thresholds and selected_gauges:
|
||||||
|
from services.database import get_macro_gauge_history
|
||||||
|
from services.data_fetcher import MACRO_GAUGE_CONFIG
|
||||||
|
gauge_label_map = {gid: label for gid, label, _, _, _ in MACRO_GAUGE_CONFIG}
|
||||||
|
gauge_label_map.update({
|
||||||
|
"slope_10y3m": "Slope 10Y-3M",
|
||||||
|
"gold_copper_ratio": "Ratio Or/Cuivre",
|
||||||
|
"spx_vs_200d": "SPX vs MA 200j",
|
||||||
|
})
|
||||||
|
_gauge_assets: Dict[str, List[str]] = {
|
||||||
|
"dxy": ["GLD", "EEM", "EURUSD=X"],
|
||||||
|
"us10y": ["TLT", "IEF", "SPY"],
|
||||||
|
"us3m": ["TLT", "IEF"],
|
||||||
|
"tips": ["TLT", "GLD"],
|
||||||
|
"tlt": ["TLT", "IEF", "SPY"],
|
||||||
|
"vix": ["VXX", "SPY", "QQQ"],
|
||||||
|
"hyg": ["HYG", "LQD", "SPY"],
|
||||||
|
"lqd": ["LQD", "HYG", "TLT"],
|
||||||
|
"ief": ["IEF", "TLT"],
|
||||||
|
"brent": ["USO", "XOM"],
|
||||||
|
"ng": ["UNG", "XOM"],
|
||||||
|
"gold": ["GLD", "SLV"],
|
||||||
|
"silver": ["SLV", "GLD"],
|
||||||
|
"copper": ["XLI", "EEM"],
|
||||||
|
"spx": ["SPY", "QQQ"],
|
||||||
|
"iwm": ["IWM", "SPY"],
|
||||||
|
"xli": ["XLI", "SPY"],
|
||||||
|
"xlk": ["XLK", "QQQ"],
|
||||||
|
"xlf": ["XLF", "SPY"],
|
||||||
|
"xlp": ["XLP", "SPY"],
|
||||||
|
"xlu": ["XLU", "SPY"],
|
||||||
|
"vvix": ["VXX", "SPY"],
|
||||||
|
"skew": ["SPY", "QQQ", "TLT"],
|
||||||
|
"ovx": ["USO", "XOM"],
|
||||||
|
"gvz": ["GLD", "SLV"],
|
||||||
|
"eem": ["EEM", "EFA"],
|
||||||
|
"emb": ["EMB", "EEM"],
|
||||||
|
"fxi": ["FXI", "EEM"],
|
||||||
|
"usdjpy": ["USDJPY=X", "GLD"],
|
||||||
|
"slope_10y3m": ["TLT", "SPY", "HYG"],
|
||||||
|
"gold_copper_ratio":["GLD", "EEM"],
|
||||||
|
"spx_vs_200d": ["SPY", "QQQ", "VXX"],
|
||||||
|
}
|
||||||
|
|
||||||
|
history = get_macro_gauge_history(days=lookback_days + 2)
|
||||||
|
if len(history) >= 1:
|
||||||
|
latest_snap = history[0]
|
||||||
|
latest_gauges = latest_snap.get("gauges", {})
|
||||||
|
latest_date = latest_snap["snapshot_date"]
|
||||||
|
oldest_gauges = history[-1].get("gauges", {}) if len(history) > 1 else {}
|
||||||
|
|
||||||
|
for gauge_id in selected_gauges:
|
||||||
|
cfg_g = gauge_thresholds.get(gauge_id, {})
|
||||||
|
if not cfg_g.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
gauge_data = latest_gauges.get(gauge_id, {})
|
||||||
|
value = gauge_data.get("value")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
value = float(value)
|
||||||
|
|
||||||
|
label = gauge_label_map.get(gauge_id, gauge_id)
|
||||||
|
assets = _gauge_assets.get(gauge_id, [])
|
||||||
|
old_data = oldest_gauges.get(gauge_id, {})
|
||||||
|
old_value = old_data.get("value")
|
||||||
|
old_value = float(old_value) if old_value is not None else None
|
||||||
|
|
||||||
|
low_thr = cfg_g.get("low_threshold")
|
||||||
|
high_thr = cfg_g.get("high_threshold")
|
||||||
|
chg_thr = cfg_g.get("change_pct_threshold")
|
||||||
|
|
||||||
|
# High threshold crossing (old below, now at or above)
|
||||||
|
if high_thr is not None:
|
||||||
|
high_thr = float(high_thr)
|
||||||
|
crossed = (old_value is not None and old_value < high_thr <= value)
|
||||||
|
at_level = (old_value is None and value >= high_thr)
|
||||||
|
if crossed or at_level:
|
||||||
|
name = f"{label} franchit {high_thr:.2g} à la hausse ({latest_date[:7]})"
|
||||||
|
prev_str = f" (précédent: {old_value:.2g})" if old_value is not None else ""
|
||||||
|
_emit(name, latest_date, "bearish", f"{gauge_id.upper()} High",
|
||||||
|
0.65,
|
||||||
|
f"{label} dépasse le seuil haut {high_thr:.2g}{prev_str} → valeur: {value:.2g}.",
|
||||||
|
assets,
|
||||||
|
f"Niveau haut sur {label} — surveiller exposition options.")
|
||||||
|
|
||||||
|
# Low threshold crossing (old above, now at or below)
|
||||||
|
if low_thr is not None:
|
||||||
|
low_thr = float(low_thr)
|
||||||
|
crossed = (old_value is not None and old_value > low_thr >= value)
|
||||||
|
at_level = (old_value is None and value <= low_thr)
|
||||||
|
if crossed or at_level:
|
||||||
|
name = f"{label} passe sous {low_thr:.2g} ({latest_date[:7]})"
|
||||||
|
prev_str = f" (précédent: {old_value:.2g})" if old_value is not None else ""
|
||||||
|
_emit(name, latest_date, "bullish", f"{gauge_id.upper()} Low",
|
||||||
|
0.65,
|
||||||
|
f"{label} passe sous le seuil bas {low_thr:.2g}{prev_str} → valeur: {value:.2g}.",
|
||||||
|
assets,
|
||||||
|
f"Niveau bas sur {label} — opportunité ou signal de retournement.")
|
||||||
|
|
||||||
|
# Change % threshold (absolute value)
|
||||||
|
if chg_thr is not None and old_value and old_value > 0:
|
||||||
|
pct_chg = (value - old_value) / old_value * 100
|
||||||
|
if abs(pct_chg) >= abs(float(chg_thr)):
|
||||||
|
sign = "+" if pct_chg > 0 else ""
|
||||||
|
direction = "bullish" if pct_chg > 0 else "bearish"
|
||||||
|
name = f"{label} variation {sign}{pct_chg:.1f}% ({latest_date[:7]})"
|
||||||
|
_emit(name, latest_date, direction, f"{gauge_id.upper()} Move",
|
||||||
|
min(0.80, 0.45 + abs(pct_chg) * 0.02),
|
||||||
|
f"{label} {sign}{pct_chg:.1f}% sur la période ({old_value:.2g} → {value:.2g}). Mouvement significatif.",
|
||||||
|
assets,
|
||||||
|
f"Mouvement {sign}{pct_chg:.1f}% sur {label} — ajuster stratégie de vol.")
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -455,6 +455,89 @@ function FundamentalConfig({
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function GaugeThresholdConfig({
|
||||||
|
selectedGauges,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
selectedGauges: string[]
|
||||||
|
config: Record<string, any>
|
||||||
|
onChange: (c: Record<string, any>) => void
|
||||||
|
}) {
|
||||||
|
if (!selectedGauges.length) return null
|
||||||
|
|
||||||
|
const thresholds: Record<string, any> = config.gauge_thresholds ?? {}
|
||||||
|
|
||||||
|
const update = (gaugeId: string, field: string, val: any) => {
|
||||||
|
const current = thresholds[gaugeId] ?? { enabled: false }
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
gauge_thresholds: { ...thresholds, [gaugeId]: { ...current, [field]: val } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGauge = (id: string) => MACRO_GAUGE_LIST.find(g => g.id === id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs text-slate-500 mb-2 flex items-center gap-2">
|
||||||
|
<span>Alertes par compteur macro sélectionné</span>
|
||||||
|
<span className="text-slate-700">— laisser vide pour désactiver le critère</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 gap-y-1 items-center text-[10px] text-slate-600 px-1 mb-1">
|
||||||
|
<span />
|
||||||
|
<span>Compteur</span>
|
||||||
|
<span className="text-center w-20">≤ Seuil bas</span>
|
||||||
|
<span className="text-center w-20">≥ Seuil haut</span>
|
||||||
|
<span className="text-center w-16">Δ% alerte</span>
|
||||||
|
</div>
|
||||||
|
{selectedGauges.map(gaugeId => {
|
||||||
|
const gauge = getGauge(gaugeId)
|
||||||
|
const cfg_g = thresholds[gaugeId] ?? { enabled: false }
|
||||||
|
const enabled = cfg_g.enabled ?? false
|
||||||
|
return (
|
||||||
|
<div key={gaugeId} className={clsx(
|
||||||
|
'grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-3 py-2 rounded-lg border transition-colors',
|
||||||
|
enabled ? 'border-rose-700/40 bg-rose-900/10' : 'border-slate-700/30 bg-dark-800/60',
|
||||||
|
)}>
|
||||||
|
<button onClick={() => update(gaugeId, 'enabled', !enabled)} className="shrink-0">
|
||||||
|
{enabled
|
||||||
|
? <ToggleRight className="w-5 h-5 text-rose-400" />
|
||||||
|
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||||
|
</button>
|
||||||
|
<span className={clsx('text-sm', enabled ? 'text-white' : 'text-slate-500')}>
|
||||||
|
{gauge?.label ?? gaugeId}
|
||||||
|
<span className="ml-1.5 text-[10px] text-slate-600">{gauge?.bloc}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number" step="any" placeholder="—"
|
||||||
|
disabled={!enabled}
|
||||||
|
value={cfg_g.low_threshold ?? ''}
|
||||||
|
onChange={e => update(gaugeId, 'low_threshold', e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||||
|
className="w-20 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white placeholder-slate-700 disabled:opacity-30"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number" step="any" placeholder="—"
|
||||||
|
disabled={!enabled}
|
||||||
|
value={cfg_g.high_threshold ?? ''}
|
||||||
|
onChange={e => update(gaugeId, 'high_threshold', e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||||
|
className="w-20 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white placeholder-slate-700 disabled:opacity-30"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number" step="any" placeholder="—"
|
||||||
|
disabled={!enabled}
|
||||||
|
value={cfg_g.change_pct_threshold ?? ''}
|
||||||
|
onChange={e => update(gaugeId, 'change_pct_threshold', e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||||
|
className="w-16 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white placeholder-slate-700 disabled:opacity-30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function ReportConfig({
|
function ReportConfig({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -644,6 +727,18 @@ function DeskEditor({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gauge threshold alerts — Sentiment only */}
|
||||||
|
{d.type === 'sentiment' && d.instruments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-400 mb-2">Configuration des alertes par compteur</div>
|
||||||
|
<GaugeThresholdConfig
|
||||||
|
selectedGauges={d.instruments}
|
||||||
|
config={d.config}
|
||||||
|
onChange={c => set('config', c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700/30">
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700/30">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user