Examples

Here are a few examples to give you a better idea of what bt is all about.

SMA Strategy

Let’s start off with a Simple Moving Average (SMA) strategy. We will start with a simple version of the strategy, namely:

  • Select the securities that are currently above their 50 day moving average

  • Weigh each selected security equally

  • Rebalance the portfolio to reflect the target weights

This should be pretty simple to build. The only thing missing above is the calculation of the simple moving average. When should this take place?

Given the flexibility of bt, there is no strict rule. The average calculation could be performed in an Algo, but that would be pretty inefficient. A better way would be to calculate the moving average at the beginning - before starting the backtest. After all, all the data is known in advance.

Now that we know what we have to do, let’s get started. First we will download some data and calculate the simple moving average.

import bt
%matplotlib inline
# download data
data = bt.get('aapl,msft,c,gs,ge', start='2010-01-01')

# calculate moving average DataFrame using pandas' rolling_mean
import pandas as pd
# a rolling mean is a moving average, right?
sma = data.rolling(50).mean()

It’s always a good idea to plot your data to make sure it looks ok. So let’s see how the data + sma plot looks like.

# let's see what the data looks like - this is by no means a pretty chart, but it does the job
plot = bt.merge(data, sma).plot(figsize=(15, 5))
_images/examples-nb_6_0.png

Looks legit.

Now that we have our data, we will need to create our security selection logic. Let’s create a basic Algo that will select the securities that are above their moving average.

Before we do that, let’s think about how we will code it. We could pass the SMA data and then extract the row (from the sma DataFrame) on the current date, compare the values to the current prices, and then keep a list of those securities where the price is above the SMA. This is the most straightforward approach. However, this is not very re-usable because the logic within the Algo will be quite specific to the task at hand and if we wish to change the logic, we will have to write a new algo.

For example, what if we wanted to select securities that were below their sma? Or what if we only wanted securities that were 5% above their sma?

What we could do instead is pre-calculate the selection logic DataFrame (a fast, vectorized operation) and write a generic Algo that takes in this boolean DataFrame and returns the securities where the value is True on a given date. This will be must faster and much more reusable. Let’s see how the implementation looks like.

class SelectWhere(bt.Algo):

    """
    Selects securities based on an indicator DataFrame.

    Selects securities where the value is True on the current date (target.now).

    Args:
        * signal (DataFrame): DataFrame containing the signal (boolean DataFrame)

    Sets:
        * selected

    """
    def __init__(self, signal):
        self.signal = signal

    def __call__(self, target):
        # get signal on target.now
        if target.now in self.signal.index:
            sig = self.signal.loc[target.now]

            # get indices where true as list
            selected = list(sig.index[sig])

            # save in temp - this will be used by the weighing algo
            target.temp['selected'] = selected

        # return True because we want to keep on moving down the stack
        return True

So there we have it. Our selection Algo.

Note

By the way, this Algo already exists - I just wanted to show you how you would code it from scratch. Here is the code.

All we have to do now is pass in a signal matrix. In our case, it’s quite easy:

signal = data > sma

Simple, concise and more importantly, fast! Let’s move on and test the strategy.

# first we create the Strategy
s = bt.Strategy('above50sma', [SelectWhere(data > sma),
                               bt.algos.WeighEqually(),
                               bt.algos.Rebalance()])

# now we create the Backtest
t = bt.Backtest(s, data)

# and let's run it!
res = bt.run(t)

So just to recap, we created the strategy, created the backtest by joining Strategy+Data, and ran the backtest. Let’s see the results.

# what does the equity curve look like?
res.plot();
_images/examples-nb_12_0.png
# and some performance stats
res.display()
 Stat                 above50sma
 -------------------  ------------
 Start                2010-01-03
 End                  2022-07-01
 Risk-free rate       0.00%

 Total Return         116.08%
 Daily Sharpe         0.42
 Daily Sortino        0.63
 CAGR                 6.36%
 Max Drawdown         -39.43%
 Calmar Ratio         0.16

 MTD                  0.00%
 3m                   -19.50%
 6m                   -26.03%
 YTD                  -26.03%
 1Y                   -22.10%
 3Y (ann.)            10.34%
 5Y (ann.)            1.89%
 10Y (ann.)           8.70%
 Since Incep. (ann.)  6.36%

 Daily Sharpe         0.42
 Daily Sortino        0.63
 Daily Mean (ann.)    8.07%
 Daily Vol (ann.)     19.45%
 Daily Skew           -0.65
 Daily Kurt           4.74
 Best Day             5.78%
 Worst Day            -8.26%

 Monthly Sharpe       0.39
 Monthly Sortino      0.65
 Monthly Mean (ann.)  8.59%
 Monthly Vol (ann.)   21.86%
 Monthly Skew         -0.37
 Monthly Kurt         0.73
 Best Month           21.65%
 Worst Month          -17.26%

 Yearly Sharpe        0.41
 Yearly Sortino       0.83
 Yearly Mean          9.78%
 Yearly Vol           23.65%
 Yearly Skew          -0.88
 Yearly Kurt          -0.67
 Best Year            34.85%
 Worst Year           -34.38%

 Avg. Drawdown        -3.56%
 Avg. Drawdown Days   47.27
 Avg. Up Month        4.76%
 Avg. Down Month      -5.35%
 Win Year %           66.67%
 Win 12m %            67.14%

Nothing stellar but at least you learnt something along the way (I hope).

Oh, and one more thing. If you were to write your own “library” of backtests, you might want to write yourself a helper function that would allow you to test different parameters and securities. That function might look something like this:

def above_sma(tickers, sma_per=50, start='2010-01-01', name='above_sma'):
    """
    Long securities that are above their n period
    Simple Moving Averages with equal weights.
    """
    # download data
    data = bt.get(tickers, start=start)
    # calc sma
    sma = data.rolling(sma_per).mean()

    # create strategy
    s = bt.Strategy(name, [SelectWhere(data > sma),
                           bt.algos.WeighEqually(),
                           bt.algos.Rebalance()])

    # now we create the backtest
    return bt.Backtest(s, data)

This function allows us to easily generate backtests. We could easily compare a few different SMA periods. Also, let’s see if we can beat a long-only allocation to the SPY.

# simple backtest to test long-only allocation
def long_only_ew(tickers, start='2010-01-01', name='long_only_ew'):
    s = bt.Strategy(name, [bt.algos.RunOnce(),
                           bt.algos.SelectAll(),
                           bt.algos.WeighEqually(),
                           bt.algos.Rebalance()])
    data = bt.get(tickers, start=start)
    return bt.Backtest(s, data)

# create the backtests
tickers = 'aapl,msft,c,gs,ge'
sma10 = above_sma(tickers, sma_per=10, name='sma10')
sma20 = above_sma(tickers, sma_per=20, name='sma20')
sma40 = above_sma(tickers, sma_per=40, name='sma40')
benchmark = long_only_ew('spy', name='spy')

# run all the backtests!
res2 = bt.run(sma10, sma20, sma40, benchmark)
res2.plot(freq='m');
_images/examples-nb_18_0.png
res2.display()
 Stat                 sma10       sma20       sma40       spy
 -------------------  ----------  ----------  ----------  ----------
 Start                2010-01-03  2010-01-03  2010-01-03  2010-01-03
 End                  2022-07-01  2022-07-01  2022-07-01  2022-07-01
 Risk-free rate       0.00%       0.00%       0.00%       0.00%

 Total Return         284.16%     229.80%     145.62%     321.22%
 Daily Sharpe         0.63        0.58        0.47        0.75
 Daily Sortino        0.99        0.91        0.73        1.15
 CAGR                 11.38%      10.03%      7.46%       12.20%
 Max Drawdown         -31.77%     -40.72%     -34.93%     -33.72%
 Calmar Ratio         0.36        0.25        0.21        0.36

 MTD                  -0.76%      0.00%       0.00%       -0.37%
 3m                   -10.58%     -22.25%     -18.82%     -16.66%
 6m                   -10.71%     -32.14%     -30.31%     -20.28%
 YTD                  -10.71%     -32.14%     -30.31%     -20.28%
 1Y                   -13.63%     -24.65%     -27.20%     -11.44%
 3Y (ann.)            28.10%      14.77%      3.73%       10.10%
 5Y (ann.)            15.80%      8.37%       1.96%       11.11%
 10Y (ann.)           13.76%      10.96%      9.67%       12.78%
 Since Incep. (ann.)  11.38%      10.03%      7.46%       12.20%

 Daily Sharpe         0.63        0.58        0.47        0.75
 Daily Sortino        0.99        0.91        0.73        1.15
 Daily Mean (ann.)    12.88%      11.52%      9.01%       13.03%
 Daily Vol (ann.)     20.48%      19.79%      18.97%      17.34%
 Daily Skew           -0.11       -0.29       -0.45       -0.59
 Daily Kurt           6.61        6.23        4.32        11.75
 Best Day             10.47%      10.47%      6.20%       9.06%
 Worst Day            -8.26%      -8.26%      -8.26%      -10.94%

 Monthly Sharpe       0.65        0.54        0.43        0.92
 Monthly Sortino      1.18        1.02        0.75        1.62
 Monthly Mean (ann.)  13.56%      11.95%      9.71%       13.00%
 Monthly Vol (ann.)   20.96%      21.94%      22.42%      14.20%
 Monthly Skew         -0.02       0.22        -0.10       -0.40
 Monthly Kurt         1.01        1.11        0.67        0.89
 Best Month           22.75%      24.73%      21.97%      12.70%
 Worst Month          -16.94%     -14.34%     -15.86%     -12.49%

 Yearly Sharpe        0.54        0.43        0.40        0.80
 Yearly Sortino       2.01        1.03        0.77        2.15
 Yearly Mean          13.38%      13.94%      9.76%       12.67%
 Yearly Vol           24.64%      32.80%      24.22%      15.79%
 Yearly Skew          0.41        -0.15       -0.87       -0.68
 Yearly Kurt          -0.43       -0.96       -0.59       0.12
 Best Year            62.47%      66.99%      39.35%      32.31%
 Worst Year           -18.59%     -37.01%     -32.06%     -20.28%

 Avg. Drawdown        -3.95%      -3.49%      -3.68%      -1.69%
 Avg. Drawdown Days   40.43       35.12       48.79       15.92
 Avg. Up Month        4.68%       5.00%       4.69%       3.20%
 Avg. Down Month      -5.00%      -4.85%      -5.70%      -3.56%
 Win Year %           58.33%      66.67%      75.00%      83.33%
 Win 12m %            68.57%      66.43%      69.29%      91.43%

And there you have it. Beating the market ain’t that easy!

SMA Crossover Strategy

Let’s build on the last section to test a moving average crossover strategy. The easiest way to achieve this is to build an Algo similar to SelectWhere, but for the purpose of setting target weights. Let’s call this algo WeighTarget. This algo will take a DataFrame of target weights that we will pre-calculate.

Basically, when the 50 day moving average will be above the 200-day moving average, we will be long (+1 target weight). Conversely, when the 50 is below the 200, we will be short (-1 target weight).

