From effda1ad4c28bd110a53dcb0b2d0d5a9c171f271 Mon Sep 17 00:00:00 2001 From: jensnesten Date: Sun, 16 Feb 2025 13:56:40 +0100 Subject: [PATCH 01/12] added beta & alpha / resolved merge conflict --- backtesting/_stats.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 7d8614d7..b564f5f0 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -131,6 +131,20 @@ def _round_timedelta(value, _period=_data_period(index)): day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change() gmean_day_return = geometric_mean(day_returns) + # Save daily returns into array to define covariance matrix + equity_returns = [] + market_returns = [] + # Calculate returns for each period + for i in range(1, len(equity)): + equity_return = (equity[i] - equity[i - 1]) / equity[i - 1] + market_return = (c[i] - c[i - 1]) / c[i - 1] + equity_returns.append(equity_return) + market_returns.append(market_return) + + equity_returns = np.array(equity_returns) + market_returns = np.array(market_returns) + cov_matrix = np.cov(equity_returns, market_returns) + # Annualized return and risk metrics are computed based on the (mostly correct) # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517 # Our annualized return matches `empyrical.annual_return(day_returns)` whereas @@ -151,6 +165,8 @@ def _round_timedelta(value, _period=_data_period(index)): with np.errstate(divide='ignore'): s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 max_dd = -np.nan_to_num(dd.max()) + s.loc['Alpha [%]'] = s.loc['Return [%]'] - s.loc['Buy & Hold Return [%]'] + s.loc['Beta'] = round(cov_matrix[0, 1] / cov_matrix[1, 1], 2) s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan) s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 From 763e2dce59f1f2039257a4962ed5f14d26232ddd Mon Sep 17 00:00:00 2001 From: jensnesten Date: Sun, 23 Feb 2025 15:33:40 +0100 Subject: [PATCH 02/12] simplified beta calculation --- .DS_Store | Bin 0 -> 6148 bytes backtesting/_stats.py | 18 ++++-------------- 2 files changed, 4 insertions(+), 14 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..600ace093eaa500e38e81eeec47401ca08c70088 GIT binary patch literal 6148 zcmeHKyG{c^3>-s>NNG~0++W}iR#Er@etLPVqp^jGD(_%y~3A)PKXNHl0H*|Y2O z+-j#dp8?qV<8TKo0nF)+xOkYFKX;$lO=XNo=R4l=hb{Ja9r2$J z=lyYaIvuYQm6ZZgKnh3!DIf)YrGWQd+I*3yC*b9fm_;fJD2tb@M z9maLc62#^SVlNyLnW0%yiAl8@F)ZoKx2o%fLt@flHGEi|Y&D@+oX+>RD2Me#MJXT! z#tPi#cIo~9p8mu9KPG7>1*E{gQov@Lhs~N-s@^)eocG#Bf24cOC*6(fpfE%`CPq8v g#@q2Kin6Zxn$LUTkQj94gHF`Xfa@ZY0)MT*7k#=G5dZ)H literal 0 HcmV?d00001 diff --git a/backtesting/_stats.py b/backtesting/_stats.py index b564f5f0..18b3cdc9 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -131,20 +131,6 @@ def _round_timedelta(value, _period=_data_period(index)): day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change() gmean_day_return = geometric_mean(day_returns) - # Save daily returns into array to define covariance matrix - equity_returns = [] - market_returns = [] - # Calculate returns for each period - for i in range(1, len(equity)): - equity_return = (equity[i] - equity[i - 1]) / equity[i - 1] - market_return = (c[i] - c[i - 1]) / c[i - 1] - equity_returns.append(equity_return) - market_returns.append(market_return) - - equity_returns = np.array(equity_returns) - market_returns = np.array(market_returns) - cov_matrix = np.cov(equity_returns, market_returns) - # Annualized return and risk metrics are computed based on the (mostly correct) # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517 # Our annualized return matches `empyrical.annual_return(day_returns)` whereas @@ -166,6 +152,10 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 max_dd = -np.nan_to_num(dd.max()) s.loc['Alpha [%]'] = s.loc['Return [%]'] - s.loc['Buy & Hold Return [%]'] + # calculate returns using vectorized operations + equity_returns = np.diff(equity) / equity[:-1] + market_returns = np.diff(c) / c[:-1] + cov_matrix = np.cov(equity_returns, market_returns) s.loc['Beta'] = round(cov_matrix[0, 1] / cov_matrix[1, 1], 2) s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan) s.loc['Max. Drawdown [%]'] = max_dd * 100 From 8f7c51f5fc76691187a3b8564e04603aa3508f93 Mon Sep 17 00:00:00 2001 From: jensnesten Date: Sun, 23 Feb 2025 18:37:21 +0100 Subject: [PATCH 03/12] remove DS_store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 600ace093eaa500e38e81eeec47401ca08c70088..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyG{c^3>-s>NNG~0++W}iR#Er@etLPVqp^jGD(_%y~3A)PKXNHl0H*|Y2O z+-j#dp8?qV<8TKo0nF)+xOkYFKX;$lO=XNo=R4l=hb{Ja9r2$J z=lyYaIvuYQm6ZZgKnh3!DIf)YrGWQd+I*3yC*b9fm_;fJD2tb@M z9maLc62#^SVlNyLnW0%yiAl8@F)ZoKx2o%fLt@flHGEi|Y&D@+oX+>RD2Me#MJXT! z#tPi#cIo~9p8mu9KPG7>1*E{gQov@Lhs~N-s@^)eocG#Bf24cOC*6(fpfE%`CPq8v g#@q2Kin6Zxn$LUTkQj94gHF`Xfa@ZY0)MT*7k#=G5dZ)H From 62a29839d621dbba6535f5c37367bf7347f8227f Mon Sep 17 00:00:00 2001 From: jensnesten Date: Tue, 25 Feb 2025 20:07:37 +0100 Subject: [PATCH 04/12] move beta & alpha / use log return --- backtesting/_stats.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 43794261..9656a5c6 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -151,13 +151,13 @@ def _round_timedelta(value, _period=_data_period(index)): with np.errstate(divide='ignore'): s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 max_dd = -np.nan_to_num(dd.max()) - s.loc['Alpha [%]'] = s.loc['Return [%]'] - s.loc['Buy & Hold Return [%]'] - # calculate returns using vectorized operations - equity_returns = np.diff(equity) / equity[:-1] - market_returns = np.diff(c) / c[:-1] - cov_matrix = np.cov(equity_returns, market_returns) - s.loc['Beta'] = round(cov_matrix[0, 1] / cov_matrix[1, 1], 2) s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan) + # calculate returns using vectorized operations + equity_log_returns = np.log(equity[1:] / equity[:-1]) + market_log_returns = np.log(c[1:] / c[:-1]) + cov_matrix = np.cov(equity_log_returns, market_log_returns) + s.loc['Beta'] = cov_matrix[0, 1] / cov_matrix[1, 1] + s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - s.loc['Beta'] * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) From 871464e188ac4fa3ebd7ae1560eef6b298ffd95b Mon Sep 17 00:00:00 2001 From: jensnesten <42718681+jensnesten@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:53:24 +0100 Subject: [PATCH 05/12] Update backtesting/_stats.py Co-authored-by: kernc --- backtesting/_stats.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 9656a5c6..07e1db55 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -152,7 +152,6 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 max_dd = -np.nan_to_num(dd.max()) s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan) - # calculate returns using vectorized operations equity_log_returns = np.log(equity[1:] / equity[:-1]) market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) From 65adda03aee4147a7ff554352278a1984aa46597 Mon Sep 17 00:00:00 2001 From: jensnesten <42718681+jensnesten@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:55:45 +0100 Subject: [PATCH 06/12] Update backtesting/_stats.py Co-authored-by: kernc --- backtesting/_stats.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 07e1db55..04740823 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -155,8 +155,9 @@ def _round_timedelta(value, _period=_data_period(index)): equity_log_returns = np.log(equity[1:] / equity[:-1]) market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) - s.loc['Beta'] = cov_matrix[0, 1] / cov_matrix[1, 1] - s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - s.loc['Beta'] * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) + beta = cov_matrix[0, 1] / cov_matrix[1, 1] + s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) + s.loc['Beta'] = beta s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) From 6be63bb52180b7794677ad8a394aa77f4500732c Mon Sep 17 00:00:00 2001 From: jensnesten Date: Thu, 27 Feb 2025 18:27:15 +0100 Subject: [PATCH 07/12] alpha & beta test --- .gitignore | 3 +++ backtesting/_stats.py | 3 ++- backtesting/test/_test.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 09daa076..0a1ac162 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ build/* *~* .venv/ + +.DS_Store +**/.DS_Store diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 04740823..82c81d9c 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -156,7 +156,8 @@ def _round_timedelta(value, _period=_data_period(index)): market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) beta = cov_matrix[0, 1] / cov_matrix[1, 1] - s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) + alpha = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) + s.loc['Alpha [%]'] = alpha s.loc['Beta'] = beta s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 47d08a9e..ce9d28f1 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -312,6 +312,8 @@ def test_compute_stats(self): 'Start': pd.Timestamp('2004-08-19 00:00:00'), 'Win Rate [%]': 46.96969696969697, 'Worst Trade [%]': -18.39887353835481, + 'Alpha [%]': 394.37391142027462, + 'Beta': 0.03803390709192, }) def almost_equal(a, b): From a213d74562eca4dc414752d72f9abdaa129abf2a Mon Sep 17 00:00:00 2001 From: jensnesten Date: Thu, 27 Feb 2025 18:37:00 +0100 Subject: [PATCH 08/12] #noqa: E501 --- backtesting/_stats.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 82c81d9c..f4f1197d 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -156,8 +156,7 @@ def _round_timedelta(value, _period=_data_period(index)): market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) beta = cov_matrix[0, 1] / cov_matrix[1, 1] - alpha = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) - s.loc['Alpha [%]'] = alpha + s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 s.loc['Beta'] = beta s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 From 5abc3d810bac49ecec2b90c6da448ed077244f58 Mon Sep 17 00:00:00 2001 From: jensnesten Date: Thu, 27 Feb 2025 18:42:15 +0100 Subject: [PATCH 09/12] add space --- .gitignore | 3 +-- backtesting/_stats.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 0a1ac162..e2928130 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,4 @@ build/* .venv/ -.DS_Store -**/.DS_Store + diff --git a/backtesting/_stats.py b/backtesting/_stats.py index f4f1197d..d911688d 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -156,7 +156,7 @@ def _round_timedelta(value, _period=_data_period(index)): market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) beta = cov_matrix[0, 1] / cov_matrix[1, 1] - s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 + s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 s.loc['Beta'] = beta s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 From 52ab559f5fc2c22b66c3ca5d0d07372fac6f8c19 Mon Sep 17 00:00:00 2001 From: jensnesten Date: Thu, 27 Feb 2025 19:14:42 +0100 Subject: [PATCH 10/12] update docs --- README.md | 2 ++ backtesting/backtesting.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index df188175..57985ef3 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ CAGR [%] 16.80 Sharpe Ratio 0.66 Sortino Ratio 1.30 Calmar Ratio 0.77 +Alpha [%] 450.62 +Beta 0.02 Max. Drawdown [%] -33.08 Avg. Drawdown [%] -5.58 Max. Drawdown Duration 688 days 00:00:00 diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 8e835082..ca5425da 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -1257,6 +1257,8 @@ def run(self, **kwargs) -> pd.Series: Sharpe Ratio 0.58038 Sortino Ratio 1.08479 Calmar Ratio 0.44144 + Alpha [%] 394.37391 + Beta 0.03803 Max. Drawdown [%] -47.98013 Avg. Drawdown [%] -5.92585 Max. Drawdown Duration 584 days 00:00:00 From b03f33345364bcb47206f3cefdecedf6d768ffe8 Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 11 Mar 2025 18:53:22 +0100 Subject: [PATCH 11/12] Revert unrelated change --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index e2928130..09daa076 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ build/* *~* .venv/ - - From fc6ffc659d50188a3b945590b6fada3935b20ab5 Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 11 Mar 2025 18:56:01 +0100 Subject: [PATCH 12/12] Add comment --- backtesting/_stats.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index d911688d..045bd7ca 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -156,7 +156,8 @@ def _round_timedelta(value, _period=_data_period(index)): market_log_returns = np.log(c[1:] / c[:-1]) cov_matrix = np.cov(equity_log_returns, market_log_returns) beta = cov_matrix[0, 1] / cov_matrix[1, 1] - s.loc['Alpha [%]'] = (s.loc['Return [%]'] - risk_free_rate * 100) - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 + # Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large + s.loc['Alpha [%]'] = s.loc['Return [%]'] - risk_free_rate * 100 - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 s.loc['Beta'] = beta s.loc['Max. Drawdown [%]'] = max_dd * 100 s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100