Skip to content

Commit b644da9

Browse files
authored
Merge pull request #887 from Lumiwealth/fix/backtest-datetime-normalization
Normalize datetime handling before backtests
2 parents b0f9077 + 652b324 commit b644da9

File tree

5 files changed

+114
-17
lines changed

5 files changed

+114
-17
lines changed

lumibot/strategies/_strategy.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,12 +1388,6 @@ def run_backtest(
13881388
if use_other_option_source and not isinstance(optionsource_class, type):
13891389
raise ValueError(f"`optionsource_class` must be a class. You passed in {optionsource_class}")
13901390

1391-
self.verify_backtest_inputs(backtesting_start, backtesting_end)
1392-
1393-
if not self.IS_BACKTESTABLE:
1394-
get_logger(__name__).warning(f"Strategy {name + ' ' if name is not None else ''}cannot be " f"backtested at the moment")
1395-
return None
1396-
13971391
try:
13981392
backtesting_start = to_datetime_aware(backtesting_start)
13991393
backtesting_end = to_datetime_aware(backtesting_end)
@@ -1405,6 +1399,12 @@ def run_backtest(
14051399
)
14061400
return None
14071401

1402+
self.verify_backtest_inputs(backtesting_start, backtesting_end)
1403+
1404+
if not self.IS_BACKTESTABLE:
1405+
get_logger(__name__).warning(f"Strategy {name + ' ' if name is not None else ''}cannot be " f"backtested at the moment")
1406+
return None
1407+
14081408
if BACKTESTING_QUIET_LOGS is not None:
14091409
quiet_logs = BACKTESTING_QUIET_LOGS
14101410

@@ -1628,18 +1628,21 @@ def verify_backtest_inputs(cls, backtesting_start, backtesting_end):
16281628
if not isinstance(backtesting_end, datetime.datetime):
16291629
raise ValueError(f"`backtesting_end` must be a datetime object. You passed in {backtesting_end}")
16301630

1631+
start_dt = to_datetime_aware(backtesting_start)
1632+
end_dt = to_datetime_aware(backtesting_end)
1633+
16311634
# Check that backtesting end is after backtesting start
1632-
if backtesting_end <= backtesting_start:
1635+
if end_dt <= start_dt:
16331636
raise ValueError(
16341637
f"`backtesting_end` must be after `backtesting_start`. You passed in "
1635-
f"{backtesting_end} and {backtesting_start}"
1638+
f"{end_dt} and {start_dt}"
16361639
)
16371640

16381641
# Check that backtesting_end is not in the future
1639-
now = datetime.datetime.now(backtesting_end.tzinfo) if backtesting_end.tzinfo else datetime.datetime.now()
1640-
if backtesting_end > now:
1642+
now = datetime.datetime.now(end_dt.tzinfo) if end_dt.tzinfo else datetime.datetime.now()
1643+
if end_dt > now:
16411644
raise ValueError(
1642-
f"`backtesting_end` cannot be in the future. You passed in {backtesting_end}, now is {now}"
1645+
f"`backtesting_end` cannot be in the future. You passed in {end_dt}, now is {now}"
16431646
)
16441647

16451648
def send_update_to_cloud(self):

lumibot/strategies/strategy_executor.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
from apscheduler.jobstores.memory import MemoryJobStore
1414
from apscheduler.schedulers.background import BackgroundScheduler
1515
from apscheduler.triggers.cron import CronTrigger
16-
from termcolor import colored
17-
1816
from lumibot.constants import LUMIBOT_DEFAULT_PYTZ
1917
from lumibot.entities import Asset, Order
2018
from lumibot.entities import Asset
@@ -1166,7 +1164,7 @@ def _strategy_sleep(self):
11661164
# For live trading, stop when market closes
11671165
return False
11681166

1169-
self.strategy.log_message(colored(f"Sleeping for {strategy_sleeptime} seconds", color="blue"))
1167+
self.strategy.logger.debug("Sleeping for %s seconds", strategy_sleeptime)
11701168

11711169
# Run process orders at the market close time first (if not continuous market)
11721170
if not is_continuous_market:

lumibot/tools/thetadata_helper.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,13 @@ def get_price_data(
311311
)
312312

313313
if cache_file.exists():
314-
logger.info(f"\nLoading '{datastyle}' pricing data for {asset} / {quote_asset} with '{timespan}' timespan from cache file...")
314+
logger.debug(
315+
"\nLoading '%s' pricing data for %s / %s with '%s' timespan from cache file...",
316+
datastyle,
317+
asset,
318+
quote_asset,
319+
timespan,
320+
)
315321
df_cached = load_cache(cache_file)
316322
if df_cached is not None and not df_cached.empty:
317323
df_all = df_cached.copy() # Make a copy so we can check the original later for differences
@@ -372,7 +378,7 @@ def get_price_data(
372378
)
373379
if not missing_dates:
374380
if df_all is not None and not df_all.empty:
375-
logger.info("ThetaData cache HIT for %s %s %s (%d rows).", asset, timespan, datastyle, len(df_all))
381+
logger.debug("ThetaData cache HIT for %s %s %s (%d rows).", asset, timespan, datastyle, len(df_all))
376382
# DEBUG-LOG: Cache hit
377383
logger.debug(
378384
"[THETA][DEBUG][CACHE][HIT] asset=%s timespan=%s datastyle=%s rows=%d start=%s end=%s",

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setuptools.setup(
77
name="lumibot",
8-
version="4.2.3",
8+
version="4.2.4",
99
author="Robert Grzesik",
1010
author_email="rob@lumiwealth.com",
1111
description="Backtesting and Trading Library, Made by Lumiwealth",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import datetime
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
from lumibot.strategies import Strategy
7+
from lumibot.strategies._strategy import _Strategy
8+
9+
10+
class MinimalStrategy(Strategy):
11+
"""No-op strategy used for backtest scaffolding."""
12+
13+
def initialize(self):
14+
self.sleeptime = "1D"
15+
16+
def on_trading_iteration(self):
17+
pass
18+
19+
20+
class DummyDataSource:
21+
"""Lightweight datasource stub capturing the start/end datetimes."""
22+
23+
SOURCE = "dummy"
24+
25+
def __init__(self, datetime_start=None, datetime_end=None, **kwargs):
26+
self.datetime_start = datetime_start
27+
self.datetime_end = datetime_end
28+
self._data_store = {}
29+
30+
31+
class DummyTrader:
32+
"""Trader stub that records strategies and returns canned results."""
33+
34+
def __init__(self, *args, **kwargs):
35+
self._strategies = []
36+
37+
def add_strategy(self, strategy):
38+
self._strategies.append(strategy)
39+
40+
def run_all(self, **_kwargs):
41+
return {strategy.name: {"dummy": True} for strategy in self._strategies}
42+
43+
44+
class _EarlyExit(Exception):
45+
"""Signal to stop run_backtest after the datasource is constructed."""
46+
47+
48+
def test_verify_backtest_inputs_accepts_mixed_timezones():
49+
"""Regression: verify_backtest_inputs must not crash on naive vs aware inputs."""
50+
naive_start = datetime.datetime(2025, 1, 1)
51+
aware_end = datetime.datetime(2025, 9, 30, tzinfo=datetime.timezone.utc)
52+
53+
# Should not raise
54+
_Strategy.verify_backtest_inputs(naive_start, aware_end)
55+
56+
57+
def test_run_backtest_normalizes_mixed_timezones():
58+
"""Strategy.run_backtest should normalize naive/aware datetimes before validation."""
59+
naive_start = datetime.datetime(2025, 1, 1)
60+
aware_end = datetime.datetime(2025, 9, 30, tzinfo=datetime.timezone.utc)
61+
62+
captured = {}
63+
64+
class CapturingDataSource(DummyDataSource):
65+
def __init__(self, datetime_start=None, datetime_end=None, **kwargs):
66+
super().__init__(datetime_start=datetime_start, datetime_end=datetime_end, **kwargs)
67+
captured["start"] = self.datetime_start
68+
captured["end"] = self.datetime_end
69+
70+
def broker_factory(data_source, *args, **kwargs):
71+
captured["data_source"] = data_source
72+
raise _EarlyExit
73+
74+
with patch("lumibot.strategies._strategy.BacktestingBroker", side_effect=broker_factory), \
75+
patch("lumibot.strategies._strategy.Trader", DummyTrader):
76+
with pytest.raises(_EarlyExit):
77+
MinimalStrategy.run_backtest(
78+
CapturingDataSource,
79+
backtesting_start=naive_start,
80+
backtesting_end=aware_end,
81+
show_plot=False,
82+
show_tearsheet=False,
83+
show_indicators=False,
84+
show_progress_bar=False,
85+
save_logfile=False,
86+
save_stats_file=False,
87+
)
88+
89+
assert "start" in captured and captured["start"].tzinfo is not None
90+
assert "end" in captured and captured["end"].tzinfo is not None

0 commit comments

Comments
 (0)