Here’s the WeighTarget implementation (this Algo also already exists in the algos module):

class WeighTarget(bt.Algo):
    """
    Sets target weights based on a target weight DataFrame.

    Args:
        * target_weights (DataFrame): DataFrame containing the target weights

    Sets:
        * weights

    """

    def __init__(self, target_weights):
        self.tw = target_weights

    def __call__(self, target):
        # get target weights on date target.now
        if target.now in self.tw.index:
            w = self.tw.loc[target.now]

            # save in temp - this will be used by the weighing algo
            # also dropping any na's just in case they pop up
            target.temp['weights'] = w.dropna()

        # return True because we want to keep on moving down the stack
        return True

So let’s start with a simple 50-200 day sma crossover for a single security.

## download some data & calc SMAs
data = bt.get('spy', start='2010-01-01')
sma50 = data.rolling(50).mean()
sma200 = data.rolling(200).mean()

## now we need to calculate our target weight DataFrame
# first we will copy the sma200 DataFrame since our weights will have the same strucutre
tw = sma200.copy()
# set appropriate target weights
tw[sma50 > sma200] = 1.0
tw[sma50 <= sma200] = -1.0
# here we will set the weight to 0 - this is because the sma200 needs 200 data points before
# calculating its first point. Therefore, it will start with a bunch of nulls (NaNs).
tw[sma200.isnull()] = 0.0

Ok so we downloaded our data, calculated the simple moving averages, and then we setup our target weight (tw) DataFrame. Let’s take a look at our target weights to see if they make any sense.

# plot the target weights + chart of price & SMAs
tmp = bt.merge(tw, data, sma50, sma200)
tmp.columns = ['tw', 'price', 'sma50', 'sma200']
ax = tmp.plot(figsize=(15,5), secondary_y=['tw'])
_images/examples-nb_26_0.png

As mentioned earlier, it’s always a good idea to plot your strategy data. It is usually easier to spot logic/programming errors this way, especially when dealing with lots of data.

Now let’s move on with the Strategy & Backtest.

ma_cross = bt.Strategy('ma_cross', [WeighTarget(tw),
                                    bt.algos.Rebalance()])

t = bt.Backtest(ma_cross, data)
res = bt.run(t)
res.plot();
_images/examples-nb_29_0.png

Ok great so there we have our basic moving average crossover strategy.

Exploring the Tree Structure

So far, we have explored strategies that allocate capital to securities. But what if we wanted to test a strategy that allocated capital to sub-strategies?

The most straightforward way would be to test the different sub-strategies, extract their equity curves and create “synthetic securities” that would basically just represent the returns achieved from allocating capital to the different sub-strategies.

Let’s see how this looks:

# first let's create a helper function to create a ma cross backtest
def ma_cross(ticker, start='2010-01-01',
             short_ma=50, long_ma=200, name='ma_cross'):
    # these are all the same steps as above
    data = bt.get(ticker, start=start)
    short_sma = data.rolling(short_ma).mean()
    long_sma  = data.rolling(long_ma).mean()

    # target weights
    tw = long_sma.copy()
    tw[short_sma > long_sma] = 1.0
    tw[short_sma <= long_sma] = -1.0
    tw[long_sma.isnull()] = 0.0

    # here we specify the children (3rd) arguemnt to make sure the strategy
    # has the proper universe. This is necessary in strategies of strategies
    s = bt.Strategy(name, [WeighTarget(tw), bt.algos.Rebalance()], [ticker])

    return bt.Backtest(s, data)

# ok now let's create a few backtests and gather the results.
# these will later become our "synthetic securities"
t1 = ma_cross('aapl', name='aapl_ma_cross')
t2 = ma_cross('msft', name='msft_ma_cross')

# let's run these strategies now
res = bt.run(t1, t2)

# now that we have run the strategies, let's extract
# the data to create "synthetic securities"
data = bt.merge(res['aapl_ma_cross'].prices, res['msft_ma_cross'].prices)

# now we have our new data. This data is basically the equity
# curves of both backtested strategies. Now we can just use this
# to test any old strategy, just like before.
s = bt.Strategy('s', [bt.algos.SelectAll(),
                      bt.algos.WeighInvVol(),
                      bt.algos.Rebalance()])

# create and run
t = bt.Backtest(s, data)
res = bt.run(t)
res.plot();
_images/examples-nb_32_0.png
res.plot_weights();
_images/examples-nb_33_0.png

As we can see above, the process is a bit more involved, but it works. It is not very elegant though, and obtaining security-level allocation information is problematic.

Luckily, bt has built-in functionality for dealing with strategies of strategies. It uses the same general principal as demonstrated above but does it seamlessly. Basically, when a strategy is a child of another strategy, it will create a “paper trade” version of itself internally. As we run our strategy, it will run its internal “paper version” and use the returns from that strategy to populate the price property.

This means that the parent strategy can use the price information (which reflects the returns of the strategy had it been employed) to determine the appropriate allocation. Again, this is basically the same process as above, just packed into 1 step.

Perhaps some code will help:

# once again, we will create a few backtests
# these will be the child strategies
t1 = ma_cross('aapl', name='aapl_ma_cross')
t2 = ma_cross('msft', name='msft_ma_cross')

# let's extract the data object
data = bt.merge(t1.data, t2.data)

# now we create the parent strategy
# we specify the children to be the two
# strategies created above
s = bt.Strategy('s', [bt.algos.SelectAll(),
                      bt.algos.WeighInvVol(),
                      bt.algos.Rebalance()],
                [t1.strategy, t2.strategy])

# create and run
t = bt.Backtest(s, data)
res = bt.run(t)
res.plot();
_images/examples-nb_36_0.png
res.plot_weights();
_images/examples-nb_37_0.png

So there you have it. Simpler, and more complete.

Buy and Hold Strategy

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import ffn
import bt

%matplotlib inline

Create Fake Index Data

names = ['foo','bar','rf']
dates = pd.date_range(start='2017-01-01',end='2017-12-31', freq=pd.tseries.offsets.BDay())
n = len(dates)
rdf = pd.DataFrame(
    np.zeros((n, len(names))),
    index = dates,
    columns = names
)

np.random.seed(1)
rdf['foo'] = np.random.normal(loc = 0.1/n,scale=0.2/np.sqrt(n),size=n)
rdf['bar'] = np.random.normal(loc = 0.04/n,scale=0.05/np.sqrt(n),size=n)
rdf['rf'] = 0.

pdf = 100*np.cumprod(1+rdf)
pdf.plot();
_images/Buy_and_hold_3_0.png

Build Strategy

# algo to fire on the beginning of every month and to run on the first date
runMonthlyAlgo = bt.algos.RunMonthly(
    run_on_first_date=True
)

# algo to set the weights
#  it will only run when runMonthlyAlgo returns true
#  which only happens on the first of every month
weights = pd.Series([0.6,0.4,0.],index = rdf.columns)
weighSpecifiedAlgo = bt.algos.WeighSpecified(**weights)

# algo to rebalance the current weights to weights set by weighSpecified
#  will only run when weighSpecifiedAlgo returns true
#  which happens every time it runs
rebalAlgo = bt.algos.Rebalance()

# a strategy that rebalances monthly to specified weights
strat = bt.Strategy('static',
    [
        runMonthlyAlgo,
        weighSpecifiedAlgo,
        rebalAlgo
    ]
)

Run Backtest

Note: The logic of the strategy is seperate from the data used in the backtest.

# set integer_positions=False when positions are not required to be integers(round numbers)
backtest = bt.Backtest(
    strat,
    pdf,
    integer_positions=False
)

res = bt.run(backtest)
res.stats
static
start 2017-01-01 00:00:00
end 2017-12-29 00:00:00
rf 0.0
total_return 0.229372
cagr 0.231653
max_drawdown -0.069257
calmar 3.344851
mtd -0.000906
three_month 0.005975
six_month 0.142562
ytd 0.229372
one_year NaN
three_year NaN
five_year NaN
ten_year NaN
incep 0.231653
daily_sharpe 1.804549
daily_sortino 3.306154
daily_mean 0.206762
daily_vol 0.114578
daily_skew 0.012208
daily_kurt -0.04456
best_day 0.020402
worst_day -0.0201
monthly_sharpe 2.806444
monthly_sortino 15.352486
monthly_mean 0.257101
monthly_vol 0.091611
monthly_skew 0.753881
monthly_kurt 0.456278
best_month 0.073657
worst_month -0.014592
yearly_sharpe NaN
yearly_sortino NaN
yearly_mean NaN
yearly_vol NaN
yearly_skew NaN
yearly_kurt NaN
best_year NaN
worst_year NaN
avg_drawdown -0.016052
avg_drawdown_days 12.695652
avg_up_month 0.03246
avg_down_month -0.008001
win_year_perc NaN
twelve_month_win_perc NaN
res.prices.head()
static
2017-01-01 100.000000
2017-01-02 100.000000
2017-01-03 99.384719
2017-01-04 99.121677
2017-01-05 98.316364
res.plot_security_weights()
_images/Buy_and_hold_10_0.png

Strategy value over time

performanceStats = res['static']
#performance stats is an ffn object
res.backtest_list[0].strategy.values.plot();
_images/Buy_and_hold_12_0.png

Strategy Outlays

Outlays are the total dollar amount spent(gained) by a purchase(sale) of securities.

res.backtest_list[0].strategy.outlays.plot();
_images/Buy_and_hold_14_0.png

You can get the change in number of shares purchased a

security_names = res.backtest_list[0].strategy.outlays.columns


res.backtest_list[0].strategy.outlays/pdf.loc[:,security_names]
res.backtest_list[0].positions.diff(1)
res.backtest_list[0].positions
foo bar
2017-01-01 0.000000 0.000000
2017-01-02 5879.285683 3998.068018
2017-01-03 5879.285683 3998.068018
2017-01-04 5879.285683 3998.068018
2017-01-05 5879.285683 3998.068018
... ... ...
2017-12-25 5324.589093 4673.239436
2017-12-26 5324.589093 4673.239436
2017-12-27 5324.589093 4673.239436
2017-12-28 5324.589093 4673.239436
2017-12-29 5324.589093 4673.239436

261 rows × 2 columns

Trend Example 1

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

import ffn
import bt

%matplotlib inline

Create fake data

rf = 0.04
np.random.seed(1)
mus = np.random.normal(loc=0.05,scale=0.02,size=5) + rf
sigmas = (mus - rf)/0.3 + np.random.normal(loc=0.,scale=0.01,size=5)

num_years = 10
num_months_per_year = 12
num_days_per_month = 21
num_days_per_year = num_months_per_year*num_days_per_month

rdf = pd.DataFrame(
    index = pd.date_range(
        start="2008-01-02",
        periods=num_years*num_months_per_year*num_days_per_month,
        freq="B"
    ),
    columns=['foo','bar','baz','fake1','fake2']
)

for i,mu in enumerate(mus):
    sigma = sigmas[i]
    rdf.iloc[:,i] = np.random.normal(
        loc=mu/num_days_per_year,
        scale=sigma/np.sqrt(num_days_per_year),
        size=rdf.shape[0]
    )
