Initial commit — Trading AI Secure project complet

Architecture Docker (8 services), FastAPI, TimescaleDB, Redis, Streamlit.
Stratégies : scalping, intraday, swing. MLEngine + RegimeDetector (HMM).
BacktestEngine + WalkForwardAnalyzer + Optuna optimizer.
Routes API complètes dont /optimize async.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tika
2026-03-08 17:38:09 +00:00
commit da30ef19ed
111 changed files with 31723 additions and 0 deletions

19
src/core/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Module Core - Composants centraux de Trading AI Secure.
Ce module contient les composants fondamentaux du système:
- RiskManager: Gestion centralisée du risque (Singleton)
- StrategyEngine: Orchestration des stratégies de trading
- SafetyLayer: Circuit breakers et protections
- ConfigManager: Gestion de la configuration
Tous les autres modules dépendent de ces composants core.
"""
from src.core.risk_manager import RiskManager
from src.core.strategy_engine import StrategyEngine
__all__ = [
'RiskManager',
'StrategyEngine',
]

234
src/core/notifications.py Normal file
View File

@@ -0,0 +1,234 @@
"""
Notifications - Trading AI Secure.
Gère les alertes multi-canaux :
- Telegram (priorité haute, temps réel)
- Email (priorité moyenne, rapports)
Usage :
from src.core.notifications import notify
notify("Max drawdown atteint !", level="critical")
notify("Trade exécuté : EURUSD +0.5%", level="info")
"""
import asyncio
import logging
import os
import smtplib
from email.mime.text import MIMEText
from typing import Literal, Optional
import httpx
logger = logging.getLogger(__name__)
NotificationLevel = Literal["info", "success", "warning", "critical"]
_EMOJIS: dict[str, str] = {
"info": "",
"success": "",
"warning": "⚠️",
"critical": "🚨",
}
# =============================================================================
# Telegram
# =============================================================================
class TelegramNotifier:
"""
Envoie des messages via un bot Telegram.
Configuration (env vars) :
TELEGRAM_BOT_TOKEN : Token du bot (obtenu via @BotFather)
TELEGRAM_CHAT_ID : Chat ID du destinataire (user ou groupe)
"""
def __init__(self):
self.bot_token: str = os.environ.get("TELEGRAM_BOT_TOKEN", "")
self.chat_id: str = os.environ.get("TELEGRAM_CHAT_ID", "")
self.enabled: bool = bool(self.bot_token and self.chat_id)
if not self.enabled:
logger.debug("Telegram notifier disabled (TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID missing)")
async def send(self, message: str, level: NotificationLevel = "info") -> bool:
"""
Envoie un message Telegram (async).
Args:
message : Corps du message
level : Niveau (info | success | warning | critical)
Returns:
True si succès
"""
if not self.enabled:
return False
emoji = _EMOJIS.get(level, "")
full_msg = f"{emoji} *Trading AI Secure*\n\n{message}"
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": full_msg,
"parse_mode": "Markdown",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
return True
except Exception as exc:
logger.error(f"Telegram send failed: {exc}")
return False
def send_sync(self, message: str, level: NotificationLevel = "info") -> bool:
"""Wrapper synchrone (crée une boucle asyncio si nécessaire)."""
if not self.enabled:
return False
try:
loop = asyncio.get_running_loop()
# On est déjà dans une boucle — programmer comme tâche non bloquante
loop.create_task(self.send(message, level))
return True
except RuntimeError:
# Pas de boucle en cours — en créer une
return asyncio.run(self.send(message, level))
# =============================================================================
# Email
# =============================================================================
class EmailNotifier:
"""
Envoie des emails via SMTP.
Configuration (env vars) :
EMAIL_FROM : Adresse expéditeur
EMAIL_TO : Adresse destinataire
EMAIL_PASSWORD : Mot de passe SMTP
SMTP_SERVER : Serveur SMTP (défaut : smtp.gmail.com)
SMTP_PORT : Port SMTP (défaut : 587)
"""
def __init__(self):
self.from_email: str = os.environ.get("EMAIL_FROM", "")
self.to_email: str = os.environ.get("EMAIL_TO", "")
self.password: str = os.environ.get("EMAIL_PASSWORD", "")
self.smtp_server: str = os.environ.get("SMTP_SERVER", "smtp.gmail.com")
self.smtp_port: int = int(os.environ.get("SMTP_PORT", "587"))
self.enabled: bool = bool(self.from_email and self.to_email and self.password)
def send(self, subject: str, body: str) -> bool:
"""Envoie un email synchrone."""
if not self.enabled:
return False
msg = MIMEText(body)
msg["Subject"] = f"[Trading AI] {subject}"
msg["From"] = self.from_email
msg["To"] = self.to_email
try:
with smtplib.SMTP(self.smtp_server, self.smtp_port) as smtp:
smtp.starttls()
smtp.login(self.from_email, self.password)
smtp.send_message(msg)
return True
except Exception as exc:
logger.error(f"Email send failed: {exc}")
return False
# =============================================================================
# NotificationService (façade)
# =============================================================================
class NotificationService:
"""
Façade unique pour toutes les notifications.
Chaque niveau est routé selon la config :
- critical → Telegram + Email
- warning → Telegram
- info → log uniquement (ou Telegram si activé)
"""
def __init__(self):
self.telegram = TelegramNotifier()
self.email = EmailNotifier()
def notify(
self,
message: str,
level: NotificationLevel = "info",
channels: Optional[list[str]] = None,
) -> None:
"""
Envoie une notification sur les canaux appropriés.
Args:
message : Corps du message
level : Niveau de criticité
channels : Force des canaux spécifiques (["telegram", "email"])
Si None, routage automatique selon le niveau.
"""
logger.log(
logging.CRITICAL if level == "critical" else
logging.WARNING if level == "warning" else
logging.INFO,
f"[NOTIFICATION/{level.upper()}] {message}",
)
if channels is None:
channels = self._default_channels(level)
if "telegram" in channels:
self.telegram.send_sync(message, level)
if "email" in channels and level in ("critical", "warning"):
subject = f"{level.upper()}: {message[:80]}"
self.email.send(subject, message)
@staticmethod
def _default_channels(level: NotificationLevel) -> list[str]:
if level == "critical":
return ["telegram", "email"]
if level == "warning":
return ["telegram"]
return [] # info/success : log seulement (éviter le spam)
# =============================================================================
# Singleton global
# =============================================================================
_service: Optional[NotificationService] = None
def get_notification_service() -> NotificationService:
"""Retourne l'instance singleton du NotificationService."""
global _service
if _service is None:
_service = NotificationService()
return _service
def notify(
message: str,
level: NotificationLevel = "info",
channels: Optional[list[str]] = None,
) -> None:
"""
Fonction raccourci pour envoyer une notification.
Usage :
notify("Max drawdown atteint !", level="critical")
"""
get_notification_service().notify(message, level, channels)

