Table of Contents
Recap
Perpetuals vs. Futures
Slippage Design
Slippage
assert()
CallsCurrent Slippage Implementation
Porting Over Slippage
Switching Slippage Implementation
Revisiting Slippage
assert()
CallsRevisiting Slippage Design
Updating The Design
Next Up
Recap
Hello again! Hope you're all doing well. The big summer break is slowly coming to an end and it's time to pick up where we left of. We didn't quite finish our backtester migration before the bnig pause so let's continue on! Last time we ended on the promise to introduce Strategy
implementations for holding costs to be able to easily switch between perpetuals and futures. On a second thought though, it might be a sensible thing to ditch this for now:
Perpetuals vs. Futures
The way crypto futures are managed makes them more risky due to their increased exposure to supply-demand imbalances. When things get heated and people are greedy for leveraged long exposure, futures prices tend to increasingly trade at a premium before expiring because this increased demand outweighs the supply of more "normal" participants. So there's more demand than supply on the futures compared to spot, which makes the futures prices trade away from their underlying spot prices.
It's true that they will converge towards the spot price on expiry but in the meantime anything can happen really. We don't want to risk getting blown out by a big move, diverging the futures-spot spread.
Perpetuals on the other hand are designed to keep their spot-peg. Their mechanism of continuous funding payments incentivizes people to trade into the direction of the spot price by paying them while also charging the ones that are putting pressure on price to fly away from it. This helps keeping the perpetuals and spot prices stay close on average. In addition, perpetuals are also usually way more liquid than futures, making it harder for them to crumble under pressure.
For this reason, we're going to focus on perpetuals and ditch the rolling Strategy
for now. If we are in dire need of a rolling model, we'll just throw it in on the fly.
Slippage Design
To recap what slippage is all about, see here
TLDR: It's the price we pay based on the price movement from the time when we submitted our orders to the time we actually get served.
Our current design for slippage calculation looks like this:
Calculate slippage in percentage / bps terms for 1 contract
Calculate execution price (close & slippage) AGAINST you
IF long, close + exec_price
ELSE, close - exec_price
Scale for notional_traded
Calculate amt. of notional_traded
slippage_paid = contracts_traded x contract_unit
In our example we're using a fixed amount of slippage of 5-10 basis points. Obviously these are very simple assumptions which are based on us trading with size that isn't really relevant to the instruments liquidity. In reality it will vary a lot depending on the instruments traded. Having a few quick and dirty estimates helps us to move ahead with the interfaces. After all, we want to arrive at an interface that makes it easy for us to swap things out in the future.
Slippage assert()
calls
Let's think about some assert()
calls that make sense to shield us against unwanted behaviour changes while working on the codebase. Obviously, slippage cannot be 0 when we have traded. Assuming we'll get filled at the exact prices we wanted to will only hurt us. On the flipside, for days when we did not trade, we don't expect to pay slippage.
print("Testing slippage structure...")
# Slippage should be positive if there are trades
assert total_slippage_paid > 0, "Total slippage should be positive"
assert isinstance(total_slippage_paid, float), "Slippage should be float values"
# No trades should result in zero slippage
zero_trade_mask = contracts_traded == 0
assert (slippage_paid_series[zero_trade_mask] == 0).all(), "Zero trades should result in zero slippage"
Next, we can confirm that the values are in line with our formula and that more contracts traded result in more slippage paid. Slippage should also be proportional to price
# Slippage should match the formula for each trade
expected_slippage = notional_traded * SLIPPAGE_PERCENT / 100
assert np.allclose(
slippage_paid_series,
expected_slippage,
# rtol=1e-5
), "Slippage calculations differ from expected values"
# Slippage should increase with the number of contracts traded
if contracts_traded.abs().max() > 0:
idx_max_trade = contracts_traded.abs().idxmax()
idx_min_trade = contracts_traded.abs().idxmin()
assert slippage_paid_series[idx_max_trade] >= slippage_paid_series[idx_min_trade], \
"Slippage should increase with trade size"
# Slippage should be proportional to price and slippage percent
sample_idx = contracts_traded.abs().idxmax()
expected_slippage = abs(contracts_traded.at[sample_idx]) * instrument.get_feature('close').at[sample_idx] * SLIPPAGE_PERCENT / 100 * instrument.contract_unit
assert np.isclose(slippage_paid_series.at[sample_idx], expected_slippage), "Slippage formula mismatch"
And finally, just to see if we messed up the endresult, we can check it against our old result:
assert (total_slippage_paid == 944.2035180831953), f"Total slippage paid {total_slippage_paid} does not match expected value."
Current Slippage Implementation
Here's the current implementation:
SLIPPAGE_PERCENT = 0.05
SLIPPAGE_PERCENT_DEC = SLIPPAGE_PERCENT / 100
# we're trading
if contract_deviation > rebalance_err_threshold:
if ideal_pos_contracts > 0:
position_direction = 1
else:
position_direction = -1
df.at[index, 'position_direction'] = position_direction
slippage_amount_per_contract = row['close'] * slippage_percent_dec
close_slipped = row['close'] + slippage_amount_per_contract if position_direction >= 0 else row['close'] - slippage_amount_per_contract
df.at[index, 'close_slipped'] = close_slipped
slippage_paid = abs(contract_diff) * slippage_amount_per_contract * contract_unit
df.at[index, 'slippage_paid'] = slippage_paid
rebalanced_pos_contracts = ideal_pos_contracts
notional_traded = contract_diff * notional_per_contract
else:
slippage_paid = 0
rebalanced_pos_contracts = current_pos_contracts
notional_traded = 0
Porting Over Slippage
Now let's port over the calculations. During this we're going to utilize the Refactoring Cycle and first run our tests - in this case the assert()
calls, see them fail, and then fix the the errors one by one. The final solution looks like this:
# Implementation
# costs.py
[...]
class FixedSlippageCost(CostCalculation):
def __init__(self, slippage_percent, notional_traded):
self.slippage_percent = slippage_percent
self.notional_traded = notional_traded
def calculate_costs(self):
return self.notional_traded * self.slippage_percent / 100
# Usage
# backtest_refactored.py
[...]
SLIPPAGE_PERCENT = 0.05 # 0.05% slippage
[...]
from costs import FixedSlippageCost
slippage_calculator = FixedSlippageCost(SLIPPAGE_PERCENT, notional_traded)
slippage_paid_series = slippage_calculator.calculate_costs()
total_slippage_paid = slippage_paid_series.sum()
[...]
All our tests are green. We successfully migrated the old calculation by also drastically simplifying its implementation and usage.
Switching Slippage Implementation
Now the above example numbers (5-10 bps) are based on my own experience and that of others too. It's a reasonable first approximation of what we're going to encounter when trading small-ish size irrelevant to the instruments overall liquidity, executing during most-liquid market hours. I'd even say we can go lower but let's keep things conservative for now. Just to showcase how to utilize our new SlippageCost
interface, I'm going to show you how to implement another quick and easy approximation for this situation:
This is fairly easy to implement since we already have everything we need: a way of calculating the daily_vol_series
and to convert the result into dollar amounts notional_traded
:
class VolBasedSlippageCostDirty(CostCalculation):
def __init__(self, daily_vol_series, notional_traded, slippage_per_unit_vol=0.01):
self.daily_vol_series = daily_vol_series
self.notional_traded = notional_traded
self.SLIPPAGE_PER_UNIT_VOL = slippage_per_unit_vol
def calculate_costs(self):
slippage_bps = self.SLIPPAGE_PER_UNIT_VOL * self.daily_vol_series
# adjust for notional traded
return self.notional_traded * slippage_bps
This CostCalculation
returns a series of dollar cost slippage values for each day traded:
daily_vol_series = calculate_vol(instrument.get_perc_returns(), VOL_LOOKBACK)
from costs import VolBasedSlippageCostDirty
vol_slippage_calculator = VolBasedSlippageCostDirty(
daily_vol_series,
notional_traded,
)
dollar_vol_slippage_paid_series = vol_slippage_calculator.calculate_costs()
total_vol_slippage_paid = dollar_vol_slippage_paid_series.sum()
print(total_vol_slippage_paid)
# Compare with FixedSlippageCost
print(f"Total Fixed Slippage Paid: {total_slippage_paid:.2f}")
print(f"Total Vol-Based Slippage Paid: {total_vol_slippage_paid:.2f}")
# Total Fixed Slippage Paid: 944.20
# Total Vol-Based Slippage Paid: 618.28
Now obviously the amount of slippage paid is different to the fixed calculation since we're not reyling on the hardcoded 5 basis points anymore but calculate them on a rolling basis:
Even though this VolatilityBased
calculation is a very simplified view of what slippage might look like in the real world, it's more aking to what's really happening when trading. At least in comparison to the FixedSlippage
assumption. So going forward, we're going to stick to the new implementation.
Revisiting Slippage assert()
Calls
Obviously our assert()
calls don't make sense anymore. It's usually a bad sign if you have to change your "tests" after making a change to the design so the test-suite does not stop working. In this case all we wanted the assert()
calls to do was to confirm we're not introducing bugs by migrating over our old implementations. They did their job and it's time to retire them in sake of our new approach.
First, our expected_slippage = notional_traded * SLIPPAGE_PERCENT / 100
doesn't make sense anymore. We need to get rid of that. The same goes for our check if the correct formula gets used.
The rest of them are pretty implementation agnostic and merely check for structural validity. Here's the updated list of assert()
calls that make sense for our new implementation:
[...]
print("Testing Slippage")
assert total_slippage_paid > 0, "Total slippage should be positive"
assert isinstance(total_slippage_paid, float), "Slippage should be float values"
zero_slippage_violations = slippage_paid_series[zero_trade_mask][slippage_paid_series[zero_trade_mask] != 0]
if not zero_slippage_violations.empty:
print("Nonzero slippage detected on zero-trade days:")
for date, value in zero_slippage_violations.items():
print(f" {date}: slippage={value}")
raise AssertionError(f"Zero trades should result in zero slippage, but found {len(zero_slippage_violations)} violations.")
if contracts_traded.abs().max() > 0:
idx_max_trade = contracts_traded.abs().idxmax()
idx_min_trade = contracts_traded.abs().idxmin()
assert slippage_paid_series[idx_max_trade] >= slippage_paid_series[idx_min_trade], \
"Slippage should increase with trade size"
[...]
Revisiting Slippage Design
Comparing our new implementation to the current design sketch of calculating slippage clearly shows that we're diverging:
"Old" design:
Calculate slippage in percentage / bps terms for 1 contract
Calculate execution price (close * slippage) AGAINST you
IF long, close + exec_price
ELSE, close - exec_price
Scale for notional_traded
Calculate amt. of notional_traded
slippage_paid = contracts_traded x contract_unit
Design of current implementation:
Calculate daily vol
Assume 1% of daily vol as slippage proxy
Scale for notional_traded
Convert to USD
We're not only diverging because of the new implementation, we're also moving away from the IF/ELSE block in the old design by assuming that slippage will always be a cost, never a gain. We can simplify the design by only calculating the slipped amount and then later deducting it from the whole PNL series instead of going row by row.
Let's merge the old and new designs, making them more abstract:
Calculate slippage paid per contract
Scale for notional_traded
notional_traded = contracts_traded x contract_unit
scaled_slippage = notional_traded * usd_slippage
Convert to USD: usd_slippage = scaled_slippage / bps_per_unit
Updating The Design
This design should be reflected in our code, the only thing that differs for both implementations is the calculation used for slippage per unit. If we sketch it out, abstracting it away, it looks like this:
class SlippageStrategy(ABC):
@abstractmethod
def bps_per_unit(self):
""" Return slippage per unit in basis points """
pass
class SlippageCost(CostCalculation):
def __init__(self, notional_traded):
self.notional_traded = notional_traded
def calculate_costs(self, slippage_strategy):
bps_per_unit = slippage_strategy.bps_per_unit()
return self.adjust_notional(bps_per_unit)
def adjust_notional(self, bps_per_unit):
return self.notional_traded * bps_per_unit
class FixedSlippageStrategy(SlippageStrategy):
def __init__(self, slippage_bps):
self.slippage_bps = slippage_bps
def bps_per_unit(self):
return self.slippage_bps
class VolBasedSlippageStrategy(SlippageStrategy):
def __init__(self, daily_vol_series, slippage_per_unit_vol=0.01):
self.daily_vol_series = daily_vol_series
self.slippage_per_unit_vol = slippage_per_unit_vol
def bps_per_unit(self):
return self.slippage_per_unit_vol * self.daily_vol_series
and it's usage:
from costs import SlippageCost, FixedSlippageStrategy, VolBasedSlippageStrategy
slippage_calculator = SlippageCost(notional_traded)
SLIPPAGE_BPS = SLIPPAGE_PERCENT / 100
slippage_strategy = FixedSlippageStrategy(SLIPPAGE_BPS)
fixed_slippage_paid_series = slippage_calculator.calculate_costs(slippage_strategy)
fixed_total_slippage_paid = fixed_slippage_paid_series.sum()
daily_vol_series = calculate_vol(instrument.get_perc_returns(), VOL_LOOKBACK)
vol_slippage_strategy = VolBasedSlippageStrategy(
daily_vol_series,
slippage_per_unit_vol=0.01 # 1% daily vol proxy
)
vol_slippage_paid_series = slippage_calculator.calculate_costs(vol_slippage_strategy)
total_vol_slippage_paid = vol_slippage_paid_series.sum()
During the refactoring we also unified the inputs to our SlippageStrategies
. Before we were using percentages for the FixedSlippage
. Since we're now more focused on expressing it as basis points instead, we converted the SLIPPAGE_PERCENT
to SLIPPAGE_BPS
to stay true to the new abstract interface - at least conceptually.
Next Up
The next article will sort of finish this series. We're going to have a look at a simple bid-ask spread implementation as last piece of the puzzle for our cost considerations. After that we can just deduct all of the costs from our PNL to get the post-cost SR.
In the future I want to focus more on trading content rather how to structure its code. Writing everything out step by step took longer than I expected and after all we're here to trade. I will do the refactorings behind the scenes and only mention changes that are relevant to what we're working on if they are not absolutely obvious.
The full code code be found here
So long, happy trading!
- Hōrōshi バガボンド
Disclaimer: The content and information provided by Vagabond Research, including all other materials, are for educational and informational purposes only and should not be considered financial advice or a recommendation to buy or sell any type of security or investment. Vagabond Research and its members are not currently regulated or authorised by the FCA, SEC, CFTC, or any other regulatory body to give investment advide. Always conduct your own research and consult with a licensed financial professional before making investment decisions. Trading and investing can involve significant risk, and you should understand these risks before making any financial decisions. Backtested and actual historic results are no guarantee of future performance. Use of the material presented is entirely at your own risk.