Files
2026-06-25 14:00:20 +03:00

167 lines
6.7 KiB
Python

"""
AHAD QUANT — Reinforcement Learning Reward Function
Calcule la récompense à chaque step de l'environnement.
Philosophie :
- Récompense directement le PnL réel (pas l'accuracy)
- Pénalise les drawdowns (capital preservation)
- Encourage le Sharpe ratio (qualité des gains)
- Pénalise légèrement l'inactivité prolongée
"""
import numpy as np
from dataclasses import dataclass, field
from typing import List
# ─── Configuration des poids ─────────────────────────────────────────────────
# PnL
PNL_SCALE = 1.0 # Multiplicateur du PnL réalisé
UNREALIZED_SCALE = 0.3 # Poids du PnL non réalisé (moindre que réalisé)
# Drawdown
DRAWDOWN_PENALTY = 2.0 # Pénalité par unité de drawdown (ex: -0.02 → -0.04)
MAX_DD_THRESHOLD = 0.05 # Drawdown > 5% : pénalité supplémentaire
# Sharpe
SHARPE_WINDOW = 20 # Fenêtre des returns pour le Sharpe
SHARPE_BONUS = 0.1 # Bonus par unité de Sharpe (encouragement)
# Inactivité
IDLE_PENALTY = -0.0002 # Pénalité par step sans position (encourage le trading)
MAX_IDLE_STEPS = 48 # Après 48 steps sans trade, la pénalité s'amplifie
# Trades
WIN_BONUS = 0.05 # Bonus à chaque trade gagnant
LOSS_MULTIPLIER = 1.5 # Les pertes comptent 1.5x plus que les gains
CONSECUTIVE_WIN = 0.02 # Bonus supplémentaire pour trades gagnants consécutifs
# Timeout
TIMEOUT_PENALTY = -0.01 # Pénalité si on tient une position > MAX_HOLD_CANDLES
TIMEOUT_THRESHOLD = 3 # Steps avant la pénalité de timeout
@dataclass
class RewardTracker:
"""
État interne du calculateur de récompense.
Une instance par épisode d'entraînement.
"""
peak_balance: float = 1.0 # Balance maximum atteinte (pour drawdown)
current_balance: float = 1.0 # Balance courante normalisée
returns_history: List[float] = field(default_factory=list) # Pour Sharpe
idle_steps: int = 0 # Steps consécutifs sans position
consecutive_wins: int = 0 # Trades gagnants consécutifs
total_trades: int = 0
winning_trades: int = 0
candles_in_pos: int = 0 # Durée de la position courante
def reset(self):
self.peak_balance = 1.0
self.current_balance = 1.0
self.returns_history = []
self.idle_steps = 0
self.consecutive_wins = 0
self.total_trades = 0
self.winning_trades = 0
self.candles_in_pos = 0
def compute_step_reward(
tracker: RewardTracker,
pnl_realized: float, # PnL réalisé ce step (0.0 si pas de close)
pnl_unrealized: float, # PnL non réalisé courant (0.0 si pas en position)
in_position: bool, # L'agent est-il en position ?
trade_closed: bool, # Un trade a-t-il été fermé ce step ?
fee_paid: float, # Frais payés ce step
) -> float:
"""
Calcule la récompense scalaire pour un step donné.
Returns:
reward (float) : récompense à donner à l'agent RL
"""
reward = 0.0
# ─── 1. PnL réalisé ──────────────────────────────────────────────────────
if trade_closed:
net_pnl = pnl_realized - fee_paid
if net_pnl >= 0:
reward += net_pnl * PNL_SCALE + WIN_BONUS
tracker.consecutive_wins += 1
reward += tracker.consecutive_wins * CONSECUTIVE_WIN
tracker.winning_trades += 1
else:
# Les pertes pèsent plus que les gains
reward += net_pnl * PNL_SCALE * LOSS_MULTIPLIER
tracker.consecutive_wins = 0
tracker.total_trades += 1
tracker.candles_in_pos = 0
# ─── 2. PnL non réalisé (signal continu en position) ─────────────────────
if in_position:
reward += pnl_unrealized * UNREALIZED_SCALE
tracker.candles_in_pos += 1
tracker.idle_steps = 0
# Pénalité timeout (position trop longue)
if tracker.candles_in_pos > TIMEOUT_THRESHOLD:
reward += TIMEOUT_PENALTY * (tracker.candles_in_pos - TIMEOUT_THRESHOLD)
else:
tracker.candles_in_pos = 0
tracker.idle_steps += 1
# ─── 3. Inactivité ───────────────────────────────────────────────────────
if not in_position:
penalty = IDLE_PENALTY
if tracker.idle_steps > MAX_IDLE_STEPS:
penalty *= 2.0 # Double la pénalité après 48h d'inactivité
reward += penalty
# ─── 4. Drawdown ─────────────────────────────────────────────────────────
tracker.current_balance = 1.0 + pnl_realized # Approx normalisée
if tracker.current_balance > tracker.peak_balance:
tracker.peak_balance = tracker.current_balance
drawdown = (tracker.peak_balance - tracker.current_balance) / tracker.peak_balance
if drawdown > 0:
reward -= drawdown * DRAWDOWN_PENALTY
if drawdown > MAX_DD_THRESHOLD:
reward -= drawdown * DRAWDOWN_PENALTY # Double pénalité si > 5%
# ─── 5. Bonus Sharpe (rétrospectif sur fenêtre glissante) ────────────────
if trade_closed:
ret = pnl_realized - fee_paid
tracker.returns_history.append(ret)
if len(tracker.returns_history) > SHARPE_WINDOW:
tracker.returns_history.pop(0)
sharpe = _rolling_sharpe(tracker.returns_history)
if sharpe > 0:
reward += sharpe * SHARPE_BONUS
return float(np.clip(reward, -1.0, 1.0))
def _rolling_sharpe(returns: List[float]) -> float:
"""Sharpe ratio simplifié sur une liste de returns."""
if len(returns) < 5:
return 0.0
arr = np.array(returns)
std = arr.std()
if std < 1e-8:
return 0.0
return float(arr.mean() / std)
def compute_episode_metrics(tracker: RewardTracker) -> dict:
"""Métriques de fin d'épisode pour les logs TensorBoard."""
win_rate = (tracker.winning_trades / tracker.total_trades
if tracker.total_trades > 0 else 0.0)
return {
"ep/total_trades": tracker.total_trades,
"ep/win_rate": win_rate,
"ep/consecutive_wins": tracker.consecutive_wins,
"ep/sharpe": _rolling_sharpe(tracker.returns_history),
}