feat: Phase 3 — indicateurs techniques calibrés par horizon option

- technical_indicators.py (nouveau) : compute_indicators() calcule RSI, MA fast/slow,
  Bollinger Bands, ATR — périodes calibrées automatiquement selon horizon_days
- config.py : endpoints GET/PUT /config/tech-indicators (activé, liste, auto-calibration)
- useApi.ts : useTechIndicatorsConfig + useSaveTechIndicatorsConfig hooks
- Config.tsx : carte "Indicateurs techniques" dans Options—Paramètres avec toggles
- auto_cycle.py : compute top-5 tickers à chaque cycle si tech_indicators_enabled=true
- ai_analyzer.py : tech_indicators_block injecté dans suggestion + scoring prompts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 16:41:42 +02:00
parent d5e31bc897
commit 50ba75e468
6 changed files with 357 additions and 2 deletions

View File

@@ -119,3 +119,31 @@ def save_options_gate(req: OptionsGateRequest):
if req.iv_gate_skew_threshold is not None:
set_config("iv_gate_skew_threshold", str(req.iv_gate_skew_threshold))
return {"status": "ok"}
# ── Tech Indicators config ──────────────────────────────────────────────────
class TechIndicatorsRequest(BaseModel):
tech_indicators_enabled: Optional[bool] = None
tech_indicators_list: Optional[str] = None # comma-separated: "rsi,ma,bollinger,atr"
tech_indicators_auto_calibrate: Optional[bool] = None
@router.get("/tech-indicators")
def get_tech_indicators():
return {
"tech_indicators_enabled": (get_config("tech_indicators_enabled") or "true").lower() == "true",
"tech_indicators_list": get_config("tech_indicators_list") or "rsi,ma,bollinger,atr",
"tech_indicators_auto_calibrate": (get_config("tech_indicators_auto_calibrate") or "true").lower() == "true",
}
@router.put("/tech-indicators")
def save_tech_indicators(req: TechIndicatorsRequest):
if req.tech_indicators_enabled is not None:
set_config("tech_indicators_enabled", "true" if req.tech_indicators_enabled else "false")
if req.tech_indicators_list is not None:
set_config("tech_indicators_list", req.tech_indicators_list)
if req.tech_indicators_auto_calibrate is not None:
set_config("tech_indicators_auto_calibrate", "true" if req.tech_indicators_auto_calibrate else "false")
return {"status": "ok"}

View File

