| """ |
| Reward Functions Module |
| Risk-adjusted reward calculations using Sharpe and Sortino ratios. |
| """ |
|
|
| import numpy as np |
| from typing import List, Optional |
| from collections import deque |
|
|
|
|
| class RewardCalculator: |
| """ |
| Calculates risk-adjusted rewards for the trading environment. |
| Combines Sharpe and Sortino ratios to punish volatility and drawdowns. |
| """ |
| |
| def __init__( |
| self, |
| window_size: int = 20, |
| risk_free_rate: float = 0.0, |
| sharpe_weight: float = 0.6, |
| sortino_weight: float = 0.4, |
| drawdown_penalty: float = 2.0, |
| holding_cost: float = 0.0001, |
| ): |
| """ |
| Initialize the reward calculator. |
| |
| Args: |
| window_size: Rolling window for ratio calculations |
| risk_free_rate: Daily risk-free rate (default 0) |
| sharpe_weight: Weight for Sharpe ratio component |
| sortino_weight: Weight for Sortino ratio component |
| drawdown_penalty: Multiplier for drawdown penalty |
| holding_cost: Cost per step for holding a position (encourages action) |
| """ |
| self.window_size = window_size |
| self.risk_free_rate = risk_free_rate |
| self.sharpe_weight = sharpe_weight |
| self.sortino_weight = sortino_weight |
| self.drawdown_penalty = drawdown_penalty |
| self.holding_cost = holding_cost |
| |
| |
| self.returns_buffer: deque = deque(maxlen=window_size) |
| self.peak_value: float = 0.0 |
| self.current_drawdown: float = 0.0 |
| |
| def reset(self, initial_value: float = 10000.0): |
| """Reset the calculator for a new episode.""" |
| self.returns_buffer.clear() |
| self.peak_value = initial_value |
| self.current_drawdown = 0.0 |
| |
| def calculate_sharpe_ratio(self, returns: np.ndarray) -> float: |
| """ |
| Calculate Sharpe ratio from returns array. |
| |
| Sharpe = (mean_return - rf) / std_return |
| """ |
| if len(returns) < 2: |
| return 0.0 |
| |
| mean_return = np.mean(returns) |
| std_return = np.std(returns) |
| |
| if std_return < 1e-8: |
| return 0.0 |
| |
| return (mean_return - self.risk_free_rate) / std_return |
| |
| def calculate_sortino_ratio(self, returns: np.ndarray) -> float: |
| """ |
| Calculate Sortino ratio from returns array. |
| Only penalizes downside volatility. |
| |
| Sortino = (mean_return - rf) / downside_std |
| """ |
| if len(returns) < 2: |
| return 0.0 |
| |
| mean_return = np.mean(returns) |
| negative_returns = returns[returns < 0] |
| |
| if len(negative_returns) < 2: |
| |
| return mean_return * 10 if mean_return > 0 else 0.0 |
| |
| downside_std = np.std(negative_returns) |
| |
| if downside_std < 1e-8: |
| return 0.0 |
| |
| return (mean_return - self.risk_free_rate) / downside_std |
| |
| def update_drawdown(self, current_value: float) -> float: |
| """ |
| Update and return current drawdown. |
| """ |
| if current_value > self.peak_value: |
| self.peak_value = current_value |
| |
| self.current_drawdown = (self.peak_value - current_value) / self.peak_value |
| return self.current_drawdown |
| |
| def calculate_reward( |
| self, |
| step_return: float, |
| portfolio_value: float, |
| position: int, |
| action_taken: int, |
| trade_pnl: Optional[float] = None, |
| ) -> float: |
| """ |
| Calculate the total reward for a step. |
| |
| Args: |
| step_return: The return for this step (pct change) |
| portfolio_value: Current total portfolio value |
| position: Current position (-1, 0, 1) |
| action_taken: Action taken this step (0=hold, 1=buy, 2=sell) |
| trade_pnl: P&L from closed trade if any |
| |
| Returns: |
| Total reward value |
| """ |
| |
| self.returns_buffer.append(step_return) |
| |
| |
| drawdown = self.update_drawdown(portfolio_value) |
| |
| |
| returns = np.array(self.returns_buffer) |
| |
| |
| sharpe = self.calculate_sharpe_ratio(returns) |
| sortino = self.calculate_sortino_ratio(returns) |
| |
| |
| risk_adjusted_reward = ( |
| self.sharpe_weight * sharpe + |
| self.sortino_weight * sortino |
| ) |
| |
| |
| drawdown_penalty = self.drawdown_penalty * drawdown |
| |
| |
| holding_penalty = self.holding_cost if position != 0 else 0 |
| |
| |
| trade_bonus = 0.0 |
| if trade_pnl is not None: |
| |
| trade_bonus = np.sign(trade_pnl) * np.log1p(abs(trade_pnl) * 100) |
| |
| |
| return_component = step_return * 10 |
| |
| |
| total_reward = ( |
| risk_adjusted_reward + |
| return_component + |
| trade_bonus - |
| drawdown_penalty - |
| holding_penalty |
| ) |
| |
| |
| return np.clip(total_reward, -10.0, 10.0) |
| |
| def get_episode_metrics(self) -> dict: |
| """ |
| Get summary metrics for the episode. |
| """ |
| returns = np.array(self.returns_buffer) if self.returns_buffer else np.array([0.0]) |
| |
| return { |
| 'sharpe_ratio': self.calculate_sharpe_ratio(returns), |
| 'sortino_ratio': self.calculate_sortino_ratio(returns), |
| 'max_drawdown': self.current_drawdown, |
| 'total_return': np.prod(1 + returns) - 1 if len(returns) > 0 else 0.0, |
| 'volatility': np.std(returns) if len(returns) > 1 else 0.0, |
| 'num_steps': len(self.returns_buffer), |
| } |
|
|
|
|
| def create_reward_calculator(config: dict) -> RewardCalculator: |
| """Factory function to create RewardCalculator from config.""" |
| return RewardCalculator( |
| window_size=config.get('window_size', 20), |
| risk_free_rate=config.get('risk_free_rate', 0.0), |
| sharpe_weight=config.get('sharpe_weight', 0.6), |
| sortino_weight=config.get('sortino_weight', 0.4), |
| drawdown_penalty=config.get('drawdown_penalty', 2.0), |
| holding_cost=config.get('holding_cost', 0.0001), |
| ) |
|
|