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:
19
src/core/__init__.py
Normal file
19
src/core/__init__.py
Normal 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
234
src/core/notifications.py
Normal 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
603
src/core/risk_manager.py
Normal 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
522
src/core/strategy_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user