Skip to content

quanttoolbox.dates

Python alternatives

  • convert.py: pandas has no built-in Excel-serial-date conversion — keep this wrapper.
  • rebalancing.py: pandas_market_calendars gives 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 datenum epoch (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 against pandas.Timestamp via 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 datetime arrays, this module uses pandas.Timestamp / pandas.DatetimeIndex throughout.

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
def datetime_to_excel(dates: pd.Timestamp | pd.DatetimeIndex | np.ndarray) -> np.ndarray:
    """Convert pandas Timestamps (or a YYYYMMDD-encoded numeric array) to
    Excel serial date numbers. Always returns a float array.

    Original: dates/Matlab2Excel_Dates.m
    """
    if isinstance(dates, pd.Timestamp | pd.DatetimeIndex):
        idx = pd.DatetimeIndex([dates]) if isinstance(dates, pd.Timestamp) else dates
        delta = idx - _EXCEL_EPOCH
        return delta.days.to_numpy().astype(float)

    arr = np.asarray(dates)
    test, yyyy, mm, dd = is_yyyymmdd(arr)
    if test:
        idx = pd.DatetimeIndex(pd.to_datetime({"year": yyyy, "month": mm, "day": dd}))
        delta = idx - _EXCEL_EPOCH
        return delta.days.to_numpy().astype(float)

    # already serial-like numeric input; nothing to convert
    return arr.astype(float)

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
def excel_column(x: int) -> str:
    """Convert a 1-indexed column number to its Excel column letter (1='A',
    27='AA', ...).

    Original: dates/excel_column.m
    """
    if x < 1:
        raise ValueError("excel_column: x must be >= 1")
    letters = ""
    n = x
    while n > 0:
        n, remainder = divmod(n - 1, 26)
        letters = chr(65 + remainder) + letters
    return letters

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
def excel_to_datetime(
    excel_dates: int | float | np.ndarray | list, date_format: str = "%d/%m/%Y"
) -> pd.DatetimeIndex | pd.Timestamp:
    """Convert Excel serial date numbers (or formatted date strings) to
    pandas Timestamps.

    Original: dates/Excel2Matlab_Dates.m
    """
    if isinstance(excel_dates, str) or (
        isinstance(excel_dates, list | np.ndarray)
        and len(excel_dates) > 0
        and isinstance(np.asarray(excel_dates).flat[0], str)
    ):
        return pd.to_datetime(excel_dates, format=date_format)

    excel_dates = np.asarray(excel_dates)
    result = _EXCEL_EPOCH + pd.to_timedelta(excel_dates, unit="D")
    if result.ndim == 0 or (hasattr(excel_dates, "shape") and excel_dates.shape == ()):
        return pd.Timestamp(result)
    return pd.DatetimeIndex(result)

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
def is_yyyymmdd(x: np.ndarray | int | float) -> tuple[bool, np.ndarray, np.ndarray, np.ndarray]:
    """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
    """
    x = np.asarray(x, dtype=float)

    if np.any(np.trunc(x) != x):
        return False, np.array([]), np.array([]), np.array([])

    dd = x - 100 * np.floor(x / 100)
    x2 = np.floor((x - dd) / 100)
    mm = x2 - 100 * np.floor(x2 / 100)
    yyyy = np.floor((x2 - mm) / 100)

    test = bool(
        dd.min() >= 1
        and dd.max() <= 31
        and mm.min() >= 1
        and mm.max() <= 12
        and yyyy.min() >= 1900
        and yyyy.max() <= 2100
    )
    return test, yyyy.astype(int), mm.astype(int), dd.astype(int)

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
def parse_date_serial(x: np.ndarray | int | float) -> pd.DatetimeIndex:
    """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
    """
    x = np.asarray(x, dtype=float)
    test, yyyy, mm, dd = is_yyyymmdd(x)
    if test:
        return pd.DatetimeIndex(pd.to_datetime({"year": yyyy, "month": mm, "day": dd}))
    # fall back: treat as Excel-style serial day count
    return pd.DatetimeIndex(excel_to_datetime(x))

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
def to_yyyymmdd(dates: pd.DatetimeIndex | pd.Timestamp) -> np.ndarray:
    """Convert pandas dates to an integer YYYYMMDD array.

    Original: dates/datenum2.m
    """
    idx = pd.DatetimeIndex([dates]) if isinstance(dates, pd.Timestamp) else pd.DatetimeIndex(dates)
    return (idx.year * 10000 + idx.month * 100 + idx.day).to_numpy()

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.DatetimeIndex of 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 from rebalancing_dates.day_name() and isn't returned as a separate value here.
  • "Business day" here means Monday-Friday only, matching MATLAB's default isbusday/busdate behavior (no holiday calendar). If you need a real trading calendar (exchange holidays), pass a custom pandas.tseries.offsets.CustomBusinessDay calendar into pandas.bdate_range upstream and feed those dates in as dates.
  • 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 dates is the closest available date on-or-before each computed target (an "asof" match), mirroring the original's indnv(..., 2) nearest-lower lookup.

annual_rebalancing(dates)

Original: dates/annual_rebalancing.m

Source code in src/quanttoolbox/dates/rebalancing.py
97
98
99
def annual_rebalancing(dates: pd.DatetimeIndex) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """Original: dates/annual_rebalancing.m"""
    return generic_rebalancing(dates, 12)

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
def generate_trading_dates(
    begin_date: pd.Timestamp | int, end_date: pd.Timestamp | int, business_days_only: bool = False
) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """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
    -------
    yyyymmdd : int ndarray of dates in YYYYMMDD format.
    dates : DatetimeIndex of the same dates.
    """
    date1 = pd.Timestamp(begin_date) if not isinstance(begin_date, pd.Timestamp) else begin_date
    date2 = pd.Timestamp(end_date) if not isinstance(end_date, pd.Timestamp) else end_date

    dates = pd.date_range(date1, date2, freq="D")
    if business_days_only:
        dates = dates[dates.weekday < 5]

    yyyymmdd = (dates.year * 10000 + dates.month * 100 + dates.day).to_numpy()
    return yyyymmdd, dates

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
def generic_rebalancing(
    dates: pd.DatetimeIndex, frequency: int
) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """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
    -------
    mask : bool ndarray, same length/order as ``dates``, True on rebalancing dates.
    rebalancing_dates : DatetimeIndex of the selected rebalancing dates.
    """
    dates = pd.DatetimeIndex(pd.to_datetime(dates)).sort_values()

    full_range = pd.date_range(dates[0], dates[-1], freq="D")
    year = full_range.year.to_numpy()
    month = full_range.month.to_numpy()
    if frequency != 1:
        month = (np.ceil(month / frequency) * frequency).astype(int)

    period_key = year * 100 + month
    eom = pd.to_datetime({"year": year, "month": month, "day": 1}) + pd.offsets.MonthEnd(0)
    eom_adj = _prev_business_day(pd.DatetimeIndex(eom))

    # one target date per distinct (year, grouped-month) period
    period_targets = pd.Series(eom_adj).groupby(period_key).first()
    target_dates = pd.DatetimeIndex(period_targets.to_numpy())

    rb_dates = _snap_to_available(target_dates, dates)
    mask = dates.isin(rb_dates)
    return mask, rb_dates

monthly_rebalancing(dates)

Original: dates/monthly_rebalancing.m

Source code in src/quanttoolbox/dates/rebalancing.py
82
83
84
def monthly_rebalancing(dates: pd.DatetimeIndex) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """Original: dates/monthly_rebalancing.m"""
    return generic_rebalancing(dates, 1)

quarterly_rebalancing(dates)

Original: dates/quarterly_rebalancing.m

Source code in src/quanttoolbox/dates/rebalancing.py
87
88
89
def quarterly_rebalancing(dates: pd.DatetimeIndex) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """Original: dates/quarterly_rebalancing.m"""
    return generic_rebalancing(dates, 3)

semi_annual_rebalancing(dates)

Original: dates/semi_annual_rebalancing.m

Source code in src/quanttoolbox/dates/rebalancing.py
92
93
94
def semi_annual_rebalancing(dates: pd.DatetimeIndex) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """Original: dates/semi_annual_rebalancing.m"""
    return generic_rebalancing(dates, 6)

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
def weekly_rebalancing(
    dates: pd.DatetimeIndex, day_of_week: int
) -> tuple[np.ndarray, pd.DatetimeIndex]:
    """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
    """
    dates = pd.DatetimeIndex(pd.to_datetime(dates)).sort_values()
    full_range = pd.date_range(dates[0], dates[-1], freq="D")

    # MATLAB weekday: 1=Sun..7=Sat  ->  pandas weekday: 0=Mon..6=Sun
    pandas_weekday = (day_of_week - 2) % 7

    target_dates = full_range[full_range.weekday == pandas_weekday]
    rb_dates = _snap_to_available(target_dates, dates)
    mask = dates.isin(rb_dates)
    return mask, rb_dates

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:])