@@ -353,6 +353,7 @@ def score_patterns_with_context(
iv_context: str = "",
risk_context: str = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
if not get_client():
@@ -571,11 +572,14 @@ Instructions de notation:
f"⚠️ Tiens compte du délai depuis le dernier cycle pour évaluer si les news sont déjà intégrées.\n"
)
_tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
user = f"""CONTEXTE GLOBAL:
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
- Top risques: {geo_score.get('top_risks', [])}
{temporal_section_sc}
{macro_section}
{_tech_sc_section}
TEMPLATE DE NOTATION:
{scoring_template}
@@ -964,6 +968,7 @@ def suggest_patterns_from_market_context(
reliability_map: Optional[Dict] = None,
iv_context: str = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
_cycle_meta = cycle_meta or {}
@@ -1109,12 +1114,14 @@ Règles supplémentaires:
"## Actualités géopolitiques du moment (triées par impact)\n" + news_block
)
tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
{temporal_news_block}
## Prix des marchés (variation J-1)
{market_block}
{tech_block_section}
## Calendrier économique à venir
{cal_block}

View File

@@ -376,12 +376,43 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
# Apply decay to news before suggestion (adds decayed_score + age_hours)
from services.ai_analyzer import apply_news_decay as _apply_decay
news = _apply_decay(news)
# ── Tech indicators for top tickers ──────────────────────────────
_tech_block = ""
try:
_ti_enabled = (get_config("tech_indicators_enabled") or "true").lower() == "true"
if _ti_enabled:
from services.technical_indicators import compute_indicators, format_indicators_for_prompt
_ti_list = [s.strip() for s in (get_config("tech_indicators_list") or "rsi,ma,bollinger,atr").split(",") if s.strip()]
_horizon_days = 45 # default mid-range if no trade horizon context yet
_ti_lines = []
# Pick up to 5 tickers from quotes (one per asset class)
_seen_tickers: set = set()
for _cls, _qs in quotes.items():
for _q in _qs[:1]:
_sym = _q.get("symbol", "")
if _sym and _sym not in _seen_tickers:
_seen_tickers.add(_sym)
_ind = compute_indicators(_sym, _horizon_days, enabled_indicators=_ti_list)
_blk = format_indicators_for_prompt(_ind)
if _blk:
_ti_lines.append(_blk)
if len(_seen_tickers) >= 5:
break
if len(_seen_tickers) >= 5:
break
if _ti_lines:
_tech_block = "\n".join(_ti_lines)
except Exception as _te:
logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}")
suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons,
reliability_map=_reliability_map or None,
iv_context=iv_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
)
except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}")
@@ -505,6 +536,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
iv_context=iv_context,
risk_context=risk_cluster_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]

View File

@@ -0,0 +1,174 @@
"""
Technical indicators computed from OHLCV data, calibrated to option horizon.
Horizon calibration:
<= 30 days : RSI(14), MA(20/50), BB(20), ATR(14)
<= 90 days : RSI(21), MA(50/100), BB(50), ATR(21)
> 90 days : RSI(28), MA(100/200),BB(100),ATR(28)
"""
from __future__ import annotations
import math
from typing import Dict, Optional
try:
import yfinance as yf
import pandas as pd
_YF_AVAILABLE = True
except ImportError:
_YF_AVAILABLE = False
def _calibration(horizon_days: int) -> dict:
if horizon_days <= 30:
return {"rsi": 14, "ma_fast": 20, "ma_slow": 50, "bb": 20, "atr": 14, "label": "Court terme"}
elif horizon_days <= 90:
return {"rsi": 21, "ma_fast": 50, "ma_slow": 100, "bb": 50, "atr": 21, "label": "Moyen terme"}
else:
return {"rsi": 28, "ma_fast": 100, "ma_slow": 200, "bb": 100, "atr": 28, "label": "Long terme"}
def _rsi(closes: "pd.Series", period: int) -> Optional[float]:
delta = closes.diff()
gain = delta.clip(lower=0).rolling(period).mean()
loss = (-delta.clip(upper=0)).rolling(period).mean()
rs = gain / loss.replace(0, float("nan"))
rsi_series = 100 - (100 / (1 + rs))
val = rsi_series.iloc[-1]
return float(val) if not math.isnan(val) else None
def _bollinger(closes: "pd.Series", period: int) -> dict:
ma = closes.rolling(period).mean()
std = closes.rolling(period).std()
upper = ma + 2 * std
lower = ma - 2 * std
price = closes.iloc[-1]
u, l, m = float(upper.iloc[-1]), float(lower.iloc[-1]), float(ma.iloc[-1])
band_width = u - l
bb_pct = ((price - l) / band_width * 100) if band_width > 0 else 50.0
return {"upper": round(u, 4), "lower": round(l, 4), "mid": round(m, 4), "bb_pct": round(bb_pct, 1)}
def _atr(df: "pd.DataFrame", period: int) -> Optional[float]:
high, low, close = df["High"], df["Low"], df["Close"]
prev_close = close.shift(1)
tr = pd.concat([
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
atr = tr.rolling(period).mean().iloc[-1]
return float(atr) if not math.isnan(atr) else None
def _trend_signal(price: float, ma_fast: float, ma_slow: float) -> str:
if price > ma_fast > ma_slow:
return "uptrend"
elif price < ma_fast < ma_slow:
return "downtrend"
elif ma_fast > ma_slow:
return "bullish_bias"
elif ma_fast < ma_slow:
return "bearish_bias"
return "sideways"
def _rsi_label(rsi_val: float) -> str:
if rsi_val >= 70:
return "SURACHETÉ ⚠"
elif rsi_val <= 30:
return "SURVENDU 🔥"
return "neutre"
def _bb_label(bb_pct: float) -> str:
if bb_pct >= 80:
return "proche bande haute — potentiel retournement"
elif bb_pct <= 20:
return "proche bande basse — potentiel rebond"
return "dans les bandes"
def compute_indicators(ticker: str, horizon_days: int, enabled_indicators: Optional[list] = None) -> dict:
"""
Compute technical indicators for *ticker* calibrated to *horizon_days*.
Returns a dict with computed values + a pre-formatted prompt_block string.
Returns {"error": "..."} if data unavailable.
"""
if not _YF_AVAILABLE:
return {"error": "yfinance not installed"}
cal = _calibration(horizon_days)
# Fetch enough history: need at least ma_slow + some buffer
lookback = cal["ma_slow"] * 2 + 50
try:
df = yf.download(ticker, period=f"{lookback}d", interval="1d", progress=False, auto_adjust=True)
except Exception as e:
return {"error": f"yfinance download failed: {e}"}
if df is None or len(df) < cal["ma_slow"]:
return {"error": f"Not enough data for {ticker} (got {len(df) if df is not None else 0} rows)"}
closes = df["Close"].dropna()
price = float(closes.iloc[-1])
enabled = set(enabled_indicators) if enabled_indicators else {"rsi", "ma", "bollinger", "atr"}
result: dict = {
"ticker": ticker,
"horizon_days": horizon_days,
"calibration": cal["label"],
"price": round(price, 4),
"periods": cal,
}
if "rsi" in enabled:
rsi_val = _rsi(closes, cal["rsi"])
result["rsi"] = round(rsi_val, 1) if rsi_val is not None else None
result["rsi_label"] = _rsi_label(rsi_val) if rsi_val is not None else "N/A"
ma_fast_val = ma_slow_val = None
if "ma" in enabled:
if len(closes) >= cal["ma_fast"]:
ma_fast_val = float(closes.rolling(cal["ma_fast"]).mean().iloc[-1])
result["ma_fast"] = round(ma_fast_val, 4)
if len(closes) >= cal["ma_slow"]:
ma_slow_val = float(closes.rolling(cal["ma_slow"]).mean().iloc[-1])
result["ma_slow"] = round(ma_slow_val, 4)
if ma_fast_val and ma_slow_val:
result["trend"] = _trend_signal(price, ma_fast_val, ma_slow_val)
if "bollinger" in enabled and len(closes) >= cal["bb"]:
bb = _bollinger(closes, cal["bb"])
result["bollinger"] = bb
result["bb_label"] = _bb_label(bb["bb_pct"])
if "atr" in enabled and len(df) >= cal["atr"]:
atr_val = _atr(df, cal["atr"])
if atr_val is not None:
result["atr"] = round(atr_val, 4)
result["atr_pct"] = round(atr_val / price * 100, 2)
# Build a human-readable prompt block
lines = [f"📊 INDICATEURS TECHNIQUES — {ticker} (horizon {horizon_days}j, calibration {cal['label']}) :"]
if "rsi" in result:
lines.append(f" - RSI({cal['rsi']}): {result['rsi']}{result['rsi_label']}")
if "ma_fast" in result and "ma_slow" in result:
above = "au-dessus ▲" if price > result["ma_slow"] else "en-dessous ▼"
lines.append(f" - MA{cal['ma_fast']}/{cal['ma_slow']}: prix {above} MA{cal['ma_slow']} → trend {result.get('trend','N/A')}")
if "bollinger" in result:
bb = result["bollinger"]
lines.append(f" - Bollinger({cal['bb']}): prix à {bb['bb_pct']}% des bandes → {result['bb_label']}")
if "atr" in result:
lines.append(f" - ATR({cal['atr']}): {result['atr']} ({result['atr_pct']}% du prix)")
result["prompt_block"] = "\n".join(lines)
return result
def format_indicators_for_prompt(indicators: dict) -> str:
"""Return the pre-formatted prompt block, or empty string on error."""
if "error" in indicators:
return ""
return indicators.get("prompt_block", "")

View File

@@ -491,6 +491,25 @@ export const useSaveOptionsGate = () => {
})
}
export const useTechIndicatorsConfig = () =>
useQuery({
queryKey: ['tech-indicators-config'],
queryFn: () => api.get('/config/tech-indicators').then(r => r.data),
staleTime: 60_000,
})
export const useSaveTechIndicatorsConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: {
tech_indicators_enabled?: boolean
tech_indicators_list?: string
tech_indicators_auto_calibrate?: boolean
}) => api.put('/config/tech-indicators', body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tech-indicators-config'] }),
})
}
export const useSimPortfolioRisk = () =>
useQuery({
queryKey: ['journal-portfolio-risk'],

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate } from '../hooks/useApi'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert } from 'lucide-react'
import clsx from 'clsx'
@@ -361,6 +361,13 @@ export default function Config() {
const [ivGateExtreme, setIvGateExtreme] = useState(80)
const [ivGateSkew, setIvGateSkew] = useState(8)
// Tech indicators local state
const { data: techIndicatorsData } = useTechIndicatorsConfig()
const { mutate: saveTechIndicators, isPending: savingTechIndicators } = useSaveTechIndicatorsConfig()
const [techEnabled, setTechEnabled] = useState(true)
const [techAutoCalibrate, setTechAutoCalibrate] = useState(true)
const [techList, setTechList] = useState<string[]>(['rsi', 'ma', 'bollinger', 'atr'])
// Analysis config local state
const [analysisTopN, setAnalysisTopN] = useState(10)
const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all')
@@ -454,6 +461,15 @@ export default function Config() {
}
}, [optionsGateData])
useEffect(() => {
if (techIndicatorsData) {
setTechEnabled(techIndicatorsData.tech_indicators_enabled ?? true)
setTechAutoCalibrate(techIndicatorsData.tech_indicators_auto_calibrate ?? true)
const list = (techIndicatorsData.tech_indicators_list || 'rsi,ma,bollinger,atr').split(',').map((s: string) => s.trim()).filter(Boolean)
setTechList(list)
}
}, [techIndicatorsData])
const displaySources = localSources ?? sources ?? {}
const toggleSource = (key: string) => {
@@ -1275,6 +1291,85 @@ export default function Config() {
{savingExitDefaults ? 'Sauvegarde...' : 'Sauvegarder les seuils'}
</button>
</div>
{/* ── Indicateurs techniques du sous-jacent ── */}
<div className="card border-purple-700/30 bg-purple-900/5">
<div className="flex items-center justify-between mb-1">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-400" /> Indicateurs techniques du sous-jacent
</h2>
<button
onClick={() => setTechEnabled(!techEnabled)}
className={clsx('text-xs px-3 py-1 rounded border font-semibold transition-all', {
'bg-purple-600 border-purple-500 text-white': techEnabled,
'bg-dark-700 border-slate-700 text-slate-400': !techEnabled,
})}>
{techEnabled ? '✓ Activé' : '○ Désactivé'}
</button>
</div>
<p className="text-xs text-slate-500 mb-4">
Injecte RSI, MA, Bollinger et ATR dans le contexte IA à chaque cycle. Les périodes sont calibrées automatiquement selon l'horizon de l'option analysée.
</p>
<div className={clsx('space-y-4 transition-opacity', !techEnabled && 'opacity-40 pointer-events-none')}>
{/* Indicateurs à activer */}
<div>
<label className="text-xs text-slate-400 font-medium block mb-2">Indicateurs actifs</label>
<div className="flex flex-wrap gap-2">
{[
{ key: 'rsi', label: 'RSI', desc: 'Momentum / surachat-survente' },
{ key: 'ma', label: 'MA fast/slow', desc: 'Tendance & golden/death cross' },
{ key: 'bollinger', label: 'Bollinger', desc: 'Position dans les bandes de vol' },
{ key: 'atr', label: 'ATR', desc: 'Volatilité réalisée récente' },
].map(({ key, label, desc }) => {
const active = techList.includes(key)
return (
<button
key={key}
title={desc}
onClick={() => setTechList(prev =>
active ? prev.filter(k => k !== key) : [...prev, key]
)}
className={clsx('text-xs px-3 py-1.5 rounded border font-semibold transition-all', {
'bg-purple-700 border-purple-500 text-white': active,
'bg-dark-700 border-slate-700 text-slate-400 hover:border-purple-600': !active,
})}>
{active ? '✓ ' : ''}{label}
</button>
)
})}
</div>
<p className="text-[10px] text-slate-600 mt-1.5">Survolez pour voir la description de chaque indicateur.</p>
</div>
{/* Calibration auto */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-300 font-medium">Calibration automatique selon horizon</p>
<p className="text-[10px] text-slate-500">30j RSI14, MA20/50 | 90j RSI21, MA50/100 | &gt;90j RSI28, MA100/200</p>
</div>
<button
onClick={() => setTechAutoCalibrate(!techAutoCalibrate)}
className={clsx('text-xs px-3 py-1 rounded border font-semibold transition-all flex-shrink-0 ml-4', {
'bg-purple-600 border-purple-500 text-white': techAutoCalibrate,
'bg-dark-700 border-slate-700 text-slate-400': !techAutoCalibrate,
})}>
{techAutoCalibrate ? '✓ Auto' : '○ Fixe'}
</button>
</div>
</div>
<button
onClick={() => saveTechIndicators(
{ tech_indicators_enabled: techEnabled, tech_indicators_list: techList.join(','), tech_indicators_auto_calibrate: techAutoCalibrate },
{ onSuccess: () => { setSavedMsg('Indicateurs techniques sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
disabled={savingTechIndicators}
className="mt-4 flex items-center gap-1.5 bg-purple-700 hover:bg-purple-600 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
<Save className="w-4 h-4" />
{savingTechIndicators ? 'Sauvegarde...' : 'Sauvegarder les indicateurs'}
</button>
</div>
</div>
)}