Skip to content

Commit

Permalink
Added LongShortLeveragedOrderSizer to support long/short leveraged po…
Browse files Browse the repository at this point in the history
…rtfolios.
  • Loading branch information
mhallsmoore committed Jun 23, 2020
1 parent 31f534e commit ee8f094
Show file tree
Hide file tree
Showing 7 changed files with 377 additions and 16 deletions.
4 changes: 2 additions & 2 deletions qstrader/portcon/order_sizer/dollar_weighted.py
@@ -1,9 +1,9 @@
import numpy as np

from qstrader.portcon.order_sizer.order_sizer import OrderSizeGeneration
from qstrader.portcon.order_sizer.order_sizer import OrderSizer


class DollarWeightedCashBufferedOrderSizeGeneration(OrderSizeGeneration):
class DollarWeightedCashBufferedOrderSizer(OrderSizer):
"""
Creates a target portfolio of quantities for each Asset
using its provided weight and total equity available in the
Expand Down
172 changes: 172 additions & 0 deletions qstrader/portcon/order_sizer/long_short.py
@@ -0,0 +1,172 @@
import numpy as np

from qstrader.portcon.order_sizer.order_sizer import OrderSizer


class LongShortLeveragedOrderSizer(OrderSizer):
"""
Creates a target portfolio of quantities for each Asset
using its provided weight and total equity available in the
Broker portfolio, leveraging up if necessary via the supplied
gross leverage.
Parameters
----------
broker : `Broker`
The derived Broker instance to obtain portfolio equity from.
broker_portfolio_id : `str`
The specific portfolio at the Broker to obtain equity from.
data_handler : `DataHandler`
To obtain latest asset prices from.
gross_leverage : `float`, optional
The amount of percentage leverage to use when sizing orders.
"""

def __init__(
self,
broker,
broker_portfolio_id,
data_handler,
gross_leverage=1.0
):
self.broker = broker
self.broker_portfolio_id = broker_portfolio_id
self.data_handler = data_handler
self.gross_leverage = self._check_set_gross_leverage(
gross_leverage
)

def _check_set_gross_leverage(self, gross_leverage):
"""
Checks and sets the gross leverage percentage value.
Parameters
----------
gross_leverage : `float`
The amount of percentage leverage to use when sizing orders.
This assumes no restriction on margin.
Returns
-------
`float`
The gross leverage percentage value.
"""
if (
gross_leverage <= 0.0
):
raise ValueError(
'Gross leverage "%s" provided to long-short levered '
'order sizer is non positive.' % gross_leverage
)
else:
return gross_leverage

def _obtain_broker_portfolio_total_equity(self):
"""
Obtain the Broker portfolio total equity.
Returns
-------
`float`
The Broker portfolio total equity.
"""
return self.broker.get_portfolio_total_equity(self.broker_portfolio_id)

def _normalise_weights(self, weights):
"""
Rescale provided weight values to ensure the
weights are scaled to gross exposure divided by
gross leverage.
Parameters
----------
weights : `dict{Asset: float}`
The un-normalised weight vector.
Returns
-------
`dict{Asset: float}`
The scaled weight vector.
"""
gross_exposure = sum(np.abs(weight) for weight in weights.values())

# If the weights are very close or equal to zero then rescaling
# is not possible, so simply return weights unscaled
if np.isclose(gross_exposure, 0.0):
return weights

gross_ratio = self.gross_leverage / gross_exposure

return {
asset: (weight * gross_ratio)
for asset, weight in weights.items()
}

def __call__(self, dt, weights):
"""
Creates a long short leveraged target portfolio from the
provided target weights at a particular timestamp.
Parameters
----------
dt : `pd.Timestamp`
The current date-time timestamp.
weights : `dict{Asset: float}`
The (potentially unnormalised) target weights.
Returns
-------
`dict{Asset: dict}`
The long short target portfolio dictionary with quantities.
"""
total_equity = self._obtain_broker_portfolio_total_equity()

# Pre-cost dollar weight
N = len(weights)
if N == 0:
# No forecasts so portfolio remains in cash
# or is fully liquidated
return {}

# Scale weights to take into account gross exposure and leverage
normalised_weights = self._normalise_weights(weights)

target_portfolio = {}
for asset, weight in sorted(normalised_weights.items()):
pre_cost_dollar_weight = total_equity * weight

# Estimate broker fees for this asset
est_quantity = 0 # TODO: Needs to be added for IB
est_costs = self.broker.fee_model.calc_total_cost(
asset, est_quantity, pre_cost_dollar_weight, broker=self.broker
)

# Calculate integral target asset quantity assuming broker costs
after_cost_dollar_weight = pre_cost_dollar_weight - est_costs
asset_price = self.data_handler.get_asset_latest_ask_price(
dt, asset
)

if np.isnan(asset_price):
raise ValueError(
'Asset price for "%s" at timestamp "%s" is Not-a-Number (NaN). '
'This can occur if the chosen backtest start date is earlier '
'than the first available price for a particular asset. Try '
'modifying the backtest start date and re-running.' % (asset, dt)
)

# Truncate the after cost dollar weight
# to nearest integer
truncated_after_cost_dollar_weight = (
np.floor(after_cost_dollar_weight)
if after_cost_dollar_weight >= 0.0
else np.ceil(after_cost_dollar_weight)
)
asset_quantity = int(
truncated_after_cost_dollar_weight / asset_price
)

# Add to the target portfolio
target_portfolio[asset] = {"quantity": asset_quantity}

return target_portfolio
2 changes: 1 addition & 1 deletion qstrader/portcon/order_sizer/order_sizer.py
@@ -1,7 +1,7 @@
from abc import ABCMeta, abstractmethod


class OrderSizeGeneration(object):
class OrderSizer(object):
"""
Creates a target portfolio of quantities for each Asset
using its provided weight and total equity available in the Broker portfolio.
Expand Down
4 changes: 2 additions & 2 deletions qstrader/system/qts.py
Expand Up @@ -11,7 +11,7 @@
FixedWeightPortfolioOptimiser
)
from qstrader.portcon.order_sizer.dollar_weighted import (
DollarWeightedCashBufferedOrderSizeGeneration
DollarWeightedCashBufferedOrderSizer
)


