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 @@
-
+
@@ -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 @@
-
+
@@ -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