Skip to content

Commit

Permalink
Adding inferring periods to core.py adn optional trading_days_per_yea…
Browse files Browse the repository at this point in the history
…r to calculate correctly statistics for non traditional markets (crypto..)

resolve comment

fix lint errors

fix lint issue

use name from #161
  • Loading branch information
oldrichsmejkal authored and timkpaine committed Jun 22, 2022
1 parent 0fe1635 commit 7546879
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 15 deletions.
106 changes: 91 additions & 15 deletions ffn/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@

from matplotlib import pyplot as plt # noqa

# module level variable, can be different for non traditional markets (eg. crypto - 360)
TRADING_DAYS_PER_YEAR = 252


class PerformanceStats(object):
"""
Expand All @@ -57,17 +60,19 @@ class PerformanceStats(object):
* lookback_returns (Series): Returns for different
lookback periods (1m, 3m, 6m, ytd...)
* stats (Series): A series that contains all the stats
* annualization_factor (float): `Annualization factor` used in various calculations; aka `nperiods`, `252`
"""

def __init__(self, prices, rf=0.0):
def __init__(self, prices, rf=0.0, annualization_factor=TRADING_DAYS_PER_YEAR):
super(PerformanceStats, self).__init__()
self.prices = prices
self.name = self.prices.name
self._start = self.prices.index[0]
self._end = self.prices.index[-1]

self.rf = rf
self.annualization_factor = annualization_factor

self._update(self.prices)

Expand Down Expand Up @@ -223,21 +228,24 @@ def _calculate(self, obj):
# Will calculate daily figures only if the input data has at least daily frequency or higher (e.g hourly)
# Rather < 2 days than <= 1 days in case of data taken at different hours of the days
if r.index.to_series().diff().min() < pd.Timedelta("2 days"):
self.daily_mean = r.mean() * 252
self.daily_vol = np.std(r, ddof=1) * np.sqrt(252)
self.daily_mean = r.mean() * self.annualization_factor
self.daily_vol = np.std(r, ddof=1) * np.sqrt(self.annualization_factor)

# if type(self.rf) is float:
if isinstance(self.rf, float):
self.daily_sharpe = r.calc_sharpe(rf=self.rf, nperiods=252)
self.daily_sortino = calc_sortino_ratio(r, rf=self.rf, nperiods=252)
self.daily_sharpe = r.calc_sharpe(
rf=self.rf, nperiods=self.annualization_factor
)
self.daily_sortino = calc_sortino_ratio(
r, rf=self.rf, nperiods=self.annualization_factor
)
# rf is a price series
else:
_rf_daily_price_returns = self.rf.to_returns()
self.daily_sharpe = r.calc_sharpe(
rf=_rf_daily_price_returns, nperiods=252
rf=_rf_daily_price_returns, nperiods=self.annualization_factor
)
self.daily_sortino = calc_sortino_ratio(
r, rf=_rf_daily_price_returns, nperiods=252
r, rf=_rf_daily_price_returns, nperiods=self.annualization_factor
)

self.best_day = r.max()
Expand Down Expand Up @@ -1402,7 +1410,9 @@ def calc_sharpe(returns, rf=0.0, nperiods=None, annualize=True):
etc.)
"""
# if type(rf) is float and rf != 0 and nperiods is None:
if nperiods is None:
nperiods = infer_freq(returns)

if isinstance(rf, float) and rf != 0 and nperiods is None:
raise Exception("Must provide nperiods if rf != 0")

Expand Down Expand Up @@ -1825,7 +1835,9 @@ def calc_erc_weights(
return pd.Series(erc_weights, index=returns.columns, name="erc")


def get_num_days_required(offset, period="d", perc_required=0.90):
def get_num_days_required(
offset, period="d", perc_required=0.90, annualization_factor=252
):
"""
Estimates the number of days required to assume that data is OK.
Expand All @@ -1849,7 +1861,7 @@ def get_num_days_required(offset, period="d", perc_required=0.90):
elif period == "m":
req = (days / 20) * perc_required
elif period == "y":
req = (days / 252) * perc_required
req = (days / annualization_factor) * perc_required
else:
raise NotImplementedError("period not supported. Supported periods are d, m, y")

