diff --git a/pyfolio/plotting.py b/pyfolio/plotting.py index 2c34b21b..9aacbdf8 100644 --- a/pyfolio/plotting.py +++ b/pyfolio/plotting.py @@ -27,6 +27,7 @@ from . import utils from . import timeseries from . import pos +from . import txn from .utils import APPROX_BDAYS_PER_MONTH @@ -1064,7 +1065,7 @@ def plot_turnover(returns, transactions, positions, y_axis_formatter = FuncFormatter(utils.one_dec_places) ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) - df_turnover = pos.get_turnover(transactions, positions) + df_turnover = txn.get_turnover(transactions, positions) df_turnover_by_month = df_turnover.resample("M") df_turnover.plot(color='steelblue', alpha=1.0, lw=0.5, ax=ax, **kwargs) df_turnover_by_month.plot( @@ -1088,6 +1089,106 @@ def plot_turnover(returns, transactions, positions, return ax +def plot_slippage_sweep(returns, transactions, positions, + slippage_params=(3, 8, 10, 12, 15, 20, 50), + ax=None, **kwargs): + """Plots a equity curves at different per-dollar slippage assumptions. + + Parameters + ---------- + returns : pd.Series + Timeseries of portfolio returns to be adjusted for various + degrees of slippage. + transactions : pd.DataFrame + Daily transaction volume and dollar ammount. + - See full explanation in tears.create_full_tear_sheet. + positions : pd.DataFrame + Daily net position values. + - See full explanation in tears.create_full_tear_sheet. + slippage_params: tuple + Slippage pameters to apply to the return time series (in + basis points). + ax : matplotlib.Axes, optional + Axes upon which to plot. + **kwargs, optional + Passed to seaborn plotting function. + + Returns + ------- + ax : matplotlib.Axes + The axes that were plotted on. + + """ + if ax is None: + ax = plt.gca() + + turnover = txn.get_turnover(transactions, positions, + period=None, average=False) + + slippage_sweep = pd.DataFrame() + for bps in slippage_params: + adj_returns = txn.adjust_returns_for_slippage(returns, turnover, bps) + label = str(bps) + " bps" + slippage_sweep[label] = timeseries.cum_returns(adj_returns, 1) + + slippage_sweep.plot(alpha=1.0, lw=0.5, ax=ax) + + ax.set_title('Cumulative Returns Given Additional Per-Dollar Slippage') + ax.set_ylabel('') + + ax.legend(loc='center left') + + return ax + + +def plot_slippage_sensitivity(returns, transactions, positions, + ax=None, **kwargs): + """Plots curve relating per-dollar slippage to average annual returns. + + Parameters + ---------- + returns : pd.Series + Timeseries of portfolio returns to be adjusted for various + degrees of slippage. + transactions : pd.DataFrame + Daily transaction volume and dollar ammount. + - See full explanation in tears.create_full_tear_sheet. + positions : pd.DataFrame + Daily net position values. + - See full explanation in tears.create_full_tear_sheet. + ax : matplotlib.Axes, optional + Axes upon which to plot. + **kwargs, optional + Passed to seaborn plotting function. + + Returns + ------- + ax : matplotlib.Axes + The axes that were plotted on. + + """ + if ax is None: + ax = plt.gca() + + turnover = txn.get_turnover(transactions, positions, + period=None, average=False) + avg_returns_given_slippage = pd.Series() + for bps in range(1, 100): + adj_returns = txn.adjust_returns_for_slippage(returns, turnover, bps) + avg_returns = timeseries.annual_return( + adj_returns, style='calendar') + avg_returns_given_slippage.loc[bps] = avg_returns + + avg_returns_given_slippage.plot(alpha=1.0, lw=2, ax=ax) + + ax.set(title='Average Annual Returns Given Additional Per-Dollar Slippage', + xticks=np.arange(0, 100, 10), + ylabel='Average Annual Return', + xlabel='Per-Dollar Slippage (bps)') + + return ax + + def plot_daily_turnover_hist(transactions, positions, ax=None, **kwargs): """Plots a histogram of daily turnover rates. @@ -1114,7 +1215,7 @@ def plot_daily_turnover_hist(transactions, positions, if ax is None: ax = plt.gca() - turnover = pos.get_turnover(transactions, positions, period=None) + turnover = txn.get_turnover(transactions, positions, period=None) sns.distplot(turnover, ax=ax, **kwargs) ax.set_title('Distribution of Daily Turnover Rates') ax.set_xlabel('Turnover Rate') diff --git a/pyfolio/pos.py b/pyfolio/pos.py index 8d7a4083..318c64a4 100644 --- a/pyfolio/pos.py +++ b/pyfolio/pos.py @@ -129,40 +129,6 @@ def extract_pos(positions, cash): return values -def get_turnover(transactions, positions, period=None): - """ - Portfolio Turnover Rate: - Average value of purchases and sales divided - by the average portfolio value for the period. - If no period is provided the period is one time step. - Parameters - ---------- - transactions_df : pd.DataFrame - Contains transactions data. - - See full explanation in tears.create_full_tear_sheet - positions : pd.DataFrame - Contains daily position values including cash - - See full explanation in tears.create_full_tear_sheet - period : str, optional - Takes the same arguments as df.resample. - Returns - ------- - turnover_rate : pd.Series - timeseries of portfolio turnover rates. - """ - - traded_value = transactions.txn_volume - portfolio_value = positions.sum(axis=1) - if period is not None: - traded_value = traded_value.resample(period, how='sum') - portfolio_value = portfolio_value.resample(period, how='mean') - # traded_value contains the summed value from buys and sells; - # this is divided by 2.0 to get the average of the two. - turnover = traded_value / 2.0 - turnover_rate = turnover / portfolio_value - return turnover_rate - - def get_sector_exposures(positions, symbol_sector_map): """ Sum position exposures by sector. diff --git a/pyfolio/tears.py b/pyfolio/tears.py index 3ef31781..110fc2e3 100644 --- a/pyfolio/tears.py +++ b/pyfolio/tears.py @@ -19,6 +19,7 @@ from . import timeseries from . import utils from . import pos +from . import txn from . import plotting from .plotting import plotting_context @@ -41,6 +42,7 @@ def create_full_tear_sheet(returns, positions=None, transactions=None, benchmark_rets=None, gross_lev=None, + slippage=None, live_start_date=None, bayesian=False, sector_mappings=None, cone_std=1.0, set_context=True): @@ -90,6 +92,13 @@ def create_full_tear_sheet(returns, positions=None, transactions=None, 2009-12-07 0.999783 2009-12-08 0.999880 2009-12-09 1.000283 + slippage : int/float, optional + Basis points of slippage to apply to returns before generating + tearsheet stats and plots. + If a value is provided, slippage parameter sweep + plots will be generated from the unadjusted returns. + Transactions and positions must also be passed. + - See txn.adjust_returns_for_slippage for more details. live_start_date : datetime, optional The point in time when the strategy began live trading, after its backtest period. @@ -111,6 +120,14 @@ def create_full_tear_sheet(returns, positions=None, transactions=None, if returns.index[0] < benchmark_rets.index[0]: returns = returns[returns.index > benchmark_rets.index[0]] + if slippage is not None and transactions is not None: + turnover = txn.get_turnover(transactions, positions, + period=None, average=False) + unadjusted_returns = returns.copy() + returns = txn.adjust_returns_for_slippage(returns, turnover, slippage) + else: + unadjusted_returns = None + create_returns_tear_sheet( returns, live_start_date=live_start_date, @@ -131,6 +148,7 @@ def create_full_tear_sheet(returns, positions=None, transactions=None, if transactions is not None: create_txn_tear_sheet(returns, positions, transactions, + unadjusted_returns=unadjusted_returns, set_context=set_context) if bayesian: @@ -374,8 +392,8 @@ def create_position_tear_sheet(returns, positions, gross_lev=None, @plotting_context -def create_txn_tear_sheet( - returns, positions, transactions, return_fig=False): +def create_txn_tear_sheet(returns, positions, transactions, + unadjusted_returns=None, return_fig=False): """ Generate a number of plots for analyzing a strategy's transactions. @@ -397,9 +415,10 @@ def create_txn_tear_sheet( set_context : boolean, optional If True, set default plotting style context. """ + vertical_sections = 5 if unadjusted_returns is not None else 3 - fig = plt.figure(figsize=(14, 3 * 6)) - gs = gridspec.GridSpec(3, 3, wspace=0.5, hspace=0.5) + fig = plt.figure(figsize=(14, vertical_sections * 6)) + gs = gridspec.GridSpec(vertical_sections, 3, wspace=0.5, hspace=0.5) ax_turnover = plt.subplot(gs[0, :]) ax_daily_volume = plt.subplot(gs[1, :], sharex=ax_turnover) ax_turnover_hist = plt.subplot(gs[2, :]) @@ -418,6 +437,20 @@ def create_txn_tear_sheet( except AttributeError: warnings.warn('Unable to generate turnover plot.', UserWarning) + if unadjusted_returns is not None: + ax_slippage_sweep = plt.subplot(gs[3, :]) + plotting.plot_slippage_sweep(unadjusted_returns, + transactions, + positions, + ax=ax_slippage_sweep + ) + ax_slippage_sensitivity = plt.subplot(gs[4, :]) + plotting.plot_slippage_sensitivity(unadjusted_returns, + transactions, + positions, + ax=ax_slippage_sensitivity + ) + plt.show() if return_fig: return fig diff --git a/pyfolio/tests/test_pos.py b/pyfolio/tests/test_pos.py index f2980a26..4d5dd1a8 100644 --- a/pyfolio/tests/test_pos.py +++ b/pyfolio/tests/test_pos.py @@ -8,19 +8,19 @@ date_range, Timestamp ) -from pandas.util.testing import (assert_frame_equal, - assert_series_equal) +from pandas.util.testing import (assert_frame_equal) + from numpy import ( absolute, arange, zeros_like, ) +import warnings + from pyfolio.pos import (get_percent_alloc, extract_pos, - get_turnover, get_sector_exposures) -import warnings class PositionsTestCase(TestCase): @@ -79,53 +79,6 @@ def test_extract_pos(self): assert_frame_equal(result, expected) - def test_get_turnover(self): - """ - Tests turnover using a 20 day period. - - With no transactions the turnover should be 0. - - with 100% of the porfolio value traded each day - the daily turnover rate should be 0.5. - - For monthly turnover it should be the sum - of the daily turnovers because 20 days < 1 month. - - e.g (20 days) * (0.5 daily turn) = 10x monthly turnover rate. - """ - dates = date_range(start='2015-01-01', freq='D', periods=20) - - positions = DataFrame([[0.0, 10.0]]*len(dates), - columns=[0, 'cash'], index=dates) - transactions = DataFrame([[0, 0]]*len(dates), - columns=['txn_volume', 'txn_shares'], - index=dates) - - # Test with no transactions - expected = Series([0.0]*len(dates), index=dates) - result = get_turnover(transactions, positions) - assert_series_equal(result, expected) - - # Monthly freq - index = date_range('01-01-2015', freq='M', periods=1) - expected = Series([0.0], index=index) - result = get_turnover(transactions, positions, period='M') - assert_series_equal(result, expected) - - # Test with 0.5 daily turnover - transactions = DataFrame([[10.0, 0]]*len(dates), - columns=['txn_volume', 'txn_shares'], - index=dates) - - expected = Series([0.5]*len(dates), index=dates) - result = get_turnover(transactions, positions) - assert_series_equal(result, expected) - - # Monthly freq: should be the sum of the daily freq - result = get_turnover(transactions, positions, period='M') - expected = Series([10.0], index=index) - assert_series_equal(result, expected) - @parameterized.expand([ (DataFrame([[1.0, 2.0, 3.0, 10.0]]*len(dates), columns=[0, 1, 2, 'cash'], index=dates), diff --git a/pyfolio/tests/test_txn.py b/pyfolio/tests/test_txn.py new file mode 100644 index 00000000..bf5df1e1 --- /dev/null +++ b/pyfolio/tests/test_txn.py @@ -0,0 +1,81 @@ +from unittest import TestCase + +from pandas import ( + Series, + DataFrame, + date_range +) +from pandas.util.testing import (assert_series_equal) + +from pyfolio.txn import (get_turnover, + adjust_returns_for_slippage) + + +class TransactionsTestCase(TestCase): + + def test_get_turnover(self): + """ + Tests turnover using a 20 day period. + + With no transactions the turnover should be 0. + + with 100% of the porfolio value traded each day + the daily turnover rate should be 0.5. + + For monthly turnover it should be the sum + of the daily turnovers because 20 days < 1 month. + + e.g (20 days) * (0.5 daily turn) = 10x monthly turnover rate. + """ + dates = date_range(start='2015-01-01', freq='D', periods=20) + + positions = DataFrame([[0.0, 10.0]]*len(dates), + columns=[0, 'cash'], index=dates) + transactions = DataFrame([[0, 0]]*len(dates), + columns=['txn_volume', 'txn_shares'], + index=dates) + + # Test with no transactions + expected = Series([0.0]*len(dates), index=dates) + result = get_turnover(transactions, positions) + assert_series_equal(result, expected) + + # Monthly freq + index = date_range('01-01-2015', freq='M', periods=1) + expected = Series([0.0], index=index) + result = get_turnover(transactions, positions, period='M') + assert_series_equal(result, expected) + + # Test with 0.5 daily turnover + transactions = DataFrame([[10.0, 0]]*len(dates), + columns=['txn_volume', 'txn_shares'], + index=dates) + + expected = Series([0.5]*len(dates), index=dates) + result = get_turnover(transactions, positions) + assert_series_equal(result, expected) + + # Monthly freq: should be the sum of the daily freq + result = get_turnover(transactions, positions, period='M') + expected = Series([10.0], index=index) + assert_series_equal(result, expected) + + def test_adjust_returns_for_slippage(self): + dates = date_range(start='2015-01-01', freq='D', periods=20) + + positions = DataFrame([[0.0, 10.0]]*len(dates), + columns=[0, 'cash'], index=dates) + + # 100% total, 50% average daily turnover + transactions = DataFrame([[10.0, 0]]*len(dates), + columns=['txn_volume', 'txn_shares'], + index=dates) + returns = Series([0.05]*len(dates), index=dates) + # 0.001% slippage per dollar traded + slippage_bps = 10 + expected = Series([0.049]*len(dates), index=dates) + + turnover = get_turnover(transactions, positions, average=False) + result = adjust_returns_for_slippage(returns, turnover, slippage_bps) + + assert_series_equal(result, expected) diff --git a/pyfolio/txn.py b/pyfolio/txn.py index 4cdedef9..478cc1e6 100644 --- a/pyfolio/txn.py +++ b/pyfolio/txn.py @@ -109,6 +109,31 @@ def get_txn_vol(transactions): return pd.concat([daily_values, daily_amounts], axis=1) +def adjust_returns_for_slippage(returns, turnover, slippage_bps): + """Apply a slippage penalty for every dollar traded. + + Parameters + ---------- + returns : pd.Series + Time series of daily returns. + turnover: pd.Series + Time series of daily total of buys and sells + divided by portfolio value. + - See txn.get_turnover. + slippage_bps: int/float + Basis points of slippage to apply. + + Returns + ------- + pd.Series + Time series of daily returns, adjusted for slippage. + """ + slippage = 0.0001 * slippage_bps + # Only include returns in the period where the algo traded. + trim_returns = returns.loc[turnover.index] + return trim_returns - turnover * slippage + + def create_txn_profits(transactions): """ Compute per-trade profits. @@ -148,3 +173,45 @@ def create_txn_profits(transactions): profits_dts = pd.DataFrame(txn_descr) return profits_dts + + +def get_turnover(transactions, positions, period=None, average=True): + """ + Portfolio Turnover Rate: + + Value of purchases and sales divided + by the average portfolio value for the period. + + If no period is provided the period is one time step. + + Parameters + ---------- + transactions_df : pd.DataFrame + Contains transactions data. + - See full explanation in tears.create_full_tear_sheet + positions : pd.DataFrame + Contains daily position values including cash + - See full explanation in tears.create_full_tear_sheet + period : str, optional + Takes the same arguments as df.resample. + average : bool + if True, return the average of purchases and sales divided + by portfolio value. If False, return the sum of + purchases and sales divided by portfolio value. + + Returns + ------- + turnover_rate : pd.Series + timeseries of portfolio turnover rates. + """ + + traded_value = transactions.txn_volume + portfolio_value = positions.sum(axis=1) + if period is not None: + traded_value = traded_value.resample(period, how='sum') + portfolio_value = portfolio_value.resample(period, how='mean') + # traded_value contains the summed value from buys and sells; + # this is divided by 2.0 to get the average of the two. + turnover = traded_value / 2.0 if average else traded_value + turnover_rate = turnover / portfolio_value + return turnover_rate