import pandas as pd
import numpy as np
from functools import reduce
from pyqstrat.evaluator import compute_return_metrics, display_return_metrics, plot_return_metrics
from pyqstrat.strategy import Strategy
from pyqstrat.pq_utils import str2date
from typing import Sequence, MutableMapping, Mapping, Tuple
[docs]class Portfolio:
'''A portfolio contains one or more strategies that run concurrently so you can test running strategies that are uncorrelated together.'''
[docs] def __init__(self, name: str = 'main') -> None:
'''Args:
name: String used for displaying this portfolio
'''
self.name = name
self.strategies: MutableMapping[str, Strategy] = {}
[docs] def add_strategy(self, name: str, strategy: Strategy) -> None:
'''
Args:
name: Name of the strategy
strategy: Strategy instance
'''
self.strategies[name] = strategy
strategy.name = name
[docs] def run_indicators(self, strategy_names: Sequence[str] = None) -> None:
'''Compute indicators for the strategies specified
Args:
strategy_names: By default this is set to None and we use all strategies.
'''
if strategy_names is None: strategy_names = list(self.strategies.keys())
if len(strategy_names) == 0: raise Exception('a portfolio must have at least one strategy')
for name in strategy_names: self.strategies[name].run_indicators()
[docs] def run_signals(self, strategy_names: Sequence[str] = None) -> None:
'''Compute signals for the strategies specified. Must be called after run_indicators
Args:
strategy_names: By default this is set to None and we use all strategies.
'''
if strategy_names is None: strategy_names = list(self.strategies.keys())
if len(strategy_names) == 0: raise Exception('a portfolio must have at least one strategy')
for name in strategy_names: self.strategies[name].run_signals()
def _generate_order_iterations(self,
strategies: Sequence[Strategy],
start_date: np.datetime64,
end_date: np.datetime64) -> Tuple[np.ndarray, Sequence[Tuple[Strategy, np.ndarray]]]:
'''
>>> class Strategy:
... def __init__(self, num):
... self.timestamps = [
... np.array(['2018-01-01', '2018-01-02', '2018-01-03'], dtype='M8[D]'),
... np.array(['2018-01-02', '2018-01-03', '2018-01-04'], dtype='M8[D]')][num]
... self.num = num
... def _generate_order_iterations(self, start_date, end_date):
... pass
... def __repr__(self):
... return f'{self.num}'
>>> all_timestamps, orders_iter = Portfolio._generate_order_iterations(None, [Strategy(0), Strategy(1)], None, None)
>>> assert(all(all_timestamps == np.array(['2018-01-01', '2018-01-02', '2018-01-03','2018-01-04'], dtype = 'M8[D]')))
>>> assert(all(orders_iter[0][1] == np.array([0, 1, 2, 3])))
>>> assert(all(orders_iter[1][1] == np.array([0, 0, 1, 2])))
'''
if strategies is None: strategies = self.strategies.values
timestamps_list = [strategy.timestamps for strategy in strategies]
all_timestamps = np.array(reduce(np.union1d, timestamps_list))
if start_date is not None:
all_timestamps = all_timestamps[(all_timestamps >= start_date)]
if end_date is not None:
all_timestamps = all_timestamps[(all_timestamps <= end_date)]
iterations = []
for strategy in strategies:
indices = np.searchsorted(strategy.timestamps, all_timestamps)
iterations.append((strategy, indices))
strategy._generate_order_iterations(start_date=start_date, end_date=end_date)
return all_timestamps, iterations
[docs] def run_rules(self, strategy_names: Sequence[str] = None, start_date: np.datetime64 = None, end_date: np.datetime64 = None) -> None:
'''Run rules for the strategies specified. Must be called after run_indicators and run_signals.
See run function for argument descriptions
'''
start_date, end_date = str2date(start_date), str2date(end_date)
if strategy_names is None: strategy_names = list(self.strategies.keys())
if len(strategy_names) == 0: raise Exception('a portfolio must have at least one strategy')
strategies = [self.strategies[key] for key in strategy_names]
min_date = min([strategy.timestamps[0] for strategy in strategies])
if start_date: min_date = max(min_date, start_date)
max_date = max([strategy.timestamps[-1] for strategy in strategies])
if end_date: max_date = min(max_date, end_date)
all_timestamps, iterations = self._generate_order_iterations(strategies, start_date, end_date)
for i, timestamp in enumerate(all_timestamps):
for (strategy, indices) in iterations:
# index into strategy timestamps
idx = indices[i]
if idx != len(strategy.timestamps) and strategy.timestamps[idx] == timestamp:
strategy._run_iteration(idx)
# Make sure we calc to the end for each strategy
for strategy in strategies:
strategy.account.calc(strategy.timestamps[-1])
[docs] def run(self, strategy_names: Sequence[str] = None, start_date: np.datetime64 = None, end_date: np.datetime64 = None) -> None:
'''
Run indicators, signals and rules.
Args:
strategy_names: A list of strategy names. By default this is set to None and we use all strategies.
start_date: Run rules starting from this date.
Sometimes we have a few strategies in a portfolio that need different lead times before they are ready to trade
so you can set this so they are all ready by this date. Default None
end_date: Don't run rules after this date. Default None
'''
start_date, end_date = str2date(start_date), str2date(end_date)
self.run_indicators()
self.run_signals()
self.run_rules(strategy_names, start_date, end_date)
[docs] def df_returns(self, sampling_frequency: str = 'D', strategy_names: Sequence[str] = None) -> pd.DataFrame:
'''
Return dataframe containing equity and returns with a date index. Equity and returns are combined from all strategies passed in.
Args:
sampling_frequency: Date frequency for rows. Default 'D' for daily so we will have one row per day
strategy_names: By default this is set to None and we use all strategies.
'''
if strategy_names is None: strategy_names = list(self.strategies.keys())
if len(strategy_names) == 0: raise Exception('portfolio must have at least one strategy')
equity_list = []
for name in strategy_names:
equity = self.strategies[name].df_returns(sampling_frequency=sampling_frequency)[['timestamp', 'equity']]
equity.columns = ['timestamp', name]
equity = equity.set_index('timestamp')
equity_list.append(equity)
df = pd.concat(equity_list, axis=1)
df['equity'] = df.sum(axis=1)
df['ret'] = df.equity.pct_change()
return df.reset_index()
[docs] def evaluate_returns(self,
sampling_frequency: str = 'D',
strategy_names: Sequence[str] = None,
plot: bool = True,
float_precision: int = 4) -> Mapping:
'''Returns a dictionary of common return metrics.
Args:
sampling_frequency: Date frequency. Default 'D' for daily so we downsample to daily returns before computing metrics
strategy_names: By default this is set to None and we use all strategies.
plot: If set to True, display plots of equity, drawdowns and returns. Default False
float_precision: Number of significant figures to show in returns. Default 4
'''
returns = self.df_returns(sampling_frequency, strategy_names)
ev = compute_return_metrics(returns.timestamp.values, returns.ret.values, returns.equity.values[0])
display_return_metrics(ev.metrics(), float_precision=float_precision)
if plot: plot_return_metrics(ev.metrics())
return ev.metrics()
[docs] def plot(self,
sampling_frequency: str = 'D',
strategy_names: Sequence[str] = None) -> None:
'''Display plots of equity, drawdowns and returns
Args:
sampling_frequency: Date frequency. Default 'D' for daily so we downsample to daily returns before computing metrics
strategy_names: A list of strategy names. By default this is set to None and we use all strategies.
'''
returns = self.df_returns(sampling_frequency, strategy_names)
timestamps = returns.timestamp.values
ev = compute_return_metrics(timestamps, returns.ret.values, returns.equity.values[0])
plot_return_metrics(ev.metrics())
def __repr__(self) -> str:
return f'{self.name} {self.strategies.keys()}'
if __name__ == "__main__":
import doctest
doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE)