Skip to content

quanttoolbox.backtest

Python alternatives

Full backtesting frameworks like vectorbt, bt, backtrader, and zipline exist, but are a different paradigm (event-driven or much heavier) than this module's lightweight, transparent, vectorized style. vectorbt specifically is Numba-accelerated and worth knowing about if performance on very large universes becomes a bottleneck — but it's not a drop-in replacement. Keep this module for its scope.

backtest.returns

quanttoolbox.backtest.returns

Price/return series conversion: simple returns, cumulative price indices, funded/unfunded conversions, and capitalized LIBOR indices.

Ported from QuantToolBox/backtest/{price2return,return2price, price2unfunded,unfunded2price,capitalized_libor,capitalized_libor_plus}.m

Translation notes:

  • price2return2.m in the original is dead/incomplete code (it references undefined variables Dates/day_of_week and shares its function name with price2return.m, so it could never have been called under normal MATLAB name resolution) -- it is NOT ported here.
  • lagn/lag1 (n-period lag) are replaced by array slicing (arr[:-n]) or pandas.Series.shift(n) where a NaN-padded result is wanted.
  • findnomiss/fillmiss(..., 4) (MATLAB: find first/last non-missing row per column, forward/back-fill gaps) map to pandas' .ffill() / .bfill() plus first_valid_index() / last_valid_index().
  • All functions accept and return 2-D arrays (rows = dates, columns = assets) to match the original's column-oriented convention, even for a single series -- pass a (n, 1) array or reshape as needed.

capitalized_libor(dates, libor_rates, method=1)

Build a capitalized (compounding) index from a LIBOR-style annualized rate series, using actual/365.25 day-count compounding.

Original: backtest/capitalized_libor.m

Source code in src/quanttoolbox/backtest/returns.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def capitalized_libor(
    dates: pd.DatetimeIndex, libor_rates: np.ndarray, method: int = 1
) -> np.ndarray:
    """Build a capitalized (compounding) index from a LIBOR-style annualized
    rate series, using actual/365.25 day-count compounding.

    Original: backtest/capitalized_libor.m
    """
    dates = pd.DatetimeIndex(dates)
    libor_rates = np.asarray(libor_rates, dtype=float)
    if libor_rates.ndim == 1:
        libor_rates = libor_rates[:, None]

    n_dates, n_cols = libor_rates.shape
    day_nums = (dates - dates[0]).days.to_numpy()

    if method == 2:
        indx_missing = np.isnan(libor_rates)
    else:
        fnm, lnm = _first_last_valid(libor_rates)

    rates_filled = _fill_missing_ffill_bfill(libor_rates)
    rates_filled = np.where(np.isnan(rates_filled), 0.0, rates_filled)

    libor_index = np.zeros((n_dates, n_cols))
    libor_index[0, :] = 100.0
    for t in range(1, n_dates):
        dt = (day_nums[t] - day_nums[t - 1]) / 365.25
        libor_index[t, :] = libor_index[t - 1, :] * (1 + rates_filled[t - 1, :] * dt)

    if method == 2:
        libor_index[indx_missing] = np.nan
    else:
        for j in range(n_cols):
            if fnm[j] > 0:
                libor_index[: fnm[j], j] = np.nan
            if lnm[j] < n_dates - 1:
                libor_index[lnm[j] + 1 :, j] = np.nan

    return libor_index

capitalized_libor_plus(dates, libor_index, plus)

Build a capitalized "LIBOR + spread" index from an existing LIBOR index and a per-series spread (in annualized terms).

Original: backtest/capitalized_libor_plus.m

Source code in src/quanttoolbox/backtest/returns.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def capitalized_libor_plus(
    dates: pd.DatetimeIndex, libor_index: np.ndarray, plus: np.ndarray
) -> np.ndarray:
    """Build a capitalized "LIBOR + spread" index from an existing LIBOR
    index and a per-series spread (in annualized terms).

    Original: backtest/capitalized_libor_plus.m
    """
    dates = pd.DatetimeIndex(dates)
    day_nums = (dates - dates[0]).days.to_numpy()
    n_dates = dates.shape[0]

    libor_index = np.asarray(libor_index, dtype=float).flatten()
    plus = np.atleast_1d(np.asarray(plus, dtype=float))
    n_cols = plus.shape[0]

    libor_filled = _fill_missing_ffill_bfill(libor_index[:, None]).flatten()
    valid = np.where(~np.isnan(libor_index))[0]
    fnm = valid[0] if valid.size else 0
    lnm = valid[-1] if valid.size else n_dates - 1

    libor_rates = price_to_return(libor_filled[:, None], 1).flatten()
    libor_rates[fnm] = 0.0
    libor_rates = np.where(np.isnan(libor_rates), 0.0, libor_rates)

    libor_index_plus = np.zeros((n_dates, n_cols))
    libor_index_plus[0, :] = 100.0
    for t in range(1, n_dates):
        dt = (day_nums[t] - day_nums[t - 1]) / 365.25
        libor_index_plus[t, :] = libor_index_plus[t - 1, :] * (1 + libor_rates[t] + plus * dt)

    if fnm > 0:
        libor_index_plus[:fnm, :] = np.nan
    if lnm < n_dates - 1:
        libor_index_plus[lnm + 1 :, :] = np.nan

    libor_index_plus = 100.0 * libor_index_plus / libor_index_plus[fnm, :]
    return libor_index_plus

price_to_return(x, n_lags=1)

Simple (arithmetic) returns over n_lags periods: x[t]/x[t-n] - 1.

Original: backtest/price2return.m

Source code in src/quanttoolbox/backtest/returns.py
30
31
32
33
34
35
36
37
38
39
40
def price_to_return(x: np.ndarray, n_lags: int = 1) -> np.ndarray:
    """Simple (arithmetic) returns over n_lags periods: x[t]/x[t-n] - 1.

    Original: backtest/price2return.m
    """
    x = np.asarray(x, dtype=float)
    if x.ndim == 1:
        x = x[:, None]
    y = np.full_like(x, np.nan)
    y[n_lags:] = x[n_lags:] / x[:-n_lags] - 1.0
    return y

price_to_unfunded(funded_prices, libor_index, method=1)

Convert funded (total-return) prices to unfunded (excess-return) prices by subtracting the LIBOR return each period.

method=1 (default): mask output using the funded series' own first/last valid range. method=2: mask output using the input's exact NaN pattern instead.

Original: backtest/price2unfunded.m

Source code in src/quanttoolbox/backtest/returns.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def price_to_unfunded(
    funded_prices: np.ndarray, libor_index: np.ndarray, method: int = 1
) -> np.ndarray:
    """Convert funded (total-return) prices to unfunded (excess-return)
    prices by subtracting the LIBOR return each period.

    method=1 (default): mask output using the funded series' own
    first/last valid range. method=2: mask output using the input's exact
    NaN pattern instead.

    Original: backtest/price2unfunded.m
    """
    funded_prices = np.asarray(funded_prices, dtype=float)
    if funded_prices.ndim == 1:
        funded_prices = funded_prices[:, None]
    libor_index = np.asarray(libor_index, dtype=float)
    if libor_index.ndim == 1:
        libor_index = libor_index[:, None]

    r, c = funded_prices.shape
    if method == 2:
        cnd = np.isnan(funded_prices)
    else:
        fnm, lnm = _first_last_valid(funded_prices)

    funded_filled = _fill_missing_ffill_bfill(funded_prices)
    libor_filled = _fill_missing_ffill_bfill(libor_index)

    r_funded = price_to_return(funded_filled, 1)
    r_libor = price_to_return(libor_filled, 1)
    r_unfunded = np.where(np.isnan(r_funded - r_libor), 0.0, r_funded - r_libor)
    unfunded_prices = 100 * np.cumprod(1 + r_unfunded, axis=0)

    if method == 2:
        unfunded_prices[cnd] = np.nan
    else:
        for j in range(c):
            if fnm[j] > 0:
                unfunded_prices[: fnm[j], j] = np.nan
            if lnm[j] < r - 1:
                unfunded_prices[lnm[j] + 1 :, j] = np.nan

    return unfunded_prices

return_to_price(x, keep_interior_nan=False)

Convert a return series back into a cumulative price index starting at 100, handling leading/trailing NaN gaps per column.

Original: backtest/return2price.m

Parameters:

Name Type Description Default
keep_interior_nan if True, interior single-period gaps in the input

(immediately after the first valid observation) are preserved as NaN in the output rather than treated as a zero return.

False
Source code in src/quanttoolbox/backtest/returns.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def return_to_price(x: np.ndarray, keep_interior_nan: bool = False) -> np.ndarray:
    """Convert a return series back into a cumulative price index starting
    at 100, handling leading/trailing NaN gaps per column.

    Original: backtest/return2price.m

    Parameters
    ----------
    keep_interior_nan : if True, interior single-period gaps in the input
        (immediately after the first valid observation) are preserved as
        NaN in the output rather than treated as a zero return.
    """
    x = np.asarray(x, dtype=float)
    if x.ndim == 1:
        x = x[:, None]
    r, c = x.shape

    x_filled = np.where(np.isnan(x), 0.0, x)
    y = 100 * np.cumprod(1 + x_filled, axis=0)

    for j in range(c):
        col = x[:, j]
        valid = np.where(~np.isnan(col))[0]
        if valid.size == 0:
            y[:, j] = np.nan
            continue
        fnm, lnm = valid[0], valid[-1]
        if fnm >= 2:
            y[: fnm - 1, j] = np.nan
        if lnm < r - 1:
            y[lnm + 1 :, j] = np.nan

    if keep_interior_nan:
        for j in range(c):
            col = x[:, j]
            valid = np.where(~np.isnan(col))[0]
            if valid.size == 0:
                continue
            fnm = valid[0]
            missing = np.isnan(col)
            if fnm >= 1:
                missing[fnm - 1] = False
            y[missing, j] = np.nan

    # renormalize each column to start at 100 from its first valid value
    for j in range(c):
        valid = np.where(~np.isnan(y[:, j]))[0]
        if valid.size > 0:
            y[:, j] = 100 * y[:, j] / y[valid[0], j]

    return y

