Skip to content

Commit f0bb902

Browse files
committed
fix(metrics): use stable OLS for beta and remove debug prints; fix lint
1 parent fb5bec5 commit f0bb902

File tree

1 file changed

+19
-48
lines changed
  • src/quant_research_starter/metrics

1 file changed

+19
-48
lines changed

src/quant_research_starter/metrics/risk.py

Lines changed: 19 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@ def _calculate_return_metrics(self) -> Dict[str, float]:
3636
cagr = self._calculate_cagr()
3737

3838
return {
39-
"total_return": total_return,
40-
"cagr": cagr,
41-
"annualized_return": cagr,
39+
"total_return": float(total_return),
40+
"cagr": float(cagr),
41+
"annualized_return": float(cagr),
4242
}
4343

4444
def _calculate_risk_metrics(self) -> Dict[str, float]:
4545
"""Calculate risk-related metrics."""
46-
vol = self.returns.std() * np.sqrt(252)
46+
vol = float(self.returns.std(ddof=1) * np.sqrt(252))
4747
downside_returns = self.returns[self.returns < 0]
4848
downside_vol = (
49-
downside_returns.std() * np.sqrt(252) if len(downside_returns) > 0 else 0
49+
float(downside_returns.std(ddof=1) * np.sqrt(252)) if len(downside_returns) > 0 else 0.0
5050
)
5151

5252
max_drawdown, drawdown_duration = self._calculate_drawdown()
@@ -56,23 +56,20 @@ def _calculate_risk_metrics(self) -> Dict[str, float]:
5656
"downside_volatility": downside_vol,
5757
"max_drawdown": max_drawdown,
5858
"drawdown_duration": drawdown_duration,
59-
"var_95": self.returns.quantile(0.05),
60-
"cvar_95": self.returns[self.returns <= self.returns.quantile(0.05)].mean(),
59+
"var_95": float(self.returns.quantile(0.05)),
60+
"cvar_95": float(self.returns[self.returns <= self.returns.quantile(0.05)].mean()),
6161
}
6262

6363
def _calculate_ratio_metrics(self) -> Dict[str, float]:
6464
"""Calculate risk-adjusted ratio metrics."""
6565
cagr = self._calculate_cagr()
66-
vol = self.returns.std() * np.sqrt(252)
66+
vol = float(self.returns.std(ddof=1) * np.sqrt(252))
6767
downside_vol = self._calculate_downside_vol()
6868

69-
sharpe = cagr / vol if vol > 0 else 0
70-
sortino = cagr / downside_vol if downside_vol > 0 else 0
71-
calmar = (
72-
cagr / abs(self._calculate_drawdown()[0])
73-
if self._calculate_drawdown()[0] < 0
74-
else 0
75-
)
69+
sharpe = float(cagr / vol) if vol > 0 else 0.0
70+
sortino = float(cagr / downside_vol) if downside_vol > 0 else 0.0
71+
dd, _ = self._calculate_drawdown()
72+
calmar = float(cagr / abs(dd)) if dd < 0 else 0.0
7673

