Skip to content

Commit 788e7d7

Browse files
committed
ENH Added round trip tearheet and supporting functions
1 parent 5e8f2d9 commit 788e7d7

File tree

4 files changed

+516
-35
lines changed

4 files changed

+516
-35
lines changed

pyfolio/plotting.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
import pandas as pd
1818
import numpy as np
19+
import scipy as sp
1920

2021
import seaborn as sns
2122
import matplotlib
2223
import matplotlib.pyplot as plt
2324
from matplotlib.ticker import FuncFormatter
25+
import matplotlib.lines as mlines
2426

2527
from sklearn import preprocessing
2628

@@ -1402,3 +1404,116 @@ def cumulate_returns(x):
14021404
ax.set_xticklabels(xticks_label)
14031405

14041406
return ax
1407+
1408+
1409+
def plot_round_trip_life_times(round_trips, ax=None):
1410+
"""
1411+
Plots timespans and directions of round trip trades.
1412+
1413+
Parameters
1414+
----------
1415+
round_trips : pd.DataFrame
1416+
DataFrame with one row per round trip trade.
1417+
- See full explanation in txn.extract_round_trips
1418+
ax : matplotlib.Axes, optional
1419+
Axes upon which to plot.
1420+
**kwargs, optional
1421+
Passed to seaborn plotting function.
1422+
1423+
Returns
1424+
-------
1425+
ax : matplotlib.Axes
1426+
The axes that were plotted on.
1427+
"""
1428+
if ax is None:
1429+
ax = plt.subplot()
1430+
1431+
symbols = round_trips.symbol.unique()
1432+
symbol_idx = pd.Series(np.arange(len(symbols)), index=symbols)
1433+
1434+
for symbol, sym_round_trips in round_trips.groupby('symbol'):
1435+
for _, row in sym_round_trips.iterrows():
1436+
c = 'b' if row.long else 'r'
1437+
y_ix = symbol_idx[symbol]
1438+
ax.plot([row['open_dt'], row['close_dt']],
1439+
[y_ix, y_ix], color=c)
1440+
1441+
ax.set_yticklabels(symbols)
1442+
1443+
red_line = mlines.Line2D([], [], color='r', label='Long')
1444+
blue_line = mlines.Line2D([], [], color='b', label='Short')
1445+
ax.legend(handles=[red_line, blue_line], loc=0)
1446+
1447+
return ax
1448+
1449+
1450+
def show_profit_attribtion(round_trips):
1451+
"""
1452+
Prints the share of total PnL contributed by each
1453+
traded name.
1454+
1455+
Parameters
1456+
----------
1457+
round_trips : pd.DataFrame
1458+
DataFrame with one row per round trip trade.
1459+
- See full explanation in txn.extract_round_trips
1460+
ax : matplotlib.Axes, optional
1461+
Axes upon which to plot.
1462+
**kwargs, optional
1463+
Passed to seaborn plotting function.
1464+
1465+
Returns
1466+
-------
1467+
ax : matplotlib.Axes
1468+
The axes that were plotted on.
1469+
"""
1470+
1471+
total_pnl = round_trips['pnl'].sum()
1472+
pct_profit_attribution = round_trips.groupby(
1473+
'symbol')['pnl'].sum() / total_pnl
1474+
1475+
print('\nProfitability (PnL / PnL total) per name:')
1476+
print(pct_profit_attribution.sort(inplace=False, ascending=False))
1477+
1478+
1479+
def plot_prob_profit_trade(round_trips, ax=None):
1480+
"""
1481+
Plots a probability distribution for the event of making
1482+
a profitable trade.
1483+
1484+
Parameters
1485+
----------
1486+
round_trips : pd.DataFrame
1487+
DataFrame with one row per round trip trade.
1488+
- See full explanation in txn.extract_round_trips
1489+
ax : matplotlib.Axes, optional
1490+
Axes upon which to plot.
1491+
**kwargs, optional
1492+
Passed to seaborn plotting function.
1493+
1494+
Returns
1495+
-------
1496+
ax : matplotlib.Axes
1497+
The axes that were plotted on.
1498+
"""
1499+
1500+
x = np.linspace(0, 1., 500)
1501+
1502+
round_trips['profitable'] = round_trips.pnl > 0
1503+
1504+
dist = sp.stats.beta(round_trips.profitable.sum(),
1505+
(~round_trips.profitable).sum())
1506+
y = dist.pdf(x)
1507+
lower_perc = dist.ppf(.025)
1508+
upper_perc = dist.ppf(.975)
1509+
1510+
lower_plot = dist.ppf(.001)
1511+
upper_plot = dist.ppf(.999)
1512+
if ax is None:
1513+
ax = plt.subplot()
1514+
ax.plot(x, y)
1515+
ax.axvline(lower_perc, color='0.5')
1516+
ax.axvline(upper_perc, color='0.5')
1517+
1518+
ax.set(xlabel='Probability making a profitable decision', ylabel='Belief',
1519+
xlim=(lower_plot, upper_plot), ylim=(0, y.max() + 1.))

