313 lines
12 KiB
Python
313 lines
12 KiB
Python
# ============================================================
|
|
# macro_features.py — Features Macro : DXY, Taux 10 ans, Sessions
|
|
# ============================================================
|
|
# Données récupérées via yfinance (Yahoo Finance) — gratuit, sans API key
|
|
# Mise à jour automatique toutes les heures en live
|
|
# ============================================================
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
import numpy as np
|
|
import pandas as pd
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, Optional, Tuple
|
|
import pytz
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MacroFeaturesModule:
|
|
"""
|
|
Récupère et met à jour les données macro en temps réel :
|
|
|
|
1. DXY (Dollar Index) — Corrélation -0.85 avec l'or
|
|
2. US10Y (Taux 10 ans USA) — Taux réels vs or
|
|
3. Session de trading — Londres/NY/Asie/Hors-session
|
|
4. Jour de la semaine — Patterns hebdomadaires
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._data: Dict = {
|
|
# DXY
|
|
"dxy_price": 100.0, # Prix actuel DXY
|
|
"dxy_return_1d": 0.0, # Variation journalière DXY
|
|
"dxy_return_5d": 0.0, # Variation 5 jours DXY
|
|
"dxy_vs_sma20": 0.0, # DXY au-dessus/dessous SMA20
|
|
"dxy_rsi": 50.0, # RSI du DXY
|
|
|
|
# Taux 10 ans US
|
|
"us10y_rate": 4.0, # Taux en %
|
|
"us10y_change_1d": 0.0, # Variation journalière en bps
|
|
"us10y_change_5d": 0.0, # Variation 5 jours
|
|
"real_rate_proxy": 0.0, # Taux 10 ans - inflation proxy
|
|
|
|
# Session de trading
|
|
"session_asia": 0, # 1 si session Asie active
|
|
"session_london": 0, # 1 si session Londres active
|
|
"session_newyork": 0, # 1 si session New York active
|
|
"session_overlap": 0, # 1 si chevauchement Londres/NY
|
|
"hour_sin": 0.0, # Heure encodée cycliquement (sin)
|
|
"hour_cos": 1.0, # Heure encodée cycliquement (cos)
|
|
|
|
# Jour de la semaine
|
|
"day_monday": 0,
|
|
"day_tuesday": 0,
|
|
"day_wednesday": 0, # Souvent FOMC
|
|
"day_thursday": 0,
|
|
"day_friday": 0, # NFP + clôture positions
|
|
"day_sin": 0.0, # Jour encodé cycliquement
|
|
"day_cos": 1.0,
|
|
|
|
# Métadonnées
|
|
"last_update": None,
|
|
"data_available": False,
|
|
}
|
|
self._lock = threading.Lock()
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
# ── API Publique ───────────────────────────────────────────
|
|
|
|
def start(self):
|
|
"""Lance la mise à jour en arrière-plan (toutes les heures)."""
|
|
self._running = True
|
|
# Première mise à jour synchrone
|
|
self._update_all()
|
|
# Puis thread en arrière-plan
|
|
self._thread = threading.Thread(target=self._update_loop, daemon=True)
|
|
self._thread.start()
|
|
logger.info("MacroFeatures : module démarré.")
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
if self._thread:
|
|
self._thread.join(timeout=5)
|
|
|
|
def get_features(self) -> Dict:
|
|
"""Retourne toutes les features macro (thread-safe)."""
|
|
with self._lock:
|
|
# Toujours mettre à jour les features temps-réel (session/heure)
|
|
self._update_time_features_inplace()
|
|
return dict(self._data)
|
|
|
|
def get_feature_vector(self) -> np.ndarray:
|
|
"""
|
|
Retourne un vecteur numpy normalisé des features macro.
|
|
Utilisé directement comme input supplémentaire du réseau IA.
|
|
"""
|
|
d = self.get_features()
|
|
vector = np.array([
|
|
# DXY (normalisé autour de 0)
|
|
np.clip(d["dxy_return_1d"] * 100, -3, 3), # % var jour
|
|
np.clip(d["dxy_return_5d"] * 100, -5, 5), # % var 5j
|
|
np.clip(d["dxy_vs_sma20"] * 100, -5, 5), # vs SMA20
|
|
np.clip((d["dxy_rsi"] - 50) / 50, -1, 1), # RSI centré
|
|
|
|
# Taux 10 ans (normalisé)
|
|
np.clip(d["us10y_rate"] / 10, 0, 1), # Niveau absolu
|
|
np.clip(d["us10y_change_1d"] / 20, -1, 1), # Variation bps/j
|
|
np.clip(d["us10y_change_5d"] / 50, -1, 1), # Variation bps/5j
|
|
np.clip(d["real_rate_proxy"] / 5, -1, 1), # Taux réels proxy
|
|
|
|
# Sessions (binaires)
|
|
float(d["session_asia"]),
|
|
float(d["session_london"]),
|
|
float(d["session_newyork"]),
|
|
float(d["session_overlap"]),
|
|
|
|
# Encodage temporel cyclique
|
|
d["hour_sin"],
|
|
d["hour_cos"],
|
|
d["day_sin"],
|
|
d["day_cos"],
|
|
|
|
# Jours spéciaux
|
|
float(d["day_wednesday"]), # FOMC souvent mercredi
|
|
float(d["day_friday"]), # NFP + clôture
|
|
], dtype=np.float32)
|
|
|
|
return np.clip(vector, -3, 3)
|
|
|
|
def get_feature_names(self) -> list:
|
|
"""Noms des features dans l'ordre du vecteur."""
|
|
return [
|
|
"dxy_return_1d", "dxy_return_5d", "dxy_vs_sma20", "dxy_rsi",
|
|
"us10y_rate", "us10y_change_1d", "us10y_change_5d", "real_rate_proxy",
|
|
"session_asia", "session_london", "session_newyork", "session_overlap",
|
|
"hour_sin", "hour_cos", "day_sin", "day_cos",
|
|
"day_wednesday", "day_friday",
|
|
]
|
|
|
|
# ── Mises à jour ───────────────────────────────────────────
|
|
|
|
def _update_loop(self):
|
|
"""Met à jour les données toutes les heures."""
|
|
while self._running:
|
|
time.sleep(3600) # 1 heure
|
|
try:
|
|
self._update_all()
|
|
except Exception as e:
|
|
logger.error(f"MacroFeatures update error: {e}")
|
|
|
|
def _update_all(self):
|
|
"""Récupère DXY + Taux 10 ans via yfinance."""
|
|
try:
|
|
import yfinance as yf
|
|
except ImportError:
|
|
logger.warning("yfinance non installé. pip install yfinance")
|
|
logger.warning("Utilisation des valeurs par défaut pour les features macro.")
|
|
with self._lock:
|
|
self._update_time_features_inplace()
|
|
return
|
|
|
|
dxy_ok = self._fetch_dxy(yf)
|
|
rates_ok = self._fetch_us10y(yf)
|
|
|
|
with self._lock:
|
|
self._update_time_features_inplace()
|
|
self._data["data_available"] = dxy_ok or rates_ok
|
|
self._data["last_update"] = datetime.utcnow()
|
|
|
|
logger.info(
|
|
f"MacroFeatures mis a jour | "
|
|
f"DXY={self._data['dxy_price']:.2f} | "
|
|
f"US10Y={self._data['us10y_rate']:.2f}% | "
|
|
f"Session={self._get_session_name()}"
|
|
)
|
|
|
|
def _fetch_dxy(self, yf) -> bool:
|
|
"""Récupère les données du Dollar Index (DX-Y.NYB)."""
|
|
try:
|
|
ticker = yf.Ticker("DX-Y.NYB")
|
|
hist = ticker.history(period="30d", interval="1d")
|
|
|
|
if hist.empty or len(hist) < 5:
|
|
logger.warning("DXY: données insuffisantes")
|
|
return False
|
|
|
|
closes = hist["Close"].values
|
|
price = float(closes[-1])
|
|
sma20 = float(np.mean(closes[-20:])) if len(closes) >= 20 else price
|
|
ret_1d = (closes[-1] / closes[-2] - 1) if len(closes) >= 2 else 0.0
|
|
ret_5d = (closes[-1] / closes[-5] - 1) if len(closes) >= 5 else 0.0
|
|
|
|
# RSI du DXY
|
|
delta = np.diff(closes[-15:])
|
|
gain = np.mean(delta[delta > 0]) if any(delta > 0) else 0
|
|
loss = abs(np.mean(delta[delta < 0])) if any(delta < 0) else 1e-9
|
|
rsi = 100 - (100 / (1 + gain / loss))
|
|
|
|
with self._lock:
|
|
self._data.update({
|
|
"dxy_price": price,
|
|
"dxy_return_1d": float(ret_1d),
|
|
"dxy_return_5d": float(ret_5d),
|
|
"dxy_vs_sma20": float((price - sma20) / sma20),
|
|
"dxy_rsi": float(rsi),
|
|
})
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.debug(f"DXY fetch error: {e}")
|
|
return False
|
|
|
|
def _fetch_us10y(self, yf) -> bool:
|
|
"""Récupère le taux des obligations US 10 ans (^TNX)."""
|
|
try:
|
|
ticker = yf.Ticker("^TNX")
|
|
hist = ticker.history(period="30d", interval="1d")
|
|
|
|
if hist.empty or len(hist) < 2:
|
|
logger.warning("US10Y: données insuffisantes")
|
|
return False
|
|
|
|
closes = hist["Close"].values
|
|
rate = float(closes[-1]) # En %
|
|
change_1d = float(closes[-1] - closes[-2]) * 100 # En bps
|
|
change_5d = float(closes[-1] - closes[-5]) * 100 if len(closes) >= 5 else 0.0
|
|
|
|
# Proxy taux réels = taux 10 ans - inflation estimée (CPI ~3%)
|
|
inflation_proxy = 3.0
|
|
real_rate = rate - inflation_proxy
|
|
|
|
with self._lock:
|
|
self._data.update({
|
|
"us10y_rate": rate,
|
|
"us10y_change_1d": change_1d,
|
|
"us10y_change_5d": change_5d,
|
|
"real_rate_proxy": real_rate,
|
|
})
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.debug(f"US10Y fetch error: {e}")
|
|
return False
|
|
|
|
def _update_time_features_inplace(self):
|
|
"""Met à jour les features temporelles (appelé sans lock)."""
|
|
now_utc = datetime.now(timezone.utc)
|
|
hour = now_utc.hour + now_utc.minute / 60.0
|
|
weekday = now_utc.weekday() # 0=lundi, 6=dimanche
|
|
|
|
# ── Sessions de trading (heures UTC) ──────────────────
|
|
# Asie : 00:00 - 08:00 UTC
|
|
# Londres : 08:00 - 17:00 UTC
|
|
# New York: 13:00 - 22:00 UTC
|
|
# Overlap : 13:00 - 17:00 UTC (Londres + NY)
|
|
|
|
asia_open = 0.0 <= hour < 8.0
|
|
london_open = 8.0 <= hour < 17.0
|
|
ny_open = 13.0 <= hour < 22.0
|
|
overlap = 13.0 <= hour < 17.0
|
|
|
|
# Week-end = marchés fermés
|
|
is_weekend = weekday >= 5
|
|
if is_weekend:
|
|
asia_open = london_open = ny_open = overlap = False
|
|
|
|
# ── Encodage cyclique heure (sin/cos) ──────────────────
|
|
hour_rad = (hour / 24.0) * 2 * np.pi
|
|
hour_sin = float(np.sin(hour_rad))
|
|
hour_cos = float(np.cos(hour_rad))
|
|
|
|
# ── Encodage cyclique jour semaine ─────────────────────
|
|
day_rad = (weekday / 7.0) * 2 * np.pi
|
|
day_sin = float(np.sin(day_rad))
|
|
day_cos = float(np.cos(day_rad))
|
|
|
|
self._data.update({
|
|
"session_asia": int(asia_open),
|
|
"session_london": int(london_open),
|
|
"session_newyork": int(ny_open),
|
|
"session_overlap": int(overlap),
|
|
"hour_sin": hour_sin,
|
|
"hour_cos": hour_cos,
|
|
"day_monday": int(weekday == 0),
|
|
"day_tuesday": int(weekday == 1),
|
|
"day_wednesday": int(weekday == 2),
|
|
"day_thursday": int(weekday == 3),
|
|
"day_friday": int(weekday == 4),
|
|
"day_sin": day_sin,
|
|
"day_cos": day_cos,
|
|
})
|
|
|
|
def _get_session_name(self) -> str:
|
|
"""Retourne le nom de la session active."""
|
|
d = self._data
|
|
if d["session_overlap"]: return "OVERLAP London/NY"
|
|
if d["session_newyork"]: return "NEW YORK"
|
|
if d["session_london"]: return "LONDRES"
|
|
if d["session_asia"]: return "ASIE"
|
|
return "HORS SESSION"
|
|
|
|
def get_dashboard_string(self) -> str:
|
|
"""Résumé pour le dashboard."""
|
|
d = self.get_features()
|
|
dxy_arrow = "↑" if d["dxy_return_1d"] > 0 else "↓"
|
|
us10y_arrow = "↑" if d["us10y_change_1d"] > 0 else "↓"
|
|
return (
|
|
f"DXY={d['dxy_price']:.2f}{dxy_arrow} | "
|
|
f"US10Y={d['us10y_rate']:.2f}%{us10y_arrow} | "
|
|
f"Session={self._get_session_name()}"
|
|
) |