7774
return {
7875
"sharpe_ratio": sharpe,
@@ -98,15 +95,6 @@ def _calculate_benchmark_metrics(self) -> Dict[str, float]:
9895

9996
x = benchmark_returns.values.astype(float)
10097
y = strategy_returns.values.astype(float)
101-
102-
# print("DEBUG_RISK: len=", len(x))
103-
# print("DEBUG_RISK: x_mean, y_mean =", x.mean(), y.mean())
104-
# print("DEBUG_RISK: x_var, y_var =", np.var(x, ddof=0), np.var(y, ddof=0))
105-
# print("DEBUG_RISK: cov_xy =", np.mean((x - x.mean()) * (y - y.mean())))
106-
# # print first 8 values to visually inspect alignment
107-
# print("DEBUG_RISK: x[:8] =", x[:8])
108-
# print("DEBUG_RISK: y[:8] =", y[:8])
109-
11098

11199
# If benchmark has (near) zero variance, beta is undefined; return 0.0 to keep old behavior.
112100
if np.allclose(np.var(x, ddof=0), 0.0):
@@ -115,7 +103,6 @@ def _calculate_benchmark_metrics(self) -> Dict[str, float]:
115103
# Use stable least-squares (with intercept) to get slope (beta)
116104
# design matrix: [x, 1]
117105
A = np.vstack([x, np.ones_like(x)]).T
118-
# lstsq returns (coeffs, residuals, rank, s); coeffs = [slope, intercept]
119106
coeffs, *_ = np.linalg.lstsq(A, y, rcond=None)
120107
slope = float(coeffs[0])
121108
beta = slope
@@ -139,16 +126,6 @@ def _calculate_benchmark_metrics(self) -> Dict[str, float]:
139126
"information_ratio": info_ratio,
140127
"active_return": float(strategy_cagr - benchmark_cagr),
141128
}
142-
# print("DEBUG_RISK: len=", len(x))
143-
# print("DEBUG_RISK: x_mean, y_mean =", x.mean(), y.mean())
144-
# print("DEBUG_RISK: x_var, y_var =", np.var(x, ddof=0), np.var(y, ddof=0))
145-
# print("DEBUG_RISK: cov_xy =", np.mean((x - x.mean()) * (y - y.mean())))
146-
# # print first 8 values to visually inspect alignment
147-
# print("DEBUG_RISK: x[:8] =", x[:8])
148-
# print("DEBUG_RISK: y[:8] =", y[:8])
149-
150-
151-
152129

153130
def _calculate_cagr(self) -> float:
154131
"""Calculate Compound Annual Growth Rate."""
@@ -162,20 +139,20 @@ def _calculate_cagr_from_returns(self, returns: pd.Series) -> float:
162139
total_return = (1 + returns).prod() - 1
163140
years = (returns.index[-1] - returns.index[0]).days / 365.25
164141

165-
return (1 + total_return) ** (1 / years) - 1 if years > 0 else 0
142+
return float((1 + total_return) ** (1 / years) - 1) if years > 0 else 0.0
166143

167144
def _calculate_downside_vol(self) -> float:
168145
"""Calculate downside volatility (for Sortino ratio)."""
169146
downside_returns = self.returns[self.returns < 0]
170-
return downside_returns.std() * np.sqrt(252) if len(downside_returns) > 0 else 0
147+
return float(downside_returns.std(ddof=1) * np.sqrt(252)) if len(downside_returns) > 0 else 0.0
171148

172149
def _calculate_drawdown(self) -> Tuple[float, int]:
173150
"""Calculate maximum drawdown and duration."""
174151
cumulative = (1 + self.returns).cumprod()
175152
running_max = cumulative.expanding().max()
176153
drawdown = (cumulative / running_max) - 1
177154

178-
max_drawdown = drawdown.min()
155+
max_drawdown = float(drawdown.min()) if not drawdown.isna().all() else 0.0
179156

180157
# Handle edge cases safely
181158
if drawdown.isna().all():
@@ -191,16 +168,10 @@ def _calculate_drawdown(self) -> Tuple[float, int]:
191168
try:
192169
prior_max_mask = running_max[running_max.index <= max_dd_period]
193170
drawdown_start_val = prior_max_mask.max()
194-
start_candidates = prior_max_mask[
195-
prior_max_mask == drawdown_start_val
196-
].index
197-
drawdown_start = (
198-
start_candidates[-1]
199-
if len(start_candidates) > 0
200-
else running_max.index[0]
201-
)
202-
drawdown_duration = (max_dd_period - drawdown_start).days
171+
start_candidates = prior_max_mask[prior_max_mask == drawdown_start_val].index
172+
drawdown_start = start_candidates[-1] if len(start_candidates) > 0 else running_max.index[0]
173+
drawdown_duration = int((max_dd_period - drawdown_start).days)
203174
except Exception:
204175
drawdown_duration = 0
205176

206-
return float(max_drawdown), int(drawdown_duration)
177+
return max_drawdown, drawdown_duration

0 commit comments

Comments
 (0)