pyfolio/tears.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ def create_full_tear_sheet(returns, positions=None, transactions=None,
167167
unadjusted_returns=unadjusted_returns,
168168
set_context=set_context)
169169

170+
create_round_trip_tear_sheet(transactions, positions,
171+
sector_mappings=sector_mappings)
172+
170173
if bayesian:
171174
create_bayesian_tear_sheet(returns,
172175
live_start_date=live_start_date,
@@ -435,8 +438,6 @@ def create_txn_tear_sheet(returns, positions, transactions,
435438
- See full explanation in create_full_tear_sheet.
436439
return_fig : boolean, optional
437440
If True, returns the figure that was plotted on.
438-
set_context : boolean, optional
439-
If True, set default plotting style context.
440441
"""
441442
vertical_sections = 5 if unadjusted_returns is not None else 3
442443

@@ -479,6 +480,101 @@ def create_txn_tear_sheet(returns, positions, transactions,
479480
return fig
480481

481482

483+
@plotting_context
484+
def create_round_trip_tear_sheet(transactions, positions,
485+
sector_mappings=None,
486+
return_fig=False):
487+
"""
488+
Generate a number of figures and plots describing the duration,
489+
frequency, and profitability of trade "round trips."
490+
A round trip is started when a new long or short position is
491+
opened and is only completed when the number of shares in that
492+
position returns to or crosses zero.
493+
494+
Parameters
495+
----------
496+
positions : pd.DataFrame
497+
Daily net position values.
498+
- See full explanation in create_full_tear_sheet.
499+
transactions : pd.DataFrame
500+
Prices and amounts of executed trades. One row per trade.
501+
- See full explanation in create_full_tear_sheet.
502+
sector_mappings : dict or pd.Series, optional
503+
Security identifier to sector mapping.
504+
Security ids as keys, sectors as values.
505+
return_fig : boolean, optional
506+
If True, returns the figure that was plotted on.
507+
"""
508+
509+
transactions_closed = txn.add_closing_transactions(positions, transactions)
510+
trades = txn.extract_round_trips(transactions_closed)
511+
512+
if len(trades) < 5:
513+
warnings.warn(
514+
"""Fewer than 5 round-trip trades made.
515+
Skipping round trip tearsheet.""", UserWarning)
516+
return
517+
518+
ndays = len(positions)
519+
520+
print(trades.drop(['open_dt', 'close_dt', 'symbol'],
521+
axis='columns').describe())
522+
print('Percent of trades profitable = {:.4}%'.format(
523+
(trades.pnl > 0).mean() * 100))
524+
525+
winning_trades = trades[trades.pnl > 0]
526+
losing_trades = trades[trades.pnl < 0]
527+
print('Mean profits per winning trade = {:.4}'.format(
528+
winning_trades.pnl.mean()))
529+
print('Mean loss per losing trade = {:.4}').format(
530+
losing_trades.pnl.abs().mean())
531+
532+
print('A decision is made every {:.4} days.'.format(ndays / len(trades)))
533+
print('{:.4} trading decisions per day.'.format(len(trades) * 1. / ndays))
534+
print('{:.4} trading decisions per month.'.format(
535+
len(trades) * 1. / (ndays / 21)))
536+
537+
plotting.show_profit_attribtion(trades)
538+
539+
if sector_mappings is not None:
540+
sector_trades = txn.apply_sector_mappings_to_round_trips(
541+
trades, sector_mappings)
542+
plotting.show_profit_attribtion(sector_trades)
543+
544+
fig = plt.figure(figsize=(14, 3 * 6))
545+
546+
fig = plt.figure(figsize=(14, 3 * 6))
547+
gs = gridspec.GridSpec(3, 2, wspace=0.5, hspace=0.5)
548+
549+
ax_trade_lifetimes = plt.subplot(gs[0, :])
550+
ax_prob_profit_trade = plt.subplot(gs[1, 0])
551+
ax_holding_time = plt.subplot(gs[1, 1])
552+
ax_pnl_per_round_trip_dollars = plt.subplot(gs[2, 0])
553+
ax_pnl_per_round_trip_pct = plt.subplot(gs[2, 1])
554+
555+
plotting.plot_round_trip_life_times(trades, ax=ax_trade_lifetimes)
556+
557+
plotting.plot_prob_profit_trade(trades, ax=ax_prob_profit_trade)
558+
559+
trade_holding_times = [x.days for x in trades['duration']]
560+
sns.distplot(trade_holding_times, kde=False, ax=ax_holding_time)
561+
ax_holding_time.set(xlabel='holding time in days')
562+
563+
sns.distplot(trades.pnl, kde=False, ax=ax_pnl_per_round_trip_dollars)
564+
ax_pnl_per_round_trip_dollars.set(xlabel='PnL per round-trip trade in $')
565+
566+
pnl_pct = trades.pnl / trades.pnl.sum() * 100
567+
sns.distplot(pnl_pct, kde=False, ax=ax_pnl_per_round_trip_pct)
568+
ax_pnl_per_round_trip_pct.set(
569+
xlabel='PnL per round-trip trade in % of total profit')
570+
571+
gs.tight_layout(fig)
572+
573+
plt.show()
574+
if return_fig:
575+
return fig
576+
577+
482578
@plotting_context
483579
def create_interesting_times_tear_sheet(
484580
returns, benchmark_rets=None, legend_loc='best', return_fig=False):

pyfolio/tests/test_txn.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1+
from nose_parameterized import parameterized
2+
13
from unittest import TestCase
24

35
from pandas import (
46
Series,
57
DataFrame,
6-
date_range
8+
date_range,
9+
Timedelta
710
)
8-
from pandas.util.testing import (assert_series_equal)
11+
from pandas.util.testing import (assert_series_equal,
12+
assert_frame_equal)
913

1014
from pyfolio.txn import (get_turnover,
11-
adjust_returns_for_slippage)
15+
adjust_returns_for_slippage,
16+
extract_round_trips,
17+
add_closing_transactions)
1218

1319

1420
class TransactionsTestCase(TestCase):
21+
dates = date_range(start='2015-01-01', freq='D', periods=20)
1522

1623
def test_get_turnover(self):
1724
"""
@@ -80,3 +87,64 @@ def test_adjust_returns_for_slippage(self):
8087
result = adjust_returns_for_slippage(returns, turnover, slippage_bps)
8188

8289
assert_series_equal(result, expected)
90+
91+
@parameterized.expand([
92+
(DataFrame(data=[[2, 10, 'A'],
93+
[-2, 15, 'A']],
94+
columns=['amount', 'price', 'symbol'],
95+
index=dates[:2]),
96+
DataFrame(data=[[dates[0], dates[1], Timedelta(days=1),
97+
10, True, 'A']],
98+
columns=['open_dt', 'close_dt',
99+
'duration', 'pnl', 'long', 'symbol'],
100+
index=[0])
101+
),
102+
(DataFrame(data=[[2, 10, 'A'],
103+
[2, 15, 'A'],
104+
[-9, 10, 'A']],
105+
columns=['amount', 'price', 'symbol'],
106+
index=dates[:3]),
107+
DataFrame(data=[[dates[0], dates[2], Timedelta(days=2),
108+
-10, True, 'A']],
109+
columns=['open_dt', 'close_dt',
110+
'duration', 'pnl', 'long', 'symbol'],
111+
index=[0])
112+
),
113+
(DataFrame(data=[[2, 10, 'A'],
114+
[-4, 15, 'A'],
115+
[3, 20, 'A']],
116+
columns=['amount', 'price', 'symbol'],
117+
index=dates[:3]),
118+
DataFrame(data=[[dates[0], dates[1], Timedelta(days=1),
119+
10, True, 'A'],
120+
[dates[1] + Timedelta(seconds=1), dates[2],
121+
Timedelta(days=1) - Timedelta(seconds=1),
122+
-10, False, 'A']],
123+
columns=['open_dt', 'close_dt',
124+
'duration', 'pnl', 'long', 'symbol'],
125+
index=[0, 1])
126+
)
127+
])
128+
def test_extract_round_trips(self, transactions, expected):
129+
round_trips = extract_round_trips(transactions)
130+
assert_frame_equal(round_trips, expected)
131+
132+
def test_add_closing_trades(self):
133+
dates = date_range(start='2015-01-01', freq='D', periods=20)
134+
transactions = DataFrame(data=[[2, 10, 'A'],
135+
[-5, 10, 'A']],
136+
columns=['amount', 'price', 'symbol'],
137+
index=[dates[:2]])
138+
positions = DataFrame(data=[[20, 0],
139+
[-30, 30],
140+
[-60, 30]],
141+
columns=['A', 'cash'],
142+
index=[dates[:3]])
143+
expected = DataFrame(data=[[2, 10, 'A'],
144+
[-5, 10, 'A'],
145+
[3, 20., 'A']],
146+
columns=['amount', 'price', 'symbol'],
147+
index=[dates[:3]])
148+
149+
transactions_closed = add_closing_transactions(positions, transactions)
150+
assert_frame_equal(transactions_closed, expected)

0 commit comments

Comments
 (0)