From a2d01de23a4eac2d092b1bc5244d0ccb1d4bf595 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Wed, 2 Aug 2023 16:18:37 +0200 Subject: [PATCH] Compute Sortino ratio (#67) (#108) Addition of Sortino ratio to FinQuant --------- Co-authored-by: Pietropaolo Frisoni Co-authored-by: Andrei Troie Co-authored-by: github-actions[bot] --- CONTRIBUTORS.md | 1 + README.md | 7 +++-- README.tex.md | 7 +++-- example/Example-Analysis.py | 17 +++++++---- finquant/portfolio.py | 59 +++++++++++++++++++++++++++++++++---- finquant/quants.py | 56 +++++++++++++++++++++++++++++++++++ finquant/returns.py | 13 ++++++++ tests/test_quants.py | 24 +++++++++++++++ tests/test_returns.py | 19 ++++++++++++ version | 4 +-- 10 files changed, 190 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 6a4969b2..08cb2a4d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,6 +15,7 @@ Thank you to all the individuals who have contributed to this project! - Stephen Pennington (@slpenn13): bug fixing - @herrfz: bug fixing - @drcsturm: bug fixing +- @aft90: helped to implement the Sortino Ratio ## Special Thanks diff --git a/README.md b/README.md index 73378f17..85971e8e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions @@ -249,8 +249,11 @@ look at the examples provided in `./example`. `./example/Example-Analysis.py`: This example shows how to use an instance of `finquant.portfolio.Portfolio`, get the portfolio's quantities, such as - Expected Returns, - Volatility, + - Downside Risk, + - Value at Risk, - Sharpe Ratio, - - Value at Risk. + - Sortino Ratio, + - Beta parameter. It also shows how to extract individual stocks from the given portfolio. Moreover it shows how to compute and visualise: - the different Returns provided by the module `finquant.returns`, diff --git a/README.tex.md b/README.tex.md index e54f4ae1..357e4593 100644 --- a/README.tex.md +++ b/README.tex.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions @@ -249,8 +249,11 @@ look at the examples provided in `./example`. `./example/Example-Analysis.py`: This example shows how to use an instance of `finquant.portfolio.Portfolio`, get the portfolio's quantities, such as - Expected Returns, - Volatility, + - Downside Risk, + - Value at Risk, - Sharpe Ratio, - - Value at Risk. + - Sortino Ratio, + - Beta parameter. It also shows how to extract individual stocks from the given portfolio. Moreover it shows how to compute and visualise: - the different Returns provided by the module `finquant.returns`, diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index ba0f4682..40507ef7 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -50,10 +50,12 @@ # -# ## Expected Return, Volatility, Sharpe Ratio and Value at Risk of Portfolio -# The annualised expected return and volatility, as well as the Sharpe Ratio and Value at Risk are automatically computed. They are obtained as shown below. +# ## Expected Return, Volatility, Sharpe Ratio, Sortino Ratio, and Value at Risk of Portfolio +# The annualised expected return and volatility, as well as the Sharpe Ratio, the Sortino Ratio, and Value at Risk are automatically computed. +# They are obtained as shown below. # The expected return and volatility are based on 252 trading days by default. -# The Sharpe Ratio is computed with a risk free rate of 0.005 by default. The Value at Risk is computed with a confidence level of 0.95 by default. +# The Sharpe Ratio and the Sortino ratio are computed with a risk free rate of 0.005 by default. +# The Value at Risk is computed with a confidence level of 0.95 by default. # @@ -67,11 +69,16 @@ # -# Sharpe ratio (computed with a risk free rate of 0.005 by default) +# Sharpe Ratio (computed with a risk free rate of 0.005 by default) print(pf.sharpe) # +# Sortino Ratio (computed with a risk free rate of 0.005 by default) +print(pf.sortino) + +# + # Value at Risk (computed with a confidence level of 0.95 by default) print(pf.var) @@ -90,7 +97,7 @@ # # ## Nicely printing out portfolio quantities -# To print the expected annualised return, volatility, Sharpe ratio, skewness and Kurtosis of the portfolio and its stocks, one can simply do `pf.properties()`. +# To print the expected annualised return, volatility, Sharpe Ratio, Sortino Ratio, skewness and Kurtosis of the portfolio and its stocks, one can simply do `pf.properties()`. # diff --git a/finquant/portfolio.py b/finquant/portfolio.py index 1401de60..e24b74b8 100644 --- a/finquant/portfolio.py +++ b/finquant/portfolio.py @@ -17,8 +17,10 @@ - daily log returns of the portfolio's stocks, - Expected (annualised) Return, - Volatility, -- Sharpe Ratio, +- Downside Risk, - Value at Risk, +- Sharpe Ratio, +- Sortino Ratio, - Beta parameter (optional), - skewness of the portfolio's stocks, - Kurtosis of the portfolio's stocks, @@ -59,7 +61,14 @@ from finquant.efficient_frontier import EfficientFrontier from finquant.market import Market from finquant.monte_carlo import MonteCarloOpt -from finquant.quants import sharpe_ratio, value_at_risk, weighted_mean, weighted_std +from finquant.quants import ( + downside_risk, + sharpe_ratio, + sortino_ratio, + value_at_risk, + weighted_mean, + weighted_std, +) from finquant.returns import ( cumulative_returns, daily_log_returns, @@ -85,8 +94,10 @@ def __init__(self): self.data = pd.DataFrame() self.expected_return = None self.volatility = None - self.sharpe = None + self.downside_risk = None self.var = None + self.sharpe = None + self.sortino = None self.skew = None self.kurtosis = None self.totalinvestment = None @@ -226,8 +237,10 @@ def _update(self): self.totalinvestment = self.portfolio.Allocation.sum() self.expected_return = self.comp_expected_return(freq=self.freq) self.volatility = self.comp_volatility(freq=self.freq) - self.sharpe = self.comp_sharpe() + self.downside_risk = self.comp_downside_risk(freq=self.freq) self.var = self.comp_var() + self.sharpe = self.comp_sharpe() + self.sortino = self.comp_sortino() self.skew = self._comp_skew() self.kurtosis = self._comp_kurtosis() if self.market_index is not None: @@ -349,6 +362,22 @@ def comp_volatility(self, freq=252): self.volatility = volatility return volatility + def comp_downside_risk(self, freq=252): + """Computes the downside risk of the portfolio. + + :Input: + :freq: ``int`` (default: ``252``), number of trading days, default + value corresponds to trading days in a year + + :Output: + :downside risk: ``float`` downside risk of the portfolio. + """ + downs_risk = downside_risk( + self.data, self.comp_weights(), self.risk_free_rate + ) * np.sqrt(freq) + self.downside_risk = downs_risk + return downs_risk + def comp_cov(self): """Compute and return a ``pandas.DataFrame`` of the covariance matrix of the portfolio. @@ -403,6 +432,17 @@ def comp_beta(self) -> float: self.beta = beta return beta + def comp_sortino(self, freq=252): + """Compute and return the Sortino Ratio of the portfolio + + :Output: + :sortino: ``float``, the Sortino Ratio of the portfolio + May be NaN if the portoflio outperformed the risk free rate at every point + """ + return sortino_ratio( + self.expected_return, self.downside_risk, self.risk_free_rate + ) + def _comp_skew(self): """Computes and returns the skewness of the stocks in the portfolio.""" return self.data.skew() @@ -664,8 +704,11 @@ def properties(self): - Expected Return, - Volatility, + - Downside Risk, + - Value at Risk (VaR), + - Confidence level of VaR, - Sharpe Ratio, - - Value at Risk, + - Sortino Ratio, - Beta (optional), - skewness, - Kurtosis @@ -682,10 +725,12 @@ def properties(self): string += f"\nRisk free rate: {self.risk_free_rate}" string += f"\nPortfolio Expected Return: {self.expected_return:0.3f}" string += f"\nPortfolio Volatility: {self.volatility:0.3f}" - string += f"\nPortfolio Sharpe Ratio: {self.sharpe:0.3f}" + string += f"\nPortfolio Downside Risk: {self.downside_risk:0.3f}" string += f"\nPortfolio Value at Risk: {self.var:0.3f}" string += f"\nConfidence level of Value at Risk: " string += f"{self.var_confidence_level * 100:0.2f} %" + string += f"\nPortfolio Sharpe Ratio: {self.sharpe:0.3f}" + string += f"\nPortfolio Sortino Ratio: {self.sortino:0.3f}" if self.beta is not None: string += f"\nPortfolio Beta: {self.beta:0.3f}" string += "\n\nSkewness:" @@ -1214,7 +1259,9 @@ def build_portfolio(**kwargs): or not pf.stocks or pf.expected_return is None or pf.volatility is None + or pf.downside_risk is None or pf.sharpe is None + or pf.sortino is None or pf.skew is None or pf.kurtosis is None ): diff --git a/finquant/quants.py b/finquant/quants.py index 95e33bcf..c072448d 100644 --- a/finquant/quants.py +++ b/finquant/quants.py @@ -8,6 +8,8 @@ import pandas as pd from scipy.stats import norm +from finquant.returns import weighted_mean_daily_returns + def weighted_mean(means, weights): """Computes the weighted mean/average, or in the case of a @@ -75,6 +77,60 @@ def sharpe_ratio(exp_return, volatility, risk_free_rate=0.005): return (exp_return - risk_free_rate) / float(volatility) +def sortino_ratio(exp_return, downside_risk, risk_free_rate=0.005): + """Computes the Sortino Ratio + + :Input: + :exp_return: ``int``/``float``, Expected Return of a portfolio + :downside_risk: ``int``/``float``, Downside Risk of a portfolio + :risk_free_rate: ``int``/``float`` (default= ``0.005``), risk free rate + + :Output: + :sortino ratio: ``float``/``NaN`` ``(exp_return - risk_free_rate)/float(downside_risk)``. + Can be ``NaN`` if ``downside_risk`` is zero + """ + if not isinstance( + exp_return, (int, float, np.int32, np.int64, np.float32, np.float64) + ): + raise ValueError("exp_return is expected to be an integer or float.") + if not isinstance( + downside_risk, (int, float, np.int32, np.int64, np.float32, np.float64) + ): + raise ValueError("volatility is expected to be an integer or float.") + if not isinstance( + risk_free_rate, (int, float, np.int32, np.int64, np.float32, np.float64) + ): + raise ValueError("risk_free_rate is expected to be an integer or float.") + if float(downside_risk) == 0: + return np.nan + else: + return (exp_return - risk_free_rate) / float(downside_risk) + + +def downside_risk(data: pd.DataFrame, weights, risk_free_rate=0.005) -> float: + """Computes the downside risk (target downside deviation of returns). + + :Input: + :data: ``pandas.DataFrame`` with daily stock prices + :weights: ``numpy.ndarray``/``pd.Series`` of weights + :risk_free_rate: ``int``/``float`` (default=``0.005``), risk free rate + + :Output: + :downside_risk: ``float``, target downside deviation + """ + if not isinstance(data, pd.DataFrame): + raise ValueError("data is expected to be a Pandas.DataFrame.") + if not isinstance(weights, (pd.Series, np.ndarray)): + raise ValueError("weights is expected to be a pandas.Series/np.ndarray.") + if not isinstance( + risk_free_rate, (int, float, np.int32, np.int64, np.float32, np.float64) + ): + raise ValueError("risk_free_rate is expected to be an integer or float.") + + wtd_daily_mean = weighted_mean_daily_returns(data, weights) + return np.sqrt(np.mean(np.minimum(0, wtd_daily_mean - risk_free_rate) ** 2)) + + def value_at_risk(investment, mu, sigma, conf_level=0.95) -> float: """Computes and returns the expected value at risk of an investment/assets. diff --git a/finquant/returns.py b/finquant/returns.py index 086b33e3..6401cd2d 100644 --- a/finquant/returns.py +++ b/finquant/returns.py @@ -36,6 +36,19 @@ def daily_returns(data): return data.pct_change().dropna(how="all").replace([np.inf, -np.inf], np.nan) +def weighted_mean_daily_returns(data, weights): + """Returns DataFrame with the daily weighted mean returns + + :Input: + :data: ``pandas.DataFrame`` with daily stock prices + :weights: ``numpy.ndarray``/``pd.Series`` of weights + + :Output: + :ret: ``numpy.array`` of weighted mean daily percentage change of Returns + """ + return np.dot(daily_returns(data), weights) + + def daily_log_returns(data): """ Returns DataFrame with daily log returns diff --git a/tests/test_quants.py b/tests/test_quants.py index 99422bc0..833c8f4d 100644 --- a/tests/test_quants.py +++ b/tests/test_quants.py @@ -1,9 +1,14 @@ +import pdb + import numpy as np +import pandas as pd import pytest from finquant.quants import ( annualised_portfolio_quantities, + downside_risk, sharpe_ratio, + sortino_ratio, value_at_risk, weighted_mean, weighted_std, @@ -34,6 +39,11 @@ def test_sharpe_ratio(): assert sharpe_ratio(0.5, 0.22, 0.005) == 2.25 +def test_sortino_ratio(): + assert sortino_ratio(0.5, 0.0, 0.02) is np.NaN + assert sortino_ratio(0.005, 8.5, 0.005) == 0.0 + + def test_value_at_risk(): assert abs(value_at_risk(1e2, 0.5, 0.25, 0.95) - 91.12) <= 1e-1 assert abs(value_at_risk(1e3, 0.8, 0.5, 0.99) - 1963.17) <= 1e-1 @@ -77,3 +87,17 @@ def test_annualised_portfolio_quantities(): orig = (1764, 347.79304190854657, 5.071981861166303) for i in range(len(res)): assert abs(res[i] - orig[i]) <= 1e-15 + + +def test_downside_risk(): + data1 = pd.DataFrame({"1": [1, 2, 4, 8], "2": [1, 2, 3, 4]}) + weights = np.array([0.25, 0.75]) + rf_rate = 0.005 + dr1 = downside_risk(data1, weights, rf_rate) + assert dr1 == 0 + + data2 = pd.DataFrame({"1": [7, 6, 5, 4, 3]}) + weights = np.array([1]) + rf_rate = 0.0 + dr2 = downside_risk(data2, weights, rf_rate) + assert abs(dr2 - 0.19409143531019335) <= 1e-15 diff --git a/tests/test_returns.py b/tests/test_returns.py index 8e4bd54d..730a30f5 100644 --- a/tests/test_returns.py +++ b/tests/test_returns.py @@ -1,3 +1,4 @@ +import numpy as np import pandas as pd from finquant.returns import ( @@ -5,6 +6,7 @@ daily_log_returns, daily_returns, historical_mean_return, + weighted_mean_daily_returns, ) @@ -41,6 +43,23 @@ def test_daily_returns(): assert all(abs(ret["2"].values - orig[1]) <= 1e-15) +def test_weighted_daily_mean_returns(): + l1 = [1.0, 1.5, 2.25, 3.375] + l2 = [1.0, 2.0, 4.0, 8.0] + expected = [0.5 * 0.25 + 1 * 0.75 for i in range(len(l1) - 1)] + weights = np.array([0.25, 0.75]) + d = {"1": l1, "2": l2} + df = pd.DataFrame(d) + ret = weighted_mean_daily_returns(df, weights) + assert all(abs(ret - expected) <= 1e-15) + + d = {"1": l1} + expected = [0.5 for i in range(len(l1) - 1)] + df = pd.DataFrame(d) + ret = weighted_mean_daily_returns(df, np.array([1])) + assert all(abs(ret - expected) <= 1e-15) + + def test_daily_log_returns(): orig = [ [ diff --git a/version b/version index bf4c4f6a..9305189e 100644 --- a/version +++ b/version @@ -1,2 +1,2 @@ -version=0.4.1 -release=0.4.1 +version=0.5.0 +release=0.5.0