|
5 | 5 | annualizes it, giving an estimate of how much the portfolio's value |
6 | 6 | fluctuates on a yearly basis. |
7 | 7 |
|
8 | | -| **Annual Volatility** | **Risk Level** | **Typical for...** | **Comments** | |
9 | | -| --------------------- | -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | |
10 | | -| **< 5%** | Very Low Risk | Cash equivalents, short-term bonds | Low return expectations; often used for capital preservation | |
11 | | -| **5% – 10%** | Low Risk | Diversified bond portfolios, conservative allocation strategies | Suitable for risk-averse investors | |
12 | | -| **10% – 15%** | Moderate Risk | Balanced portfolios, large-cap equity indexes (e.g., S\&P 500 ≈ \~15%) | Standard for traditional diversified portfolios | |
13 | | -| **15% – 25%** | High Risk | Growth stocks, hedge funds, active equity strategies | Higher return potential, but more drawdowns | |
14 | | -| **> 25%** | Very High Risk | Crypto, leveraged ETFs, speculative strategies | High potential returns, but prone to large losses; often not suitable long-term | |
| 8 | +| **Annual Volatility** | **Risk Level (Standalone)** | **Context Matters: Sharpe Ratio Impact** | **Comments** | |
| 9 | +| --------------------- | --------------------------- | ---------------------------------------- | ----------- | |
| 10 | +| **< 5%** | Very Low Risk | Sharpe > 2.0 = Excellent<br>Sharpe < 0.5 = Poor | Low volatility is great unless returns are negative | |
| 11 | +| **5% – 10%** | Low Risk | Sharpe > 1.0 = Good<br>Sharpe < 0.3 = Mediocre | Typical for conservative portfolios | |
| 12 | +| **10% – 15%** | Moderate Risk | Sharpe > 0.8 = Good<br>Sharpe < 0.2 = Risky | S&P 500 benchmark; quality matters | |
| 13 | +| **15% – 25%** | High Risk | Sharpe > 0.6 = Acceptable<br>Sharpe < 0.0 = Avoid | **Example: 30% CAGR + 23% vol = Sharpe ~1.3 = Excellent** | |
| 14 | +| **> 25%** | Very High Risk | Sharpe > 0.4 = Maybe acceptable<br>Sharpe < 0.0 = Dangerous | Only viable with strong positive returns | |
| 15 | +
|
| 16 | +
|
| 17 | +Key takeaway: Don't interpret volatility in isolation. Always calculate |
| 18 | +and compare the Sharpe Ratio to assess true strategy quality. |
| 19 | +Your 30% CAGR with 23% volatility is exceptional because the return far outweighs the risk taken. |
15 | 20 |
|
16 | 21 | """ |
17 | 22 |
|
|
23 | 28 | from investing_algorithm_framework.domain import PortfolioSnapshot |
24 | 29 |
|
25 | 30 |
|
26 | | -def get_annual_volatility(snapshots: List[PortfolioSnapshot]) -> float: |
| 31 | +def get_annual_volatility( |
| 32 | + snapshots: List[PortfolioSnapshot], |
| 33 | + trading_days_per_year=365 |
| 34 | +) -> float: |
27 | 35 | """ |
28 | 36 | Calculate the annualized volatility of portfolio net values. |
29 | 37 |
|
| 38 | + !Important Note: |
| 39 | +
|
| 40 | + Volatility measures variability, not direction. For example: |
| 41 | +
|
| 42 | + A standard deviation of 0.238 (23.8%) means returns swing |
| 43 | + wildly around their average, but it doesn't tell you if that average |
| 44 | + is positive or negative. |
| 45 | +
|
| 46 | + Two scenarios with the same 23.8% volatility: |
| 47 | + Mean return = +15% per year, Std = 23.8% |
| 48 | + 16% chance of losing >8.8% (15% - 23.8%) |
| 49 | + 16% chance of gaining >38.8% (15% + 23.8%) |
| 50 | + This is excellent — high growth with swings |
| 51 | +
|
| 52 | + Mean return = -5% per year, Std = 23.8% |
| 53 | + 16% chance of losing >28.8% (-5% - 23.8%) |
| 54 | + 16% chance of gaining >18.8% (-5% + 23.8%) |
| 55 | + This is terrible — losing money with high risk |
| 56 | +
|
| 57 | + To assess if "always good returns with high std" is perfect, you need |
| 58 | + to consider risk-adjusted metrics like the Sharpe Ratio: |
| 59 | + Sharpe Ratio = (Mean Return - Risk-Free Rate) / Volatility |
| 60 | + Higher is better; tells you return per unit of risk taken |
| 61 | +
|
30 | 62 | Args: |
31 | 63 | snapshots (List[PortfolioSnapshot]): List of portfolio snapshots |
32 | 64 | from the backtest report. |
| 65 | + trading_days_per_year (int): Number of trading days in a year. |
33 | 66 |
|
34 | 67 | Returns: |
35 | 68 | Float: Annualized volatility as a float |
36 | 69 | """ |
37 | 70 |
|
38 | 71 | if len(snapshots) < 2: |
39 | | - return 0.0 # Not enough data to calculate volatility |
| 72 | + return 0.0 |
40 | 73 |
|
41 | 74 | # Build DataFrame from snapshots |
42 | 75 | records = [ |
43 | 76 | (snapshot.total_value, snapshot.created_at) for snapshot in snapshots |
44 | 77 | ] |
45 | 78 | df = pd.DataFrame(records, columns=['total_value', 'created_at']) |
46 | 79 | df['created_at'] = pd.to_datetime(df['created_at']) |
47 | | - df = df.sort_values('created_at').drop_duplicates('created_at').copy() |
| 80 | + df = df.set_index('created_at').sort_index().drop_duplicates() |
48 | 81 |
|
49 | | - # Calculate log returns |
50 | | - df['log_return'] = np.log(df['total_value'] / df['total_value'].shift(1)) |
51 | | - df = df.dropna() |
| 82 | + # Resample to daily frequency, taking the last value of each day |
| 83 | + df_daily = df.resample('D').last() |
| 84 | + df_daily = df_daily.dropna() |
52 | 85 |
|
53 | | - if df.empty: |
| 86 | + if len(df_daily) < 2: |
54 | 87 | return 0.0 |
55 | 88 |
|
56 | | - daily_volatility = df['log_return'].std() |
57 | | - |
58 | | - start_date = snapshots[0].created_at |
59 | | - end_date = snapshots[-1].created_at |
60 | | - # Estimate trading days per year based on snapshot frequency |
61 | | - total_days = (end_date - start_date).days |
62 | | - num_observations = len(df) |
| 89 | + # Calculate log returns on daily data |
| 90 | + df_daily['log_return'] = np.log(df_daily['total_value'] / df_daily['total_value'].shift(1)) |
| 91 | + df_daily = df_daily.dropna() |
63 | 92 |
|
64 | | - if total_days > 0: |
65 | | - trading_days_per_year = (num_observations / total_days) * 365 |
66 | | - else: |
67 | | - trading_days_per_year = 365 # Default fallback |
| 93 | + # Calculate daily volatility (standard deviation of daily returns) |
| 94 | + daily_volatility = df_daily['log_return'].std() |
68 | 95 |
|
| 96 | + # Annualize using trading days per year |
69 | 97 | return daily_volatility * np.sqrt(trading_days_per_year) |
0 commit comments