Skip to content

Commit f3761c3

Browse files
committed
Add docs for decision schedules with tests from schedule_tests.ipynb
1 parent f43b59a commit f3761c3

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

src/backtest_lib/backtest/schedule.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Decision schedules for controlling backtest evaluation."""
2+
13
import re
24
from collections.abc import Callable, Iterable, Iterator
35
from datetime import datetime, timedelta
@@ -71,6 +73,12 @@ def _parse_step(s: str) -> timedelta | relativedelta:
7173

7274

7375
class DecisionSchedule[I: Comparable]:
76+
"""Iterable schedule of decision points.
77+
78+
Schedules can be built from explicit iterables of comparable values or from
79+
time-based schedules created via :func:`make_decision_schedule`.
80+
"""
81+
7482
_schedule: Iterable[I]
7583
_start: I
7684
_end: I | None
@@ -94,9 +102,16 @@ def __init__(
94102

95103
@property
96104
def schedule(self) -> Iterable[I]:
105+
"""Return the underlying iterable of schedule values."""
97106
return self._schedule
98107

99108
def __iter__(self) -> Iterator[I]:
109+
"""Iterate over schedule values within optional bounds.
110+
111+
The iterator skips values before ``start`` and stops at ``end`` (with
112+
optional inclusive behavior) while validating that the schedule is
113+
non-decreasing.
114+
"""
100115
start = self._start
101116
end = self._end
102117
prev = None
@@ -180,6 +195,50 @@ def make_decision_schedule[I: Comparable](
180195
*,
181196
inclusive_end: bool = True,
182197
) -> DecisionSchedule:
198+
"""Create a decision schedule from an iterable or time-based string.
199+
200+
The schedule can be a re-iterable of comparable values or a string interval
201+
(e.g., ``"2h"``) or cron expression (e.g., ``"0 * * * *"``) when paired with
202+
a ``datetime`` start.
203+
204+
Example:
205+
>>> import datetime as dt
206+
>>> from backtest_lib.backtest.schedule import make_decision_schedule
207+
>>> schedule = make_decision_schedule(
208+
... "2h",
209+
... start=dt.datetime(2021, 11, 13),
210+
... )
211+
>>> it = iter(schedule)
212+
>>> next(it)
213+
datetime.datetime(2021, 11, 13, 0, 0)
214+
>>> next(it)
215+
datetime.datetime(2021, 11, 13, 2, 0)
216+
217+
>>> cron_schedule = make_decision_schedule(
218+
... "0 * * * *",
219+
... start=dt.datetime(2025, 2, 1),
220+
... )
221+
>>> cron_it = iter(cron_schedule)
222+
>>> next(cron_it)
223+
datetime.datetime(2025, 2, 1, 1, 0)
224+
>>> next(cron_it)
225+
datetime.datetime(2025, 2, 1, 2, 0)
226+
227+
>>> schedule = make_decision_schedule(
228+
... [dt.datetime(2025, 1, 1), dt.datetime(2025, 1, 2)]
229+
... )
230+
>>> list(schedule)
231+
[datetime.datetime(2025, 1, 1, 0, 0), datetime.datetime(2025, 1, 2, 0, 0)]
232+
233+
Args:
234+
schedule: Iterable of schedule values, interval string, or cron string.
235+
start: Start value (required for string schedules).
236+
end: Optional end value used to truncate the schedule.
237+
inclusive_end: Whether the end bound is inclusive.
238+
239+
Returns:
240+
DecisionSchedule for the provided specification.
241+
"""
183242
if isinstance(schedule, str):
184243
if not isinstance(start, DateTimeLike):
185244
raise TypeError("For string schedules, start must be a datetime.")
@@ -232,6 +291,29 @@ def decision_schedule_factory[I: Comparable](
232291
*,
233292
inclusive_end: bool = True,
234293
) -> DecisionSchedule[I]:
294+
"""Create a decision schedule from a factory of iterators.
295+
296+
Example:
297+
>>> import datetime as dt
298+
>>> from backtest_lib.backtest.schedule import decision_schedule_factory
299+
>>> def factory():
300+
... yield from [
301+
... dt.datetime(2025, 1, 1),
302+
... dt.datetime(2025, 1, 2),
303+
... ]
304+
>>> schedule = decision_schedule_factory(factory)
305+
>>> list(schedule)
306+
[datetime.datetime(2025, 1, 1, 0, 0), datetime.datetime(2025, 1, 2, 0, 0)]
307+
308+
Args:
309+
factory: Callable that returns a fresh iterator of schedule values.
310+
start: Optional start value for bounds checking.
311+
end: Optional end value used to truncate the schedule.
312+
inclusive_end: Whether the end bound is inclusive.
313+
314+
Returns:
315+
DecisionSchedule built from the factory.
316+
"""
235317
schedule = _IterFactoryIterable(factory)
236318

237319
if start is None:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import datetime as dt
2+
3+
from backtest_lib.backtest.schedule import make_decision_schedule
4+
5+
6+
def test_iterable_schedule_is_reiterable() -> None:
7+
schedule = make_decision_schedule(
8+
[dt.datetime(2025, 1, 1), dt.datetime(2025, 1, 2)]
9+
)
10+
assert list(schedule) == [
11+
dt.datetime(2025, 1, 1),
12+
dt.datetime(2025, 1, 2),
13+
]
14+
assert list(schedule) == [
15+
dt.datetime(2025, 1, 1),
16+
dt.datetime(2025, 1, 2),
17+
]
18+
19+
20+
def test_interval_schedule_emits_steps() -> None:
21+
schedule = make_decision_schedule("2h", start=dt.datetime(2021, 11, 13))
22+
it = iter(schedule)
23+
assert next(it) == dt.datetime(2021, 11, 13, 0, 0)
24+
assert next(it) == dt.datetime(2021, 11, 13, 2, 0)
25+
26+
27+
def test_cron_schedule_emits_hours() -> None:
28+
schedule = make_decision_schedule("0 * * * *", start=dt.datetime(2025, 2, 1))
29+
it = iter(schedule)
30+
assert next(it) == dt.datetime(2025, 2, 1, 1, 0)
31+
assert next(it) == dt.datetime(2025, 2, 1, 2, 0)
32+
33+
34+
def test_bounded_schedule_stops_at_end() -> None:
35+
schedule = make_decision_schedule(
36+
"1w",
37+
start=dt.datetime(2024, 2, 1),
38+
end=dt.datetime(2024, 2, 3),
39+
)
40+
assert list(schedule) == [dt.datetime(2024, 2, 1, 0, 0)]

0 commit comments

Comments
 (0)