Expand Down Expand Up @@ -2276,9 +2288,68 @@ def deannualize(returns, nperiods):
monthly, etc.
"""
if nperiods is None:
nperiods = infer_freq(returns)
return np.power(1 + returns, 1.0 / nperiods) - 1.0


def infer_freq(data):
"""
Infer the most likely frequency given the input index. If the frequency is
uncertain or index is not DateTime like, just return None
Args:
* data (DataFrame, Series): Any timeseries dataframe or series
"""
try:
return pd.infer_freq(data.index, warn=False)
except Exception:
return None


def infer_nperiods(data, annualization_factor=None):
if annualization_factor is None:
annualization_factor = TRADING_DAYS_PER_YEAR

freq = infer_freq(data)

if freq is None:
return None

def whole_periods_str_to_nperiods(freq):
if freq == "Y" or freq == "A":
return 1
if freq == "M":
return 12
if freq == "D":
return annualization_factor
if freq == "H":
return annualization_factor * 24
if freq == "T":
return annualization_factor * 24 * 60
if freq == "S":
return annualization_factor * 24 * 60 * 60
return None

""
if len(freq) == 1:
return whole_periods_str_to_nperiods(freq)
else:
try:
if freq.startswith("A"):
return 1
else:
whole_periods_str = freq[-1]
num_str = freq[:-1]
num = int(num_str)
return num * whole_periods_str_to_nperiods(whole_periods_str)
except KeyboardInterrupt:
raise
except BaseException:
return None

return None


def calc_sortino_ratio(returns, rf=0.0, nperiods=None, annualize=True):
"""
Calculates the `Sortino ratio <https://www.investopedia.com/terms/s/sortinoratio.asp>`_ given a series of returns
Expand All @@ -2291,10 +2362,12 @@ def calc_sortino_ratio(returns, rf=0.0, nperiods=None, annualize=True):
provided if rf is non-zero and rf is not a price series
"""
# if type(rf) is float and rf != 0 and nperiods is None:
if isinstance(rf, float) and rf != 0 and nperiods is None:
raise Exception("nperiods must be set if rf != 0 and rf is not a price series")

if nperiods is None:
nperiods = infer_freq(returns)

er = returns.to_excess_returns(rf, nperiods=nperiods)

negative_returns = np.minimum(er[1:], 0.0)
Expand Down Expand Up @@ -2322,9 +2395,10 @@ def to_excess_returns(returns, rf, nperiods=None):
* excess_returns (Series, DataFrame): Returns - rf
"""
# if type(rf) is float and nperiods is not None:
if isinstance(rf, float) and nperiods is not None:
if nperiods is None:
nperiods = infer_freq(returns)

if isinstance(rf, float) and nperiods is not None:
_rf = deannualize(rf, nperiods)
else:
_rf = rf
Expand Down Expand Up @@ -2370,7 +2444,9 @@ def to_ulcer_performance_index(prices, rf=0.0, nperiods=None):
* nperiods (int): Used to deannualize rf if rf is provided (non-zero)
"""
# if type(rf) is float and rf != 0 and nperiods is None:
if nperiods is None:
nperiods = infer_freq(prices)

if isinstance(rf, float) and rf != 0 and nperiods is None:
raise Exception("nperiods must be set if rf != 0 and rf is not a price series")

Expand Down
35 changes: 35 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,38 @@ def test_drawdown_details():

drawdown = ffn.to_drawdown_series(returns)
drawdown_details = ffn.drawdown_details(drawdown, index_type=drawdown.index)


def test_infer_nperiods():
daily = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'D'))
hourly = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'H'))
yearly = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'Y'))
monthly = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'M'))
minutely = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'T'))
secondly = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = 'S'))

minutely_30 = pd.DataFrame(np.random.randn(10),
index = pd.date_range(start='2018-01-01', periods = 10, freq = '30T'))


not_known_vals = np.concatenate((pd.date_range(start='2018-01-01', periods = 5, freq = '1H').values,
pd.date_range(start='2018-01-02', periods = 5, freq = '5H').values))

not_known = pd.DataFrame(np.random.randn(10),
index = pd.DatetimeIndex(not_known_vals))

assert ffn.core.infer_nperiods(daily) == ffn.core.TRADING_DAYS_PER_YEAR
assert ffn.core.infer_nperiods(hourly) == ffn.core.TRADING_DAYS_PER_YEAR * 24
assert ffn.core.infer_nperiods(minutely) == ffn.core.TRADING_DAYS_PER_YEAR * 24 * 60
assert ffn.core.infer_nperiods(secondly) == ffn.core.TRADING_DAYS_PER_YEAR * 24 * 60 * 60
assert ffn.core.infer_nperiods(monthly) == 12
assert ffn.core.infer_nperiods(yearly) == 1
assert ffn.core.infer_nperiods(minutely_30) == ffn.core.TRADING_DAYS_PER_YEAR * 24 * 60 * 30
assert ffn.core.infer_nperiods(not_known) is None

0 comments on commit 7546879

Please sign in to comment.