603
src/core/risk_manager.py Normal file
View File

@@ -0,0 +1,603 @@
"""
Risk Manager - Gestion Centralisée du Risque (Singleton).
Ce module implémente le Risk Manager, composant central responsable de:
- Validation pré-trade de tous les ordres
- Monitoring des positions en temps réel
- Calcul des métriques de risque (VaR, CVaR, drawdown)
- Déclenchement des circuit breakers
- Gestion des limites de risque
Le Risk Manager utilise le pattern Singleton pour garantir une instance unique
et un état global cohérent à travers toute l'application.
"""
import threading
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime, timedelta
import numpy as np
import logging
logger = logging.getLogger(__name__)
# Import différé pour éviter les imports circulaires
def _get_notifier():
from src.core.notifications import get_notification_service
return get_notification_service()
@dataclass
class Position:
"""Représente une position ouverte."""
symbol: str
quantity: float
entry_price: float
current_price: float
stop_loss: float
take_profit: float
strategy: str
entry_time: datetime
unrealized_pnl: float
risk_amount: float
deal_id: Optional[str] = None
@dataclass
class RiskMetrics:
"""Métriques de risque en temps réel."""
total_risk: float
current_drawdown: float
daily_pnl: float
weekly_pnl: float
portfolio_var: float
portfolio_cvar: float
largest_position: float
num_positions: int
risk_utilization: float # % du risque max utilisé
class RiskManager:
"""
Risk Manager Central (Singleton).
Garantit:
- Une seule instance dans toute l'application
- État global cohérent
- Thread-safe pour accès concurrent
Responsabilités:
- Validation de tous les trades avant exécution
- Monitoring continu des positions
- Calcul des métriques de risque
- Déclenchement des circuit breakers
- Application des limites de risque
Usage:
risk_manager = RiskManager()
is_valid, error = risk_manager.validate_trade(...)
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""Implémentation du pattern Singleton thread-safe."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialise le Risk Manager (une seule fois)."""
if not hasattr(self, 'initialized'):
self.initialized = True
# Configuration
self.config = {}
# État du portfolio
self.positions: Dict[str, Position] = {}
self.portfolio_value: float = 100000.0 # Capital initial
self.peak_value: float = 100000.0
self.initial_capital: float = 100000.0
# Historique
self.daily_trades: List[Dict] = []
self.pnl_history: List[float] = []
self.drawdown_history: List[float] = []
self.equity_curve: List[float] = [100000.0]
# Circuit breakers
self.trading_halted: bool = False
self.halt_reason: Optional[str] = None
# Statistiques
self.total_trades: int = 0
self.winning_trades: int = 0
self.losing_trades: int = 0
logger.info("Risk Manager initialized (Singleton)")
def initialize(self, config: Dict):
"""
Configure le Risk Manager avec les paramètres.
Args:
config: Configuration des limites de risque
"""
self.config = config
self.initial_capital = config.get('initial_capital', 100000.0)
self.portfolio_value = self.initial_capital
self.peak_value = self.initial_capital
self.equity_curve = [self.initial_capital]
logger.info(f"Risk Manager configured with capital: ${self.initial_capital:,.2f}")
logger.info(f"Max portfolio risk: {config['global_limits']['max_portfolio_risk']:.1%}")
logger.info(f"Max drawdown: {config['global_limits']['max_drawdown']:.1%}")
def validate_trade(
self,
symbol: str,
quantity: float,
price: float,
stop_loss: float,
take_profit: float,
strategy: str
) -> Tuple[bool, Optional[str]]:
"""
Valide un trade avant exécution.
Effectue toutes les vérifications de risque:
1. Trading halted?
2. Stop-loss obligatoire
3. Risque par trade
4. Risque total portfolio
5. Taille position
6. Corrélation
7. Nombre de trades quotidiens
8. Risk/Reward ratio
9. Drawdown actuel
Args:
symbol: Symbole à trader
quantity: Quantité
price: Prix d'entrée
stop_loss: Niveau stop-loss
take_profit: Niveau take-profit
strategy: Nom de la stratégie
Returns:
(is_valid, error_message)
- is_valid: True si trade valide
- error_message: Message d'erreur si invalide
"""
# 1. Vérifier si trading halted
if self.trading_halted:
return False, f"Trading halted: {self.halt_reason}"
# 2. Vérifier stop-loss obligatoire
if stop_loss is None or stop_loss == 0:
return False, "Stop-loss is mandatory"
# 3. Calculer risque du trade
risk_amount = abs(price - stop_loss) * quantity
risk_pct = risk_amount / self.portfolio_value
# 4. Vérifier limites par trade
strategy_config = self.config.get('strategy_limits', {}).get(strategy, {})
max_risk_per_trade = strategy_config.get('risk_per_trade', 0.02)
if risk_pct > max_risk_per_trade:
return False, f"Risk per trade ({risk_pct:.2%}) exceeds limit ({max_risk_per_trade:.2%})"
# 5. Vérifier risque total portfolio
total_risk = self._calculate_total_risk() + risk_amount
max_portfolio_risk = self.config['global_limits']['max_portfolio_risk'] * self.portfolio_value
if total_risk > max_portfolio_risk:
return False, f"Total portfolio risk ({total_risk:.2f}) exceeds limit ({max_portfolio_risk:.2f})"
# 6. Vérifier taille position
position_value = price * quantity
position_pct = position_value / self.portfolio_value
max_position_size = self.config['global_limits']['max_position_size']
if position_pct > max_position_size:
return False, f"Position size ({position_pct:.2%}) exceeds limit ({max_position_size:.2%})"
# 7. Vérifier corrélation
if not self._check_correlation(symbol, strategy):
return False, "Correlation with existing positions too high"
# 8. Vérifier nombre de trades quotidiens
strategy_trades_today = len([
t for t in self.daily_trades
if t['strategy'] == strategy and t['time'].date() == datetime.now().date()
])
max_trades = strategy_config.get('max_trades_per_day', 100)
if strategy_trades_today >= max_trades:
return False, f"Max daily trades for {strategy} reached ({max_trades})"
# 9. Vérifier Risk/Reward ratio
risk = abs(price - stop_loss)
reward = abs(take_profit - price)
rr_ratio = reward / risk if risk > 0 else 0
if rr_ratio < 1.5:
return False, f"Risk/Reward ratio ({rr_ratio:.2f}) below minimum (1.5)"
# 10. Vérifier drawdown actuel
current_dd = self._calculate_current_drawdown()
max_dd = self.config['global_limits']['max_drawdown']
if current_dd >= max_dd:
return False, f"Max drawdown reached ({current_dd:.2%})"
# Toutes validations passées
logger.debug(f"Trade validated: {symbol} {quantity} @ {price}")
return True, None
def add_position(self, position: Position):
"""
Ajoute une position au portfolio.
Args:
position: Position à ajouter
"""
self.positions[position.symbol] = position
# Enregistrer trade
self.daily_trades.append({
'symbol': position.symbol,
'strategy': position.strategy,
'time': position.entry_time,
'risk': position.risk_amount,
'quantity': position.quantity,
'price': position.entry_price
})
self.total_trades += 1
logger.info(f"Position added: {position.symbol} ({position.strategy})")
def update_position(self, symbol: str, current_price: float):
"""
Met à jour le prix d'une position.
Args:
symbol: Symbole de la position
current_price: Prix actuel
"""
if symbol not in self.positions:
return
position = self.positions[symbol]
position.current_price = current_price
position.unrealized_pnl = (current_price - position.entry_price) * position.quantity
# Vérifier conditions de sortie
self._check_exit_conditions(position)
def close_position(self, symbol: str, exit_price: float, reason: str = 'manual') -> float:
"""
Ferme une position et retourne P&L.
Args:
symbol: Symbole de la position
exit_price: Prix de sortie
reason: Raison de la fermeture
Returns:
P&L de la position
"""
if symbol not in self.positions:
logger.warning(f"Attempted to close non-existent position: {symbol}")
return 0.0
position = self.positions[symbol]
pnl = (exit_price - position.entry_price) * position.quantity
# Mettre à jour portfolio
self.portfolio_value += pnl
self.pnl_history.append(pnl)
self.equity_curve.append(self.portfolio_value)
# Mettre à jour peak
if self.portfolio_value > self.peak_value:
self.peak_value = self.portfolio_value
# Statistiques
if pnl > 0:
self.winning_trades += 1
else:
self.losing_trades += 1
# Supprimer position
del self.positions[symbol]
logger.info(f"Position closed: {symbol} | P&L: ${pnl:.2f} | Reason: {reason}")
return pnl
def get_risk_metrics(self) -> RiskMetrics:
"""
Calcule et retourne les métriques de risque en temps réel.
Returns:
RiskMetrics avec toutes les métriques
"""
total_risk = self._calculate_total_risk()
max_portfolio_risk = self.config['global_limits']['max_portfolio_risk'] * self.portfolio_value
return RiskMetrics(
total_risk=total_risk,
current_drawdown=self._calculate_current_drawdown(),
daily_pnl=self._calculate_daily_pnl(),
weekly_pnl=self._calculate_weekly_pnl(),
portfolio_var=self._calculate_var(),
portfolio_cvar=self._calculate_cvar(),
largest_position=self._get_largest_position(),
num_positions=len(self.positions),
risk_utilization=total_risk / max_portfolio_risk if max_portfolio_risk > 0 else 0
)
def check_circuit_breakers(self):
"""
Vérifie toutes les conditions de circuit breakers.
Déclenche arrêt automatique si:
- Drawdown excessif
- Perte journalière excessive
- Volatilité extrême
- Autres conditions critiques
"""
# 1. Drawdown excessif
current_dd = self._calculate_current_drawdown()
max_dd = self.config['global_limits']['max_drawdown']
if current_dd >= max_dd:
self.halt_trading(f"Max drawdown reached: {current_dd:.2%}")
return
# 2. Perte journalière excessive
daily_pnl_pct = self._calculate_daily_pnl() / self.portfolio_value
max_daily_loss = self.config['global_limits']['max_daily_loss']
if daily_pnl_pct <= -max_daily_loss:
self.halt_trading(f"Max daily loss reached: {daily_pnl_pct:.2%}")
return
# 3. Volatilité extrême
if self._detect_volatility_spike():
self.halt_trading("Extreme volatility detected")
return
def halt_trading(self, reason: str):
"""
Arrête le trading immédiatement.
Args:
reason: Raison de l'arrêt
"""
self.trading_halted = True
self.halt_reason = reason
logger.critical(f"🚨 TRADING HALTED: {reason}")
self._send_emergency_alert(reason)
def resume_trading(self):
"""Reprend le trading (manuel uniquement)."""
self.trading_halted = False
self.halt_reason = None
logger.info("✅ Trading resumed")
# ========================================================================
# MÉTHODES PRIVÉES - CALCULS
# ========================================================================
def _calculate_total_risk(self) -> float:
"""Calcule le risque total du portfolio."""
return sum(pos.risk_amount for pos in self.positions.values())
def _calculate_current_drawdown(self) -> float:
"""Calcule le drawdown actuel."""
if self.peak_value == 0:
return 0.0
return (self.peak_value - self.portfolio_value) / self.peak_value
def _calculate_daily_pnl(self) -> float:
"""Calcule le P&L du jour."""
today = datetime.now().date()
# P&L réalisé aujourd'hui
daily_realized = sum(
pnl for pnl, trade in zip(self.pnl_history, self.daily_trades)
if trade['time'].date() == today
) if self.pnl_history else 0.0
# P&L non réalisé
unrealized = sum(pos.unrealized_pnl for pos in self.positions.values())
return daily_realized + unrealized
def _calculate_weekly_pnl(self) -> float:
"""Calcule le P&L réalisé + non-réalisé de la semaine en cours."""
now = datetime.now()
# Lundi de la semaine courante à minuit
week_start = (now - timedelta(days=now.weekday())).replace(
hour=0, minute=0, second=0, microsecond=0
)
# P&L réalisé cette semaine
weekly_realized = sum(
pnl
for pnl, trade in zip(self.pnl_history, self.daily_trades)
if trade["time"] >= week_start
) if self.pnl_history else 0.0
# P&L non réalisé
unrealized = sum(pos.unrealized_pnl for pos in self.positions.values())
return weekly_realized + unrealized
def _calculate_var(self, confidence: float = 0.95) -> float:
"""
Calcule Value at Risk (VaR).
Args:
confidence: Niveau de confiance (0.95 = 95%)
Returns:
VaR en valeur absolue
"""
if len(self.pnl_history) < 30:
return 0.0
returns = np.array(self.pnl_history[-30:]) / self.portfolio_value
var = np.percentile(returns, (1 - confidence) * 100)
return abs(var * self.portfolio_value)
def _calculate_cvar(self, confidence: float = 0.95) -> float:
"""
Calcule Conditional Value at Risk (CVaR / Expected Shortfall).
Args:
confidence: Niveau de confiance
Returns:
CVaR en valeur absolue
"""
if len(self.pnl_history) < 30:
return 0.0
returns = np.array(self.pnl_history[-30:]) / self.portfolio_value
var_threshold = np.percentile(returns, (1 - confidence) * 100)
# Moyenne des pertes au-delà du VaR
tail_losses = returns[returns <= var_threshold]
cvar = np.mean(tail_losses) if len(tail_losses) > 0 else 0
return abs(cvar * self.portfolio_value)
def _get_largest_position(self) -> float:
"""Retourne la taille de la plus grande position (en %)."""
if not self.positions:
return 0.0
largest = max(
abs(pos.quantity * pos.current_price) for pos in self.positions.values()
)
return largest / self.portfolio_value
def _check_correlation(self, symbol: str, strategy: str) -> bool:
"""
Vérifie la corrélation avec les positions existantes.
Args:
symbol: Symbole à vérifier
strategy: Stratégie
Returns:
True si corrélation acceptable
"""
if len(self.positions) == 0:
return True
# Simplification: vérifier si même stratégie
# En production: calculer corrélation réelle des returns
same_strategy_positions = [
pos for pos in self.positions.values()
if pos.strategy == strategy
]
max_correlation = self.config['global_limits']['max_correlation']
# Si trop de positions de même stratégie, corrélation trop haute
if len(same_strategy_positions) >= 3:
return False
return True
def _check_exit_conditions(self, position: Position):
"""
Vérifie les conditions de sortie (stop-loss / take-profit).
Args:
position: Position à vérifier
"""
# Stop-loss hit
if position.current_price <= position.stop_loss:
self.close_position(position.symbol, position.stop_loss, reason='stop_loss')
logger.warning(f"⚠️ Stop-loss hit for {position.symbol}")
# Take-profit hit
elif position.current_price >= position.take_profit:
self.close_position(position.symbol, position.take_profit, reason='take_profit')
logger.info(f"✅ Take-profit hit for {position.symbol}")
def _detect_volatility_spike(self) -> bool:
"""
Détecte un spike de volatilité anormal.
Returns:
True si spike détecté
"""
if len(self.pnl_history) < 20:
return False
recent_vol = np.std(self.pnl_history[-5:])
baseline_vol = np.std(self.pnl_history[-20:-5])
# Spike si volatilité > 3x baseline
return recent_vol > 3 * baseline_vol if baseline_vol > 0 else False
def _send_emergency_alert(self, reason: str):
"""
Envoie une alerte d'urgence via tous les canaux configurés.
Args:
reason: Raison de l'alerte
"""
metrics = self.get_statistics()
message = (
f"*TRADING HALTED*\n\n"
f"Raison : {reason}\n\n"
f"Portfolio : ${metrics['portfolio_value']:,.2f}\n"
f"Drawdown : {metrics['current_drawdown']:.2%}\n"
f"Trades : {metrics['total_trades']}\n"
f"Positions : {metrics['num_positions']}"
)
try:
_get_notifier().notify(message, level="critical")
except Exception as exc:
logger.error(f"Failed to send emergency alert: {exc}")
def get_statistics(self) -> Dict:
"""
Retourne les statistiques complètes du Risk Manager.
Returns:
Dictionnaire avec toutes les statistiques
"""
win_rate = self.winning_trades / self.total_trades if self.total_trades > 0 else 0
return {
'portfolio_value': self.portfolio_value,
'initial_capital': self.initial_capital,
'total_return': (self.portfolio_value - self.initial_capital) / self.initial_capital,
'peak_value': self.peak_value,
'current_drawdown': self._calculate_current_drawdown(),
'total_trades': self.total_trades,
'winning_trades': self.winning_trades,
'losing_trades': self.losing_trades,
'win_rate': win_rate,
'num_positions': len(self.positions),
'total_risk': self._calculate_total_risk(),
'trading_halted': self.trading_halted,
}