unfunded_to_price(unfunded_prices, libor_index, method=1)

Convert unfunded (excess-return) prices to funded (total-return) prices by adding back the LIBOR return each period.

Original: backtest/unfunded2price.m

Source code in src/quanttoolbox/backtest/returns.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def unfunded_to_price(
    unfunded_prices: np.ndarray, libor_index: np.ndarray, method: int = 1
) -> np.ndarray:
    """Convert unfunded (excess-return) prices to funded (total-return)
    prices by adding back the LIBOR return each period.

    Original: backtest/unfunded2price.m
    """
    unfunded_prices = np.asarray(unfunded_prices, dtype=float)
    if unfunded_prices.ndim == 1:
        unfunded_prices = unfunded_prices[:, None]
    libor_index = np.asarray(libor_index, dtype=float)
    if libor_index.ndim == 1:
        libor_index = libor_index[:, None]

    r, c = unfunded_prices.shape
    if method == 2:
        cnd = np.isnan(unfunded_prices)
    else:
        fnm, lnm = _first_last_valid(unfunded_prices)

    unfunded_filled = _fill_missing_ffill_bfill(unfunded_prices)
    libor_filled = _fill_missing_ffill_bfill(libor_index)

    r_unfunded = price_to_return(unfunded_filled, 1)
    r_libor = price_to_return(libor_filled, 1)
    r_funded = np.where(np.isnan(r_unfunded + r_libor), 0.0, r_unfunded + r_libor)
    funded_prices = 100 * np.cumprod(1 + r_funded, axis=0)

    if method == 2:
        funded_prices[cnd] = np.nan
    else:
        for j in range(c):
            if fnm[j] > 0:
                funded_prices[: fnm[j], j] = np.nan
            if lnm[j] < r - 1:
                funded_prices[lnm[j] + 1 :, j] = np.nan

    return funded_prices

Examples

