From 50ba75e468b5269eb8b978ae998c66398ba19fac Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 20 Jun 2026 16:41:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20indicateurs=20t?= =?UTF-8?q?echniques=20calibr=C3=A9s=20par=20horizon=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/config.py | 28 ++++ backend/services/ai_analyzer.py | 9 +- backend/services/auto_cycle.py | 32 +++++ backend/services/technical_indicators.py | 174 +++++++++++++++++++++++ frontend/src/hooks/useApi.ts | 19 +++ frontend/src/pages/Config.tsx | 97 ++++++++++++- 6 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 backend/services/technical_indicators.py diff --git a/backend/routers/config.py b/backend/routers/config.py index 56d0e24..0791fab 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -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"} diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index b949378..768d3a0 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -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} diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 22072f1..1766eaf 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -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")] diff --git a/backend/services/technical_indicators.py b/backend/services/technical_indicators.py new file mode 100644 index 0000000..a1b4ef4 --- /dev/null +++ b/backend/services/technical_indicators.py @@ -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", "") diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index e5b24f9..4944882 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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'], diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 2465a84..f28aa45 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -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(['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'} + + {/* ── Indicateurs techniques du sous-jacent ── */} +
+
+

+ Indicateurs techniques du sous-jacent +

+ +
+

+ 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. +

+ +
+ {/* Indicateurs à activer */} +
+ +
+ {[ + { 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 ( + + ) + })} +
+

Survolez pour voir la description de chaque indicateur.

+
+ + {/* Calibration auto */} +
+
+

Calibration automatique selon horizon

+

≤30j → RSI14, MA20/50 | ≤90j → RSI21, MA50/100 | >90j → RSI28, MA100/200

+
+ +
+
+ + +
)}