Coverage for src / monte_neo / metrics / sharpe.py: 70%

54 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-28 16:27 +0200

1"""Sharpe and Sortino ratio metrics. 

2 

3Risk-adjusted return metrics for evaluating trading performance. 

4""" 

5 

6from __future__ import annotations 

7 

8import numpy as np 

9 

10from monte_neo.utils.logger import get_logger 

11 

12logger = get_logger(__name__) 

13 

14 

15class SharpeRatioMetric: 

16 """Sharpe Ratio calculator.""" 

17 

18 def __init__( 

19 self, 

20 risk_free_rate: float = 0.0, 

21 periods_per_year: int = 252, 

22 ) -> None: 

23 """Initialize Sharpe calculator. 

24 

25 Args: 

26 risk_free_rate: Annual risk-free rate. 

27 periods_per_year: Trading periods per year. 

28 """ 

29 self.risk_free_rate = risk_free_rate 

30 self.periods_per_year = periods_per_year 

31 

32 def calculate(self, returns: list[float] | np.ndarray) -> float: 

33 """Calculate Sharpe ratio. 

34 

35 Sharpe = (Mean Return - Risk Free) / Std(Returns) * sqrt(periods) 

36 

37 Values > 1 are good, > 2 are excellent. 

38 

39 Args: 

40 returns: List of period returns. 

41 

42 Returns: 

43 Annualized Sharpe ratio. 

44 """ 

45 if not len(returns): 

46 return 0.0 

47 

48 returns = np.array(returns) 

49 

50 if len(returns) < 2 or np.std(returns) == 0: 

51 return 0.0 

52 

53 # Convert annual risk-free to period risk-free 

54 period_rf = (1 + self.risk_free_rate) ** (1 / self.periods_per_year) - 1 

55 

56 excess_returns = returns - period_rf 

57 mean_excess = np.mean(excess_returns) 

58 std_returns = np.std(returns, ddof=1) 

59 

60 # Annualize 

61 sharpe = (mean_excess / std_returns) * np.sqrt(self.periods_per_year) 

62 

63 return float(sharpe) 

64 

65 def calculate_rolling( 

66 self, 

67 returns: list[float] | np.ndarray, 

68 window: int = 20, 

69 ) -> np.ndarray: 

70 """Calculate rolling Sharpe ratio. 

71 

72 Args: 

73 returns: List of period returns. 

74 window: Rolling window size. 

75 

76 Returns: 

77 Array of rolling Sharpe ratios. 

78 """ 

79 if len(returns) < window: 

80 return np.array([self.calculate(returns)]) 

81 

82 returns = np.array(returns) 

83 rolling_sharpe = [] 

84 

85 for i in range(window, len(returns) + 1): 

86 window_returns = returns[i - window : i] 

87 rolling_sharpe.append(self.calculate(window_returns)) 

88 

89 return np.array(rolling_sharpe) 

90 

91 

92class SortinoRatioMetric: 

93 """Sortino Ratio calculator (downside risk adjusted).""" 

94 

95 def __init__( 

96 self, 

97 risk_free_rate: float = 0.0, 

98 periods_per_year: int = 252, 

99 ) -> None: 

100 """Initialize Sortino calculator. 

101 

102 Args: 

103 risk_free_rate: Annual risk-free rate. 

104 periods_per_year: Trading periods per year. 

105 """ 

106 self.risk_free_rate = risk_free_rate 

107 self.periods_per_year = periods_per_year 

108 

109 def calculate(self, returns: list[float] | np.ndarray) -> float: 

110 """Calculate Sortino ratio. 

111 

112 Sortino = (Mean Return - Risk Free) / Downside Deviation * sqrt(periods) 

113 

114 Uses only negative returns for volatility calculation. 

115 

116 Args: 

117 returns: List of period returns. 

118 

119 Returns: 

120 Annualized Sortino ratio. 

121 """ 

122 if not len(returns): 

123 return 0.0 

124 

125 returns = np.array(returns) 

126 

127 # Convert annual risk-free to period risk-free 

128 period_rf = (1 + self.risk_free_rate) ** (1 / self.periods_per_year) - 1 

129 

130 excess_returns = returns - period_rf 

131 mean_excess = np.mean(excess_returns) 

132 

133 # Downside deviation (only negative returns) 

134 downside_returns = returns[returns < 0] 

135 

136 if len(downside_returns) < 2: 

137 return float("inf") if mean_excess > 0 else 0.0 

138 

139 downside_std = np.std(downside_returns, ddof=1) 

140 

141 if downside_std == 0: 

142 return float("inf") if mean_excess > 0 else 0.0 

143 

144 # Annualize 

145 sortino = (mean_excess / downside_std) * np.sqrt(self.periods_per_year) 

146 

147 return float(sortino) 

148 

149 def calculate_downside_deviation( 

150 self, 

151 returns: list[float] | np.ndarray, 

152 target: float = 0, 

153 ) -> float: 

154 """Calculate downside deviation. 

155 

156 Args: 

157 returns: List of period returns. 

158 target: Target return (default: 0). 

159 

160 Returns: 

161 Downside deviation. 

162 """ 

163 returns = np.array(returns) 

164 below_target = returns[returns < target] 

165 

166 if len(below_target) < 2: 

167 return 0.0 

168 

169 return float(np.std(below_target, ddof=1))