Flat transaction cost under a realistic rebalancing schedule — backtest/backtest5.py
"""Translated from Examples/backtest/backtest5.m -- generate_backtest with
a flat transaction cost (0.01) under monthly rebalancing (the original
builds weekly and daily rebalancing schedules too, but overwrites both
before use, so only the monthly one -- `RB_Dates = ones(nDates,1)`, i.e.
every date is a rebalance -- is actually exercised; reproduced as-is)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(123456789)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

weights = np.ones((n_dates, n_assets))
weights = weights / np.sum(weights, axis=1, keepdims=True)

rb_dates = np.ones(
    n_dates
)  # "monthly rebalancing" per the original's final overwrite -> every date

result1 = generate_backtest(dates, weights, indices, rb_dates)
tc = 0.01
result2 = generate_backtest(dates, weights, indices, rb_dates, tc_bid_ask=tc)

print("backtest without / with transaction costs, turnover, TC (first 5 rows):")
print(
    np.round(
        np.column_stack(
            [result1.backtest, result2.backtest, result2.turnover, result2.transaction_costs]
        )[:5],
        4,
    )
)

to_total = np.nansum(result2.turnover)
cost1 = 1 - result2.backtest[-1] / result1.backtest[-1]
cost2 = to_total * tc
print("\nTotal turnover:", round(to_total, 4))
print("Cost1 (1 - final wealth ratio):", round(cost1, 5))
print("Cost2 (total turnover * tc):", round(cost2, 5))
Funded vs. unfunded backtest formulations cross-checked — backtest/unfunded1.py
"""Translated from Examples/backtest/unfunded1.m -- compares a funded
(price-return) backtest against economically equivalent unfunded
(excess-return) formulations, three different ways:
Backtest1 -- pure funded weights on funded prices.
Backtest2 -- funded weights plus an explicit cash/LIBOR leg, funded prices.
Backtest3 -- generate_backtest_funded_unfunded with the LIBOR leg funded
and the risky assets unfunded (price_to_unfunded'd), which should match
Backtest1/Backtest2 economically since unfunding + re-funding is neutral.

The original draws R from MATLAB's unseeded `randn`; a fixed seed
(`np.random.default_rng(0)`) is substituted here. Plotting is dropped."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest, generate_backtest_funded_unfunded
from quanttoolbox.backtest.returns import price_to_unfunded

n_dates = 3000
dates = np.arange(1, n_dates + 1)
rb = np.zeros(n_dates)
rb[np.arange(1, n_dates, 5) - 1] = 1.0

sigma = 0.20 * np.sqrt(1 / 260)
rng = np.random.default_rng(0)
r = sigma * rng.standard_normal((n_dates, 2))
prices_funded = 100 * np.cumprod(1 + r, axis=0)

rate = 0.03
r_libor = rate * np.sqrt(1 / 260) * np.ones((n_dates, 1))
libor_index = 100 * np.cumprod(1 + r_libor, axis=0)
prices_unfunded = price_to_unfunded(prices_funded, libor_index, method=1)

weights = np.tile([0.5, 0.5], (n_dates, 1))

w1 = weights
backtest1 = generate_backtest(dates, w1, prices_funded, rb).backtest

w2 = np.column_stack([weights, 1 - np.sum(weights, axis=1)])
backtest2 = generate_backtest(dates, w2, np.column_stack([prices_funded, libor_index]), rb).backtest

weights_libor = np.ones((n_dates, 1))
w3 = weights
backtest3 = generate_backtest_funded_unfunded(
    dates, weights_libor, libor_index, w3, prices_unfunded, rb
).backtest

print(
    "Backtest1 (funded), Backtest2 (funded + cash leg), Backtest3 (unfunded formulation) -- first/last 5 rows:"
)
print(np.round(np.column_stack([backtest1, backtest2, backtest3])[:5], 3))
print(np.round(np.column_stack([backtest1, backtest2, backtest3])[-5:], 3))
Maximum drawdown in relative mode — backtest/mdd1.py
"""Translated from Examples/backtest/mdd1.m -- maximum_drawdown on 3
simulated price indices (numeric core only; the original's plot marking
the drawdown peak/trough on each series is dropped).

The original explicitly seeds MATLAB's RNG (`rng(1234567)`); NumPy's
generator is seeded the same way for a comparable (not bit-identical)
run."""

import numpy as np

from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.backtest.stats import maximum_drawdown
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(1234567)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

max_dd, start_dd, end_dd, tau_dd = maximum_drawdown(indices, relative=True)

print("Maximum drawdown per asset:", np.round(max_dd, 4))
print("Start date (peak):", [str(dates[i].date()) for i in start_dd])
print("End date (trough):", [str(dates[i].date()) for i in end_dd])
print("Duration (trading days):", tau_dd)
Per-asset bid/ask transaction costs vs. turnover — backtest/backtest4.py
"""Translated from Examples/backtest/backtest4.m -- generate_backtest with
per-asset bid/ask transaction costs, comparing turnover computed inside
the backtest against `static_turnover` computed independently from the
rebalance-date weights.

The original explicitly seeds MATLAB's RNG (`rng(123456789)`); NumPy's
generator is seeded the same way for a comparable (not bit-identical --
different RNG algorithms) run."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.backtest.stats import static_turnover
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20160304])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(123456789)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)
indices = np.ones_like(indices)  # matches the original: prices reset to a flat 1.0

weights = rng.random((n_dates, n_assets))
weights = weights / np.sum(weights, axis=1, keepdims=True)

rb_dates = np.arange(5, 30, 5) - 1  # seqa(5,5,5) -> positions 5,10,15,20,25 (1-indexed)

result1 = generate_backtest(dates, weights, indices, rb_dates)
result2 = generate_backtest(
    dates, weights, indices, rb_dates, tc_bid_ask=np.array([0.01, 0.01, 0.05])
)

print("backtest without / with transaction costs, and turnover/TC (first 8 rows):")
print(
    np.round(
        np.column_stack(
            [result1.backtest, result2.backtest, result2.turnover, result2.transaction_costs]
        )[:8],
        4,
    )
)

rb_mask = result2.rebalancing[:, 0] == 1
w_rb = weights[rb_mask]
to_from_backtest = result2.turnover[rb_mask]
# static_turnover gives one turnover per consecutive pair of rebalance
# weights (n_rb - 1 values), vs. the backtest's own per-rebalance turnover
# series (n_rb values, first entry 0 at the initial rebalance) -- printed
# separately since they don't align row-for-row.
to_static = static_turnover(w_rb)
print("\n100*weights at rebalance dates, turnover (from backtest):")
print(np.round(np.column_stack([100 * w_rb, to_from_backtest]), 4))
print("\nturnover between consecutive rebalances (static_turnover, independent cross-check):")
print(np.round(to_static, 4))
Small hand-traceable funded/unfunded round-trip (n=8) — backtest/unfunded2.py
"""Translated from Examples/backtest/unfunded2.m -- a tiny (n=8),
hand-traceable version of unfunded1.py's funded/unfunded round-trip:
checks that price_to_unfunded -> unfunded_to_price recovers the original
funded prices, and that the three backtest formulations agree, on a
deterministic constant-return series (no randomness involved, so no
fixed-seed substitution is needed here)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest, generate_backtest_funded_unfunded
from quanttoolbox.backtest.returns import price_to_return, price_to_unfunded, unfunded_to_price

n_dates = 8
dates = np.arange(1, n_dates + 1)
rb = np.ones(n_dates)  # every date is a rebalance

r = 0.03 * np.ones((n_dates, 1))
prices_funded = 100 * np.cumprod(1 + r, axis=0)
prices_funded = 100 * prices_funded / prices_funded[0]
r1 = price_to_return(prices_funded, 1)

r_libor = 0.01 * np.ones((n_dates, 1))
libor_index = 100 * np.cumprod(1 + r_libor, axis=0)
prices_unfunded = price_to_unfunded(prices_funded, libor_index, method=1)
r2 = price_to_return(prices_unfunded, 1)
r3 = price_to_return(libor_index, 1)
p3 = unfunded_to_price(prices_unfunded, libor_index)

print("returns: r (input), r1 (from prices_funded), r2 (unfunded), r3 (libor):")
print(np.round(np.column_stack([r, r1, r2, r3]), 5))
print("\nprices: funded, round-tripped-back-to-funded (p3), libor:")
print(np.round(np.column_stack([prices_funded, p3, libor_index]), 4))

weights = np.ones((n_dates, 1))
w1 = weights
backtest1 = generate_backtest(dates, w1, prices_funded, rb).backtest

w2 = np.column_stack([weights, 1 - np.sum(weights, axis=1)])
backtest2 = generate_backtest(dates, w2, np.column_stack([prices_funded, libor_index]), rb).backtest

weights_libor = np.ones((n_dates, 1))
w3 = weights
backtest3 = generate_backtest_funded_unfunded(
    dates, weights_libor, libor_index, w3, prices_unfunded, rb
).backtest

print("\nBacktest1 (funded), Backtest2 (funded + cash leg), Backtest3 (unfunded formulation):")
print(np.round(np.column_stack([backtest1, backtest2, backtest3]), 4))
Three rebalancing schedules on a small backtest — backtest/backtest2.py
"""Translated from Examples/backtest/backtest2.m -- generate_backtest at
three rebalancing schedules (single rebalance at t0, four fixed
positional rebalances, and a schedule mixing an actual date subset with
two out-of-range placeholder dates) on a simulated 3-asset equal-weight
portfolio, with one price set to NaN in the third test.

The original draws R from MATLAB's unseeded `randn`; a fixed seed
(`np.random.default_rng(0)`) is substituted here for reproducibility,
same convention used elsewhere in this port. Plotting is dropped (see
`docs/migration_map.md`'s example translation tracker Notes column
convention)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# generate_trading_dates takes pd.Timestamp (or a bare int, which it feeds
# straight to pd.Timestamp(int) -- interpreted as a nanosecond epoch, NOT
# a YYYYMMDD date). parse_date_serial(...) is used here to get an actual
# 2016-01-01/2016-12-31 pair instead.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(0)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

weights = np.full((n_dates, n_assets), 1.0 / n_assets)

# Test 1: single rebalance at t0
rb_dates1 = dates[:1]
result1 = generate_backtest(dates, weights, indices, rb_dates1)
y1 = np.sum(indices * weights, axis=1)
y1 = 100 * y1 / y1[0]
print("Test 1 (single rebalance): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result1.backtest, y1])[:3])
print(np.column_stack([result1.backtest, y1])[-3:])

# Test 2: four fixed positional rebalances (MATLAB 1-indexed [1,50,150,200])
rb_dates2 = np.array([1, 50, 150, 200]) - 1
result2 = generate_backtest(dates, weights, indices, rb_dates2)
y2 = np.sum(indices * weights, axis=1)
y2 = 100 * y2 / y2[0]
print("\nTest 2 (4 fixed rebalances): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result2.backtest, y2])[:3])
print(np.column_stack([result2.backtest, y2])[-3:])

# Test 3: an actual date subset plus two out-of-range placeholder dates
# (20150101/20170101, both before/after the calendar -- they simply won't
# match anything in `dates`), and a NaN price injected on day 3.
rb_idx = np.array([10, 50, 150, 200]) - 1
placeholder_before, placeholder_after = parse_date_serial([20150101, 20170101])
rb_dates3 = dates[rb_idx].insert(0, placeholder_before)
rb_dates3 = rb_dates3.insert(len(rb_dates3), placeholder_after)
indices3 = indices.copy()
indices3[2, 0] = np.nan
result3 = generate_backtest(dates, weights, indices3, rb_dates3)
y3 = np.sum(indices3 * weights, axis=1)
y3 = 100 * y3 / y3[0]
print("\nTest 3 (date subset + NaN price): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result3.backtest, y3])[:3])
print(np.column_stack([result3.backtest, y3])[-3:])

backtest.stats

quanttoolbox.backtest.stats

Drawdown, turnover, average-return, and monthly/yearly performance statistics.

Ported from QuantToolBox/backtest/{maximum_drawdown,static_turnover, annualized_turnover,average_return,index_repeated_data,monthly_statistics, yearly_statistics}.m

Translation notes:

  • maximum_drawdown returns 0-indexed start/end row positions (not MATLAB's 1-indexed), so downstream code should adjust index arithmetic accordingly (e.g. when mapping back to a Dates array).
  • monthly_statistics/yearly_statistics reproduce the originals' "reindex onto a full trading calendar, forward-fill gaps" pattern using pandas (reindex + ffill) instead of MATLAB's manual indnv/fillmiss combination.
  • Both statistics functions accept a begin_date/end_date as either a pandas.Timestamp or an integer YYYYMMDD (0 meaning "use the full available range"), matching the original's dual calling convention.

annualized_turnover(dates, turnover, by_year=False)

Annualize a turnover series, either as a single average-per-year figure over the whole sample, or broken out year by year.

Original: backtest/annualized_turnover.m

Source code in src/quanttoolbox/backtest/stats.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def annualized_turnover(
    dates: pd.DatetimeIndex, turnover: np.ndarray, by_year: bool = False
) -> np.ndarray:
    """Annualize a turnover series, either as a single average-per-year
    figure over the whole sample, or broken out year by year.

    Original: backtest/annualized_turnover.m
    """
    dates = pd.DatetimeIndex(dates)
    turnover = np.asarray(turnover, dtype=float)
    if turnover.ndim == 1:
        turnover = turnover[:, None]

    if not by_year:
        valid = ~np.isnan(turnover).any(axis=1)
        to_valid = turnover[valid]
        dt_years = (dates[valid][-1] - dates[valid][0]).days / 365.25
        return np.sum(to_valid, axis=0) / dt_years

    years = dates.year.to_numpy()
    unique_years = np.unique(years)
    n_years = unique_years.shape[0]
    n_series = turnover.shape[1]

    tau = np.zeros((n_years, n_series))
    for i, yr in enumerate(unique_years):
        mask = years == yr
        for j in range(n_series):
            col = turnover[mask, j]
            if np.all(np.isnan(col)):
                tau[i, j] = np.nan
            else:
                tau[i, j] = np.nansum(col)
    return tau

average_return(r, n_lags)

Trailing n_lags-period moving average of a return series (NaN-filled as zero before averaging).

Original: backtest/average_return.m

Source code in src/quanttoolbox/backtest/stats.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def average_return(r: np.ndarray, n_lags: int) -> np.ndarray:
    """Trailing n_lags-period moving average of a return series (NaN-filled
    as zero before averaging).

    Original: backtest/average_return.m
    """
    r = np.asarray(r, dtype=float)
    if r.ndim == 1:
        r = r[:, None]
    r_filled = np.where(np.isnan(r), 0.0, r)
    n_dates, n_cols = r.shape

    x = np.full((n_dates, n_cols), np.nan)
    for i in range(n_lags - 1, n_dates):
        x[i, :] = np.mean(r_filled[i - n_lags + 1 : i + 1, :], axis=0)
    return x

index_repeated_data(x, precision)

Identify rows that repeat the previous row's value (after rounding to precision decimal places) -- useful for spotting stale/non-trading price data.

Original: backtest/index_repeated_data.m

Returns:

Name Type Description
idx_repeated 0-indexed positions where the value repeats the prior row.
idx_changed 0-indexed positions where the value differs from the prior row.
is_repeated boolean mask, same length as x.
Source code in src/quanttoolbox/backtest/stats.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def index_repeated_data(x: np.ndarray, precision: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Identify rows that repeat the previous row's value (after rounding to
    `precision` decimal places) -- useful for spotting stale/non-trading
    price data.

    Original: backtest/index_repeated_data.m

    Returns
    -------
    idx_repeated : 0-indexed positions where the value repeats the prior row.
    idx_changed : 0-indexed positions where the value differs from the prior row.
    is_repeated : boolean mask, same length as x.
    """
    x = np.asarray(x, dtype=float).flatten()
    scaled = np.round(x * 10**precision)

    y = np.concatenate([[np.nan], scaled[:-1]])
    cnd = scaled == y
    both_nan = np.isnan(scaled) & np.isnan(y)
    cnd = cnd | both_nan
    cnd[0] = False  # no prior row to compare against

    idx = np.arange(x.shape[0])
    return idx[cnd], idx[~cnd], cnd

maximum_drawdown(x, relative=False)

Maximum drawdown of each column of a price/index series.

Original: backtest/maximum_drawdown.m

Returns:

Name Type Description
max_dd maximum drawdown per column (negative or zero).
start_dd 0-indexed row where the drawdown period started (peak).
end_dd 0-indexed row where the maximum drawdown was reached (trough).
tau_dd drawdown duration in rows (end_dd - start_dd + 1).
Source code in src/quanttoolbox/backtest/stats.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def maximum_drawdown(
    x: np.ndarray, relative: bool = False
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Maximum drawdown of each column of a price/index series.

    Original: backtest/maximum_drawdown.m

    Returns
    -------
    max_dd : maximum drawdown per column (negative or zero).
    start_dd : 0-indexed row where the drawdown period started (peak).
    end_dd : 0-indexed row where the maximum drawdown was reached (trough).
    tau_dd : drawdown duration in rows (end_dd - start_dd + 1).
    """
    x = np.asarray(x, dtype=float)
    if x.ndim == 1:
        x = x[:, None]
    n, c = x.shape

    running_max = np.full((n, c), np.nan)
    for i in range(1, n):
        running_max[i, :] = np.nanmax(x[: i + 1, :], axis=0)

    dd = running_max - x
    dd = np.where(np.isnan(dd), 0.0, dd)
    if relative:
        dd = dd / running_max
        dd = np.where(np.isnan(dd), 0.0, dd)

    max_dd = np.max(dd, axis=0)
    end_dd = np.argmax(dd, axis=0)

    start_dd = np.zeros(c, dtype=int)
    for j in range(c):
        i = end_dd[j]
        while i > 0 and dd[i, j] != 0:
            i -= 1
        start_dd[j] = i

    tau_dd = end_dd - start_dd + 1
    return -max_dd, start_dd, end_dd, tau_dd

monthly_statistics(dates, backtests, begin_date=0, end_date=0)

Monthly and yearly returns/volatility/drawdown statistics for one or more backtest series.

Original: backtest/monthly_statistics.m

Source code in src/quanttoolbox/backtest/stats.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def monthly_statistics(
    dates: pd.DatetimeIndex,
    backtests: np.ndarray,
    begin_date: pd.Timestamp | int = 0,
    end_date: pd.Timestamp | int = 0,
) -> tuple[MonthlyStats, YearlyStats]:
    """Monthly and yearly returns/volatility/drawdown statistics for one or
    more backtest series.

    Original: backtest/monthly_statistics.m
    """
    dates = pd.DatetimeIndex(dates)
    backtests = np.asarray(backtests, dtype=float)
    if backtests.ndim == 1:
        backtests = backtests[:, None]

    begin = dates[0] if begin_date in (0, None) else pd.Timestamp(begin_date)
    end = dates[-1] if end_date in (0, None) else pd.Timestamp(end_date)

    mask = (dates >= begin) & (dates <= end)
    dates, backtests = dates[mask], backtests[mask]

    full_dates, backtests = _reindex_to_full_calendar(dates, backtests, begin, end)
    n_cols = backtests.shape[1]

    monthly_period = full_dates.year * 100 + full_dates.month
    unique_months = np.unique(monthly_period)
    n_months = unique_months.shape[0]
    month_to_row = {m: i for i, m in enumerate(unique_months)}

    mu = np.full((n_months, n_cols), np.nan)
    for j in range(n_cols):
        col = backtests[:, j]
        valid = np.where(~np.isnan(col))[0]
        if valid.size == 0:
            continue
        fnm, lnm = valid[0], valid[-1]
        x0 = col[fnm]
        for t in range(fnm + 1, lnm + 1):
            if monthly_period[t] != monthly_period[t - 1]:
                x1 = col[t - 1]
                mu[month_to_row[monthly_period[t - 1]], j] = x1 / x0 - 1.0
                x0 = x1
        if lnm > fnm and monthly_period[lnm] == monthly_period[lnm - 1]:
            mu[month_to_row[monthly_period[lnm]], j] = col[lnm] / x0 - 1.0

    years_arr = unique_months // 100
    months_arr = unique_months % 100
    monthly_stats = MonthlyStats(years=years_arr, months=months_arr, mu=mu)

    unique_years = np.unique(years_arr)
    n_years = unique_years.shape[0]
    mu_y = np.full((n_years, n_cols), np.nan)
    sigma_y = np.full((n_years, n_cols), np.nan)
    max_dd_y = np.full((n_years, n_cols), np.nan)

    num_dates_full = full_dates.year * 10000 + full_dates.month * 100 + full_dates.day
    for j in range(n_cols):
        for t, yr in enumerate(unique_years):
            begin_year = (yr - 1) * 10000 + 1231
            end_year = begin_year + 10000
            mask_y = (num_dates_full >= begin_year) & (num_dates_full <= end_year)
            data = backtests[mask_y, j]
            data = data[~np.isnan(data)]
            if data.size == 0:
                continue
            mu_y[t, j] = data[-1] / data[0] - 1.0
            r = price_to_return_1d(data)
            sigma_y[t, j] = np.sqrt(260) * np.nanstd(r[1:], ddof=1)
            prices = return_to_price_1d(r)
            dd_result = maximum_drawdown(prices, relative=True)
            max_dd_y[t, j] = dd_result[0][0]

    yearly_stats = YearlyStats(years=unique_years, mu=mu_y, sigma=sigma_y, max_dd=max_dd_y)
    return monthly_stats, yearly_stats

price_to_return_1d(x)

1-D convenience wrapper around price_to_return for internal use here.

Source code in src/quanttoolbox/backtest/stats.py
364
365
366
367
368
369
def price_to_return_1d(x: np.ndarray) -> np.ndarray:
    """1-D convenience wrapper around price_to_return for internal use here."""
    x = np.asarray(x, dtype=float)
    y = np.full_like(x, np.nan)
    y[1:] = x[1:] / x[:-1] - 1.0
    return y

return_to_price_1d(r)

1-D convenience wrapper around return_to_price for internal use here.

Source code in src/quanttoolbox/backtest/stats.py
372
373
374
375
def return_to_price_1d(r: np.ndarray) -> np.ndarray:
    """1-D convenience wrapper around return_to_price for internal use here."""
    r_filled = np.where(np.isnan(r), 0.0, r)
    return 100 * np.cumprod(1 + r_filled)

static_turnover(x, y=None)

Turnover between consecutive rows of x (or between x and y if given).

Original: backtest/static_turnover.m

Source code in src/quanttoolbox/backtest/stats.py
74
75
76
77
78
79
80
81
82
83
84
85
86
def static_turnover(x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray:
    """Turnover between consecutive rows of x (or between x and y if given).

    Original: backtest/static_turnover.m
    """
    x = np.asarray(x, dtype=float)
    if y is None:
        if x.ndim == 1:
            x = x[:, None]
        diff = np.abs(x[1:] - x[:-1])
        return np.sum(diff, axis=1)
    y = np.asarray(y, dtype=float)
    return np.sum(np.abs(x - y), axis=0)

yearly_statistics(dates, backtests, benchmark, begin_date=0, end_date=0)

Yearly return/volatility/tracking-error/beta/correlation statistics for one or more backtest series against a benchmark.

Original: backtest/yearly_statistics.m

Source code in src/quanttoolbox/backtest/stats.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def yearly_statistics(
    dates: pd.DatetimeIndex,
    backtests: np.ndarray,
    benchmark: np.ndarray,
    begin_date: pd.Timestamp | int = 0,
    end_date: pd.Timestamp | int = 0,
) -> YearlyStats:
    """Yearly return/volatility/tracking-error/beta/correlation statistics
    for one or more backtest series against a benchmark.

    Original: backtest/yearly_statistics.m
    """
    dates = pd.DatetimeIndex(dates)
    backtests = np.asarray(backtests, dtype=float)
    if backtests.ndim == 1:
        backtests = backtests[:, None]
    benchmark = np.asarray(benchmark, dtype=float).flatten()

    begin = dates[0] if begin_date in (0, None) else pd.Timestamp(begin_date)
    end = dates[-1] if end_date in (0, None) else pd.Timestamp(end_date)

    mask = (dates >= begin) & (dates <= end)
    dates, backtests, benchmark = dates[mask], backtests[mask], benchmark[mask]

    full_dates, backtests = _reindex_to_full_calendar(dates, backtests, begin, end)
    _, benchmark_2d = _reindex_to_full_calendar(dates, benchmark[:, None], begin, end)
    benchmark = benchmark_2d.flatten()
    n_cols = backtests.shape[1]

    years_full = full_dates.year.to_numpy()
    unique_years = np.unique(years_full)
    n_years = unique_years.shape[0]

    mu = np.full((n_years, n_cols), np.nan)
    sigma = np.full((n_years, n_cols), np.nan)
    mu_te = np.full((n_years, n_cols), np.nan)
    sigma_te = np.full((n_years, n_cols), np.nan)
    max_dd = np.full((n_years, n_cols), np.nan)
    beta = np.full((n_years, n_cols), np.nan)
    rho = np.full((n_years, n_cols), np.nan)

    num_dates_full = full_dates.year * 10000 + full_dates.month * 100 + full_dates.day
    for j in range(n_cols):
        for t, yr in enumerate(unique_years):
            begin_year = (yr - 1) * 10000 + 1231
            end_year = begin_year + 10000
            mask_y = (num_dates_full >= begin_year) & (num_dates_full <= end_year)
            b = backtests[mask_y, j]
            bench = benchmark[mask_y]
            valid = ~np.isnan(b) & ~np.isnan(bench)
            b, bench = b[valid], bench[valid]
            if b.size <= 1:
                continue

            mu[t, j] = b[-1] / b[0] - 1.0
            mu_te[t, j] = (b[-1] / b[0]) / (bench[-1] / bench[0]) - 1.0

            r_b = price_to_return_1d(b)
            r_bench = price_to_return_1d(bench)
            sigma[t, j] = np.sqrt(260) * np.nanstd(r_b[1:], ddof=1)
            prices = return_to_price_1d(r_b)
            dd_result = maximum_drawdown(prices, relative=True)
            max_dd[t, j] = dd_result[0][0]

            e = r_b - r_bench
            sigma_te[t, j] = np.sqrt(260) * np.nanstd(e[1:], ddof=1)

            joint = np.column_stack([r_bench, r_b])
            joint = joint[~np.isnan(joint).any(axis=1)]
            if joint.shape[0] > 1:
                cov = np.cov(joint, rowvar=False)
                with np.errstate(invalid="ignore", divide="ignore"):
                    beta[t, j] = cov[1, 0] / cov[1, 1]
                    rho[t, j] = cov[1, 0] / np.sqrt(cov[0, 0] * cov[1, 1])

    return YearlyStats(
        years=unique_years,
        mu=mu,
        sigma=sigma,
        mu_te=mu_te,
        sigma_te=sigma_te,
        max_dd=max_dd,
        beta=beta,
        rho=rho,
    )

Examples

Maximum drawdown in relative mode — backtest/mdd1.py
"""Translated from Examples/backtest/mdd1.m -- maximum_drawdown on 3
simulated price indices (numeric core only; the original's plot marking
the drawdown peak/trough on each series is dropped).

The original explicitly seeds MATLAB's RNG (`rng(1234567)`); NumPy's
generator is seeded the same way for a comparable (not bit-identical)
run."""

import numpy as np

from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.backtest.stats import maximum_drawdown
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(1234567)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

max_dd, start_dd, end_dd, tau_dd = maximum_drawdown(indices, relative=True)

print("Maximum drawdown per asset:", np.round(max_dd, 4))
print("Start date (peak):", [str(dates[i].date()) for i in start_dd])
print("End date (trough):", [str(dates[i].date()) for i in end_dd])
print("Duration (trading days):", tau_dd)
Per-asset bid/ask transaction costs vs. turnover — backtest/backtest4.py
"""Translated from Examples/backtest/backtest4.m -- generate_backtest with
per-asset bid/ask transaction costs, comparing turnover computed inside
the backtest against `static_turnover` computed independently from the
rebalance-date weights.

The original explicitly seeds MATLAB's RNG (`rng(123456789)`); NumPy's
generator is seeded the same way for a comparable (not bit-identical --
different RNG algorithms) run."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.backtest.stats import static_turnover
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20160304])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(123456789)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)
indices = np.ones_like(indices)  # matches the original: prices reset to a flat 1.0