pdf = np.cumprod(1+rdf)*100

pdf.plot();
_images/Trend_1_3_0.png

Create Trend signal over the last 12 months

sma  = pdf.rolling(window=num_days_per_month*12,center=False).median().shift(1)
plt.plot(pdf.index,pdf['foo'])
plt.plot(sma.index,sma['foo'])
plt.show()
_images/Trend_1_5_0.png
#sma with 1 day lag
sma.tail()
foo bar baz fake1 fake2
2017-08-23 623.241267 340.774506 99.764885 263.491447 619.963986
2017-08-24 623.167989 341.096742 99.764885 263.502145 620.979948
2017-08-25 622.749149 341.316672 99.764885 263.502145 622.421401
2017-08-28 622.353039 341.494307 99.807732 263.517071 622.962579
2017-08-29 622.153294 341.662442 99.807732 263.517071 622.992416
#sma with 0 day lag
pdf.rolling(window=num_days_per_month*12,center=False).median().tail()
foo bar baz fake1 fake2
2017-08-23 623.167989 341.096742 99.764885 263.502145 620.979948
2017-08-24 622.749149 341.316672 99.764885 263.502145 622.421401
2017-08-25 622.353039 341.494307 99.807732 263.517071 622.962579
2017-08-28 622.153294 341.662442 99.807732 263.517071 622.992416
2017-08-29 621.907867 341.948212 99.807732 263.634283 624.310473
# target weights
trend = sma.copy()
trend[pdf > sma] = True
trend[pdf <= sma] = False
trend[sma.isnull()] = False
trend.tail()
foo bar baz fake1 fake2
2017-08-23 False True True True True
2017-08-24 False True True True True
2017-08-25 False True True True True
2017-08-28 False True True True True
2017-08-29 False True True True True

Compare EW and 1/vol

Both strategies rebalance daily using trend with 1 day lag and weights limited to 40%.

tsmom_invvol_strat = bt.Strategy(
    'tsmom_invvol',
    [
        bt.algos.RunDaily(),
        bt.algos.SelectWhere(trend),
        bt.algos.WeighInvVol(),
        bt.algos.LimitWeights(limit=0.4),
        bt.algos.Rebalance()
    ]
)

tsmom_ew_strat = bt.Strategy(
    'tsmom_ew',
    [
        bt.algos.RunDaily(),
        bt.algos.SelectWhere(trend),
        bt.algos.WeighEqually(),
        bt.algos.LimitWeights(limit=0.4),
        bt.algos.Rebalance()
    ]
)
# create and run
tsmom_invvol_bt = bt.Backtest(
    tsmom_invvol_strat,
    pdf,
    initial_capital=50000000.0,
    commissions=lambda q, p: max(100, abs(q) * 0.0021),
    integer_positions=False,
    progress_bar=True
)
tsmom_invvol_res = bt.run(tsmom_invvol_bt)

