Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compute Sortino ratio (#67) #108

Merged
merged 33 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b71933a
Compute Sortino ratio (#67)
aft90 Jul 18, 2023
2090462
formatting with black
fmilthaler Jul 18, 2023
bcbd442
fixing imports with isort
fmilthaler Jul 18, 2023
41872f2
Automated formatting changes
github-actions[bot] Jul 26, 2023
d01579e
testing PR
PietropaoloFrisoni Jul 26, 2023
551897e
Automated version changes
github-actions[bot] Jul 26, 2023
49e660d
Updating README files
github-actions[bot] Jul 26, 2023
b583bb7
testing PR
PietropaoloFrisoni Jul 26, 2023
1db7ebb
Merge branch 'master' into feature/sortino-ratio
PietropaoloFrisoni Jul 26, 2023
d682a0b
removing space in version
PietropaoloFrisoni Jul 26, 2023
d0c6b52
trying to solve error in downside_risk function to run test
PietropaoloFrisoni Jul 26, 2023
a098603
Automated formatting changes
github-actions[bot] Jul 26, 2023
59392cc
trying to solve error in downside_risk function to run test
PietropaoloFrisoni Jul 26, 2023
49604f6
Automated formatting changes
github-actions[bot] Jul 26, 2023
96b7297
solving python error replacing np.isnan
PietropaoloFrisoni Jul 27, 2023
cb5e276
Automated formatting changes
github-actions[bot] Jul 27, 2023
7bd24f2
adding descriptions
PietropaoloFrisoni Jul 28, 2023
02d4623
Updating README files
github-actions[bot] Jul 28, 2023
c5f7138
Automated formatting changes
github-actions[bot] Jul 28, 2023
4a21feb
updating README
PietropaoloFrisoni Jul 28, 2023
9332ebc
Updating README files
github-actions[bot] Jul 28, 2023
b3a48f6
updating README
PietropaoloFrisoni Jul 30, 2023
0851e66
Updating README files
github-actions[bot] Jul 30, 2023
35858e0
new candidate version
PietropaoloFrisoni Jul 30, 2023
ffda69c
temporarily disable tests (WIP)
PietropaoloFrisoni Jul 30, 2023
892669d
Automated formatting changes
github-actions[bot] Jul 30, 2023
6638b66
Sortino ratio implementation
PietropaoloFrisoni Jul 31, 2023
d933d65
adding sortino ratio tests
PietropaoloFrisoni Jul 31, 2023
661462b
better format in downside risk function
PietropaoloFrisoni Aug 1, 2023
2aafa30
adding Sortino Ratio to example-analysis.py
PietropaoloFrisoni Aug 2, 2023
c8ca3c1
updating contributors
PietropaoloFrisoni Aug 2, 2023
9585e09
Partially reverting commit "adding descriptions"
PietropaoloFrisoni Aug 2, 2023
a1254a3
Updating README files
github-actions[bot] Aug 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading