quanttoolbox.dates¶
Python alternatives
convert.py: pandas has no built-in Excel-serial-date conversion — keep this wrapper.rebalancing.py:pandas_market_calendarsgives real exchange holiday calendars (ours is weekday-only) — worth wiring in as an optional calendar source for production use; keep our nearest-available-date snapping logic either way.
dates.convert¶
quanttoolbox.dates.convert
¶
Excel <-> Python date conversion and date-format helpers.
Ported from QuantToolBox/dates/{Excel2Matlab_Dates,Matlab2Excel_Dates, is_yyyymmdd,numdate,datenum2,excel_column}.m
Translation notes:
- MATLAB's
datenumepoch (day 1 = 1-Jan-0000, proleptic) is NOT used here. Excel's own serial-date epoch (day 1 = 1-Jan-1900, with the famous 1900-leap-year bug baked in) is handled directly againstpandas.Timestampvia a fixed offset, which is simpler and avoids MATLAB's separate datenum epoch entirely. - Per the original warning: Excel's serial dates are only valid for dates after 1900-02-29 (which doesn't actually exist -- Excel incorrectly treats 1900 as a leap year). Do not use these helpers for dates before 1900-03-01.
- Where MATLAB used
datetimearrays, this module usespandas.Timestamp/pandas.DatetimeIndexthroughout.
datetime_to_excel(dates)
¶
Convert pandas Timestamps (or a YYYYMMDD-encoded numeric array) to Excel serial date numbers. Always returns a float array.
Original: dates/Matlab2Excel_Dates.m
Source code in src/quanttoolbox/dates/convert.py
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | |
excel_column(x)
¶
Convert a 1-indexed column number to its Excel column letter (1='A', 27='AA', ...).
Original: dates/excel_column.m
Source code in src/quanttoolbox/dates/convert.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 | |
excel_to_datetime(excel_dates, date_format='%d/%m/%Y')
¶
Convert Excel serial date numbers (or formatted date strings) to pandas Timestamps.
Original: dates/Excel2Matlab_Dates.m
Source code in src/quanttoolbox/dates/convert.py
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | |
is_yyyymmdd(x)
¶
Test whether a numeric array is encoded as YYYYMMDD integers, and if so, decompose it into (year, month, day) components.
Original: dates/is_yyyymmdd.m
Source code in src/quanttoolbox/dates/convert.py
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 | |
parse_date_serial(x)
¶
Parse a numeric date-like array that may be either YYYYMMDD-encoded or a plain serial day count, returning pandas Timestamps.
Original: dates/numdate.m
Source code in src/quanttoolbox/dates/convert.py
111 112 113 114 115 116 117 118 119 120 121 122 | |
to_yyyymmdd(dates)
¶
Convert pandas dates to an integer YYYYMMDD array.
Original: dates/datenum2.m
Source code in src/quanttoolbox/dates/convert.py
102 103 104 105 106 107 108 | |
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))
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))
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:])
dates.rebalancing¶
quanttoolbox.dates.rebalancing
¶
Rebalancing-date calendar generation.
Ported from QuantToolBox/dates/{generic_rebalancing,annual_rebalancing, monthly_rebalancing,quarterly_rebalancing,semi_annual_rebalancing, weekly_rebalancing,generate_trading_dates}.m
Translation notes:
- All rebalancing functions take a
pandas.DatetimeIndexof available trading dates and return (mask, rebalancing_dates) instead of MATLAB's (RB, RB_Dates, RB_Days) triple -- the weekday name (RB_Days) is easily recovered fromrebalancing_dates.day_name()and isn't returned as a separate value here. - "Business day" here means Monday-Friday only, matching MATLAB's default
isbusday/busdatebehavior (no holiday calendar). If you need a real trading calendar (exchange holidays), pass a custompandas.tseries.offsets.CustomBusinessDaycalendar intopandas.bdate_rangeupstream and feed those dates in asdates. - The original's month-end target is the previous business day if the calendar end-of-month falls on a weekend; here that becomes a simple weekday rollback.
- The final rebalancing date snapped from the input
datesis the closest available date on-or-before each computed target (an "asof" match), mirroring the original'sindnv(..., 2)nearest-lower lookup.
annual_rebalancing(dates)
¶
Original: dates/annual_rebalancing.m
Source code in src/quanttoolbox/dates/rebalancing.py
97 98 99 | |
generate_trading_dates(begin_date, end_date, business_days_only=False)
¶
Generate a calendar of dates between begin_date and end_date, optionally restricted to business days (Mon-Fri, no holiday calendar).
Original: dates/generate_trading_dates.m
Returns:
| Name | Type | Description |
|---|---|---|
yyyymmdd |
int ndarray of dates in YYYYMMDD format.
|
|
dates |
DatetimeIndex of the same dates.
|
|
Source code in src/quanttoolbox/dates/rebalancing.py
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | |
generic_rebalancing(dates, frequency)
¶
Generate rebalancing dates at the given month-frequency (1=monthly, 3=quarterly, 6=semi-annual, 12=annual), snapped to available dates.
Original: dates/generic_rebalancing.m
Returns:
| Name | Type | Description |
|---|---|---|
mask |
bool ndarray, same length/order as ``dates``, True on rebalancing dates.
|
|
rebalancing_dates |
DatetimeIndex of the selected rebalancing dates.
|
|
Source code in src/quanttoolbox/dates/rebalancing.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 | |
monthly_rebalancing(dates)
¶
Original: dates/monthly_rebalancing.m
Source code in src/quanttoolbox/dates/rebalancing.py
82 83 84 | |
quarterly_rebalancing(dates)
¶
Original: dates/quarterly_rebalancing.m
Source code in src/quanttoolbox/dates/rebalancing.py
87 88 89 | |
semi_annual_rebalancing(dates)
¶
Original: dates/semi_annual_rebalancing.m
Source code in src/quanttoolbox/dates/rebalancing.py
92 93 94 | |
weekly_rebalancing(dates, day_of_week)
¶
Generate weekly rebalancing dates on a given weekday, snapped to available dates.
day_of_week uses MATLAB's weekday convention: 1=Sunday,
2=Monday, ..., 7=Saturday (to preserve call-site compatibility with the
original). Internally converted to pandas' Monday=0 convention.
Original: dates/weekly_rebalancing.m
Source code in src/quanttoolbox/dates/rebalancing.py
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
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))
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))
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:])