Expand Down Expand Up @@ -73,7 +73,7 @@ def _initialise_models(self):
TODO: Ensure this is dynamically generated from config.
"""
# Portfolio Construction
order_sizer = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
self.broker,
self.broker_portfolio_id,
self.data_handler,
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/portcon/test_pcm_e2e.py
Expand Up @@ -13,7 +13,7 @@
FixedWeightPortfolioOptimiser
)
from qstrader.portcon.order_sizer.dollar_weighted import (
DollarWeightedCashBufferedOrderSizeGeneration
DollarWeightedCashBufferedOrderSizer
)


Expand Down Expand Up @@ -54,7 +54,7 @@ def test_pcm_fixed_weight_optimiser_fixed_alpha_weights_call_end_to_end(
broker.create_portfolio(port_id, 'Portfolio')
broker.subscribe_funds_to_portfolio(port_id, initial_funds)

order_sizer = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
broker, port_id, data_handler, cash_buffer_perc
)
optimiser = FixedWeightPortfolioOptimiser(data_handler)
Expand Down
18 changes: 9 additions & 9 deletions tests/unit/portcon/order_sizer/test_dollar_weighted.py
Expand Up @@ -6,7 +6,7 @@


from qstrader.portcon.order_sizer.dollar_weighted import (
DollarWeightedCashBufferedOrderSizeGeneration
DollarWeightedCashBufferedOrderSizer
)


Expand All @@ -32,14 +32,14 @@ def test_check_set_cash_buffer(cash_buffer_perc, expected):

if expected is None:
with pytest.raises(ValueError):
osg = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
broker, broker_portfolio_id, data_handler, cash_buffer_perc
)
else:
osg = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
broker, broker_portfolio_id, data_handler, cash_buffer_perc
)
assert osg.cash_buffer_percentage == cash_buffer_perc
assert order_sizer.cash_buffer_percentage == cash_buffer_perc


@pytest.mark.parametrize(
Expand Down Expand Up @@ -81,14 +81,14 @@ def test_normalise_weights(weights, expected):
data_handler = Mock()
cash_buffer_perc = 0.05

osg = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
broker, broker_portfolio_id, data_handler, cash_buffer_perc
)
if expected is None:
with pytest.raises(ValueError):
result = osg._normalise_weights(weights)
result = order_sizer._normalise_weights(weights)
else:
result = osg._normalise_weights(weights)
result = order_sizer._normalise_weights(weights)
assert result == pytest.approx(expected)


Expand Down Expand Up @@ -138,9 +138,9 @@ def test_call(total_equity, cash_buffer_perc, weights, asset_prices, expected):
data_handler = Mock()
data_handler.get_asset_latest_ask_price.side_effect = lambda self, x: asset_prices[x]

osg = DollarWeightedCashBufferedOrderSizeGeneration(
order_sizer = DollarWeightedCashBufferedOrderSizer(
broker, broker_portfolio_id, data_handler, cash_buffer_perc
)

result = osg(dt, weights)
result = order_sizer(dt, weights)
assert result == expected

0 comments on commit ee8f094

Please sign in to comment.