Cleaned up code, improved examples notebook

This commit is contained in:
Javier Rodríguez Chatruc
2020-01-30 17:41:10 -03:00
parent 10a0918d53
commit 3ec734508d
14 changed files with 85498 additions and 2925 deletions
+54 -60
View File
@@ -1,24 +1,23 @@
import pandas as pd
import numpy as np
import pyprind
from strategy.strategy import Strategy
from portfolio.portfolio import Portfolio
class Backtest:
"""Processes signals from the Strategy object"""
def __init__(self, schema):
self.schema = schema
self._strategy = None
self._portfolio = None
self._data = None
@property
def strategy(self):
return self._strategy
def portfolio(self):
return self._portfolio
@strategy.setter
def strategy(self, strat):
assert isinstance(strat, Strategy)
self._strategy = strat
@portfolio.setter
def portfolio(self, portfolio):
assert isinstance(portfolio, Portfolio)
self._portfolio = portfolio
@property
def data(self):
@@ -29,88 +28,83 @@ class Backtest:
self._data = data
def run(self, initial_capital=1_000_000, periods='1'):
"""Runs a backtest and returns a dataframe with the daily balance"""
assert self._data is not None
assert self._strategy is not None
assert self._portfolio is not None
self.current_capital = 0
self.current_cash = initial_capital
self.inventory = pd.DataFrame(columns=['symbol', 'cost', 'qty'])
self.balance = pd.DataFrame()
data_iterator = self._data.iter_dates()
monthly_iterator = self._data.iter_months()
rebalancing_days = pd.date_range(self._data['date'].iloc[0], self._data['date'].iloc[-1], freq=periods + 'BMS').to_pydatetime()
first_day = self._data['date'].iloc[0]
last_day = self._data['date'].iloc[-1]
rebalancing_days = pd.date_range(first_day, last_day, freq=periods +
'BMS').to_pydatetime() if periods is not None else []
bar = pyprind.ProgBar(data_iterator.ngroups, bar_char='')
self.balance = pd.DataFrame(
{
'capital': self.current_cash,
'cash': self.current_cash
},
index=[self.data.start_date - pd.Timedelta(1, unit='day')])
self.balance = pd.DataFrame({
'capital': self.current_cash,
'cash': self.current_cash
},
index=[self._data.start_date - pd.Timedelta(1, unit='day')])
for date, stocks in data_iterator:
for date, data in data_iterator:
if date == self._data._data['date'][0]:
self.rebalance_portfolio(stocks)
self._update_balance(date, stocks)
self._rebalance_portfolio(data)
self._update_balance(date, data)
if date in rebalancing_days:
self.rebalance_portfolio(stocks)
self._rebalance_portfolio(data)
bar.update()
self.balance['% change'] = self.balance['capital'].pct_change()
self.balance['accumulated return'] = (
1.0 + self.balance['% change']).cumprod()
self.balance['accumulated return'] = (1.0 + self.balance['% change']).cumprod()
return self.balance
def rebalance_portfolio(self, stocks):
def _rebalance_portfolio(self, data):
"""Rebalances the portfolio so that the total money is allocated according to the given percentages"""
money_total = self.current_cash + self.current_capital
for asset in self._strategy.assets:
stock = stocks[stocks['symbol'] == asset.symbol]
stock_price = stock[self.schema['Adj Close']].values[0]
qty = (money_total * asset.percentage) // stock_price
inventory_entry = self.inventory[self.inventory['symbol'] ==
asset.symbol]
for asset in self._portfolio.assets:
asset_current = data[data['symbol'] == asset.symbol]
asset_price = asset_current[self.schema['Adj Close']].values[0]
qty = (money_total * asset.percentage) // asset_price
inventory_entry = self.inventory[self.inventory['symbol'] == asset.symbol]
self.inventory.drop(inventory_entry.index, inplace=True)
update = pd.Series([asset.symbol, stock_price, qty])
update.index = self.inventory.columns
self.inventory = self.inventory.append(update, ignore_index=True)
updated_asset = pd.Series([asset.symbol, asset_price, qty])
updated_asset.index = self.inventory.columns
self.inventory = self.inventory.append(updated_asset, ignore_index=True)
# Update current cash
invested_capital = sum(self.inventory['cost'] * self.inventory['qty'])
self.current_cash = money_total - invested_capital
def _update_balance(self, date, stocks):
"""Updates positions and calculates statistics for the current date.
Args:
date (pd.Timestamp): Current date.
stocks (pd.DataFrame): DataFrame of (daily/monthly) stocks.
"""
def _update_balance(self, date, data):
"""Updates self.balance for the given date"""
costs = []
for asset in self._strategy.assets:
asset_entry = stocks[stocks['symbol'] == asset.symbol]
inventory_asset_entry = self.inventory[self.inventory['symbol'] ==
asset.symbol]
cost = asset_entry[self.schema['Adj Close']].values[0]
qty = inventory_asset_entry['qty'].values[0]
for asset in self._portfolio.assets:
asset_current = data[data['symbol'] == asset.symbol]
inventory_asset = self.inventory[self.inventory['symbol'] == asset.symbol]
cost = asset_current[self.schema['Adj Close']].values[0]
qty = inventory_asset['qty'].values[0]
costs.append(cost * qty)
total_value = sum(costs)
self.current_capital = total_value
money_total = total_value + self.current_cash
row = pd.Series(
{
'total_value': total_value,
'cash': self.current_cash,
'capital': money_total,
},
name=date)
row = pd.Series({
'total_value': total_value,
'cash': self.current_cash,
'capital': money_total,
}, name=date)
self.balance = self.balance.append(row)
+1 -1
View File
@@ -1,2 +1,2 @@
from .schema import *
from .historical_stock_data import HistoricalStockData
from .historical_asset_data import HistoricalAssetData
@@ -3,13 +3,13 @@ from .schema import Schema
import pandas as pd
class HistoricalStockData:
"""Historical Stock Data container class."""
class HistoricalAssetData:
"""Historical Asset Data container class."""
def __init__(self, file, schema=None, **params):
if schema:
assert isinstance(schema, Schema)
else:
self.schema = HistoricalStockData.default_schema()
self.schema = HistoricalAssetData.default_schema()
file_extension = os.path.splitext(file)[1]
@@ -35,14 +35,6 @@ class HistoricalStockData:
"""Returns `pd.DataFrameGroupBy` that groups contracts by date"""
return self._data.groupby(self.schema['date'])
def iter_months(self):
"""Returns `pd.DataFrameGroupBy` that groups contracts by month"""
date_col = self.schema['date']
iterator = self._data.groupby(pd.Grouper(
key=date_col, freq="MS")).apply(lambda g: g[g[date_col] == g[
date_col].min()]).reset_index(drop=True).groupby(date_col)
return iterator
def __getattr__(self, attr):
"""Pass method invocation to `self._data`"""
@@ -75,6 +67,6 @@ class HistoricalStockData:
return self._data.__repr__()
def default_schema():
"""Returns default schema for Historical Options Data"""
"""Returns default schema for Historical Asset Data"""
schema = Schema.canonical()
return schema
+4 -8
View File
@@ -3,9 +3,7 @@ class Schema:
Used to run validations and provide uniform access to fields in the data set.
"""
columns = [
"symbol", "date", "open", "close", "high", "low", "volume", "Adj Close"
]
columns = ["symbol", "date", "open", "close", "high", "low", "volume", "Adj Close"]
def canonical():
"""Builder method that returns a `Schema` with default mappings"""
@@ -41,8 +39,7 @@ class Schema:
return iter(self._mappings.items())
def __repr__(self):
return "Schema({})".format(
[Field(k, m) for k, m in self._mappings.items()])
return "Schema({})".format([Field(k, m) for k, m in self._mappings.items()])
def __eq__(self, other):
return self._mappings == other._mappings
@@ -67,8 +64,7 @@ class Field:
def _combine_fields(self, op, other, invert=False):
if isinstance(other, Field):
name = Field._format_query(self.name, op, other.name, invert)
mapping = Field._format_query(self.mapping, op, other.mapping,
invert)
mapping = Field._format_query(self.mapping, op, other.mapping, invert)
elif isinstance(other, (int, float)):
name = Field._format_query(self.name, op, other, invert)
mapping = Field._format_query(self.mapping, op, other, invert)
@@ -160,4 +156,4 @@ class Filter:
return data.eval(self.query)
def __repr__(self):
return "Filter(query='{}')".format(self.query)
return "Filter(query='{}')".format(self.query)
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
from .portfolio import Portfolio
from .asset import Asset
+8
View File
@@ -0,0 +1,8 @@
class Asset:
"""Asset data class"""
def __init__(self, symbol, percentage):
self.symbol = symbol
self.percentage = percentage
def __repr__(self):
return "Asset(symbol={}, percentage={}, direction={})".format(self.symbol, self.percentage, self.direction)
@@ -1,37 +1,31 @@
import math
from functools import reduce
import pandas as pd
import numpy as np
from .direction import Direction
from .asset import Asset
class Strategy:
class Portfolio:
def __init__(self, direction=Direction.BUY):
assert isinstance(direction, Direction)
self.direction = direction
self.assets = []
def add_asset(self, asset):
"""Adds asset to the strategy"""
"""Adds asset to the Portfolio"""
assert isinstance(asset, Asset)
self.assets.append(asset)
return self
def add_assets(self, assets):
"""Adds assets to the strategy"""
"""Adds assets to the Portfolio"""
for asset in assets:
self.add_asset(asset)
return self
def remove_asset(self, asset_number):
"""Removes asset from the strategy"""
"""Removes asset from the Portfolio"""
self.assets.pop(asset_number)
return self
def clear_assets(self):
"""Removes *all* assets from the strategy"""
"""Removes *all* assets from the Portfolio"""
self.assets = []
return self
+1 -1
View File
@@ -1 +1 @@
from .charts import returns_chart
from .charts import returns_chart, returns_histogram, monthly_returns_heatmap
+30 -16
View File
@@ -8,19 +8,13 @@ def returns_chart(report):
time_interval = alt.selection(type='interval', encodings=['x'])
# Area plot
areas = alt.Chart().mark_area(opacity=0.7).encode(
x='index:T',
y=alt.Y('accumulated return:Q', axis=alt.Axis(format='%')))
areas = alt.Chart().mark_area(opacity=0.7).encode(x='index:T',
y=alt.Y('accumulated return:Q', axis=alt.Axis(format='%')))
# Nearest point selector
nearest = alt.selection(type='single',
nearest=True,
on='mouseover',
fields=['index'],
empty='none')
nearest = alt.selection(type='single', nearest=True, on='mouseover', fields=['index'], empty='none')
points = areas.mark_point().encode(
opacity=alt.condition(nearest, alt.value(1), alt.value(0)))
points = areas.mark_point().encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0)))
# Transparent date selector
selectors = alt.Chart().mark_point().encode(
@@ -29,20 +23,40 @@ def returns_chart(report):
).add_selection(nearest)
text = areas.mark_text(
align='left', dx=5, dy=-5).encode(text=alt.condition(
nearest, 'accumulated return:Q', alt.value(' '), format='.2%'))
align='left', dx=5,
dy=-5).encode(text=alt.condition(nearest, 'accumulated return:Q', alt.value(' '), format='.2%'))
layered = alt.layer(selectors,
points,
text,
areas.encode(
alt.X('index:T',
axis=alt.Axis(title='date'),
scale=alt.Scale(domain=time_interval))),
alt.X('index:T', axis=alt.Axis(title='date'), scale=alt.Scale(domain=time_interval))),
width=700,
height=350,
title='Wealth over time')
lower = areas.properties(width=700, height=70).add_selection(time_interval)
return alt.vconcat(layered, lower, data=report.reset_index())
return alt.vconcat(layered, lower, data=report.reset_index())
def returns_histogram(report):
bar = alt.Chart(report).mark_bar().encode(x=alt.X('% change:Q',
bin=alt.BinParams(maxbins=100),
axis=alt.Axis(format='%')),
y='count():Q')
return bar
def monthly_returns_heatmap(report):
resample = report.resample('M')['capital'].last()
monthly_returns = resample.pct_change().reset_index()
monthly_returns['capital'].iat[0] = resample.iloc[0] / report.iloc[0]['capital'] - 1
monthly_returns.columns = ['date', 'capital']
chart = alt.Chart(monthly_returns).mark_rect().encode(
alt.X('year(date):O', title='Year'), alt.Y('month(date):O', title='Month'),
alt.Color('mean(capital)', title='Return', scale=alt.Scale(scheme='redyellowgreen')),
alt.Tooltip('mean(capital)', format='.2f')).properties(title='Monthly Returns')
return chart
-2
View File
@@ -1,2 +0,0 @@
from .strategy import Strategy
from .asset import Asset
-16
View File
@@ -1,16 +0,0 @@
from .direction import Direction
from datahandler.schema import Schema
class Asset:
"""Strategy Leg data class"""
def __init__(self, symbol, percentage, direction=Direction.BUY):
assert isinstance(direction, Direction)
self.symbol = symbol
self.percentage = percentage
self.direction = direction
def __repr__(self):
return "Asset(symbol={}, percentage={}, direction={})".format(
self.symbol, self.percentage, self.direction)
-10
View File
@@ -1,10 +0,0 @@
from enum import Enum
class Direction(Enum):
BUY = 'ask' # Schema field for BUY price
SELL = 'bid' # Schema field for SELL price
def __invert__(self):
flip = Direction.SELL if self == Direction.BUY else Direction.BUY
return flip
File diff suppressed because one or more lines are too long