weights = rng.random((n_dates, n_assets))
weights = weights / np.sum(weights, axis=1, keepdims=True)

rb_dates = np.arange(5, 30, 5) - 1  # seqa(5,5,5) -> positions 5,10,15,20,25 (1-indexed)

result1 = generate_backtest(dates, weights, indices, rb_dates)
result2 = generate_backtest(
    dates, weights, indices, rb_dates, tc_bid_ask=np.array([0.01, 0.01, 0.05])
)

print("backtest without / with transaction costs, and turnover/TC (first 8 rows):")
print(
    np.round(
        np.column_stack(
            [result1.backtest, result2.backtest, result2.turnover, result2.transaction_costs]
        )[:8],
        4,
    )
)

rb_mask = result2.rebalancing[:, 0] == 1
w_rb = weights[rb_mask]
to_from_backtest = result2.turnover[rb_mask]
# static_turnover gives one turnover per consecutive pair of rebalance
# weights (n_rb - 1 values), vs. the backtest's own per-rebalance turnover
# series (n_rb values, first entry 0 at the initial rebalance) -- printed
# separately since they don't align row-for-row.
to_static = static_turnover(w_rb)
print("\n100*weights at rebalance dates, turnover (from backtest):")
print(np.round(np.column_stack([100 * w_rb, to_from_backtest]), 4))
print("\nturnover between consecutive rebalances (static_turnover, independent cross-check):")
print(np.round(to_static, 4))

