Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sector Exposure #166

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions pyfolio/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ def plot_exposures(returns, positions_alloc, ax=None, **kwargs):
- See full explanation in tears.create_full_tear_sheet.
positions_alloc : pd.DataFrame
Portfolio allocation of positions. See
pos.get_portfolio_alloc.
pos.get_percent_alloc.
ax : matplotlib.Axes, optional
Axes upon which to plot.
**kwargs, optional
Expand Down Expand Up @@ -847,7 +847,7 @@ def show_and_plot_top_positions(returns, positions_alloc,
Daily returns of the strategy, noncumulative.
- See full explanation in tears.create_full_tear_sheet.
positions_alloc : pd.DataFrame
Portfolio allocation of positions. See pos.get_portfolio_alloc.
Portfolio allocation of positions. See pos.get_percent_alloc.
show_and_plot : int, optional
By default, this is 2, and both prints and plots.
If this is 0, it will only plot; if 1, it will only print.
Expand Down Expand Up @@ -921,6 +921,46 @@ def show_and_plot_top_positions(returns, positions_alloc,
return ax


def plot_sector_allocations(returns, sector_alloc, ax=None, **kwargs):
"""Plots the sector exposures of the portfolio over time.

Parameters
----------
returns : pd.Series
Daily returns of the strategy, noncumulative.
- See full explanation in tears.create_full_tear_sheet.
sector_alloc : pd.DataFrame
Portfolio allocation of positions. See pos.get_sector_alloc.
ax : matplotlib.Axes, optional
Axes upon which to plot.
**kwargs, optional
Passed to plotting function.

Returns
-------
ax : matplotlib.Axes, conditional
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conditional?

The axes that were plotted on.
"""
if ax is None:
ax = plt.gcf()

sector_alloc.plot(title='Sector Allocation Over Time',
alpha=0.4, ax=ax, **kwargs)

box = ax.get_position()
ax.set_position([box.x0, box.y0 + box.height * 0.1,
box.width, box.height * 0.9])

# Put a legend below current axis
ax.legend(
loc='upper center', frameon=True, bbox_to_anchor=(
0.5, -0.14), ncol=5)

df_cum_rets = timeseries.cum_returns(returns, starting_value=1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for getting the index we don't need to convert to cum_rets. And I wonder if we even need that. Does the index of sector_alloc not contain the right xlims?

ax.set_xlim((df_cum_rets.index[0], df_cum_rets.index[-1]))
ax.set_ylabel('Exposure by sector')


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not returning ax.

def plot_return_quantiles(returns, df_weekly, df_monthly, ax=None, **kwargs):
"""Creates a box plot of daily, weekly, and monthly return
distributions.
Expand Down
47 changes: 42 additions & 5 deletions pyfolio/pos.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,26 @@
from __future__ import division

import pandas as pd
import numpy as np
import warnings


def get_portfolio_alloc(positions):
def get_percent_alloc(values):
"""
Determines a portfolio's allocations.

Parameters
----------
positions : pd.DataFrame
values : pd.DataFrame
Contains position values or amounts.

Returns
-------
positions_alloc : pd.DataFrame
allocations : pd.DataFrame
Positions and their allocations.
"""
return positions.divide(
positions.abs().sum(axis='columns'),
return values.divide(
values.abs().sum(axis='columns'),
axis='rows'
)

Expand Down Expand Up @@ -163,3 +165,38 @@ def get_turnover(transactions, positions, period=None):
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.

Parameters
----------
positions : pd.DataFrame
Contains position values or amounts.
symbol_sector_map : dict or pd.Series
Security identifier to sector mapping.
Security ids as keys/index, sectors as values.

Returns
-------
positions_alloc : pd.DataFrame
Sectors and their allocations.
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a small example self-contained example would be helpful here I think.

cash = positions.pop('cash')

unmapped_pos = np.setdiff1d(positions.columns.values,
symbol_sector_map.keys())
if len(unmapped_pos) > 0:
warn_message = """Warning: Symbols {} have no sector mapping.
They will not be included in sector allocations""".format(
" ".join(map(str, unmapped_pos)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

", "?

warnings.warn(warn_message, UserWarning)

sector_exp = positions.groupby(
by=symbol_sector_map, axis=1).sum()

sector_exp['cash'] = cash

return sector_exp
24 changes: 20 additions & 4 deletions pyfolio/tears.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def create_full_tear_sheet(returns, positions=None, transactions=None,
benchmark_rets=None,
gross_lev=None,
live_start_date=None, bayesian=False,
sector_mappings=None,
cone_std=1.0, set_context=True):
"""
Generate a number of tear sheets that are useful
Expand Down Expand Up @@ -125,6 +126,7 @@ def create_full_tear_sheet(returns, positions=None, transactions=None,
if positions is not None:
create_position_tear_sheet(returns, positions,
gross_lev=gross_lev,
sector_mappings=sector_mappings,
set_context=set_context)

if transactions is not None:
Expand Down Expand Up @@ -301,7 +303,7 @@ def create_returns_tear_sheet(returns, live_start_date=None,
@plotting_context
def create_position_tear_sheet(returns, positions, gross_lev=None,
show_and_plot_top_pos=2,
return_fig=False):
return_fig=False, sector_mappings=None):
"""
Generate a number of plots for analyzing a
strategy's positions and holdings.
Expand All @@ -328,16 +330,21 @@ def create_position_tear_sheet(returns, positions, gross_lev=None,
If True, returns the figure that was plotted on.
set_context : boolean, optional
If True, set default plotting style context.
sector_mapping: dict or pd.Series.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

, optional

Security identifier to sector mapping.
Security ids as keys, sectors as values.
"""

fig = plt.figure(figsize=(14, 4 * 6))
gs = gridspec.GridSpec(4, 3, wspace=0.5, hspace=0.5)
vertical_sections = 5 if sector_mappings else 4

fig = plt.figure(figsize=(14, vertical_sections * 6))
gs = gridspec.GridSpec(vertical_sections, 3, wspace=0.5, hspace=0.5)
ax_gross_leverage = plt.subplot(gs[0, :])
ax_exposures = plt.subplot(gs[1, :], sharex=ax_gross_leverage)
ax_top_positions = plt.subplot(gs[2, :], sharex=ax_gross_leverage)
ax_holdings = plt.subplot(gs[3, :], sharex=ax_gross_leverage)

positions_alloc = pos.get_portfolio_alloc(positions)
positions_alloc = pos.get_percent_alloc(positions)

if gross_lev is not None:
plotting.plot_gross_leverage(returns, gross_lev, ax=ax_gross_leverage)
Expand All @@ -352,6 +359,15 @@ def create_position_tear_sheet(returns, positions, gross_lev=None,

plotting.plot_holdings(returns, positions_alloc, ax=ax_holdings)

if sector_mappings is not None:
sector_exposures = pos.get_sector_exposures(positions, sector_mappings)

sector_alloc = pos.get_percent_alloc(sector_exposures)
sector_alloc = sector_alloc.drop('cash', axis='columns')
ax_sector_alloc = plt.subplot(gs[4, :], sharex=ax_gross_leverage)
plotting.plot_sector_allocations(returns, sector_alloc,
ax=ax_sector_alloc)

plt.show()
if return_fig:
return fig
Expand Down
50 changes: 46 additions & 4 deletions pyfolio/tests/test_pos.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from unittest import TestCase
from nose_parameterized import parameterized
from collections import OrderedDict

from pandas import (
Expand All @@ -15,14 +16,17 @@
zeros_like,
)

from pyfolio.pos import (get_portfolio_alloc,
from pyfolio.pos import (get_percent_alloc,
extract_pos,
get_turnover)
get_turnover,
get_sector_exposures)
import warnings


class PositionsTestCase(TestCase):
dates = date_range(start='2015-01-01', freq='D', periods=20)

def test_get_portfolio_alloc(self):
def test_get_percent_alloc(self):
raw_data = arange(15, dtype=float).reshape(5, 3)
# Make the first column negative to test absolute magnitudes.
raw_data[:, 0] *= -1
Expand All @@ -33,7 +37,7 @@ def test_get_portfolio_alloc(self):
columns=['A', 'B', 'C']
)

result = get_portfolio_alloc(frame)
result = get_percent_alloc(frame)
expected_raw = zeros_like(raw_data)
for idx, row in enumerate(raw_data):
expected_raw[idx] = row / absolute(row).sum()
Expand Down Expand Up @@ -121,3 +125,41 @@ def test_get_turnover(self):
result = get_turnover(transactions, positions, period='M')
expected = Series([10.0], index=index)
assert_series_equal(result, expected)

@parameterized.expand([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

(DataFrame([[1.0, 2.0, 3.0, 10.0]]*len(dates),
columns=[0, 1, 2, 'cash'], index=dates),
{0: 'A', 1: 'B', 2: 'A'},
DataFrame([[4.0, 2.0, 10.0]]*len(dates),
columns=['A', 'B', 'cash'], index=dates),
False),
(DataFrame([[1.0, 2.0, 3.0, 10.0]]*len(dates),
columns=[0, 1, 2, 'cash'], index=dates),
Series(index=[0, 1, 2], data=['A', 'B', 'A']),
DataFrame([[4.0, 2.0, 10.0]]*len(dates),
columns=['A', 'B', 'cash'], index=dates),
False),
(DataFrame([[1.0, 2.0, 3.0, 10.0]]*len(dates),
columns=[0, 1, 2, 'cash'], index=dates),
{0: 'A', 1: 'B'},
DataFrame([[1.0, 2.0, 10.0]]*len(dates),
columns=['A', 'B', 'cash'], index=dates),
True)
])
def test_sector_exposure(self, positions, mapping,
expected_sector_exposure,
warning_expected):
"""
Tests sector exposure mapping and rollup.

"""
with warnings.catch_warnings(record=True) as w:
result_sector_exposure = get_sector_exposures(positions,
mapping)

assert_frame_equal(result_sector_exposure,
expected_sector_exposure)
if warning_expected:
assert len(w) == 1
else:
assert len(w) == 0