Skip to content

Commit

Permalink
Compute Sortino ratio (#67) (#108)
Browse files Browse the repository at this point in the history
Addition of Sortino ratio to FinQuant

---------

Co-authored-by: Pietropaolo Frisoni <pfrisoni@uwo.ca>
Co-authored-by: Andrei Troie <andreitroie90@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
4 people committed Aug 2, 2023
1 parent 2b7dbc3 commit a2d01de
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 17 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<img src="https://img.shields.io/github/stars/fmilthaler/FinQuant.svg?style=social&label=Star" alt='pypi'>
</a>
<a href="https://pypi.org/project/FinQuant">
<img src="https://img.shields.io/badge/pypi-v0.4.1-brightgreen.svg?style=popout" alt='pypi'>
<img src="https://img.shields.io/badge/pypi-v0.5.0-brightgreen.svg?style=popout" alt='pypi'>
</a>
<a href="https://github.com/fmilthaler/FinQuant">
<img src="https://github.com/fmilthaler/finquant/actions/workflows/pytest.yml/badge.svg?branch=master" alt='GitHub Actions'>
Expand Down Expand Up @@ -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`,
Expand Down
7 changes: 5 additions & 2 deletions README.tex.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<img src="https://img.shields.io/github/stars/fmilthaler/FinQuant.svg?style=social&label=Star" alt='pypi'>
</a>
<a href="https://pypi.org/project/FinQuant">
<img src="https://img.shields.io/badge/pypi-v0.4.1-brightgreen.svg?style=popout" alt='pypi'>
<img src="https://img.shields.io/badge/pypi-v0.5.0-brightgreen.svg?style=popout" alt='pypi'>
</a>
<a href="https://github.com/fmilthaler/FinQuant">
<img src="https://github.com/fmilthaler/finquant/actions/workflows/pytest.yml/badge.svg?branch=master" alt='GitHub Actions'>
Expand Down Expand Up @@ -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`,
Expand Down
17 changes: 12 additions & 5 deletions example/Example-Analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@

# <markdowncell>

# ## 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.

# <codecell>

Expand All @@ -67,11 +69,16 @@

# <codecell>

# 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)

# <codecell>

# Sortino Ratio (computed with a risk free rate of 0.005 by default)
print(pf.sortino)

# <codecell>

# Value at Risk (computed with a confidence level of 0.95 by default)
print(pf.var)

Expand All @@ -90,7 +97,7 @@
# <markdowncell>

# ## 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()`.

# <codecell>

Expand Down
59 changes: 53 additions & 6 deletions finquant/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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:"
Expand Down Expand Up @@ -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
):
Expand Down
56 changes: 56 additions & 0 deletions finquant/quants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions finquant/returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/test_quants.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
19 changes: 19 additions & 0 deletions tests/test_returns.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import numpy as np
import pandas as pd

from finquant.returns import (
cumulative_returns,
daily_log_returns,
daily_returns,
historical_mean_return,
weighted_mean_daily_returns,
)


Expand Down Expand Up @@ -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 = [
[
Expand Down

0 comments on commit a2d01de

Please sign in to comment.