backtest.reporting

quanttoolbox.backtest.reporting

Backtest simulation engines and comprehensive performance reporting.

Ported from QuantToolBox/backtest/{generate_backtest,generate_backtest2, backtest_reporting}.m

Translation notes:

  • generate_backtest (buy-and-hold-between-rebalances simulator, with optional per-asset bid/ask transaction costs) is ported preserving its loop-over-rebalancing-periods structure, since each period's asset universe (nonzero weights, valid prices) can differ and doesn't vectorize cleanly across periods.
  • backtest_reporting is entirely numeric in the original (it builds up a results struct); the MATLAB disp/printing that would normally accompany a report script isn't part of this function, so nothing was dropped here -- the returned BacktestReport dataclass holds every field the original struct held.
  • Frequency auto-detection (daily/weekly/monthly, from the average gap between dates) is preserved exactly, including the original's hard failure (empty result) for irregular calendars.

backtest_reporting(dates, backtest, b_index=None, r_index=None, begin_date=0, end_date=0)

Comprehensive performance report: annualized return/vol, Sharpe and information ratios, beta/correlation to a benchmark, maximum drawdown, and monthly/yearly breakdowns.

Original: backtest/backtest_reporting.m

Returns None if the input calendar's frequency can't be classified as daily/weekly/monthly (matching the original's early-return behavior).

Source code in src/quanttoolbox/backtest/reporting.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def backtest_reporting(
    dates: pd.DatetimeIndex,
    backtest: np.ndarray,
    b_index: np.ndarray | None = None,
    r_index: np.ndarray | None = None,
    begin_date: pd.Timestamp | int = 0,
    end_date: pd.Timestamp | int = 0,
) -> BacktestReport | None:
    """Comprehensive performance report: annualized return/vol, Sharpe and
    information ratios, beta/correlation to a benchmark, maximum drawdown,
    and monthly/yearly breakdowns.

    Original: backtest/backtest_reporting.m

    Returns None if the input calendar's frequency can't be classified as
    daily/weekly/monthly (matching the original's early-return behavior).
    """
    dates = pd.DatetimeIndex(dates)
    backtest = np.asarray(backtest, dtype=float)
    if backtest.ndim == 1:
        backtest = backtest[:, None]
    n_dates = dates.shape[0]

    freq_result = _detect_frequency(dates)
    if freq_result is None:
        return None
    frequency, freq_label = freq_result

    begin = dates[0] if begin_date in (0, None) else pd.Timestamp(begin_date)
    end = dates[-1] if end_date in (0, None) else pd.Timestamp(end_date)

    use_benchmark = b_index is not None and not np.isscalar(b_index)
    if b_index is None or np.isscalar(b_index):
        b_index = np.full(n_dates, 100.0)
    else:
        b_index = np.asarray(b_index, dtype=float).flatten()
    if r_index is None or np.isscalar(r_index):
        r_index = np.full(n_dates, 100.0)
    else:
        r_index = np.asarray(r_index, dtype=float).flatten()

    monthly_stats, yearly_stats = monthly_statistics(dates, backtest, begin, end)
    yearly_stats_te = yearly_statistics(dates, backtest, b_index, begin, end)

    valid_bt = ~np.isnan(backtest).all(axis=1)
    first_valid = np.argmax(valid_bt)
    last_valid = n_dates - 1 - np.argmax(valid_bt[::-1])
    begin = max(begin, dates[first_valid])
    end = min(end, dates[last_valid])

    if use_benchmark:
        valid_b = ~np.isnan(b_index)
        first_valid_b = np.argmax(valid_b)
        last_valid_b = n_dates - 1 - np.argmax(valid_b[::-1])
        begin = max(begin, dates[first_valid_b])
        end = min(end, dates[last_valid_b])

    mask = (dates >= begin) & (dates <= end)
    dates_m = dates[mask]
    backtest_m = backtest[mask]
    b_index_m = b_index[mask]
    r_index_m = r_index[mask]

    dt_years = (dates_m[-1] - dates_m[0]).days / 365.25

    mu = (backtest_m[-1, :] / backtest_m[0, :]) ** (1 / dt_years) - 1.0
    mu_r = (r_index_m[-1] / r_index_m[0]) ** (1 / dt_years) - 1.0
    if use_benchmark:
        mu_b = (b_index_m[-1] / b_index_m[0]) ** (1 / dt_years) - 1.0
        mu_te = ((backtest_m[-1, :] / backtest_m[0, :]) / (b_index_m[-1] / b_index_m[0])) ** (
            1 / dt_years
        ) - 1.0
    else:
        mu_b, mu_te = np.nan, np.full(backtest_m.shape[1], np.nan)

    r_bt = price_to_return_1d_columns(backtest_m)
    r_r = price_to_return_1d(r_index_m)
    r_b = price_to_return_1d(b_index_m)
    e = r_bt - r_b[:, None]

    sigma = np.sqrt(frequency) * np.nanstd(r_bt, axis=0, ddof=1)
    sigma_r = np.sqrt(frequency) * np.nanstd(r_r, ddof=1)
    if use_benchmark:
        sigma_b = np.sqrt(frequency) * np.nanstd(r_b, ddof=1)
        sigma_te = np.sqrt(frequency) * np.nanstd(e, axis=0, ddof=1)
    else:
        sigma_b, sigma_te = np.nan, np.full(backtest_m.shape[1], np.nan)

    sharpe_ratio = (mu - mu_r) / sigma
    with np.errstate(invalid="ignore", divide="ignore"):
        if use_benchmark:
            sharpe_ratio_b = (mu_b - mu_r) / sigma_b
            information_ratio = mu_te / sigma_te
        else:
            sharpe_ratio_b, information_ratio = np.nan, np.full(backtest_m.shape[1], np.nan)

    joint_r = np.column_stack([r_b, r_bt])
    valid_r = ~np.isnan(joint_r).any(axis=1)
    joint_r = joint_r[valid_r]
    cov = np.cov(joint_r, rowvar=False)
    with np.errstate(invalid="ignore", divide="ignore"):
        beta = cov[1:, 0] / cov[0, 0]
        rho = cov[1:, 0] / (np.sqrt(cov[0, 0]) * np.sqrt(np.diag(cov)[1:]))

    max_dd, *_ = maximum_drawdown(backtest_m, relative=True)
    if use_benchmark:
        max_dd_b_arr, *_ = maximum_drawdown(b_index_m[:, None], relative=True)
        max_dd_b: float = float(max_dd_b_arr[0])
    else:
        max_dd_b = float("nan")

    return BacktestReport(
        frequency_label=freq_label,
        frequency=frequency,
        begin_date=int(dates_m[0].year * 10000 + dates_m[0].month * 100 + dates_m[0].day),
        end_date=int(dates_m[-1].year * 10000 + dates_m[-1].month * 100 + dates_m[-1].day),
        time_period_years=dt_years,
        mu=mu,
        mu_benchmark=mu_b,
        mu_risk_free=mu_r,
        mu_tracking_error=mu_te,
        sigma=sigma,
        sigma_benchmark=sigma_b,
        sigma_risk_free=sigma_r,
        sigma_tracking_error=sigma_te,
        sharpe_ratio=sharpe_ratio,
        sharpe_ratio_benchmark=sharpe_ratio_b,
        information_ratio=information_ratio,
        beta=beta,
        rho=rho,
        max_dd=max_dd,
        max_dd_benchmark=max_dd_b,
        monthly_stats=monthly_stats,
        yearly_stats=yearly_stats,
        yearly_stats_te=yearly_stats_te,
    )

generate_backtest(dates, weights, prices, rb_dates, tc_bid_ask=None)

Simulate a buy-and-hold-between-rebalances portfolio backtest.

On each rebalancing date, the portfolio is reset to the target weights for that date (using prices at that date), then held (drifting with asset prices) until the next rebalancing date. Assets with NaN price or NaN/zero weight at a rebalance are excluded for that period. If tc_bid_ask is given, per-unit bid/ask transaction costs are charged on turnover at each rebalance, and the simulation tracks actual unit holdings rather than pure weights.

Original: backtest/generate_backtest.m

Parameters:

Name Type Description Default
dates full daily calendar of the backtest.
required
weights (n_dates, n_assets) target weights (only rows at rebalancing

dates are used).

required
prices (n_dates, n_assets) asset price levels.
required
rb_dates rebalancing dates, either as a DatetimeIndex (subset of

dates) or a boolean/0-1 mask array aligned with dates.

required
tc_bid_ask optional (n_assets,) or (n_assets, 2) [bid, ask] per-unit

transaction cost rates.

None
Source code in src/quanttoolbox/backtest/reporting.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def generate_backtest(
    dates: pd.DatetimeIndex,
    weights: np.ndarray,
    prices: np.ndarray,
    rb_dates: pd.DatetimeIndex | np.ndarray,
    tc_bid_ask: np.ndarray | None = None,
) -> BacktestResult:
    """Simulate a buy-and-hold-between-rebalances portfolio backtest.

    On each rebalancing date, the portfolio is reset to the target
    `weights` for that date (using prices at that date), then held
    (drifting with asset prices) until the next rebalancing date.
    Assets with NaN price or NaN/zero weight at a rebalance are excluded
    for that period. If `tc_bid_ask` is given, per-unit bid/ask
    transaction costs are charged on turnover at each rebalance, and the
    simulation tracks actual unit holdings rather than pure weights.

    Original: backtest/generate_backtest.m

    Parameters
    ----------
    dates : full daily calendar of the backtest.
    weights : (n_dates, n_assets) target weights (only rows at rebalancing
        dates are used).
    prices : (n_dates, n_assets) asset price levels.
    rb_dates : rebalancing dates, either as a DatetimeIndex (subset of
        `dates`) or a boolean/0-1 mask array aligned with `dates`.
    tc_bid_ask : optional (n_assets,) or (n_assets, 2) [bid, ask] per-unit
        transaction cost rates.
    """
    dates = pd.DatetimeIndex(dates)
    weights = np.asarray(weights, dtype=float)
    prices = np.asarray(prices, dtype=float)
    n_dates = dates.shape[0]
    n_assets = weights.shape[1]

    rb = np.zeros(n_dates, dtype=bool)
    if isinstance(rb_dates, pd.DatetimeIndex | pd.Series) or (
        isinstance(rb_dates, np.ndarray) and np.issubdtype(rb_dates.dtype, np.datetime64)
    ):
        rb[dates.isin(pd.DatetimeIndex(rb_dates))] = True
    else:
        rb_arr = np.asarray(rb_dates)
        if rb_arr.shape[0] == n_dates:
            rb = rb_arr.astype(bool)
        else:
            rb[rb_arr.astype(int)] = True
    rb[-1] = True

    rb_idx = np.where(rb)[0]
    n_rb = rb_idx.shape[0]
    rb_effective = np.zeros(n_dates, dtype=bool)

    use_tc = tc_bid_ask is not None
    if use_tc:
        tc_bid_ask = np.asarray(tc_bid_ask, dtype=float)
        if tc_bid_ask.ndim == 2 and tc_bid_ask.shape[1] == 2:
            if tc_bid_ask.shape[0] == 1:
                tc_bid = np.full(n_assets, tc_bid_ask[0, 0])
                tc_ask = np.full(n_assets, tc_bid_ask[0, 1])
            else:
                tc_bid = tc_bid_ask[:, 0]
                tc_ask = tc_bid_ask[:, 1]
        else:
            tc_flat = tc_bid_ask.flatten()
            tc_bid = np.full(n_assets, tc_flat[0]) if tc_flat.shape[0] == 1 else tc_flat
            tc_ask = tc_bid.copy()

    backtest = np.full(n_dates, np.nan)
    turnover = np.zeros(n_dates) if use_tc else np.full(n_dates, np.nan)
    tc_series = np.zeros(n_dates) if use_tc else np.full(n_dates, np.nan)
    wealth = 100.0

    indx_t_begin = rb_idx[0]
    if indx_t_begin > 0:
        rb[:indx_t_begin] = False
        if use_tc:
            turnover[:indx_t_begin] = np.nan
            tc_series[:indx_t_begin] = np.nan

    n_previous = None
    rebalancing_begin = True

    for i in range(n_rb - 1):
        rb_effective[indx_t_begin] = True
        indx_t_end = rb_idx[i + 1]
        indx_t = np.arange(indx_t_begin, indx_t_end + 1)

        w_t = weights[indx_t_begin, :].copy()
        nonzero = np.where(w_t != 0)[0]
        if nonzero.size == 0:
            indx_t_begin = indx_t_end
            continue

        if use_tc:
            active = np.arange(n_assets)
        else:
            active = nonzero
            w_t = w_t[active]

        isnan_w = np.isnan(w_t)
        p_begin = prices[indx_t_begin, active]
        p_window = prices[np.ix_(indx_t, active)] / p_begin[None, :]
        p_end = prices[indx_t_end, active]
        isnan_begin = np.isnan(p_begin)
        isnan_end = np.isnan(p_end)

        zero_mask = isnan_begin | isnan_end | isnan_w
        if np.any(zero_mask):
            w_t = np.where(zero_mask, 0.0, w_t)
            p_window = p_window.copy()
            p_window[:, zero_mask] = 1.0

        w_sum = np.sum(w_t)
        if w_sum != 0:
            w_t = w_t / w_sum

        if use_tc:
            n_t_units = (wealth * w_t) / p_window[0, :]
            if i == 0 or rebalancing_begin:
                tc_t, to_t = 0.0, 0.0
                rebalancing_begin = False
            else:
                tc_bid_t = np.maximum(n_t_units - n_previous, 0.0) * p_window[0, :] * tc_bid
                tc_ask_t = np.maximum(n_previous - n_t_units, 0.0) * p_window[0, :] * tc_ask
                tc_t = np.sum(tc_bid_t + tc_ask_t)
                to_t = np.sum(np.abs(n_t_units - n_previous) * p_window[0, :]) / wealth
            backtest_t = p_window @ n_t_units - tc_t
            backtest[indx_t] = backtest_t
            n_previous = n_t_units
            turnover[indx_t[0]] = to_t
            tc_series[indx_t[0]] = tc_t
        else:
            backtest_t = p_window @ w_t
            backtest[indx_t] = wealth * backtest_t

        wealth = backtest[indx_t[-1]]
        indx_t_begin = indx_t_end

    rebalancing = np.column_stack([rb, rb_effective]).astype(float)
    return BacktestResult(
        backtest=backtest,
        rebalancing=rebalancing,
        turnover=turnover if use_tc else None,
        transaction_costs=tc_series if use_tc else None,
    )

generate_backtest_funded_unfunded(dates, weights_funded, prices_funded, weights_unfunded, prices_unfunded, rb_dates)

Simulate a backtest combining a "funded" (price-return) leg and an "unfunded" (excess-return, i.e. financed) leg, rebalanced together.

Pass weights_funded=0 (or weights_unfunded=0) to disable that leg entirely, matching the original's scalar-zero sentinel convention.

Original: backtest/generate_backtest2.m

Source code in src/quanttoolbox/backtest/reporting.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def generate_backtest_funded_unfunded(
    dates: pd.DatetimeIndex,
    weights_funded: np.ndarray | float,
    prices_funded: np.ndarray | float,
    weights_unfunded: np.ndarray | float,
    prices_unfunded: np.ndarray | float,
    rb_dates: pd.DatetimeIndex | np.ndarray,
) -> BacktestResult:
    """Simulate a backtest combining a "funded" (price-return) leg and an
    "unfunded" (excess-return, i.e. financed) leg, rebalanced together.

    Pass ``weights_funded=0`` (or ``weights_unfunded=0``) to disable that
    leg entirely, matching the original's scalar-zero sentinel convention.

    Original: backtest/generate_backtest2.m
    """
    dates = pd.DatetimeIndex(dates)
    n_dates = dates.shape[0]

    rb = np.zeros(n_dates, dtype=bool)
    if isinstance(rb_dates, pd.DatetimeIndex | pd.Series) or (
        isinstance(rb_dates, np.ndarray) and np.issubdtype(rb_dates.dtype, np.datetime64)
    ):
        rb[dates.isin(pd.DatetimeIndex(rb_dates))] = True
    else:
        rb_arr = np.asarray(rb_dates)
        if rb_arr.shape[0] == n_dates:
            rb = rb_arr.astype(bool)
        else:
            rb[rb_arr.astype(int)] = True
    rb[-1] = True

    rb_idx = np.where(rb)[0]
    n_rb = rb_idx.shape[0]
    rb_effective = np.zeros(n_dates, dtype=bool)

    def _prep(
        weights: np.ndarray | float, prices: np.ndarray | float
    ) -> tuple[np.ndarray, np.ndarray, int]:
        if np.isscalar(weights) and weights == 0:
            return np.ones((n_dates, 1)), np.ones((n_dates, 1)), 1
        w = np.asarray(weights, dtype=float)
        p = np.asarray(prices, dtype=float)
        return w, p, w.shape[1]

    weights_funded, prices_funded, n_funded = _prep(weights_funded, prices_funded)
    weights_unfunded, prices_unfunded, n_unfunded = _prep(weights_unfunded, prices_unfunded)

    backtest = np.full(n_dates, np.nan)
    wealth = 100.0
    indx_t_begin = rb_idx[0]
    if indx_t_begin > 0:
        rb[:indx_t_begin] = False

    for i in range(n_rb - 1):
        rb_effective[indx_t_begin] = True
        indx_t_end = rb_idx[i + 1]
        indx_t = np.arange(indx_t_begin, indx_t_end + 1)

        w_funded = weights_funded[indx_t_begin, :]
        p_begin_funded = prices_funded[indx_t_begin, :]
        p_funded = prices_funded[indx_t, :] / p_begin_funded[None, :]
        n_units_funded = (wealth * w_funded) / p_funded[0, :]
        backtest_t = p_funded @ n_units_funded

        w_unfunded = weights_unfunded[indx_t_begin, :]
        wealth_unfunded_t = wealth * w_unfunded
        p_begin_unfunded = prices_unfunded[indx_t_begin, :]
        r_unfunded = prices_unfunded[indx_t, :] / p_begin_unfunded[None, :] - 1.0
        backtest_t = backtest_t + r_unfunded @ wealth_unfunded_t

        backtest[indx_t] = backtest_t
        wealth = backtest[indx_t[-1]]
        indx_t_begin = indx_t_end

    rebalancing = np.column_stack([rb, rb_effective]).astype(float)
    return BacktestResult(
        backtest=backtest, rebalancing=rebalancing, turnover=None, transaction_costs=None
    )

price_to_return_1d_columns(x)

Column-wise version of price_to_return_1d for a 2-D (n_dates, n_cols) array.

Source code in src/quanttoolbox/backtest/reporting.py
454
455
456
457
458
def price_to_return_1d_columns(x: np.ndarray) -> np.ndarray:
    """Column-wise version of price_to_return_1d for a 2-D (n_dates, n_cols) array."""
    y = np.full_like(x, np.nan)
    y[1:, :] = x[1:, :] / x[:-1, :] - 1.0
    return y

Examples

Flat transaction cost under a realistic rebalancing schedule — backtest/backtest5.py
"""Translated from Examples/backtest/backtest5.m -- generate_backtest with
a flat transaction cost (0.01) under monthly rebalancing (the original
builds weekly and daily rebalancing schedules too, but overwrites both
before use, so only the monthly one -- `RB_Dates = ones(nDates,1)`, i.e.
every date is a rebalance -- is actually exercised; reproduced as-is)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(123456789)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

weights = np.ones((n_dates, n_assets))
weights = weights / np.sum(weights, axis=1, keepdims=True)

rb_dates = np.ones(
    n_dates
)  # "monthly rebalancing" per the original's final overwrite -> every date

result1 = generate_backtest(dates, weights, indices, rb_dates)
tc = 0.01
result2 = generate_backtest(dates, weights, indices, rb_dates, tc_bid_ask=tc)

print("backtest without / with transaction costs, turnover, TC (first 5 rows):")
print(
    np.round(
        np.column_stack(
            [result1.backtest, result2.backtest, result2.turnover, result2.transaction_costs]
        )[:5],
        4,
    )
)

to_total = np.nansum(result2.turnover)
cost1 = 1 - result2.backtest[-1] / result1.backtest[-1]
cost2 = to_total * tc
print("\nTotal turnover:", round(to_total, 4))
print("Cost1 (1 - final wealth ratio):", round(cost1, 5))
print("Cost2 (total turnover * tc):", round(cost2, 5))
Funded vs. unfunded backtest formulations cross-checked — backtest/unfunded1.py
"""Translated from Examples/backtest/unfunded1.m -- compares a funded
(price-return) backtest against economically equivalent unfunded
(excess-return) formulations, three different ways:
Backtest1 -- pure funded weights on funded prices.
Backtest2 -- funded weights plus an explicit cash/LIBOR leg, funded prices.
Backtest3 -- generate_backtest_funded_unfunded with the LIBOR leg funded
and the risky assets unfunded (price_to_unfunded'd), which should match
Backtest1/Backtest2 economically since unfunding + re-funding is neutral.

The original draws R from MATLAB's unseeded `randn`; a fixed seed
(`np.random.default_rng(0)`) is substituted here. Plotting is dropped."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest, generate_backtest_funded_unfunded
from quanttoolbox.backtest.returns import price_to_unfunded

n_dates = 3000
dates = np.arange(1, n_dates + 1)
rb = np.zeros(n_dates)
rb[np.arange(1, n_dates, 5) - 1] = 1.0

sigma = 0.20 * np.sqrt(1 / 260)
rng = np.random.default_rng(0)
r = sigma * rng.standard_normal((n_dates, 2))
prices_funded = 100 * np.cumprod(1 + r, axis=0)

rate = 0.03
r_libor = rate * np.sqrt(1 / 260) * np.ones((n_dates, 1))
libor_index = 100 * np.cumprod(1 + r_libor, axis=0)
prices_unfunded = price_to_unfunded(prices_funded, libor_index, method=1)

weights = np.tile([0.5, 0.5], (n_dates, 1))

w1 = weights
backtest1 = generate_backtest(dates, w1, prices_funded, rb).backtest

w2 = np.column_stack([weights, 1 - np.sum(weights, axis=1)])
backtest2 = generate_backtest(dates, w2, np.column_stack([prices_funded, libor_index]), rb).backtest

weights_libor = np.ones((n_dates, 1))
w3 = weights
backtest3 = generate_backtest_funded_unfunded(
    dates, weights_libor, libor_index, w3, prices_unfunded, rb
).backtest

print(
    "Backtest1 (funded), Backtest2 (funded + cash leg), Backtest3 (unfunded formulation) -- first/last 5 rows:"
)
print(np.round(np.column_stack([backtest1, backtest2, backtest3])[:5], 3))
print(np.round(np.column_stack([backtest1, backtest2, backtest3])[-5:], 3))
generate_backtest at four rebalancing frequencies — backtest/backtest3.py
"""Translated from Examples/backtest/backtest3.m -- generate_backtest at
four different rebalancing frequencies (buy-and-hold, every period, every
2 periods, every 3 periods) on a fixed 7-date, 2-asset price series."""

import numpy as np
import pandas as pd

from quanttoolbox.backtest.reporting import generate_backtest

indices = np.array(
    [
        [100, 100],
        [150, 50],
        [250, 100],
        [100, 150],
        [110, 125],
        [160, 140],
        [150, 150],
    ],
    dtype=float,
)
weights = np.full((7, 2), 0.50)
dates = pd.bdate_range("2023-01-02", periods=7)

# buy & hold (single rebalance at t=0)
rb_mask = np.zeros(7, dtype=bool)
rb_mask[0] = True
r1 = generate_backtest(dates, weights, indices, dates[rb_mask])
print("buy & hold:", np.round(r1.backtest, 3))

# rebalance every period
r2 = generate_backtest(dates, weights, indices, dates)
print("every period:", np.round(r2.backtest, 3))

# rebalance every 2 periods (indices 0,2,4,6 -> 1st,3rd,5th,7th)
rb_mask3 = np.zeros(7, dtype=bool)
rb_mask3[[0, 2, 4, 6]] = True
r3 = generate_backtest(dates, weights, indices, dates[rb_mask3])
print("every 2 periods:", np.round(r3.backtest, 3))

# rebalance every 3 periods (indices 0,3,6 -> 1st,4th,7th)
rb_mask4 = np.zeros(7, dtype=bool)
rb_mask4[[0, 3, 6]] = True
r4 = generate_backtest(dates, weights, indices, dates[rb_mask4])
print("every 3 periods:", np.round(r4.backtest, 3))
Per-asset bid/ask transaction costs vs. turnover — backtest/backtest4.py
"""Translated from Examples/backtest/backtest4.m -- generate_backtest with
per-asset bid/ask transaction costs, comparing turnover computed inside
the backtest against `static_turnover` computed independently from the
rebalance-date weights.

The original explicitly seeds MATLAB's RNG (`rng(123456789)`); NumPy's
generator is seeded the same way for a comparable (not bit-identical --
different RNG algorithms) run."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.backtest.stats import static_turnover
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# See backtest2.py's translation note: generate_trading_dates needs an
# actual pd.Timestamp, not a bare YYYYMMDD int, hence parse_date_serial.
d1, d2 = parse_date_serial([20160101, 20160304])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(123456789)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)
indices = np.ones_like(indices)  # matches the original: prices reset to a flat 1.0

weights = rng.random((n_dates, n_assets))
weights = weights / np.sum(weights, axis=1, keepdims=True)

rb_dates = np.arange(5, 30, 5) - 1  # seqa(5,5,5) -> positions 5,10,15,20,25 (1-indexed)

result1 = generate_backtest(dates, weights, indices, rb_dates)
result2 = generate_backtest(
    dates, weights, indices, rb_dates, tc_bid_ask=np.array([0.01, 0.01, 0.05])
)

print("backtest without / with transaction costs, and turnover/TC (first 8 rows):")
print(
    np.round(
        np.column_stack(
            [result1.backtest, result2.backtest, result2.turnover, result2.transaction_costs]
        )[:8],
        4,
    )
)

rb_mask = result2.rebalancing[:, 0] == 1
w_rb = weights[rb_mask]
to_from_backtest = result2.turnover[rb_mask]
# static_turnover gives one turnover per consecutive pair of rebalance
# weights (n_rb - 1 values), vs. the backtest's own per-rebalance turnover
# series (n_rb values, first entry 0 at the initial rebalance) -- printed
# separately since they don't align row-for-row.
to_static = static_turnover(w_rb)
print("\n100*weights at rebalance dates, turnover (from backtest):")
print(np.round(np.column_stack([100 * w_rb, to_from_backtest]), 4))
print("\nturnover between consecutive rebalances (static_turnover, independent cross-check):")
print(np.round(to_static, 4))
Small hand-traceable funded/unfunded round-trip (n=8) — backtest/unfunded2.py
"""Translated from Examples/backtest/unfunded2.m -- a tiny (n=8),
hand-traceable version of unfunded1.py's funded/unfunded round-trip:
checks that price_to_unfunded -> unfunded_to_price recovers the original
funded prices, and that the three backtest formulations agree, on a
deterministic constant-return series (no randomness involved, so no
fixed-seed substitution is needed here)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest, generate_backtest_funded_unfunded
from quanttoolbox.backtest.returns import price_to_return, price_to_unfunded, unfunded_to_price

n_dates = 8
dates = np.arange(1, n_dates + 1)
rb = np.ones(n_dates)  # every date is a rebalance

r = 0.03 * np.ones((n_dates, 1))
prices_funded = 100 * np.cumprod(1 + r, axis=0)
prices_funded = 100 * prices_funded / prices_funded[0]
r1 = price_to_return(prices_funded, 1)

r_libor = 0.01 * np.ones((n_dates, 1))
libor_index = 100 * np.cumprod(1 + r_libor, axis=0)
prices_unfunded = price_to_unfunded(prices_funded, libor_index, method=1)
r2 = price_to_return(prices_unfunded, 1)
r3 = price_to_return(libor_index, 1)
p3 = unfunded_to_price(prices_unfunded, libor_index)

print("returns: r (input), r1 (from prices_funded), r2 (unfunded), r3 (libor):")
print(np.round(np.column_stack([r, r1, r2, r3]), 5))
print("\nprices: funded, round-tripped-back-to-funded (p3), libor:")
print(np.round(np.column_stack([prices_funded, p3, libor_index]), 4))

weights = np.ones((n_dates, 1))
w1 = weights
backtest1 = generate_backtest(dates, w1, prices_funded, rb).backtest

w2 = np.column_stack([weights, 1 - np.sum(weights, axis=1)])
backtest2 = generate_backtest(dates, w2, np.column_stack([prices_funded, libor_index]), rb).backtest

weights_libor = np.ones((n_dates, 1))
w3 = weights
backtest3 = generate_backtest_funded_unfunded(
    dates, weights_libor, libor_index, w3, prices_unfunded, rb
).backtest

print("\nBacktest1 (funded), Backtest2 (funded + cash leg), Backtest3 (unfunded formulation):")
print(np.round(np.column_stack([backtest1, backtest2, backtest3]), 4))
Three rebalancing schedules on a small backtest — backtest/backtest2.py
"""Translated from Examples/backtest/backtest2.m -- generate_backtest at
three rebalancing schedules (single rebalance at t0, four fixed
positional rebalances, and a schedule mixing an actual date subset with
two out-of-range placeholder dates) on a simulated 3-asset equal-weight
portfolio, with one price set to NaN in the third test.

The original draws R from MATLAB's unseeded `randn`; a fixed seed
(`np.random.default_rng(0)`) is substituted here for reproducibility,
same convention used elsewhere in this port. Plotting is dropped (see
`docs/migration_map.md`'s example translation tracker Notes column
convention)."""

import numpy as np

from quanttoolbox.backtest.reporting import generate_backtest
from quanttoolbox.backtest.returns import return_to_price
from quanttoolbox.dates.convert import parse_date_serial
from quanttoolbox.dates.rebalancing import generate_trading_dates

# generate_trading_dates takes pd.Timestamp (or a bare int, which it feeds
# straight to pd.Timestamp(int) -- interpreted as a nanosecond epoch, NOT
# a YYYYMMDD date). parse_date_serial(...) is used here to get an actual
# 2016-01-01/2016-12-31 pair instead.
d1, d2 = parse_date_serial([20160101, 20161231])
_, dates = generate_trading_dates(d1, d2, business_days_only=True)
n_dates = len(dates)

n_assets = 3
sigma = 0.20 / np.sqrt(260)

rng = np.random.default_rng(0)
r = sigma * rng.standard_normal((n_dates, n_assets))
indices = return_to_price(r)

weights = np.full((n_dates, n_assets), 1.0 / n_assets)

# Test 1: single rebalance at t0
rb_dates1 = dates[:1]
result1 = generate_backtest(dates, weights, indices, rb_dates1)
y1 = np.sum(indices * weights, axis=1)
y1 = 100 * y1 / y1[0]
print("Test 1 (single rebalance): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result1.backtest, y1])[:3])
print(np.column_stack([result1.backtest, y1])[-3:])

# Test 2: four fixed positional rebalances (MATLAB 1-indexed [1,50,150,200])
rb_dates2 = np.array([1, 50, 150, 200]) - 1
result2 = generate_backtest(dates, weights, indices, rb_dates2)
y2 = np.sum(indices * weights, axis=1)
y2 = 100 * y2 / y2[0]
print("\nTest 2 (4 fixed rebalances): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result2.backtest, y2])[:3])
print(np.column_stack([result2.backtest, y2])[-3:])

# Test 3: an actual date subset plus two out-of-range placeholder dates
# (20150101/20170101, both before/after the calendar -- they simply won't
# match anything in `dates`), and a NaN price injected on day 3.
rb_idx = np.array([10, 50, 150, 200]) - 1
placeholder_before, placeholder_after = parse_date_serial([20150101, 20170101])
rb_dates3 = dates[rb_idx].insert(0, placeholder_before)
rb_dates3 = rb_dates3.insert(len(rb_dates3), placeholder_after)
indices3 = indices.copy()
indices3[2, 0] = np.nan
result3 = generate_backtest(dates, weights, indices3, rb_dates3)
y3 = np.sum(indices3 * weights, axis=1)
y3 = 100 * y3 / y3[0]
print("\nTest 3 (date subset + NaN price): backtest vs. buy&hold, first/last 3 rows")
print(np.column_stack([result3.backtest, y3])[:3])
print(np.column_stack([result3.backtest, y3])[-3:])