tsmom_ew_bt = bt.Backtest(
    tsmom_ew_strat,
    pdf,

    initial_capital=50000000.0,
    commissions=lambda q, p: max(100, abs(q) * 0.0021),
    integer_positions=False,
    progress_bar=True
)
tsmom_ew_res = bt.run(tsmom_ew_bt)
 tsmom_invvol
 0% [############################# ] 100% | ETA: 00:00:00tsmom_ew
 0% [############################# ] 100% | ETA: 00:00:00
ax = plt.subplot()
ax.plot(tsmom_ew_res.prices.index,tsmom_ew_res.prices,label='EW')
pdf.plot(ax=ax)

ax.legend()
plt.legend()
plt.show()
_images/Trend_1_12_0.png
tsmom_ew_res.stats
tsmom_ew
start 2008-01-01 00:00:00
end 2017-08-29 00:00:00
rf 0.0
total_return 1.982933
cagr 0.119797
max_drawdown -0.103421
calmar 1.158343
mtd 0.017544
three_month 0.040722
six_month 0.079362
ytd 0.08107
one_year 0.100432
three_year 0.159895
five_year 0.172284
ten_year 0.119797
incep 0.119797
daily_sharpe 1.356727
daily_sortino 2.332895
daily_mean 0.112765
daily_vol 0.083116
daily_skew 0.029851
daily_kurt 0.96973
best_day 0.02107
worst_day -0.021109
monthly_sharpe 1.373241
monthly_sortino 2.966223
monthly_mean 0.118231
monthly_vol 0.086096
monthly_skew -0.059867
monthly_kurt 0.571064
best_month 0.070108
worst_month -0.064743
yearly_sharpe 1.741129
yearly_sortino inf
yearly_mean 0.129033
yearly_vol 0.074109
yearly_skew 0.990397
yearly_kurt 1.973883
best_year 0.285249
worst_year 0.024152
avg_drawdown -0.015516
avg_drawdown_days 25.223214
avg_up_month 0.024988
avg_down_month -0.012046
win_year_perc 1.0
twelve_month_win_perc 0.971429

Trend Example 2

import numpy as np
import pandas as pd

import bt
import matplotlib.pyplot as plt

%matplotlib inline
np.random.seed(0)
returns =  np.random.normal(0.08/12,0.2/np.sqrt(12),12*10)
pdf = pd.DataFrame(
    np.cumprod(1+returns),
    index = pd.date_range(start="2008-01-01",periods=12*10,freq="m"),
    columns=['foo']
)

pdf.plot();
_images/Trend_2_2_0.png
runMonthlyAlgo = bt.algos.RunMonthly()
rebalAlgo = bt.algos.Rebalance()

class Signal(bt.Algo):

    """

    Mostly copied from StatTotalReturn

    Sets temp['Signal'] with total returns over a given period.

    Sets the 'Signal' based on the total return of each
    over a given lookback period.

    Args:
        * lookback (DateOffset): lookback period.
        * lag (DateOffset): Lag interval. Total return is calculated in
            the inteval [now - lookback - lag, now - lag]

    Sets:
        * stat

    Requires:
        * selected

    """

    def __init__(self, lookback=pd.DateOffset(months=3),
                 lag=pd.DateOffset(days=0)):
        super(Signal, self).__init__()
        self.lookback = lookback
        self.lag = lag

    def __call__(self, target):
        selected = 'foo'
        t0 = target.now - self.lag

        if target.universe[selected].index[0] > t0:
            return False
        prc = target.universe[selected].loc[t0 - self.lookback:t0]


        trend = prc.iloc[-1]/prc.iloc[0] - 1
        signal = trend > 0.

        if signal:
            target.temp['Signal'] = 1.
        else:
            target.temp['Signal'] = 0.

        return True

signalAlgo = Signal(pd.DateOffset(months=12),pd.DateOffset(months=1))

class WeighFromSignal(bt.Algo):

    """
    Sets temp['weights'] from the signal.
    Sets:
        * weights

    Requires:
        * selected

    """

    def __init__(self):
        super(WeighFromSignal, self).__init__()

    def __call__(self, target):
        selected = 'foo'
        if target.temp['Signal'] is None:
            raise(Exception('No Signal!'))

        target.temp['weights'] = {selected : target.temp['Signal']}
        return True

weighFromSignalAlgo = WeighFromSignal()
s = bt.Strategy(
    'example1',
    [
        runMonthlyAlgo,
        signalAlgo,
        weighFromSignalAlgo,
        rebalAlgo
    ]
)

t = bt.Backtest(s, pdf, integer_positions=False, progress_bar=True)
res = bt.run(t)
 example1
 0% [############################# ] 100% | ETA: 00:00:00
res.plot_security_weights();
_images/Trend_2_5_0.png
t.positions
foo
2008-01-30 0.000000
2008-01-31 0.000000
2008-02-29 0.000000
2008-03-31 0.000000
2008-04-30 0.000000
... ...
2017-08-31 631321.251898
2017-09-30 631321.251898
2017-10-31 631321.251898
2017-11-30 631321.251898
2017-12-31 631321.251898

121 rows × 1 columns

res.prices.tail()
example1
2017-08-31 240.302579
2017-09-30 255.046653
2017-10-31 254.464421
2017-11-30 265.182603
2017-12-31 281.069771
res.stats
example1
start 2008-01-30 00:00:00
end 2017-12-31 00:00:00
rf 0.0
total_return 1.810698
cagr 0.109805
max_drawdown -0.267046
calmar 0.411186
mtd 0.05991
three_month 0.102033
six_month 0.22079
ytd 0.879847
one_year 0.879847
three_year 0.406395
five_year 0.227148
ten_year 0.109805
incep 0.109805
daily_sharpe 3.299555
daily_sortino 6.352869
daily_mean 2.448589
daily_vol 0.742097
daily_skew 0.307861
daily_kurt 1.414455
best_day 0.137711
worst_day -0.14073
monthly_sharpe 0.723148
monthly_sortino 1.392893
monthly_mean 0.117579
monthly_vol 0.162594
monthly_skew 0.301545
monthly_kurt 1.379006
best_month 0.137711
worst_month -0.14073
yearly_sharpe 0.503939
yearly_sortino 5.019272
yearly_mean 0.14814
yearly_vol 0.293964
yearly_skew 2.317496
yearly_kurt 5.894955
best_year 0.879847
worst_year -0.088543
avg_drawdown -0.091255
avg_drawdown_days 369.714286
avg_up_month 0.064341
avg_down_month -0.012928
win_year_perc 0.555556
twelve_month_win_perc 0.46789

Strategy Combination

This notebook creates a parent strategy(combined) with 2 child strategies(Equal Weight, Inv Vol).

Alternatively, it creates the 2 child strategies, runs the backtest, combines the results, and creates a parent strategy using both of the backtests.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import ffn
import bt

%matplotlib inline

Create fake data

rf = 0.04
np.random.seed(1)
mus = np.random.normal(loc=0.05,scale=0.02,size=5) + rf
sigmas = (mus - rf)/0.3 + np.random.normal(loc=0.,scale=0.01,size=5)

num_years = 10
num_months_per_year = 12
num_days_per_month = 21
num_days_per_year = num_months_per_year*num_days_per_month

rdf = pd.DataFrame(
    index = pd.date_range(
        start="2008-01-02",
        periods=num_years*num_months_per_year*num_days_per_month,
        freq="B"
    ),
    columns=['foo','bar','baz','fake1','fake2']
)

for i,mu in enumerate(mus):
    sigma = sigmas[i]
    rdf.iloc[:,i] = np.random.normal(
        loc=mu/num_days_per_year,
        scale=sigma/np.sqrt(num_days_per_year),
        size=rdf.shape[0]
    )
pdf = np.cumprod(1+rdf)*100
pdf.iloc[0,:] = 100

pdf.plot();
_images/Strategy_Combination_3_0.png
strategy_names = np.array(
    [
        'Equal Weight',
        'Inv Vol'
    ]
)

runMonthlyAlgo = bt.algos.RunMonthly(
    run_on_first_date=True,
    run_on_end_of_period=True
)
selectAllAlgo = bt.algos.SelectAll()
rebalanceAlgo = bt.algos.Rebalance()

strats = []
tests = []

for i,s in enumerate(strategy_names):
    if s == "Equal Weight":
        wAlgo = bt.algos.WeighEqually()
    elif s == "Inv Vol":
        wAlgo = bt.algos.WeighInvVol()

    strat = bt.Strategy(
        str(s),
        [
            runMonthlyAlgo,
            selectAllAlgo,
            wAlgo,
            rebalanceAlgo
        ]
    )
    strats.append(strat)

    t = bt.Backtest(
        strat,
        pdf,
        integer_positions = False,
        progress_bar=False
    )
    tests.append(t)
combined_strategy = bt.Strategy(
    'Combined',
    algos = [
        runMonthlyAlgo,
        selectAllAlgo,
        bt.algos.WeighEqually(),
        rebalanceAlgo
    ],
    children = [x.strategy for x in tests]
)

combined_test = bt.Backtest(
    combined_strategy,
    pdf,
    integer_positions = False,
    progress_bar = False
)

res = bt.run(combined_test)
res.prices.plot();
_images/Strategy_Combination_6_0.png
res.get_security_weights().plot();
_images/Strategy_Combination_7_0.png

In order to get the weights of each strategy, you can run each strategy, get the prices for each strategy, combine them into one price dataframe, run the combined strategy on the new data set.

strategy_names = np.array(
    [
        'Equal Weight',
        'Inv Vol'
    ]
)

runMonthlyAlgo = bt.algos.RunMonthly(
    run_on_first_date=True,
    run_on_end_of_period=True
)
selectAllAlgo = bt.algos.SelectAll()
rebalanceAlgo = bt.algos.Rebalance()

strats = []
tests = []
results = []

for i,s in enumerate(strategy_names):
    if s == "Equal Weight":
        wAlgo = bt.algos.WeighEqually()
    elif s == "Inv Vol":
        wAlgo = bt.algos.WeighInvVol()

    strat = bt.Strategy(
        s,
        [
            runMonthlyAlgo,
            selectAllAlgo,
            wAlgo,
            rebalanceAlgo
        ]
    )
    strats.append(strat)

    t = bt.Backtest(
        strat,
        pdf,
        integer_positions = False,
        progress_bar=False
    )
    tests.append(t)

    res = bt.run(t)
    results.append(res)
fig, ax = plt.subplots(nrows=1,ncols=1)
for i,r in enumerate(results):
    r.plot(ax=ax)
_images/Strategy_Combination_10_0.png
merged_prices_df = bt.merge(results[0].prices,results[1].prices)

combined_strategy = bt.Strategy(
    'Combined',
    algos = [
        runMonthlyAlgo,
        selectAllAlgo,
        bt.algos.WeighEqually(),
        rebalanceAlgo
    ]
)

combined_test = bt.Backtest(
    combined_strategy,
    merged_prices_df,
    integer_positions = False,
    progress_bar = False
)

res = bt.run(combined_test)
res.plot();
_images/Strategy_Combination_12_0.png
res.get_security_weights().plot();
_images/Strategy_Combination_13_0.png

Equally Weighted Risk Contributions Portfolio

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import ffn
import bt

%matplotlib inline

Create Fake Index Data

mean = np.array([0.05/252 + 0.02/252, 0.03/252 + 0.02/252])
volatility = np.array([0.2/np.sqrt(252), 0.05/np.sqrt(252)])
variance = np.power(volatility,2)
correlation = np.array(
    [
        [1, 0.25],
        [0.25,1]
    ]
)
covariance = np.zeros((2,2))
for i in range(len(variance)):
    for j in range(len(variance)):
        covariance[i,j] = correlation[i,j]*volatility[i]*volatility[j]

covariance
 array([[1.58730159e-04, 9.92063492e-06],
        [9.92063492e-06, 9.92063492e-06]])
names = ['foo','bar','rf']
dates = pd.date_range(start='2015-01-01',end='2018-12-31', freq=pd.tseries.offsets.BDay())
n = len(dates)
rdf = pd.DataFrame(
    np.zeros((n, len(names))),
    index = dates,
    columns = names
)

np.random.seed(1)
rdf.loc[:,['foo','bar']] = np.random.multivariate_normal(mean,covariance,size=n)
rdf['rf'] = 0.02/252

pdf = 100*np.cumprod(1+rdf)
pdf.plot();
_images/ERC_4_0.png

Build and run ERC Strategy

You can read more about ERC here. http://thierry-roncalli.com/download/erc.pdf

runAfterDaysAlgo = bt.algos.RunAfterDays(
    20*6 + 1
)

selectTheseAlgo = bt.algos.SelectThese(['foo','bar'])

# algo to set the weights so each asset contributes the same amount of risk
#  with data over the last 6 months excluding yesterday
weighERCAlgo = bt.algos.WeighERC(
    lookback=pd.DateOffset(days=20*6),
    covar_method='standard',
    risk_parity_method='slsqp',
    maximum_iterations=1000,
    tolerance=1e-9,
    lag=pd.DateOffset(days=1)
)

rebalAlgo = bt.algos.Rebalance()

strat = bt.Strategy(
    'ERC',
    [
        runAfterDaysAlgo,
        selectTheseAlgo,
        weighERCAlgo,
        rebalAlgo
    ]
)

backtest = bt.Backtest(
    strat,
    pdf,
    integer_positions=False
)

res_target = bt.run(backtest)
res_target.get_security_weights().plot();
_images/ERC_7_0.png
res_target.prices.plot();
_images/ERC_8_0.png
weights_target = res_target.get_security_weights().copy()
rolling_cov_target = pdf.loc[:,weights_target.columns].pct_change().rolling(window=252).cov()*252


trc_target = pd.DataFrame(
    np.nan,
    index = weights_target.index,
    columns = weights_target.columns
)

for dt in pdf.index:
    trc_target.loc[dt,:] = weights_target.loc[dt,:].values*(rolling_cov_target.loc[dt,:].values@weights_target.loc[dt,:].values)/np.sqrt(weights_target.loc[dt,:].values@rolling_cov_target.loc[dt,:].values@weights_target.loc[dt,:].values)


fig, ax = plt.subplots(nrows=1,ncols=1)
trc_target.plot(ax=ax)
ax.set_title('Total Risk Contribution')
ax.plot();
_images/ERC_9_0.png

You can see the Total Risk Contribution is roughly equal from both assets.

Predicted Tracking Error Rebalance Portfolio

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import ffn
import bt

%matplotlib inline

Create Fake Index Data

names = ['foo','bar','rf']
dates = pd.date_range(start='2015-01-01',end='2018-12-31', freq=pd.tseries.offsets.BDay())
n = len(dates)
rdf = pd.DataFrame(
    np.zeros((n, len(names))),
    index = dates,
    columns = names
)

np.random.seed(1)
rdf['foo'] = np.random.normal(loc = 0.1/252,scale=0.2/np.sqrt(252),size=n)
rdf['bar'] = np.random.normal(loc = 0.04/252,scale=0.05/np.sqrt(252),size=n)
rdf['rf'] = 0.

pdf = 100*np.cumprod(1+rdf)
pdf.plot();
_images/PTE_3_0.png

Build and run Target Strategy

I will first run a strategy that rebalances everyday.

Then I will use those weights as target to rebalance to whenever the PTE is too high.

selectTheseAlgo = bt.algos.SelectThese(['foo','bar'])

# algo to set the weights to 1/vol contributions from each asset
#  with data over the last 3 months excluding yesterday
weighInvVolAlgo = bt.algos.WeighInvVol(
    lookback=pd.DateOffset(months=3),
    lag=pd.DateOffset(days=1)
)

# algo to rebalance the current weights to weights set in target.temp
rebalAlgo = bt.algos.Rebalance()

# a strategy that rebalances daily to 1/vol weights
strat = bt.Strategy(
    'Target',
    [
        selectTheseAlgo,
        weighInvVolAlgo,
        rebalAlgo
    ]
)

# set integer_positions=False when positions are not required to be integers(round numbers)
backtest = bt.Backtest(
    strat,
    pdf,
    integer_positions=False
)

res_target = bt.run(backtest)
res_target.get_security_weights().plot();
_images/PTE_6_0.png

Now use the PTE rebalance algo to trigger a rebalance whenever predicted tracking error is greater than 1%.

# algo to fire whenever predicted tracking error is greater than 1%
wdf = res_target.get_security_weights()

PTE_rebalance_Algo = bt.algos.PTE_Rebalance(
    0.01,
    wdf,
    lookback=pd.DateOffset(months=3),
    lag=pd.DateOffset(days=1),
    covar_method='standard',
    annualization_factor=252
)

selectTheseAlgo = bt.algos.SelectThese(['foo','bar'])

# algo to set the weights to 1/vol contributions from each asset
#  with data over the last 12 months excluding yesterday
weighTargetAlgo = bt.algos.WeighTarget(
    wdf
)

rebalAlgo = bt.algos.Rebalance()

# a strategy that rebalances monthly to specified weights
strat = bt.Strategy(
    'PTE',
    [
        PTE_rebalance_Algo,
        selectTheseAlgo,
        weighTargetAlgo,
        rebalAlgo
    ]
)

# set integer_positions=False when positions are not required to be integers(round numbers)
backtest = bt.Backtest(
    strat,
    pdf,
    integer_positions=False
)

res_PTE = bt.run(backtest)
fig, ax = plt.subplots(nrows=1,ncols=1)
res_target.get_security_weights().plot(ax=ax)

realized_weights_df = res_PTE.get_security_weights()
realized_weights_df['PTE foo'] = realized_weights_df['foo']
realized_weights_df['PTE bar'] = realized_weights_df['bar']
realized_weights_df = realized_weights_df.loc[:,['PTE foo', 'PTE bar']]
realized_weights_df.plot(ax=ax)

ax.set_title('Target Weights vs PTE Weights')
ax.plot();
_images/PTE_9_0.png
trans_df = pd.DataFrame(
    index=res_target.prices.index,
    columns=['Target','PTE']
)

transactions = res_target.get_transactions()
transactions = (transactions['quantity'] * transactions['price']).reset_index()

bar_mask = transactions.loc[:,'Security'] == 'bar'
foo_mask = transactions.loc[:,'Security'] == 'foo'

trans_df.loc[trans_df.index[4:],'Target'] = np.abs(transactions[bar_mask].iloc[:,2].values) + np.abs(transactions[foo_mask].iloc[:,2].values)
transactions = res_PTE.get_transactions()
transactions = (transactions['quantity'] * transactions['price']).reset_index()

bar_mask = transactions.loc[:,'Security'] == 'bar'
foo_mask = transactions.loc[:,'Security'] == 'foo'

trans_df.loc[transactions[bar_mask].iloc[:,0],'PTE'] =  np.abs(transactions[bar_mask].iloc[:,2].values)
trans_df.loc[transactions[foo_mask].iloc[:,0],'PTE'] +=  np.abs(transactions[foo_mask].iloc[:,2].values)
trans_df = trans_df.fillna(0)
fig, ax = plt.subplots(nrows=1,ncols=1)
trans_df.cumsum().plot(ax=ax)
ax.set_title('Cumulative sum of notional traded')
ax.plot();
_images/PTE_13_0.png

If we plot the total risk contribution of each asset class and divide by the total volatility, then we can see that both strategy’s contribute roughly similar amounts of volatility from both of the securities.

weights_target = res_target.get_security_weights()
rolling_cov_target = pdf.loc[:,weights_target.columns].pct_change().rolling(window=3*20).cov()*252

weights_PTE = res_PTE.get_security_weights().loc[:,weights_target.columns]
rolling_cov_PTE = pdf.loc[:,weights_target.columns].pct_change().rolling(window=3*20).cov()*252


trc_target = pd.DataFrame(
    np.nan,
    index = weights_target.index,
    columns = weights_target.columns
)

trc_PTE = pd.DataFrame(
    np.nan,
    index = weights_PTE.index,
    columns = [x + " PTE" for x in weights_PTE.columns]
)

for dt in pdf.index:
    trc_target.loc[dt,:] = weights_target.loc[dt,:].values*(rolling_cov_target.loc[dt,:].values@weights_target.loc[dt,:].values)/np.sqrt(weights_target.loc[dt,:].values@rolling_cov_target.loc[dt,:].values@weights_target.loc[dt,:].values)
    trc_PTE.loc[dt,:] = weights_PTE.loc[dt,:].values*(rolling_cov_PTE.loc[dt,:].values@weights_PTE.loc[dt,:].values)/np.sqrt(weights_PTE.loc[dt,:].values@rolling_cov_PTE.loc[dt,:].values@weights_PTE.loc[dt,:].values)


fig, ax = plt.subplots(nrows=1,ncols=1)
trc_target.plot(ax=ax)
trc_PTE.plot(ax=ax)
ax.set_title('Total Risk Contribution')
ax.plot();
_images/PTE_15_0.png

Looking at the Target strategy’s and PTE strategy’s Total Risk they are very similar.

fig, ax = plt.subplots(nrows=1,ncols=1)
trc_target.sum(axis=1).plot(ax=ax,label='Target')
trc_PTE.sum(axis=1).plot(ax=ax,label='PTE')
ax.legend()
ax.set_title('Total Risk')
ax.plot();
_images/PTE_17_0.png
transactions = res_PTE.get_transactions()
transactions = (transactions['quantity'] * transactions['price']).reset_index()

bar_mask = transactions.loc[:,'Security'] == 'bar'
dates_of_PTE_transactions = transactions[bar_mask].iloc[:,0]
dates_of_PTE_transactions
 0    2015-01-06
 2    2015-01-07
 4    2015-01-08
 6    2015-01-09
 8    2015-01-12
 10   2015-02-20
 12   2015-04-07
 14   2015-09-01
 16   2017-03-23
 18   2017-06-23
 20   2017-10-24
 Name: Date, dtype: datetime64[ns]
fig, ax = plt.subplots(nrows=1,ncols=1)
np.sum(np.abs(trc_target.values - trc_PTE.values))
    #.abs().sum(axis=1).plot()

ax.set_title('Total Risk')
ax.plot(
    trc_target.index,
    np.sum(np.abs(trc_target.values - trc_PTE.values),axis=1),
    label='PTE'
)

for i,dt in enumerate(dates_of_PTE_transactions):
    if i == 0:
        ax.axvline(x=dt,color='red',label='PTE Transaction')
    else:
        ax.axvline(x=dt,color='red')

ax.legend();
_images/PTE_19_0.png

We can see the Predicted Tracking Error of the PTE Strategy with each transaction marked.

Fixed Income Examples

This example notebook illustrates some of the more sophisticated functionality of the package, especially related to fixed income securities and strategies. For fixed income strategies:

  • capital allocations are not necessary, and initial capital is not used

  • bankruptcy is disabled (as money can always be borrowed at some rate, potentially represented as another asset)

  • weights are based off notional_value rather than value. For fixed income securities, notional value is just the position. For non-fixed income securities (i.e. equities), it is the market value of the position.

  • strategy notional_value is always positive, equal to the sum of the magnitudes of the notional values of all its children

  • strategy price is computed from additive PNL returns per unit of notional_value, with a reference price of PAR

  • “rebalancing” the portfolio adjusts notionals rather than capital allocations based on weights

Further to the above characteristics of fixed income strategies, we also demonstrate the usage of the following features which arise in these types of use case:

  • Coupon paying securities (i.e. bonds)

  • Handing of security lifecycle such as new issues and maturity

  • Usage of “On-The-Run” instruments, and rolling of positions into the “new” on-the-run security at pre-defined times

  • Risk tracking/aggregation and hedging from pre-computed risk per unit notional

The notebook contains the following parts:

  1. Setup

  2. Market data generation

    1. Rolling series of government bonds

    2. Corporate bonds with spreads driven by a common factor

  3. Example 1: Basic Strategies

    1. Weigh all active corporate bond equally

    2. Add hedging of interest rates risk with the on-the-run government bond

  4. Example 2: Nested Strategies

    1. One strategy buys the top N bonds, by yield

    2. Another strategy sells the bottom N bonds, by yield

    3. Parent strategy gives 50% weight to each of the above

    4. Add hedges of remaining interest rates risk with the on-the-run government bond

Setup

import bt
import pandas as pd
from pandas.tseries.frequencies import to_offset
import numpy as np
np.random.seed(1234)
%matplotlib inline
# (Approximate) Price to yield calcs, and pvbp, for later use. Note we use clean price here.
def price_to_yield( p, ttm, coupon ):
    return ( coupon + (100. - p)/ttm ) / ( ( 100. + p)/2. ) * 100
def yield_to_price( y, ttm, coupon ):
    return (coupon + 100/ttm - 0.5 * y) / ( y/200 + 1/ttm)
def pvbp( y, ttm, coupon ):
    return (yield_to_price( y + 0.01, ttm, coupon ) - yield_to_price( y, ttm, coupon ))
# Utility function to set data frame values to nan before the security has been issued or after it has matured
def censor( data, ref_data ):
    for bond in data:
        data.loc[ (data.index > ref_data['mat_date'][bond]) | (data.index < ref_data['issue_date'][bond]), bond] = np.NaN
    return data.ffill(limit=1,axis=0) # Because bonds might mature during a gap in the index (i.e. on the weekend)
# Backtesting timeline setup
start_date = pd.Timestamp('2020-01-01')
end_date = pd.Timestamp('2022-01-01')
timeline = pd.date_range( start_date, end_date, freq='B')

Market Data Generation

# Government Bonds: Create synthetic data for a single series of rolling government bonds

# Reference Data
roll_freq = 'Q'
maturity = 10
coupon = 2.0
roll_dates = pd.date_range( start_date, end_date+to_offset(roll_freq), freq=roll_freq) # Go one period beyond the end date to be safe
issue_dates = roll_dates - roll_dates.freq
mat_dates = issue_dates + pd.offsets.DateOffset(years=maturity)
series_name = 'govt_10Y'
names = pd.Series(mat_dates).apply( lambda x : 'govt_%s' % x.strftime('%Y_%m'))
# Build a time series of OTR
govt_otr = pd.DataFrame( [ [ name for name, roll_date in zip(names, roll_dates) if roll_date >=d ][0] for d in timeline ],
                        index=timeline,
                        columns=[series_name])
# Create a data frame of reference data
govt_data = pd.DataFrame( {'mat_date':mat_dates, 'issue_date': issue_dates, 'roll_date':roll_dates}, index = names)
govt_data['coupon'] = coupon

# Create the "roll map"
govt_roll_map = govt_otr.copy()
govt_roll_map['target'] = govt_otr[series_name].shift(-1)
govt_roll_map = govt_roll_map[ govt_roll_map[series_name] != govt_roll_map['target']]
govt_roll_map['factor'] = 1.
govt_roll_map = govt_roll_map.reset_index().set_index(series_name).rename(columns={'index':'date'}).dropna()

# Market Data and Risk
govt_yield_initial = 2.0
govt_yield_vol = 1.
govt_yield = pd.DataFrame( columns = govt_data.index, index=timeline )
govt_yield_ts = (govt_yield_initial + np.cumsum( np.random.normal( 0., govt_yield_vol/np.sqrt(252), len(timeline)))).reshape(-1,1)
govt_yield.loc[:,:] = govt_yield_ts

govt_mat = pd.DataFrame( columns = govt_data.index, index=timeline, data=pd.NA ).astype('datetime64')
govt_mat.loc[:,:] = govt_data['mat_date'].values.T
govt_ttm = (govt_mat - timeline.values.reshape(-1,1))/pd.Timedelta('1Y')
govt_coupon = pd.DataFrame( columns = govt_data.index, index=timeline )
govt_coupon.loc[:,:] = govt_data['coupon'].values.T
govt_accrued = govt_coupon.multiply( timeline.to_series().diff()/pd.Timedelta('1Y'), axis=0 )
govt_accrued.iloc[0] = 0

govt_price = yield_to_price( govt_yield, govt_ttm, govt_coupon )
govt_price[ govt_ttm <= 0 ] = 100.
govt_price = censor(govt_price, govt_data)
govt_pvbp = pvbp( govt_yield, govt_ttm, govt_coupon)
govt_pvbp[ govt_ttm <= 0 ] = 0.
govt_pvbp = censor(govt_pvbp, govt_data)
 /opt/homebrew/lib/python3.9/site-packages/IPython/core/interactiveshell.py:3397: FutureWarning: Units 'M', 'Y' and 'y' do not represent unambiguous timedelta values and will be removed in a future version
   exec(code_obj, self.user_global_ns, self.user_ns)
# Corporate Bonds: Create synthetic data for a universe of corporate bonds

# Reference Data
n_corp = 50    # Number of corporate bonds to generate
avg_ttm = 10   # Average time to maturity, in years
coupon_mean = 5
coupon_std = 1.5
mat_dates = start_date + np.random.exponential(avg_ttm*365, n_corp).astype(int) * pd.offsets.Day()
issue_dates = np.minimum( mat_dates, end_date ) - np.random.exponential(avg_ttm*365, n_corp).astype(int) * pd.offsets.Day()
names = pd.Series( [ 'corp{:04d}'.format(i) for i in range(n_corp)])
coupons = np.random.normal( coupon_mean, coupon_std, n_corp ).round(3)
corp_data = pd.DataFrame( {'mat_date':mat_dates, 'issue_date': issue_dates, 'coupon':coupons}, index=names)

# Market Data and Risk
# Model: corporate yield = government yield + credit spread
# Model: credit spread changes = beta * common factor changes + idiosyncratic changes
corp_spread_initial = np.random.normal( 2, 1, len(corp_data) )
corp_betas_raw = np.random.normal( 1, 0.5, len(corp_data) )
corp_factor_vol = 0.5
corp_idio_vol = 0.5
corp_factor_ts = np.cumsum( np.random.normal( 0, corp_factor_vol/np.sqrt(252), len(timeline))).reshape(-1,1)
corp_idio_ts = np.cumsum( np.random.normal( 0, corp_idio_vol/np.sqrt(252), len(timeline))).reshape(-1,1)
corp_spread = corp_spread_initial + np.multiply( corp_factor_ts, corp_betas_raw ) + corp_idio_ts
corp_yield = govt_yield_ts + corp_spread
corp_yield = pd.DataFrame(  columns = corp_data.index, index=timeline, data = corp_yield )

corp_mat = pd.DataFrame( columns = corp_data.index, index=timeline, data=start_date )
corp_mat.loc[:,:] = corp_data['mat_date'].values.T
corp_ttm = (corp_mat - timeline.values.reshape(-1,1))/pd.Timedelta('1Y')
corp_coupon = pd.DataFrame( columns = corp_data.index, index=timeline )
corp_coupon.loc[:,:] = corp_data['coupon'].values.T
corp_accrued = corp_coupon.multiply( timeline.to_series().diff()/pd.Timedelta('1Y'), axis=0 )
corp_accrued.iloc[0] = 0

corp_price = yield_to_price( corp_yield, corp_ttm, corp_coupon )
corp_price[ corp_ttm <= 0 ] = 100.
corp_price = censor(corp_price, corp_data)

corp_pvbp = pvbp( corp_yield, corp_ttm, corp_coupon)
corp_pvbp[ corp_ttm <= 0 ] = 0.
corp_pvbp = censor(corp_pvbp, corp_data)

bidoffer_bps = 5.
corp_bidoffer = -bidoffer_bps * corp_pvbp

corp_betas = pd.DataFrame( columns = corp_data.index, index=timeline )
corp_betas.loc[:,:] = corp_betas_raw
corp_betas = censor(corp_betas, corp_data)
 /opt/homebrew/lib/python3.9/site-packages/IPython/core/interactiveshell.py:3397: FutureWarning: Units 'M', 'Y' and 'y' do not represent unambiguous timedelta values and will be removed in a future version
   exec(code_obj, self.user_global_ns, self.user_ns)

Example 1: Basic Strategies

# Set up a strategy and a backtest

# The goal here is to define an equal weighted portfolio of corporate bonds,
# and to hedge the rates risk with the rolling series of government bonds

# Define Algo Stacks as the various building blocks
# Note that the order in which we execute these is extremely important

lifecycle_stack = bt.core.AlgoStack(
    # Close any matured bond positions (including hedges)
    bt.algos.ClosePositionsAfterDates( 'maturity' ),
    # Roll government bond positions into the On The Run
    bt.algos.RollPositionsAfterDates( 'govt_roll_map' ),
)
risk_stack = bt.AlgoStack(
    # Specify how frequently to calculate risk
    bt.algos.Or( [bt.algos.RunWeekly(),
                  bt.algos.RunMonthly()] ),
    # Update the risk given any positions that have been put on so far in the current step
    bt.algos.UpdateRisk( 'pvbp', history=1),
    bt.algos.UpdateRisk( 'beta', history=1),
)
hedging_stack = bt.AlgoStack(
    # Specify how frequently to hedge risk
    bt.algos.RunMonthly(),
    # Select the "alias" for the on-the-run government bond...
    bt.algos.SelectThese( [series_name], include_no_data = True ),
    # ... and then resolve it to the underlying security for the given date
    bt.algos.ResolveOnTheRun( 'govt_otr' ),
    # Hedge out the pvbp risk using the selected government bond
    bt.algos.HedgeRisks( ['pvbp']),
    # Need to update risk again after hedging so that it gets recorded correctly (post-hedges)
    bt.algos.UpdateRisk( 'pvbp', history=True),
)
debug_stack = bt.core.AlgoStack(
    # Specify how frequently to display debug info
    bt.algos.RunMonthly(),
    bt.algos.PrintInfo('Strategy {name} : {now}.\tNotional:  {_notl_value:0.0f},\t Value: {_value:0.0f},\t Price: {_price:0.4f}'),
    bt.algos.PrintRisk('Risk: \tPVBP: {pvbp:0.0f},\t Beta: {beta:0.0f}'),
)
trading_stack =bt.core.AlgoStack(
         # Specify how frequently to rebalance the portfolio
         bt.algos.RunMonthly(),
         # Select instruments for rebalancing. Start with everything
         bt.algos.SelectAll(),
         # Prevent matured/rolled instruments from coming back into the mix
         bt.algos.SelectActive(),
         # Select only corp instruments
         bt.algos.SelectRegex( 'corp' ),
         # Specify how to weigh the securities
         bt.algos.WeighEqually(),
         # Set the target portfolio size
         bt.algos.SetNotional( 'notional_value' ),
         # Rebalance the portfolio
         bt.algos.Rebalance()
)

govt_securities = [ bt.CouponPayingHedgeSecurity( name ) for name in govt_data.index]
corp_securities = [ bt.CouponPayingSecurity( name ) for name in corp_data.index ]
securities = govt_securities + corp_securities
base_strategy = bt.FixedIncomeStrategy('BaseStrategy', [ lifecycle_stack, bt.algos.Or( [trading_stack, risk_stack, debug_stack ] ) ], children = securities)
hedged_strategy = bt.FixedIncomeStrategy('HedgedStrategy', [ lifecycle_stack, bt.algos.Or( [trading_stack, risk_stack, hedging_stack, debug_stack ] ) ], children = securities)

#Collect all the data for the strategies

# Here we use clean prices as the data and accrued as the coupon. Could alternatively use dirty prices and cashflows.
data = pd.concat( [ govt_price, corp_price ], axis=1) / 100.  # Because we need prices per unit notional
additional_data = { 'coupons' : pd.concat([govt_accrued, corp_accrued], axis=1) / 100.,
                   'bidoffer' : corp_bidoffer/100.,
                   'notional_value' : pd.Series( data=1e6, index=data.index ),
                   'maturity' : pd.concat([govt_data, corp_data], axis=0).rename(columns={"mat_date": "date"}),
                   'govt_roll_map' : govt_roll_map,
                   'govt_otr' : govt_otr,
                   'unit_risk' : {'pvbp' : pd.concat( [ govt_pvbp, corp_pvbp] ,axis=1)/100.,
                                  'beta' : corp_betas * corp_pvbp / 100.},
                  }
base_test = bt.Backtest( base_strategy, data, 'BaseBacktest',
                initial_capital = 0,
                additional_data = additional_data )
hedge_test = bt.Backtest( hedged_strategy, data, 'HedgedBacktest',
                initial_capital = 0,
                additional_data = additional_data)
out = bt.run( base_test, hedge_test )
 Strategy BaseStrategy : 2020-01-01 00:00:00.        Notional:  1000000,      Value: -1644,   Price: 99.8356
 Risk:       PVBP: -658,      Beta: -659
 Strategy BaseStrategy : 2020-02-03 00:00:00.        Notional:  1000000,      Value: -6454,   Price: 99.3546
 Risk:       PVBP: -642,      Beta: -643
 Strategy BaseStrategy : 2020-03-02 00:00:00.        Notional:  1000000,      Value: -26488,  Price: 97.3512
 Risk:       PVBP: -611,      Beta: -613
 Strategy BaseStrategy : 2020-04-01 00:00:00.        Notional:  1000000,      Value: -20295,  Price: 97.9705
 Risk:       PVBP: -607,      Beta: -608
 Strategy BaseStrategy : 2020-05-01 00:00:00.        Notional:  1000000,      Value: -43692,  Price: 95.6308
 Risk:       PVBP: -573,      Beta: -574
 Strategy BaseStrategy : 2020-06-01 00:00:00.        Notional:  1000000,      Value: -41095,  Price: 95.8905
 Risk:       PVBP: -566,      Beta: -566
 Strategy BaseStrategy : 2020-07-01 00:00:00.        Notional:  1000000,      Value: -15724,  Price: 98.4985
 Risk:       PVBP: -609,      Beta: -608
 Strategy BaseStrategy : 2020-08-03 00:00:00.        Notional:  1000000,      Value: -22308,  Price: 97.8400
 Risk:       PVBP: -587,      Beta: -594
 Strategy BaseStrategy : 2020-09-01 00:00:00.        Notional:  1000000,      Value: 12832,   Price: 101.4263
 Risk:       PVBP: -644,      Beta: -650
 Strategy BaseStrategy : 2020-10-01 00:00:00.        Notional:  1000000,      Value: 35263,   Price: 103.6965
 Risk:       PVBP: -683,      Beta: -680
 Strategy BaseStrategy : 2020-11-02 00:00:00.        Notional:  1000000,      Value: 3702,    Price: 100.5404
 Risk:       PVBP: -638,      Beta: -646
 Strategy BaseStrategy : 2020-12-01 00:00:00.        Notional:  1000000,      Value: -18534,  Price: 98.3168
 Risk:       PVBP: -606,      Beta: -613
 Strategy BaseStrategy : 2021-01-01 00:00:00.        Notional:  1000000,      Value: -11054,  Price: 99.0648
 Risk:       PVBP: -603,      Beta: -609
 Strategy BaseStrategy : 2021-02-01 00:00:00.        Notional:  1000000,      Value: -16424,  Price: 98.5537
 Risk:       PVBP: -602,      Beta: -609
 Strategy BaseStrategy : 2021-03-01 00:00:00.        Notional:  1000000,      Value: -34462,  Price: 96.6943
 Risk:       PVBP: -603,      Beta: -586
 Strategy BaseStrategy : 2021-04-01 00:00:00.        Notional:  1000000,      Value: -23533,  Price: 97.7872
 Risk:       PVBP: -603,      Beta: -586
 Strategy BaseStrategy : 2021-05-03 00:00:00.        Notional:  1000000,      Value: -27024,  Price: 97.4381
 Risk:       PVBP: -590,      Beta: -574
 Strategy BaseStrategy : 2021-06-01 00:00:00.        Notional:  1000000,      Value: -50723,  Price: 95.0682
 Risk:       PVBP: -558,      Beta: -541
 Strategy BaseStrategy : 2021-07-01 00:00:00.        Notional:  1000000,      Value: -52714,  Price: 94.8690
 Risk:       PVBP: -547,      Beta: -528
 Strategy BaseStrategy : 2021-08-02 00:00:00.        Notional:  1000000,      Value: -53039,  Price: 94.8067
 Risk:       PVBP: -550,      Beta: -531
 Strategy BaseStrategy : 2021-09-01 00:00:00.        Notional:  1000000,      Value: -39027,  Price: 96.2079
 Risk:       PVBP: -550,      Beta: -524
 Strategy BaseStrategy : 2021-10-01 00:00:00.        Notional:  1000000,      Value: -2051,   Price: 99.9002
 Risk:       PVBP: -588,      Beta: -561
 Strategy BaseStrategy : 2021-11-01 00:00:00.        Notional:  1000000,      Value: -8616,   Price: 99.2438
 Risk:       PVBP: -573,      Beta: -544
 Strategy BaseStrategy : 2021-12-01 00:00:00.        Notional:  1000000,      Value: 53520,   Price: 105.6538
 Risk:       PVBP: -656,      Beta: -623
 Strategy HedgedStrategy : 2020-01-01 00:00:00.      Notional:  1000000,      Value: -1644,   Price: 99.8356
 Risk:       PVBP: 0,         Beta: -659
 Strategy HedgedStrategy : 2020-02-03 00:00:00.      Notional:  1000000,      Value: -10996,  Price: 98.9004
 Risk:       PVBP: 0,         Beta: -643
 Strategy HedgedStrategy : 2020-03-02 00:00:00.      Notional:  1000000,      Value: -16765,  Price: 98.3235
 Risk:       PVBP: 0,         Beta: -613
 Strategy HedgedStrategy : 2020-04-01 00:00:00.      Notional:  1000000,      Value: -21649,  Price: 97.8351
 Risk:       PVBP: -0,        Beta: -608
 Strategy HedgedStrategy : 2020-05-01 00:00:00.      Notional:  1000000,      Value: -33399,  Price: 96.6601
 Risk:       PVBP: 0,         Beta: -574
 Strategy HedgedStrategy : 2020-06-01 00:00:00.      Notional:  1000000,      Value: -22927,  Price: 97.7073
 Risk:       PVBP: -0,        Beta: -566
 Strategy HedgedStrategy : 2020-07-01 00:00:00.      Notional:  1000000,      Value: -14965,  Price: 98.5366
 Risk:       PVBP: -0,        Beta: -608
 Strategy HedgedStrategy : 2020-08-03 00:00:00.      Notional:  1000000,      Value: 5092,    Price: 100.5423
 Risk:       PVBP: -0,        Beta: -594
 Strategy HedgedStrategy : 2020-09-01 00:00:00.      Notional:  1000000,      Value: 22278,   Price: 102.2828
 Risk:       PVBP: 0,         Beta: -650
 Strategy HedgedStrategy : 2020-10-01 00:00:00.      Notional:  1000000,      Value: 13903,   Price: 101.4286
 Risk:       PVBP: -0,        Beta: -680
 Strategy HedgedStrategy : 2020-11-02 00:00:00.      Notional:  1000000,      Value: 12081,   Price: 101.2464
 Risk:       PVBP: -0,        Beta: -646
 Strategy HedgedStrategy : 2020-12-01 00:00:00.      Notional:  1000000,      Value: 10531,   Price: 101.0914
 Risk:       PVBP: -0,        Beta: -613
 Strategy HedgedStrategy : 2021-01-01 00:00:00.      Notional:  1000000,      Value: 12144,   Price: 101.2528
 Risk:       PVBP: 0,         Beta: -609
 Strategy HedgedStrategy : 2021-02-01 00:00:00.      Notional:  1000000,      Value: 15903,   Price: 101.6469
 Risk:       PVBP: -0,        Beta: -609
 Strategy HedgedStrategy : 2021-03-01 00:00:00.      Notional:  1000000,      Value: 11958,   Price: 101.2204
 Risk:       PVBP: 0,         Beta: -586
 Strategy HedgedStrategy : 2021-04-01 00:00:00.      Notional:  1000000,      Value: 28170,   Price: 102.8417
 Risk:       PVBP: -0,        Beta: -586
 Strategy HedgedStrategy : 2021-05-03 00:00:00.      Notional:  1000000,      Value: 34561,   Price: 103.4807
 Risk:       PVBP: 0,         Beta: -574
 Strategy HedgedStrategy : 2021-06-01 00:00:00.      Notional:  1000000,      Value: 29233,   Price: 102.9479
 Risk:       PVBP: -0,        Beta: -541
 Strategy HedgedStrategy : 2021-07-01 00:00:00.      Notional:  1000000,      Value: 10323,   Price: 101.0569
 Risk:       PVBP: 0,         Beta: -528
 Strategy HedgedStrategy : 2021-08-02 00:00:00.      Notional:  1000000,      Value: 14539,   Price: 101.4646
 Risk:       PVBP: 0,         Beta: -531
 Strategy HedgedStrategy : 2021-09-01 00:00:00.      Notional:  1000000,      Value: 10754,   Price: 101.0860
 Risk:       PVBP: 0,         Beta: -524
 Strategy HedgedStrategy : 2021-10-01 00:00:00.      Notional:  1000000,      Value: 32502,   Price: 103.2515
 Risk:       PVBP: -0,        Beta: -561
 Strategy HedgedStrategy : 2021-11-01 00:00:00.      Notional:  1000000,      Value: 24506,   Price: 102.4519
 Risk:       PVBP: -0,        Beta: -544
 Strategy HedgedStrategy : 2021-12-01 00:00:00.      Notional:  1000000,      Value: 42093,   Price: 104.2905
 Risk:       PVBP: -0,        Beta: -623
# Extract Tear Sheet for base backtest
stats = out['BaseBacktest']
stats.display()
 Stats for BaseBacktest from 2019-12-31 00:00:00 - 2021-12-31 00:00:00
 Annual risk-free rate considered: 0.00%
 Summary:
 Total Return      Sharpe  CAGR    Max Drawdown
 --------------  --------  ------  --------------
 2.34%               0.19  1.16%   -10.64%

 Annualized Returns:
 mtd     3m     6m     ytd    1y     3y     5y    10y    incep.
 ------  -----  -----  -----  -----  -----  ----  -----  --------
 -3.06%  1.45%  8.12%  3.43%  3.43%  1.16%  -     -      1.16%

 Periodic:
         daily    monthly    yearly
 ------  -------  ---------  --------
 sharpe  0.19     0.18       0.38
 mean    1.38%    1.49%      1.19%
 vol     7.26%    8.35%      3.17%
 skew    0.16     0.75       -
 kurt    0.52     0.70       -
 best    1.59%    6.32%      3.43%
 worst   -1.44%   -3.29%     -1.05%

 Drawdowns:
 max      avg       # days
 -------  ------  --------
 -10.64%  -2.59%     79.22

 Misc:
 ---------------  ------
 avg. up month    1.88%
 avg. down month  -1.63%
 up year %        50.00%
 12m up %         57.14%
 ---------------  ------
# Extract Tear Sheet for hedged backtest
stats = out['HedgedBacktest']
stats.display()
 Stats for HedgedBacktest from 2019-12-31 00:00:00 - 2021-12-31 00:00:00
 Annual risk-free rate considered: 0.00%
 Summary:
 Total Return      Sharpe  CAGR    Max Drawdown
 --------------  --------  ------  --------------
 3.51%               0.41  1.74%   -3.87%

 Annualized Returns:
 mtd     3m      6m     ytd    1y     3y     5y    10y    incep.
 ------  ------  -----  -----  -----  -----  ----  -----  --------
 -0.47%  -0.30%  2.29%  2.46%  2.46%  1.74%  -     -      1.74%

 Periodic:
         daily    monthly    yearly
 ------  -------  ---------  --------
 sharpe  0.41     0.43       1.71
 mean    1.75%    1.81%      1.74%
 vol     4.26%    4.22%      1.02%
 skew    -0.17    0.67       -
 kurt    0.21     -0.46      -
 best    0.69%    2.82%      2.46%
 worst   -1.07%   -1.62%     1.02%

 Drawdowns:
 max     avg       # days
 ------  ------  --------
 -3.87%  -1.02%     49.57

 Misc:
 ---------------  -------
 avg. up month    1.25%
 avg. down month  -0.78%
 up year %        100.00%
 12m up %         85.71%
 ---------------  -------
# Total PNL time series values
pd.DataFrame( {'base':base_test.strategy.values, 'hedged':hedge_test.strategy.values} ).plot();
_images/Fixed_Income_13_0.png
# Total risk time series values
pd.DataFrame( {'base_pvbp':base_test.strategy.risks['pvbp'],
               'hedged_pvbp':hedge_test.strategy.risks['pvbp'],
               'beta':hedge_test.strategy.risks['beta']} ).dropna().plot();
_images/Fixed_Income_14_0.png
# Total bid/offer paid (same for both strategies)
pd.DataFrame( {'base_pvbp':base_test.strategy.bidoffers_paid,
               'hedged_pvbp':hedge_test.strategy.bidoffers_paid }).cumsum().dropna().plot();
_images/Fixed_Income_15_0.png

Example 2: Nested Strategies

# Set up a more complex strategy and a backtest

# The goal of the more complex strategy is to define two sub-strategies of corporate bonds
# - Highest yield bonds
# - Lowest yield bonds
# Then we will go long the high yield bonds, short the low yield bonds in equal weight
# Lastly we will hedge the rates risk with the government bond

govt_securities = [ bt.CouponPayingHedgeSecurity( name ) for name in govt_data.index]
corp_securities = [ bt.CouponPayingSecurity( name ) for name in corp_data.index ]

def get_algos( n, sort_descending ):
    ''' Helper function to return the algos for long or short portfolio, based on top n yields'''
    return [
        # Close any matured bond positions
        bt.algos.ClosePositionsAfterDates( 'corp_maturity' ),
        # Specify how frequenty to rebalance
        bt.algos.RunMonthly(),
        # Select instruments for rebalancing. Start with everything
        bt.algos.SelectAll(),
        # Prevent matured/rolled instruments from coming back into the mix
        bt.algos.SelectActive(),
        # Set the stat to be used for selection
        bt.algos.SetStat( 'corp_yield' ),
        # Select the top N yielding bonds
        bt.algos.SelectN( n, sort_descending, filter_selected=True ),
        # Specify how to weigh the securities
        bt.algos.WeighEqually(),
        bt.algos.ScaleWeights(1. if sort_descending else -1.), # Determine long/short
        # Set the target portfolio size
        bt.algos.SetNotional( 'notional_value' ),
        # Rebalance the portfolio
        bt.algos.Rebalance(),
    ]
bottom_algos = []
top_strategy = bt.FixedIncomeStrategy('TopStrategy', get_algos( 10, True ), children = corp_securities)
bottom_strategy = bt.FixedIncomeStrategy('BottomStrategy',get_algos( 10, False ), children = corp_securities)

risk_stack = bt.AlgoStack(
    # Specify how frequently to calculate risk
    bt.algos.Or( [bt.algos.RunWeekly(),
                  bt.algos.RunMonthly()] ),
    # Update the risk given any positions that have been put on so far in the current step
    bt.algos.UpdateRisk( 'pvbp', history=2),
    bt.algos.UpdateRisk( 'beta', history=2),
)
hedging_stack = bt.AlgoStack(
    # Close any matured hedge positions (including hedges)
    bt.algos.ClosePositionsAfterDates( 'govt_maturity' ),
    # Roll government bond positions into the On The Run
    bt.algos.RollPositionsAfterDates( 'govt_roll_map' ),
    # Specify how frequently to hedge risk
    bt.algos.RunMonthly(),
    # Select the "alias" for the on-the-run government bond...
    bt.algos.SelectThese( [series_name], include_no_data = True ),
    # ... and then resolve it to the underlying security for the given date
    bt.algos.ResolveOnTheRun( 'govt_otr' ),
    # Hedge out the pvbp risk using the selected government bond
    bt.algos.HedgeRisks( ['pvbp']),
    # Need to update risk again after hedging so that it gets recorded correctly (post-hedges)
    bt.algos.UpdateRisk( 'pvbp', history=2),
)
debug_stack = bt.core.AlgoStack(
    # Specify how frequently to display debug info
    bt.algos.RunMonthly(),
    bt.algos.PrintInfo('{now}: End   {name}\tNotional:  {_notl_value:0.0f},\t Value: {_value:0.0f},\t Price: {_price:0.4f}'),
    bt.algos.PrintRisk('Risk: \tPVBP: {pvbp:0.0f},\t Beta: {beta:0.0f}'),
)
trading_stack =bt.core.AlgoStack(
    # Specify how frequently to rebalance the portfolio of sub-strategies
    bt.algos.RunOnce(),
    # Specify how to weigh the sub-strategies
    bt.algos.WeighSpecified( TopStrategy=0.5, BottomStrategy=-0.5),
    # Rebalance the portfolio
    bt.algos.Rebalance()
)

children = [ top_strategy, bottom_strategy ] + govt_securities
base_strategy = bt.FixedIncomeStrategy('BaseStrategy', [ bt.algos.Or( [trading_stack, risk_stack, debug_stack ] ) ], children = children)
hedged_strategy = bt.FixedIncomeStrategy('HedgedStrategy', [ bt.algos.Or( [trading_stack, risk_stack, hedging_stack, debug_stack ] ) ], children = children)

# Here we use clean prices as the data and accrued as the coupon. Could alternatively use dirty prices and cashflows.
data = pd.concat( [ govt_price, corp_price ], axis=1) / 100.  # Because we need prices per unit notional
additional_data = { 'coupons' : pd.concat([govt_accrued, corp_accrued], axis=1) / 100., # Because we need coupons per unit notional
                   'notional_value' : pd.Series( data=1e6, index=data.index ),
                   'govt_maturity' : govt_data.rename(columns={"mat_date": "date"}),
                   'corp_maturity' : corp_data.rename(columns={"mat_date": "date"}),
                   'govt_roll_map' : govt_roll_map,
                   'govt_otr' : govt_otr,
                   'corp_yield' : corp_yield,
                   'unit_risk' : {'pvbp' : pd.concat( [ govt_pvbp, corp_pvbp] ,axis=1)/100.,
                                  'beta' : corp_betas * corp_pvbp / 100.},
                  }
base_test = bt.Backtest( base_strategy, data, 'BaseBacktest',
                initial_capital = 0,
                additional_data = additional_data)
hedge_test = bt.Backtest( hedged_strategy, data, 'HedgedBacktest',
                initial_capital = 0,
                additional_data = additional_data)
out = bt.run( base_test, hedge_test )
 2020-01-01 00:00:00: End   BaseStrategy     Notional:  0,    Value: 0,       Price: 100.0000
 Risk:       PVBP: 0,         Beta: 0
 2020-02-03 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 3277,    Price: 100.1639
 Risk:       PVBP: 51,        Beta: 41
 2020-03-02 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 7297,    Price: 100.3649
 Risk:       PVBP: 45,        Beta: 34
 2020-04-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 9336,    Price: 100.4668
 Risk:       PVBP: 44,        Beta: 34
 2020-05-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 13453,   Price: 100.6727
 Risk:       PVBP: 38,        Beta: 28
 2020-06-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 15887,   Price: 100.7943
 Risk:       PVBP: 37,        Beta: 26
 2020-07-01 00:00:00: End   BaseStrategy     Notional:  1800000,      Value: 16024,   Price: 100.8010
 Risk:       PVBP: 39,        Beta: 28
 2020-08-03 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 14785,   Price: 100.7391
 Risk:       PVBP: -152,      Beta: -124
 2020-09-01 00:00:00: End   BaseStrategy     Notional:  1800000,      Value: 30310,   Price: 101.5550
 Risk:       PVBP: -263,      Beta: -204
 2020-10-01 00:00:00: End   BaseStrategy     Notional:  1900000,      Value: 35915,   Price: 101.8430
 Risk:       PVBP: -109,      Beta: -53
 2020-11-02 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 37649,   Price: 101.9297
 Risk:       PVBP: -12,       Beta: 36
 2020-12-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 39045,   Price: 101.9995
 Risk:       PVBP: -13,       Beta: 34
 2021-01-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 40569,   Price: 102.0758
 Risk:       PVBP: -14,       Beta: 31
 2021-02-01 00:00:00: End   BaseStrategy     Notional:  1900000,      Value: 41228,   Price: 102.1094
 Risk:       PVBP: -16,       Beta: 27
 2021-03-01 00:00:00: End   BaseStrategy     Notional:  1900000,      Value: 38916,   Price: 101.9868
 Risk:       PVBP: -101,      Beta: -47
 2021-04-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 40755,   Price: 102.0788
 Risk:       PVBP: 9,         Beta: -31
 2021-05-03 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 43290,   Price: 102.2055
 Risk:       PVBP: -6,        Beta: -43
 2021-06-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 35947,   Price: 101.8384
 Risk:       PVBP: -235,      Beta: -91
 2021-07-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 35671,   Price: 101.8246
 Risk:       PVBP: -123,      Beta: -129
 2021-08-02 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 37756,   Price: 101.9288
 Risk:       PVBP: 3,         Beta: -29
 2021-09-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 38434,   Price: 101.9627
 Risk:       PVBP: -7,        Beta: -43
 2021-10-01 00:00:00: End   BaseStrategy     Notional:  1900000,      Value: 37082,   Price: 101.8966
 Risk:       PVBP: 73,        Beta: 19
 2021-11-01 00:00:00: End   BaseStrategy     Notional:  2000000,      Value: 39526,   Price: 102.0187
 Risk:       PVBP: 51,        Beta: 53
 2021-12-01 00:00:00: End   BaseStrategy     Notional:  1900000,      Value: 29228,   Price: 101.4826
 Risk:       PVBP: 125,       Beta: 97
 2020-01-01 00:00:00: End   HedgedStrategy   Notional:  0,    Value: 0,       Price: 100.0000
 Risk:       PVBP: 0,         Beta: 0
 2020-02-03 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 3277,    Price: 100.1639
 Risk:       PVBP: 0,         Beta: 41
 2020-03-02 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 6159,    Price: 100.3079
 Risk:       PVBP: 0,         Beta: 34
 2020-04-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 9008,    Price: 100.4504
 Risk:       PVBP: 0,         Beta: 34
 2020-05-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 12274,   Price: 100.6137
 Risk:       PVBP: 0,         Beta: 28
 2020-06-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 14189,   Price: 100.7094
 Risk:       PVBP: 0,         Beta: 26
 2020-07-01 00:00:00: End   HedgedStrategy   Notional:  1800000,      Value: 15451,   Price: 100.7752
 Risk:       PVBP: 0,         Beta: 28
 2020-08-03 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 12494,   Price: 100.6273
 Risk:       PVBP: 0,         Beta: -124
 2020-09-01 00:00:00: End   HedgedStrategy   Notional:  1800000,      Value: 23384,   Price: 101.1967
 Risk:       PVBP: 0,         Beta: -204
 2020-10-01 00:00:00: End   HedgedStrategy   Notional:  1900000,      Value: 16414,   Price: 100.8372
 Risk:       PVBP: -0,        Beta: -53
 2020-11-02 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 22887,   Price: 101.1609
 Risk:       PVBP: 0,         Beta: 36
 2020-12-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 24681,   Price: 101.2506
 Risk:       PVBP: 0,         Beta: 34
 2021-01-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 26080,   Price: 101.3205
 Risk:       PVBP: -0,        Beta: 31
 2021-02-01 00:00:00: End   HedgedStrategy   Notional:  1900000,      Value: 26954,   Price: 101.3647
 Risk:       PVBP: 0,         Beta: 27
 2021-03-01 00:00:00: End   HedgedStrategy   Notional:  1900000,      Value: 25008,   Price: 101.2611
 Risk:       PVBP: 0,         Beta: -47
 2021-04-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 27730,   Price: 101.3972
 Risk:       PVBP: 0,         Beta: -31
 2021-05-03 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 30112,   Price: 101.5163
 Risk:       PVBP: 0,         Beta: -43
 2021-06-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 22951,   Price: 101.1582
 Risk:       PVBP: -0,        Beta: -91
 2021-07-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 15553,   Price: 100.7884
 Risk:       PVBP: 0,         Beta: -129
 2021-08-02 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 18657,   Price: 100.9436
 Risk:       PVBP: 0,         Beta: -29
 2021-09-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 19441,   Price: 100.9827
 Risk:       PVBP: 0,         Beta: -43
 2021-10-01 00:00:00: End   HedgedStrategy   Notional:  1900000,      Value: 17903,   Price: 100.9072
 Risk:       PVBP: 0,         Beta: 19
 2021-11-01 00:00:00: End   HedgedStrategy   Notional:  2000000,      Value: 20524,   Price: 101.0383
 Risk:       PVBP: 0,         Beta: 53
 2021-12-01 00:00:00: End   HedgedStrategy   Notional:  1900000,      Value: 14170,   Price: 100.7071
 Risk:       PVBP: 0,         Beta: 97
# Total PNL time series values
pd.DataFrame( {'base':base_test.strategy.values,
               'hedged':hedge_test.strategy.values,
               'top':base_test.strategy['TopStrategy'].values,
               'bottom':base_test.strategy['BottomStrategy'].values}
            ).plot();
_images/Fixed_Income_18_0.png
# Total pvbp time series values
pd.DataFrame( {'base':base_test.strategy.risks['pvbp'],
               'hedged':hedge_test.strategy.risks['pvbp'],
               'top':base_test.strategy['TopStrategy'].risks['pvbp'],
               'bottom':base_test.strategy['BottomStrategy'].risks['pvbp']}
            ).dropna().plot();
_images/Fixed_Income_19_0.png
# Total beta time series values
pd.DataFrame( {'base':base_test.strategy.risks['beta'],
               'hedged':hedge_test.strategy.risks['beta'],
               'top':base_test.strategy['TopStrategy'].risks['beta'],
               'bottom':base_test.strategy['BottomStrategy'].risks['beta']}
            ).dropna().plot();
_images/Fixed_Income_20_0.png
# "Price" time series values
pd.DataFrame( {'base':base_test.strategy.prices,
               'hedged':hedge_test.strategy.prices,
               'top':base_test.strategy['TopStrategy'].prices,
               'bottom':base_test.strategy['BottomStrategy'].prices}
            ).plot();
_images/Fixed_Income_21_0.png
# Show transactions
out.get_transactions('HedgedBacktest').head(20)
price quantity
Date Security
2020-01-01 corp0000 1.009697 -100000.0
corp0001 0.991417 100000.0
corp0002 1.016553 -100000.0
corp0005 1.035779 -100000.0
corp0009 1.014195 100000.0
corp0015 0.849097 100000.0
corp0017 1.018107 -100000.0
corp0018 1.009549 100000.0
corp0019 0.908531 100000.0
corp0023 1.216847 100000.0
corp0024 1.094375 -100000.0
corp0025 1.054762 -100000.0
corp0030 0.888091 100000.0
corp0032 1.086487 -100000.0
corp0035 0.996676 100000.0
corp0036 1.070212 -100000.0
corp0037 0.992530 100000.0
corp0044 0.959150 100000.0
corp0048 0.987408 -100000.0
corp0049 1.016879 -100000.0