522
src/core/strategy_engine.py Normal file
View File

@@ -0,0 +1,522 @@
"""
Strategy Engine - Orchestrateur des Stratégies de Trading.
Ce module gère l'exécution et la coordination de toutes les stratégies:
- Chargement dynamique des stratégies
- Distribution des données marché
- Collecte et filtrage des signaux
- Coordination avec le Risk Manager
- Gestion du cycle de vie des stratégies
"""
import asyncio
from typing import Dict, List, Optional
from datetime import datetime
import logging
from src.core.risk_manager import RiskManager, Position
from src.strategies.base_strategy import BaseStrategy, Signal
logger = logging.getLogger(__name__)
class StrategyEngine:
"""
Moteur central de gestion des stratégies.
Responsabilités:
- Charger et initialiser les stratégies
- Distribuer les données marché à toutes les stratégies
- Collecter les signaux de trading
- Filtrer les signaux avec le Risk Manager
- Coordonner l'exécution des ordres
- Monitorer la performance des stratégies
Usage:
engine = StrategyEngine(config, risk_manager)
await engine.load_strategy('intraday')
await engine.run()
"""
def __init__(self, config: Dict, risk_manager: RiskManager):
"""
Initialise le Strategy Engine.
Args:
config: Configuration des stratégies
risk_manager: Instance du Risk Manager
"""
self.config = config
self.risk_manager = risk_manager
# Stratégies actives
self.strategies: Dict[str, BaseStrategy] = {}
# Signaux en attente
self.pending_signals: List[Signal] = []
# ML Engine (initialisé paresseusement lors du premier run)
self.ml_engine = None
# État
self.running = False
self.interval = 60 # Secondes entre chaque itération
logger.info("Strategy Engine initialized")
async def load_strategy(self, strategy_name: str):
"""
Charge une stratégie dynamiquement.
Args:
strategy_name: Nom de la stratégie ('scalping', 'intraday', 'swing')
"""
logger.info(f"Loading strategy: {strategy_name}")
try:
# Import dynamique de la stratégie
if strategy_name == 'scalping':
from src.strategies.scalping.scalping_strategy import ScalpingStrategy
strategy_class = ScalpingStrategy
elif strategy_name == 'intraday':
from src.strategies.intraday.intraday_strategy import IntradayStrategy
strategy_class = IntradayStrategy
elif strategy_name == 'swing':
from src.strategies.swing.swing_strategy import SwingStrategy
strategy_class = SwingStrategy
else:
raise ValueError(f"Unknown strategy: {strategy_name}")
# Récupérer configuration de la stratégie
strategy_config = self.config.get(f'{strategy_name}_strategy', {})
# Créer instance
strategy = strategy_class(strategy_config)
# Ajouter aux stratégies actives
self.strategies[strategy_name] = strategy
logger.info(f"✅ Strategy loaded: {strategy_name}")
except Exception as e:
logger.error(f"Failed to load strategy {strategy_name}: {e}")
raise
async def run(self):
"""
Boucle principale du Strategy Engine.
Cycle:
1. Récupérer données marché
2. Analyser avec chaque stratégie
3. Collecter signaux
4. Filtrer avec Risk Manager
5. Exécuter signaux valides
6. Mettre à jour positions
7. Vérifier circuit breakers
8. Sleep jusqu'à prochaine itération
"""
self.running = True
logger.info("Strategy Engine started")
try:
while self.running:
iteration_start = datetime.now()
# 1. Récupérer données marché
market_data = await self._fetch_market_data()
# 2. Mettre en cache la volatilité dans Redis
self._cache_volatility(market_data)
# 3. Mettre à jour le ML Engine avec les nouvelles données
await self._update_ml_engine(market_data)
# 4. Analyser avec chaque stratégie (+ filtre ML par régime)
signals = await self._analyze_strategies(market_data)
# 5. Filtrer avec Risk Manager
valid_signals = self._filter_signals(signals)
# 6. Publier les signaux dans Redis (pour GET /signals)
self._publish_signals_to_redis(valid_signals)
# 7. Exécuter signaux valides
await self._execute_signals(valid_signals)
# 8. Mettre à jour positions
await self._update_positions(market_data)
# 9. Vérifier circuit breakers
self.risk_manager.check_circuit_breakers()
# 10. Log statistiques
self._log_statistics()
# 11. Sleep jusqu'à prochaine itération
elapsed = (datetime.now() - iteration_start).total_seconds()
sleep_time = max(0, self.interval - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except Exception as e:
logger.exception(f"Error in Strategy Engine main loop: {e}")
raise
finally:
logger.info("Strategy Engine stopped")
async def stop(self):
"""Arrête le Strategy Engine."""
logger.info("Stopping Strategy Engine...")
self.running = False
# Fermer toutes les positions
await self._close_all_positions()
async def _fetch_market_data(self) -> Dict:
"""
Récupère les données marché pour tous les symboles actifs
via le DataService (Yahoo Finance → Alpha Vantage failover).
Returns:
Dictionnaire {symbol: DataFrame}
"""
from datetime import timedelta
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
if not hasattr(self, "_data_service"):
config = ConfigLoader.load_all()
self._data_service = DataService(config)
market_data: Dict = {}
now = datetime.now()
start = now - timedelta(days=5) # 5 jours pour indicateurs TA
symbols = self.config.get("symbols", ["EURUSD"])
for symbol in symbols:
try:
df = await self._data_service.get_historical_data(
symbol=symbol,
timeframe="1h",
start_date=start,
end_date=now,
)
if df is not None and not df.empty:
market_data[symbol] = df
logger.debug(f"Market data fetched: {symbol} ({len(df)} rows)")
else:
logger.warning(f"No data returned for {symbol}")
except Exception as exc:
logger.error(f"Failed to fetch market data for {symbol}: {exc}")
return market_data
async def _update_ml_engine(self, market_data: Dict):
"""
Initialise (paresseusement) et met à jour le ML Engine avec les données fraîches.
Le ML Engine est initialisé au premier appel avec les données disponibles,
puis mis à jour à chaque itération pour que la détection de régime soit courante.
"""
if not market_data:
return
# Première itération : entraîner le RegimeDetector
if self.ml_engine is None:
try:
from src.ml.ml_engine import MLEngine
self.ml_engine = MLEngine(config=self.config.get("ml", {}))
# Utiliser les données du premier symbole disponible
first_df = next(iter(market_data.values()))
if len(first_df) >= 50:
self.ml_engine.initialize(first_df)
logger.info("ML Engine initialisé avec données marché")
except Exception as exc:
logger.warning(f"ML Engine init échoué (non bloquant): {exc}")
self.ml_engine = None
return
# Itérations suivantes : mettre à jour le régime
try:
first_df = next(iter(market_data.values()))
self.ml_engine.update_with_new_data(first_df)
except Exception as exc:
logger.debug(f"ML Engine update skipped: {exc}")
async def _analyze_strategies(self, market_data: Dict) -> List[Signal]:
"""
Analyse le marché avec toutes les stratégies actives.
Args:
market_data: Données marché
Returns:
Liste de signaux générés
"""
signals = []
for strategy_name, strategy in self.strategies.items():
try:
# Vérifier si la stratégie est appropriée pour le régime ML actuel
if self.ml_engine is not None:
if not self.ml_engine.should_trade(strategy_name):
regime_info = self.ml_engine.get_regime_info()
logger.info(
f"⏭️ {strategy_name} suspendu — régime "
f"{regime_info.get('regime_name', '?')}"
)
continue
# Adapter les paramètres de la stratégie selon le régime
base_params = self.config.get(f"{strategy_name}_strategy", {})
adapted_params = self.ml_engine.adapt_parameters(
current_data=next(iter(market_data.values())),
strategy_name=strategy_name,
base_params=base_params,
)
strategy.update_params(adapted_params)
# Analyser avec la stratégie
signal = strategy.analyze(market_data)
if signal:
# Annoter le signal avec le régime ML
if self.ml_engine is not None:
regime = self.ml_engine.get_regime_info()
signal.metadata = signal.metadata or {}
signal.metadata["regime"] = regime.get("regime_name")
logger.info(f"Signal: {strategy_name}{signal.symbol} {signal.direction}")
signals.append(signal)
except Exception as exc:
logger.error(f"Erreur analyse {strategy_name}: {exc}")
return signals
def _filter_signals(self, signals: List[Signal]) -> List[Signal]:
"""
Filtre les signaux avec le Risk Manager.
Args:
signals: Signaux à filtrer
Returns:
Signaux valides uniquement
"""
valid_signals = []
for signal in signals:
# Calculer taille position
position_size = self._calculate_position_size(signal)
# Valider avec Risk Manager
is_valid, error = self.risk_manager.validate_trade(
symbol=signal.symbol,
quantity=position_size,
price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=signal.strategy
)
if is_valid:
signal.quantity = position_size
valid_signals.append(signal)
logger.info(f"✅ Signal validated: {signal.symbol}")
else:
logger.warning(f"❌ Signal rejected: {signal.symbol} - {error}")
return valid_signals
def _calculate_position_size(self, signal: Signal) -> float:
"""
Calcule la taille de position optimale pour un signal.
Args:
signal: Signal de trading
Returns:
Taille de position
"""
# Récupérer stratégie
strategy = self.strategies.get(signal.strategy)
if strategy:
# Calculer la volatilité réelle si des données sont disponibles
current_volatility = self._estimate_volatility(signal.symbol)
return strategy.calculate_position_size(
signal=signal,
portfolio_value=self.risk_manager.portfolio_value,
current_volatility=current_volatility,
)
# Fallback: taille fixe
return 1000.0
async def _execute_signals(self, signals: List[Signal]):
"""
Exécute les signaux validés.
Args:
signals: Signaux à exécuter
"""
for signal in signals:
try:
await self._execute_signal(signal)
except Exception as e:
logger.error(f"Failed to execute signal {signal.symbol}: {e}")
def _estimate_volatility(self, symbol: str) -> float:
"""
Estime la volatilité annualisée depuis le cache Redis (clé trading:volatility:{symbol}).
Returns:
Volatilité annualisée (par défaut 0.02 = 2% si données absentes)
"""
try:
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
val = r.get(f"trading:volatility:{symbol}")
if val:
return float(val)
except Exception:
pass
return 0.02 # Valeur par défaut conservatrice
def _cache_volatility(self, market_data: Dict):
"""
Calcule la volatilité annualisée depuis les données fraîches et la met en cache Redis.
Clé : trading:volatility:{symbol}, TTL : 1h.
"""
try:
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
for symbol, df in market_data.items():
col = "close" if "close" in df.columns else ("Close" if "Close" in df.columns else None)
if col and len(df) > 20:
vol = float(df[col].pct_change().dropna().std() * (252 ** 0.5))
r.set(f"trading:volatility:{symbol}", str(vol), ex=3600)
logger.debug(f"Volatilité cachée : {symbol} = {vol:.4f}")
except Exception as exc:
logger.debug(f"Cache volatilité Redis échoué (non bloquant) : {exc}")
def _publish_signals_to_redis(self, signals: List[Signal]):
"""
Publie les signaux actifs dans Redis (clé trading:signals, TTL 5 min).
Permet à l'API GET /signals de les retourner en temps réel.
"""
try:
import json
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
payload = [
{
"symbol": s.symbol,
"direction": s.direction,
"confidence": getattr(s, "confidence", 0.0) or 0.0,
"strategy": s.strategy,
"timestamp": datetime.now().isoformat(),
}
for s in signals
]
r.set("trading:signals", json.dumps(payload), ex=300)
logger.debug(f"{len(signals)} signal(s) publiés dans Redis")
except Exception as exc:
logger.debug(f"Publication signaux Redis échouée (non bloquant) : {exc}")
async def _execute_signal(self, signal: Signal):
"""
Exécute un signal individuel.
En paper / simulation : ajoute directement la position au Risk Manager.
En live (Phase 5) : passer par le connecteur IG Markets.
"""
logger.info(f"Executing signal: {signal.symbol} {signal.direction} @ {signal.entry_price}")
# Phase 5 : remplacer par appel IG Markets API
# ig_connector.place_order(signal)
position = Position(
symbol=signal.symbol,
quantity=signal.quantity,
entry_price=signal.entry_price,
current_price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=signal.strategy,
entry_time=datetime.now(),
unrealized_pnl=0.0,
risk_amount=abs(signal.entry_price - signal.stop_loss) * signal.quantity
)
# Ajouter au Risk Manager
self.risk_manager.add_position(position)
async def _update_positions(self, market_data: Dict):
"""
Met à jour toutes les positions avec les prix actuels issus de market_data.
"""
for symbol, position in list(self.risk_manager.positions.items()):
df = market_data.get(symbol)
if df is not None and not df.empty and "close" in df.columns:
current_price = float(df["close"].iloc[-1])
else:
# Pas de données fraîches : conserver le dernier prix connu
current_price = position.current_price
self.risk_manager.update_position(symbol, current_price)
async def _close_all_positions(self):
"""Ferme toutes les positions ouvertes."""
logger.info("Closing all positions...")
for symbol in list(self.risk_manager.positions.keys()):
position = self.risk_manager.positions[symbol]
self.risk_manager.close_position(
symbol=symbol,
exit_price=position.current_price,
reason='engine_stop'
)
def _log_statistics(self):
"""Log les statistiques du Strategy Engine."""
stats = self.risk_manager.get_statistics()
metrics = self.risk_manager.get_risk_metrics()
logger.info(
f"Portfolio: ${stats['portfolio_value']:,.2f} | "
f"Return: {stats['total_return']:.2%} | "
f"DD: {stats['current_drawdown']:.2%} | "
f"Positions: {stats['num_positions']} | "
f"Risk: {metrics.risk_utilization:.1%}"
)
def get_performance_summary(self) -> Dict:
"""
Retourne un résumé de performance de toutes les stratégies.
Returns:
Dictionnaire avec performance par stratégie
"""
summary = {}
for strategy_name, strategy in self.strategies.items():
summary[strategy_name] = {
'win_rate': strategy.win_rate,
'sharpe_ratio': strategy.sharpe_ratio,
'total_trades': len(strategy.closed_trades),
'avg_win': strategy.avg_win,
'avg_loss': strategy.avg_loss,
}
return summary