From 42a5309a247109c009d36e5dd468ff3a4040bc58 Mon Sep 17 00:00:00 2001 From: Andrew Campbell Date: Wed, 14 Oct 2015 11:49:58 -0400 Subject: [PATCH] STY moved get_turnover to txn.py. Added parameter to set average vs. total turnover. --- pyfolio/plotting.py | 48 ++++++++++------------- pyfolio/pos.py | 34 ---------------- pyfolio/tears.py | 8 ++-- pyfolio/tests/test_pos.py | 55 ++------------------------ pyfolio/tests/test_txn.py | 81 +++++++++++++++++++++++++++++++++++++++ pyfolio/txn.py | 44 ++++++++++++++++++++- 6 files changed, 151 insertions(+), 119 deletions(-) create mode 100644 pyfolio/tests/test_txn.py diff --git a/pyfolio/plotting.py b/pyfolio/plotting.py index 3405495f..5090e61c 100644 --- a/pyfolio/plotting.py +++ b/pyfolio/plotting.py @@ -1065,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( @@ -1090,7 +1090,8 @@ def plot_turnover(returns, transactions, positions, def plot_slippage_sweep(returns, transactions, positions, - slippage_params=None, ax=None, **kwargs): + slippage_params=(3, 8, 10, 12, 15, 20, 50), + ax=None, **kwargs): """Plots a equity curves at different per-dollar slippage assumptions. Parameters @@ -1101,10 +1102,10 @@ def plot_slippage_sweep(returns, transactions, positions, transactions : pd.DataFrame Daily transaction volume and dollar ammount. - See full explanation in tears.create_full_tear_sheet. - positions : pd.DataFrame + positions : pd.DataFrame Daily net position values. - See full explanation in tears.create_full_tear_sheet. - slippage_params: list + slippage_params: tuple Slippage pameters to apply to the return time series (in basis points). ax : matplotlib.Axes, optional @@ -1121,13 +1122,8 @@ def plot_slippage_sweep(returns, transactions, positions, if ax is None: ax = plt.gca() - turnover = pos.get_turnover(transactions, positions, period=None) - # Multiply average long/short turnover by two to get the total - # value of buys and sells each day. - turnover *= 2 - - if slippage_params is None: - slippage_params = [3, 8, 10, 12, 15, 20, 50] + turnover = txn.get_turnover(transactions, positions, + period='D', average=False) slippage_sweep = pd.DataFrame() for bps in slippage_params: @@ -1138,8 +1134,7 @@ def plot_slippage_sweep(returns, transactions, positions, slippage_sweep.plot(alpha=1.0, lw=0.5, ax=ax) ax.set_title('Cumulative Returns Given Per-Dollar Slippage Assumption') - df_cum_rets = timeseries.cum_returns(returns, starting_value=1) - ax.set_xlim((df_cum_rets.index[0], df_cum_rets.index[-1])) + ax.legend(loc='center left') return ax @@ -1157,9 +1152,9 @@ def plot_slippage_sensitivity(returns, transactions, positions, transactions : pd.DataFrame Daily transaction volume and dollar ammount. - See full explanation in tears.create_full_tear_sheet. - slippage_params: list - Slippage pameters to apply to the return time series (in - basis points). + 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 @@ -1174,24 +1169,21 @@ def plot_slippage_sensitivity(returns, transactions, positions, if ax is None: ax = plt.gca() - turnover = pos.get_turnover(transactions, positions, period=None) - # Multiply average long/short turnover by two to get the total - # value of buys and sells each day. - turnover *= 2 + 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.sharpe_ratio( - adj_returns, returns_style='calendar') + 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 Per-Dollar Slippage Assumption') - # ax.tick_params(axis='x', which='major', labelsize=10) - ax.set_xticks(np.arange(0, 100, 10)) - ax.set_ylabel('Average Annual Return') - ax.set_xlabel('Per-Dollar Slippage (bps)') + ax.set(title='Average Annual Returns Given Per-Dollar Slippage Assumption', + xticks=np.arange(0, 100, 10), + ylabel='Average Annual Return', + xlabel='Per-Dollar Slippage (bps)') return ax @@ -1222,7 +1214,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 2eee5709..110fc2e3 100644 --- a/pyfolio/tears.py +++ b/pyfolio/tears.py @@ -121,10 +121,8 @@ def create_full_tear_sheet(returns, positions=None, transactions=None, returns = returns[returns.index > benchmark_rets.index[0]] if slippage is not None and transactions is not None: - turnover = pos.get_turnover(transactions, positions, period=None) - # Multiply average long/short turnover by two to get the total - # value of buys and sells each day. - turnover *= 2 + turnover = txn.get_turnover(transactions, positions, + period=None, average=False) unadjusted_returns = returns.copy() returns = txn.adjust_returns_for_slippage(returns, turnover, slippage) else: @@ -440,7 +438,7 @@ def create_txn_tear_sheet(returns, positions, transactions, warnings.warn('Unable to generate turnover plot.', UserWarning) if unadjusted_returns is not None: - ax_slippage_sweep = plt.subplot(gs[3, :], sharex=ax_turnover) + ax_slippage_sweep = plt.subplot(gs[3, :]) plotting.plot_slippage_sweep(unadjusted_returns, transactions, positions, 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 344c2d85..478cc1e6 100644 --- a/pyfolio/txn.py +++ b/pyfolio/txn.py @@ -119,7 +119,7 @@ def adjust_returns_for_slippage(returns, turnover, slippage_bps): turnover: pd.Series Time series of daily total of buys and sells divided by portfolio value. - - See pos.get_turnover. + - See txn.get_turnover. slippage_bps: int/float Basis points of slippage to apply. @@ -173,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