Accounting for IBKR borrow fees in Zipline

I'd like to assess IBKR borrow fees on overnight short positions in Zipline. The commission and slippage classes in Zipline don't seem suited to assessing perpetual fees. Perhaps I could modify the portfolio cash balance in before_trading_starts. However, the portfolio context is immutable, and I don't see a method in Zipline that allows me to modify cash. This seems like a common thing to want to do. Are there any best practices for it?

Cash can be modified via context.capital_changes, which is a little-known feature of Zipline that is currently not documented. You're right that before_trading_start is the logical place to assess borrow fees. Built-in support for applying borrow fees in the same way you apply commissions or slippage is planned, but for now you can use the function below to implement it in your strategies:


import pandas as pd
import io
import requests
import zipline.api as algo
from quantrocket.fundamental import download_ibkr_borrow_fees

def initialize(context: algo.Context):
    ...

def before_trading_start(context: algo.Context, data: algo.BarData):
    
    assess_borrow_fees(context)
    ...
    
def assess_borrow_fees(context: algo.Context):
    """
    Assess borrow fees on overnight short positions. This function should be run in before_trading_start. 

    The approach used is as follows:

    - Get a list of current short positions. As long as this function is called in before_trading_start,
      these positions were held overnight.
    - Query borrow fees for the short positions; the previous session's borrow fees are the ones that
      apply to the current positions
    - Calculate the assessed borrow fee for each position. The assessed fee is calculated as follows:
        - Start with the published FeeRate (e.g. 2.5 = 2.5% annual rate)
        - Divide by 100 to convert to decimal format (2.5 -> 0.025)
        - Divide by 360 to convert to daily rate (industry convention is to divide by 360, not 365)
        - Calculate position value (number of shares X latest price)
        - Multiply position value by 102% (by industry convention)
        - Multiply adjusted position value by daily borrow fee rate to get daily fee
        - Multiply daily fee by the number of days since the previous session (fee is 3x on weekends)
    - Store the total assessed fees for all short positions in context.capital_changes, timestamped to 
      the next session. Zipline will debit this amount from the account prior to the start of the next 
      session. 
    """
    short_positions = {asset: position for asset, position in context.portfolio.positions.items() if position.amount < 0}

    if not short_positions:
        return

    calendar = context.exchange_calendar
    dt = algo.get_datetime(tz=calendar.tz)
    today_session = dt.normalize().tz_localize(None)
    previous_session = calendar.previous_session(today_session)
    next_session = calendar.next_session(today_session)
    first_of_month = today_session.replace(day=1)

    # query borrow fees for previous session
    f = io.StringIO()
    try:
        download_ibkr_borrow_fees(
            f,
            sids=[asset.real_sid for asset in short_positions],
            # query back to the first of month, since borrow fees are stored sparsely: https://qrok.it/dl/qr/ibkr-short
            start_date=min(
                first_of_month.date(), 
                # but also ensure start date is before end date (required for this endpoint)
                (previous_session - pd.Timedelta(days=1)).date()
            ),
            end_date=previous_session.date()
        )
    except requests.HTTPError as e:
        if "No securities match the query parameters" in repr(e):
            return
        else:
            raise

    borrow_fees = pd.read_csv(f, parse_dates=["Date"])
    borrow_fees = borrow_fees.sort_values("Date").drop_duplicates('Sid', keep="last")
    borrow_fees = borrow_fees.set_index("Sid").FeeRate

    # convert to decimals
    borrow_fees = borrow_fees / 100
    # convert to daily rates
    daily_borrow_fees = borrow_fees / 360 # industry convention is to divide annual fee by 360, not 365

    # count the days since the last session (weekend borrow fees are 3x)
    days_since_last_session = (today_session - previous_session).days

    total_assessed_borrow_fees = 0
    
    daily_borrow_fees = daily_borrow_fees.to_dict()
    
    for asset, short_position in short_positions.items():

        assessed_borrow_fee = (
            # the assessed borrow fee for this sid is the daily borrow fee rate...
            daily_borrow_fees[asset.real_sid] 
            # ...multiplied by the position value...
            * abs(short_position.amount) * short_position.last_sale_price
            # ...multiplied by 102% (by industry convention)...
            * 1.02
            # ...multiplied by the number of days since the last session
            * days_since_last_session
        )
        total_assessed_borrow_fees += assessed_borrow_fee

    # Now that we have calculated the total borrow fees, debit it via Zipline's 
    # capital_changes built-in. Capital changes for today have already been
    # applied when before_trading_start runs, so schedule the capital changes 
    # for the next session
    context.capital_changes[next_session] = {
        "type": "delta",
        "value": -total_assessed_borrow_fees
    }
    

Zipline will print the amounts debited in the detailed logs, so have the logs open while running your backtest.

2 Likes

Wow! Thanks Brian. This looks great! I'm glad I asked.