Source code for pyqstrat.evaluator

# $$_ Lines starting with # $$_* autogenerated by jup_mini. Do not modify these
# $$_code
# $$_ %%checkall
import pandas as pd
import numpy as np
import statsmodels as sm
import statsmodels.api as smapi
import math
from pyqstrat.pq_utils import monotonically_increasing, infer_frequency
from pyqstrat.plot import TimeSeries, DateLine, Subplot, HorizontalLine, BucketedValues, Plot, LinePlotAttributes
import matplotlib as mpl
import matplotlib.figure as mpl_fig
from typing import Tuple, Sequence, Mapping, MutableMapping, Optional, Any, Callable, Dict


[docs]def compute_periods_per_year(timestamps: np.ndarray) -> float: """ Computes trading periods per year for an array of numpy datetime64's. e.g. if most of the timestamps are separated by 1 day, will return 252. Args: timestamps: a numpy array of datetime64's >>> compute_periods_per_year(np.array(['2018-01-01', '2018-01-02', '2018-01-03', '2018-01-09'], dtype='M8[D]')) 252.0 >>> round(compute_periods_per_year(np.array(['2018-01-01 10:00', '2018-01-01 10:05', '2018-01-01 10:10'], dtype='M8[m]')), 2) 72576.05 """ if not len(timestamps): return np.nan freq = infer_frequency(timestamps) if freq == 31: return 12 return 252. / freq if freq != 0 else np.nan
[docs]def compute_amean(returns: np.ndarray, periods_per_year: int) -> float: ''' Computes arithmetic mean of a return array, ignoring NaNs Args: returns: Represents returns at any frequency periods_per_year: Frequency of the returns, e.g. 252 for daily returns >>> compute_amean(np.array([0.003, 0.004, np.nan]), 252) 0.882 ''' if not len(returns): return np.nan return np.nanmean(returns) * periods_per_year
[docs]def compute_num_periods(timestamps: np.ndarray, periods_per_year: float) -> float: ''' Given an array of timestamps, we compute how many periods there are between the first and last element, where the length of a period is defined by periods_per_year. For example, if there are 6 periods per year, then each period would be approx. 2 months long. Args: timestamps (np.ndarray of np.datetime64): a numpy array of returns, can contain nans periods_per_year: number of periods between first and last return >>> assert(compute_num_periods(np.array(['2015-01-01', '2015-03-01', '2015-05-01'], dtype='M8[D]'), 6) == 2) ''' if not len(timestamps): return np.nan assert(monotonically_increasing(timestamps)) fraction_of_year = (timestamps[-1] - timestamps[0]) / (np.timedelta64(1, 's') * 365 * 24 * 60 * 60) return round(fraction_of_year * periods_per_year)
[docs]def compute_gmean(timestamps: np.ndarray, returns: np.ndarray, periods_per_year: float) -> float: """ Compute geometric mean of an array of returns Args: returns: a numpy array of returns, can contain nans periods_per_year: Used for annualizing returns >>> round(compute_gmean(np.array(['2015-01-01', '2015-03-01', '2015-05-01'], dtype='M8[D]'), np.array([0.001, 0.002, 0.003]), 252.), 6) 0.018362 """ if not len(returns): return np.nan assert(len(returns) == len(timestamps)) assert(isinstance(timestamps, np.ndarray) and isinstance(returns, np.ndarray)) mask = np.isfinite(returns) timestamps = timestamps[mask] returns = returns[mask] num_periods = compute_num_periods(timestamps, periods_per_year) g_mean = ((1.0 + returns).prod())**(1.0 / num_periods) g_mean = np.power(g_mean, periods_per_year) - 1.0 return g_mean
[docs]def compute_std(returns: np.ndarray) -> float: """ Computes standard deviation of an array of returns, ignoring nans """ if not len(returns): return np.nan return np.nanstd(returns)
[docs]def compute_sortino(returns: np.ndarray, amean: float, periods_per_year: float) -> float: ''' Note that this assumes target return is 0. Args: returns: a numpy array of returns amean: arithmetic mean of returns periods_per_year: number of trading periods per year >>> print(round(compute_sortino(np.array([0.001, -0.001, 0.002]), 0.001, 252), 6)) 0.133631 ''' if not len(returns) or not np.isfinite(amean) or periods_per_year <= 0: return np.nan returns = np.where((~np.isfinite(returns)), 0.0, returns) normalized_rets = np.where(returns > 0.0, 0.0, returns) sortino_denom = np.std(normalized_rets) sortino = np.nan if sortino_denom == 0 else amean / (sortino_denom * np.sqrt(periods_per_year)) return sortino
[docs]def compute_sharpe(returns: np.ndarray, amean: float, periods_per_year: float) -> float: ''' Note that this does not take into risk free returns so it's really a sharpe0, i.e. assumes risk free returns are 0 Args: returns: a numpy array of returns amean: arithmetic mean of returns periods_per_year: number of trading periods per year >>> round(compute_sharpe(np.array([0.001, -0.001, 0.002]), 0.001, 252), 6) 0.050508 ''' if not len(returns) or not np.isfinite(amean) or periods_per_year <= 0: return np.nan returns = np.where((~np.isfinite(returns)), 0.0, returns) s = np.std(returns) sharpe = np.nan if s == 0 else amean / (s * np.sqrt(periods_per_year)) return sharpe
[docs]def compute_k_ratio(equity: np.ndarray, periods_per_year: int, halflife_years: float = None) -> float: ''' Compute k-ratio (2013 or original versions by Lars Kestner). See https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2230949 We also implement a modification that allows higher weighting for more recent returns. Args: equity: a numpy array of the equity in your account periods_per_year: 252 for daily values halflife_years: If set, we use weighted linear regression to give less weight to older returns. In this case, we compute the original k-ratio which does not use periods per year or number of observations If not set, we compute the 2013 version of the k-ratio which weights k-ratio by sqrt(periods_per_year) / nobs Returns: weighted or unweighted k-ratio >>> np.random.seed(0) >>> t = np.arange(1000) >>> ret = np.random.normal(loc = 0.0025, scale = 0.01, size = len(t)) >>> equity = (1 + ret).cumprod() >>> assert(math.isclose(compute_k_ratio(equity, 252, None), 3.888, abs_tol=0.001)) >>> assert(math.isclose(compute_k_ratio(equity, 252, 0.5), 602.140, abs_tol=0.001)) ''' equity = equity[np.isfinite(equity)] equity = np.log(equity) t = np.arange(len(equity)) if halflife_years: halflife = halflife_years * periods_per_year k = math.log(0.5) / halflife w = np.empty(len(equity), dtype=float) w = np.exp(k * t) w = w ** 2 # Statsmodels requires square of weights w = w[::-1] fit = sm.regression.linear_model.WLS(endog=equity, exog=t, weights=w, hasconst=False).fit() k_ratio = fit.params[0] / fit.bse[0] else: fit = smapi.OLS(endog=equity, exog=np.arange(len(equity)), hasconst=False).fit() k_ratio = fit.params[0] * math.sqrt(periods_per_year) / (fit.bse[0] * len(equity)) return k_ratio
[docs]def compute_equity(timestamps: np.ndarray, starting_equity: float, returns: np.ndarray) -> np.ndarray: ''' Given starting equity, timestamps and returns, create a numpy array of equity at each date''' return starting_equity * np.cumprod(1. + returns)
[docs]def compute_rolling_dd(timestamps: np.ndarray, equity: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: ''' Compute numpy array of rolling drawdown percentage Args: timestamps: numpy array of datetime64 equity: numpy array of equity ''' assert(len(timestamps) == len(equity)) if not len(timestamps): return np.array([], dtype='M8[ns]'), np.array([], dtype=float) s = pd.Series(equity, index=timestamps) rolling_max = s.expanding(min_periods=1).max() dd = np.where(s >= rolling_max, 0.0, -(s - rolling_max) / rolling_max) return timestamps, dd
[docs]def compute_maxdd_pct(rolling_dd: np.ndarray) -> float: '''Compute max drawdown percentage given a numpy array of rolling drawdowns, ignoring NaNs''' if not len(rolling_dd): return np.nan assert(rolling_dd.ndim < 2) return np.nanmax(rolling_dd)
[docs]def compute_maxdd_date(rolling_dd_dates: np.ndarray, rolling_dd: np.ndarray) -> np.datetime64: ''' Compute date of max drawdown given numpy array of timestamps, and corresponding rolling dd percentages''' if not len(rolling_dd_dates): return pd.NaT assert(len(rolling_dd_dates) == len(rolling_dd)) return rolling_dd_dates[np.argmax(rolling_dd)]
[docs]def compute_maxdd_start(rolling_dd_dates: np.ndarray, rolling_dd: np.ndarray, mdd_date: np.datetime64) -> np.datetime64: '''Compute date when max drawdown starts, given numpy array of timestamps corresponding rolling dd percentages and date of the max draw down''' if not len(rolling_dd_dates) or pd.isnull(mdd_date): return pd.NaT assert(len(rolling_dd_dates) == len(rolling_dd)) maxdd_dates = rolling_dd_dates[(rolling_dd <= 0) & (rolling_dd_dates < mdd_date)] if not len(maxdd_dates): return pd.NaT return maxdd_dates[-1]
[docs]def compute_mar(returns: np.ndarray, periods_per_year: float, mdd_pct: float) -> float: '''Compute MAR ratio, which is annualized return divided by biggest drawdown since inception.''' if not len(returns) or np.isnan(mdd_pct) or mdd_pct == 0: return np.nan ret = np.mean(returns) * periods_per_year / mdd_pct return ret # type: ignore
[docs]def compute_dates_3yr(timestamps: np.ndarray) -> np.ndarray: ''' Given an array of numpy datetimes, return those that are within 3 years of the last date in the array''' if not len(timestamps): return np.array([], dtype='M8[D]') last_date = timestamps[-1] d = pd.to_datetime(last_date) start_3yr = np.datetime64(d.replace(year=d.year - 3)) return timestamps[timestamps > start_3yr]
[docs]def compute_returns_3yr(timestamps: np.ndarray, returns: np.ndarray) -> np.ndarray: '''Given an array of numpy datetimes and an array of returns, return those that are within 3 years of the last date in the datetime array ''' if not len(timestamps): return np.array([], dtype=float) assert(len(timestamps) == len(returns)) timestamps_3yr = compute_dates_3yr(timestamps) return returns[timestamps >= timestamps_3yr[0]]
[docs]def compute_rolling_dd_3yr(timestamps: np.ndarray, equity: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: '''Compute rolling drawdowns over the last 3 years''' if not len(timestamps): return np.array([], dtype='M8[D]'), np.array([], dtype=float) last_date = timestamps[-1] d = pd.to_datetime(last_date) start_3yr = np.datetime64(d.replace(year=d.year - 3)) equity = equity[timestamps >= start_3yr] timestamps = timestamps[timestamps >= start_3yr] return compute_rolling_dd(timestamps, equity)
[docs]def compute_maxdd_pct_3yr(rolling_dd_3yr: np.ndarray) -> float: '''Compute max drawdown percentage over the last 3 years''' return compute_maxdd_pct(rolling_dd_3yr)
[docs]def compute_maxdd_date_3yr(rolling_dd_3yr_timestamps: np.ndarray, rolling_dd_3yr: np.ndarray) -> np.datetime64: '''Compute max drawdown date over the last 3 years''' return compute_maxdd_date(rolling_dd_3yr_timestamps, rolling_dd_3yr)
[docs]def compute_maxdd_start_3yr(rolling_dd_3yr_timestamps: np.ndarray, rolling_dd_3yr: np.ndarray, mdd_date_3yr: np.datetime64) -> np.datetime64: '''Comput max drawdown start date over the last 3 years''' return compute_maxdd_start(rolling_dd_3yr_timestamps, rolling_dd_3yr, mdd_date_3yr)
[docs]def compute_calmar(returns_3yr: np.ndarray, periods_per_year: float, mdd_pct_3yr: float) -> float: '''Compute Calmar ratio, which is the annualized return divided by max drawdown over the last 3 years''' return compute_mar(returns_3yr, periods_per_year, mdd_pct_3yr)
[docs]def compute_bucketed_returns(timestamps: np.ndarray, returns: np.ndarray) -> Tuple[Sequence[int], Sequence[np.ndarray]]: ''' Bucket returns by year Returns: A tuple with the first element being a list of years and the second a list of numpy arrays containing returns for each corresponding year ''' assert(len(timestamps) == len(returns)) if not len(timestamps): return [], [np.array([], dtype=float)] s = pd.Series(returns, index=timestamps) years_list = [] rets_list = [] for year, rets in s.groupby(s.index.map(lambda x: x.year)): years_list.append(year) rets_list.append(rets.values) return years_list, rets_list
[docs]def compute_annual_returns(timestamps: np.ndarray, returns: np.ndarray, periods_per_year: float) -> Tuple[np.ndarray, np.ndarray]: '''Takes the output of compute_bucketed_returns and returns geometric mean of returns by year Returns: A tuple with the first element being an array of years (integer) and the second element an array of annualized returns for those years ''' assert(len(timestamps) == len(returns) and periods_per_year > 0) if not len(timestamps): return np.array([], dtype=int), np.array([], dtype=float) df = pd.DataFrame({'ret': returns, 'timestamp': timestamps}) years = [] gmeans = [] for k, g in df.groupby(df.timestamp.map(lambda x: x.year)): years.append(k) gmeans.append(compute_gmean(g.timestamp.values, g.ret.values, periods_per_year)) return np.array(years), np.array(gmeans)
[docs]class Evaluator: """You add functions to the evaluator that are dependent on the outputs of other functions. The evaluator will call these functions in the right order so dependencies are computed first before the functions that need their output. You can retrieve the output of a metric using the metric member function >>> evaluator = Evaluator(initial_metrics={'x': np.array([1, 2, 3]), 'y': np.array([3, 4, 5])}) >>> evaluator.add_metric('z', lambda x, y: sum(x, y), dependencies=['x', 'y']) >>> evaluator.compute() >>> evaluator.metric('z') array([ 9, 10, 11]) """
[docs] def __init__(self, initial_metrics: Dict[str, Any]) -> None: """Inits Evaluator with a dictionary of initial metrics that are used to compute subsequent metrics Args: initial_metrics: a dictionary of string name -> metric. metric can be any object including a scalar, an array or a tuple """ assert(type(initial_metrics) == dict) self.metric_values: Dict[str, Any] = initial_metrics.copy() self._metrics: MutableMapping[str, Tuple[Callable, Sequence[str]]] = {}
[docs] def add_metric(self, name: str, func: Callable, dependencies: Sequence[str]) -> None: self._metrics[name] = (func, dependencies)
[docs] def compute(self, metric_names: Sequence[str] = None) -> None: '''Compute metrics using the internal dependency graph Args: metric_names: an array of metric names. If not passed in, evaluator will compute and store all metrics ''' if metric_names is None: metric_names = list(self._metrics.keys()) for metric_name in metric_names: self.compute_metric(metric_name)
[docs] def compute_metric(self, metric_name: str) -> None: ''' Compute and store a single metric: Args: metric_name: string representing the metric to compute ''' func, dependencies = self._metrics[metric_name] for dependency in dependencies: if dependency not in self.metric_values: self.compute_metric(dependency) dependency_values = {k: self.metric_values[k] for k in dependencies} values = func(**dependency_values) self.metric_values[metric_name] = values
[docs] def metric(self, metric_name: str) -> Any: '''Return the value of a single metric given its name''' return self.metric_values[metric_name]
[docs] def metrics(self) -> Mapping[str, Any]: '''Return a dictionary of metric name -> metric value''' return self.metric_values
[docs]def handle_non_finite_returns(timestamps: np.ndarray, rets: np.ndarray, leading_non_finite_to_zeros: bool, subsequent_non_finite_to_zeros: bool) -> Tuple[np.ndarray, np.ndarray]: ''' >>> np.set_printoptions(formatter={'float': '{: .6g}'.format}) >>> timestamps = np.arange(np.datetime64('2019-01-01'), np.datetime64('2019-01-07')) >>> rets = np.array([np.nan, np.nan, 3, 4, np.nan, 5]) >>> handle_non_finite_returns(timestamps, rets, leading_non_finite_to_zeros = False, subsequent_non_finite_to_zeros = True) (array(['2019-01-03', '2019-01-04', '2019-01-05', '2019-01-06'], dtype='datetime64[D]'), array([ 3, 4, 0, 5])) >>> handle_non_finite_returns(timestamps, rets, leading_non_finite_to_zeros = True, subsequent_non_finite_to_zeros = False) (array(['2019-01-01', '2019-01-02', '2019-01-03', '2019-01-04', '2019-01-06'], dtype='datetime64[D]'), array([ 0, 0, 3, 4, 5])) >>> handle_non_finite_returns(timestamps, rets, leading_non_finite_to_zeros = False, subsequent_non_finite_to_zeros = False) (array(['2019-01-01', '2019-01-02', '2019-01-03', '2019-01-04', '2019-01-06'], dtype='datetime64[D]'), array([ 0, 0, 3, 4, 5])) >>> rets = np.array([1, 2, 3, 4, 4.5, 5]) >>> handle_non_finite_returns(timestamps, rets, leading_non_finite_to_zeros = False, subsequent_non_finite_to_zeros = True) (array(['2019-01-01', '2019-01-02', '2019-01-03', '2019-01-04', '2019-01-05', '2019-01-06'], dtype='datetime64[D]'), array([ 1, 2, 3, 4, 4.5, 5])) ''' first_non_nan_index_ = np.ravel(np.nonzero(~np.isnan(rets))) # type: ignore if len(first_non_nan_index_): first_non_nan_index = first_non_nan_index_[0] else: first_non_nan_index = -1 if first_non_nan_index > 0 and first_non_nan_index < len(rets): if leading_non_finite_to_zeros: rets[:first_non_nan_index] = np.nan_to_num(rets[:first_non_nan_index]) else: timestamps = timestamps[first_non_nan_index:] rets = rets[first_non_nan_index:] if subsequent_non_finite_to_zeros: rets = np.nan_to_num(rets) else: timestamps = timestamps[np.isfinite(rets)] rets = rets[np.isfinite(rets)] return timestamps, rets
[docs]def compute_return_metrics(timestamps: np.ndarray, rets: np.ndarray, starting_equity: float, leading_non_finite_to_zeros: bool = False, subsequent_non_finite_to_zeros: bool = True) -> Evaluator: ''' Compute a set of common metrics using returns (for example, of an instrument or a portfolio) Args: timestamps (np.array of datetime64): Timestamps for the returns rets (nd.array of float): The returns, use 0.01 for 1% starting_equity (float): Starting equity value in your portfolio leading_non_finite_to_zeros (bool, optional): If set, we replace leading nan, inf, -inf returns with zeros. For example, you may need a warmup period for moving averages. Default False subsequent_non_finite_to_zeros (bool, optional): If set, we replace any nans that follow the first non nan value with zeros. There may be periods where you have no prices but removing these returns would result in incorrect annualization. Default True Returns: An Evaluator object containing computed metrics off the returns passed in. If needed, you can add your own metrics to this object based on the values of existing metrics and recompute the Evaluator. Otherwise, you can just use the output of the evaluator using the metrics function. >>> timestamps = np.array(['2015-01-01', '2015-03-01', '2015-05-01', '2015-09-01'], dtype='M8[D]') >>> rets = np.array([0.01, 0.02, np.nan, -0.015]) >>> starting_equity = 1.e6 >>> ev = compute_return_metrics(timestamps, rets, starting_equity) >>> metrics = ev.metrics() >>> assert(round(metrics['gmean'], 6) == 0.021061) >>> assert(round(metrics['sharpe'], 6) == 0.599382) >>> assert(all(metrics['returns_3yr'] == np.array([0.01, 0.02, 0, -0.015]))) ''' assert(starting_equity > 0.) assert(type(rets) == np.ndarray and rets.dtype == np.float64) assert(type(timestamps) == np.ndarray and np.issubdtype(timestamps.dtype, np.datetime64) and monotonically_increasing(timestamps)) timestamps, rets = handle_non_finite_returns(timestamps, rets, leading_non_finite_to_zeros, subsequent_non_finite_to_zeros) ev = Evaluator({'timestamps': timestamps, 'returns': rets, 'starting_equity': starting_equity}) ev.add_metric('periods_per_year', compute_periods_per_year, dependencies=['timestamps']) ev.add_metric('amean', compute_amean, dependencies=['returns', 'periods_per_year']) ev.add_metric('std', compute_std, dependencies=['returns']) ev.add_metric('up_periods', lambda returns: len(returns[returns > 0]), dependencies=['returns']) ev.add_metric('down_periods', lambda returns: len(returns[returns < 0]), dependencies=['returns']) ev.add_metric('up_pct', lambda up_periods, down_periods: up_periods * 1.0 / (up_periods + down_periods) if (up_periods + down_periods) != 0 else np.nan, dependencies=['up_periods', 'down_periods']) ev.add_metric('gmean', compute_gmean, dependencies=['timestamps', 'returns', 'periods_per_year']) ev.add_metric('sharpe', compute_sharpe, dependencies=['returns', 'periods_per_year', 'amean']) ev.add_metric('sortino', compute_sortino, dependencies=['returns', 'periods_per_year', 'amean']) ev.add_metric('equity', compute_equity, dependencies=['timestamps', 'starting_equity', 'returns']) ev.add_metric('k_ratio', compute_k_ratio, dependencies=['equity', 'periods_per_year']) ev.add_metric('k_ratio_weighted', lambda equity, periods_per_year: compute_k_ratio(equity, periods_per_year, 3), dependencies=['equity', 'periods_per_year']) # Drawdowns ev.add_metric('rolling_dd', compute_rolling_dd, dependencies=['timestamps', 'equity']) ev.add_metric('mdd_pct', lambda rolling_dd: compute_maxdd_pct(rolling_dd[1]), dependencies=['rolling_dd']) ev.add_metric('mdd_date', lambda rolling_dd: compute_maxdd_date(rolling_dd[0], rolling_dd[1]), dependencies=['rolling_dd']) ev.add_metric('mdd_start', lambda rolling_dd, mdd_date: compute_maxdd_start(rolling_dd[0], rolling_dd[1], mdd_date), dependencies=['rolling_dd', 'mdd_date']) ev.add_metric('mar', compute_mar, dependencies=['returns', 'periods_per_year', 'mdd_pct']) ev.add_metric('timestamps_3yr', compute_dates_3yr, dependencies=['timestamps']) ev.add_metric('returns_3yr', compute_returns_3yr, dependencies=['timestamps', 'returns']) ev.add_metric('rolling_dd_3yr', compute_rolling_dd_3yr, dependencies=['timestamps', 'equity']) ev.add_metric('mdd_pct_3yr', lambda rolling_dd_3yr: compute_maxdd_pct_3yr(rolling_dd_3yr[1]), dependencies=['rolling_dd_3yr']) ev.add_metric('mdd_date_3yr', lambda rolling_dd_3yr: compute_maxdd_date_3yr(rolling_dd_3yr[0], rolling_dd_3yr[1]), dependencies=['rolling_dd_3yr']) ev.add_metric('mdd_start_3yr', lambda rolling_dd_3yr, mdd_date_3yr: compute_maxdd_start_3yr(rolling_dd_3yr[0], rolling_dd_3yr[1], mdd_date_3yr), dependencies=['rolling_dd_3yr', 'mdd_date_3yr']) ev.add_metric('calmar', compute_calmar, dependencies=['returns_3yr', 'periods_per_year', 'mdd_pct_3yr']) ev.add_metric('annual_returns', compute_annual_returns, dependencies=['timestamps', 'returns', 'periods_per_year']) ev.add_metric('bucketed_returns', compute_bucketed_returns, dependencies=['timestamps', 'returns']) ev.compute() return ev
[docs]def display_return_metrics(metrics: Mapping[str, Any], float_precision: int = 3, show=True) -> pd.DataFrame: ''' Creates a dataframe making it convenient to view the output of the metrics obtained using the compute_return_metrics function. Args: float_precision: Change if you want to display floats with more or less significant figures than the default, 3 significant figures. Returns: A one row dataframe with formatted metrics. ''' from IPython.display import display _metrics = {} cols = ['gmean', 'amean', 'std', 'shrp', 'srt', 'k', 'calmar', 'mar', 'mdd_pct', 'mdd_start', 'mdd_date', 'dd_3y_pct', 'up_periods', 'down_periods', 'up_pct', 'mdd_start_3yr', 'mdd_date_3yr'] translate = {'shrp': 'sharpe', 'srt': 'sortino', 'dd_3y_pct': 'mdd_pct_3yr', 'k': 'k_ratio'} for col in cols: key = col if col in translate: key = translate[col] _metrics[col] = metrics[key] _metrics['mdd_dates'] = f'{str(metrics["mdd_start"])[:10]}/{str(metrics["mdd_date"])[:10]}' _metrics['up_dwn'] = f'{metrics["up_periods"]}/{metrics["down_periods"]}/{metrics["up_pct"]:.3g}' _metrics['dd_3y_timestamps'] = f'{str(metrics["mdd_start_3yr"])[:10]}/{str(metrics["mdd_date_3yr"])[:10]}' years = metrics['annual_returns'][0] ann_rets = metrics['annual_returns'][1] for i, year in enumerate(years): _metrics[str(year)] = ann_rets[i] format_str = '{:.' + str(float_precision) + 'g}' for k, v in _metrics.items(): if isinstance(v, float) or isinstance(v, float): _metrics[k] = format_str.format(v) cols = ['gmean', 'amean', 'std', 'shrp', 'srt', 'k', 'calmar', 'mar', 'mdd_pct', 'mdd_dates', 'dd_3y_pct', 'dd_3y_timestamps', 'up_dwn'] + [ str(year) for year in sorted(years)] df = pd.DataFrame(index=['']) for metric_name, metric_value in _metrics.items(): df.insert(0, metric_name, metric_value) df = df[cols] if show: display(df) return df
[docs]def plot_return_metrics(metrics: Mapping[str, Any], title: str = None, disp_attribs: LinePlotAttributes = None, drawdown_lines: bool = True, zero_line: bool = True, show_date_gaps: bool = True) -> Optional[Tuple[mpl_fig.Figure, mpl.axes.Axes]]: ''' Plot equity, rolling drawdowns and and a boxplot of annual returns given the output of compute_return_metrics. ''' timestamps = metrics['timestamps'] equity = metrics['equity'] equity = TimeSeries('equity', timestamps=timestamps, values=equity, display_attributes=disp_attribs) mdd_date, mdd_start = metrics['mdd_start'], metrics['mdd_date'] mdd_date_3yr, mdd_start_3yr = metrics['mdd_start_3yr'], metrics['mdd_date_3yr'] if drawdown_lines: date_lines = [DateLine(name='max dd', date=mdd_start, color='red'), DateLine(date=mdd_date, color='red'), DateLine(name='3y dd', date=mdd_start_3yr, color='orange'), DateLine(date=mdd_date_3yr, color='orange')] else: date_lines = [] horizontal_lines = [HorizontalLine(metrics['starting_equity'], color='black')] if zero_line else [] equity_subplot = Subplot(equity, ylabel='Equity', height_ratio=0.6, log_y=True, y_tick_format='${x:,.0f}', date_lines=date_lines, horizontal_lines=horizontal_lines) rolling_dd = TimeSeries('drawdowns', timestamps=metrics['rolling_dd'][0], values=metrics['rolling_dd'][1], display_attributes=disp_attribs) horizontal_lines = [HorizontalLine(y=0, color='black')] if zero_line else [] dd_subplot = Subplot(rolling_dd, ylabel='Drawdowns', height_ratio=0.2, date_lines=date_lines, horizontal_lines=horizontal_lines) years = metrics['bucketed_returns'][0] ann_rets = metrics['bucketed_returns'][1] ann_ret = BucketedValues('annual returns', bucket_names=years, bucket_values=ann_rets) ann_ret_subplot = Subplot(ann_ret, ylabel='Annual Returns', height_ratio=0.2, horizontal_lines=horizontal_lines) plt = Plot([equity_subplot, dd_subplot, ann_ret_subplot], title=title, show_date_gaps=show_date_gaps) return plt.draw()
[docs]def test_evaluator() -> None: from datetime import datetime, timedelta np.random.seed(10) timestamps: np.ndarray = np.arange(datetime(2018, 1, 1), datetime(2018, 3, 1), timedelta(days=1)) rets = np.random.normal(size=len(timestamps)) / 1000 starting_equity = 1.e6 ev = compute_return_metrics(timestamps, rets, starting_equity) display_return_metrics(ev.metrics()) plot_return_metrics(ev.metrics(), zero_line=False) assert(round(ev.metric('sharpe'), 6) == 2.932954) assert(round(ev.metric('sortino'), 6) == 5.690878) assert(ev.metric('annual_returns')[0] == [2018]) assert(round(ev.metric('annual_returns')[1][0], 6) == [0.063530]) assert(ev.metric('mdd_start') == np.datetime64('2018-01-19')) assert(ev.metric('mdd_date') == np.datetime64('2018-01-22'))
if __name__ == "__main__": test_evaluator() import doctest doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE) # $$_end_code