Skip to content

Commit 4dab01b

Browse files
authored
Merge pull request #961 from Lumiwealth/be-fix-get-trading-days
2 parents c8f1465 + 8de0847 commit 4dab01b

File tree

3 files changed

+173
-7
lines changed

3 files changed

+173
-7
lines changed

lumibot/tools/helpers.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,23 @@ def get_trading_days(
160160

161161
# More robust datetime conversion with explicit timezone handling
162162
def format_datetime(dtm):
163+
"""
164+
Convert dtm to a timezone-aware Python datetime in tzinfo.
165+
166+
Handles inputs that may be:
167+
- tz-naive (localize to tzinfo)
168+
- tz-aware (convert to tzinfo)
169+
- pandas Timestamp or Python datetime
170+
- NaT/None (returned as-is)
171+
"""
163172
if pd.isna(dtm):
164173
return dtm
165-
# Convert to Python datetime and ensure proper timezone conversion
166-
return pd.Timestamp(dtm).tz_convert(tzinfo).to_pydatetime()
174+
ts = pd.Timestamp(dtm)
175+
if ts.tz is None:
176+
ts = ts.tz_localize(tzinfo)
177+
else:
178+
ts = ts.tz_convert(tzinfo)
179+
return ts.to_pydatetime()
167180

168181
def ensure_tz_aware(dtm, tzinfo):
169182
dtm = pd.to_datetime(dtm)
@@ -175,15 +188,15 @@ def ensure_tz_aware(dtm, tzinfo):
175188
else:
176189
end_date = ensure_tz_aware(get_lumibot_datetime(), tzinfo)
177190

178-
# Normalize to date-only boundaries (pandas_market_calendars schedules use a tz-naive date index).
191+
# Normalize to date-only boundaries, but ensure tz-awareness to match schedule index
179192
try:
180-
start_day = pd.Timestamp(start_date.date())
193+
start_day = pd.Timestamp(start_date.date(), tz=tzinfo)
181194
except Exception:
182-
start_day = pd.Timestamp(pd.to_datetime(start_date).date())
195+
start_day = pd.Timestamp(pd.to_datetime(start_date).date(), tz=tzinfo)
183196
try:
184-
end_day_exclusive = pd.Timestamp(end_date.date())
197+
end_day_exclusive = pd.Timestamp(end_date.date(), tz=tzinfo)
185198
except Exception:
186-
end_day_exclusive = pd.Timestamp(pd.to_datetime(end_date).date())
199+
end_day_exclusive = pd.Timestamp(pd.to_datetime(end_date).date(), tz=tzinfo)
187200

188201
# Make end_date exclusive by moving it one day earlier.
189202
schedule_end_day = end_day_exclusive - pd.Timedelta(days=1)
@@ -229,6 +242,25 @@ def _get_year_schedule(year: int) -> pd.DataFrame:
229242

230243
full_schedule = year_schedules[0] if len(year_schedules) == 1 else pd.concat(year_schedules, axis=0)
231244

245+
# Ensure the index is tz-aware and aligned with requested tz BEFORE slicing
246+
# Calendars may return:
247+
# - DatetimeIndex (tz-naive or tz-aware)
248+
# - Index of Python datetimes (tz-naive or tz-aware)
249+
idx = full_schedule.index
250+
if isinstance(idx, pd.DatetimeIndex):
251+
full_schedule.index = idx.tz_localize(tzinfo) if idx.tz is None else idx.tz_convert(tzinfo)
252+
else:
253+
# Likely an Index of Python datetime/date objects; can be mixed naive/aware
254+
# Prefer parsing as naive and localizing (preserves calendar days)
255+
# but fall back to utc=True if any tz-aware elements are present.
256+
try:
257+
tmp = pd.to_datetime(idx, errors="raise")
258+
full_schedule.index = pd.DatetimeIndex(tmp).tz_localize(tzinfo)
259+
except ValueError:
260+
# Pandas requires utc=True when any tz-aware python datetimes are present
261+
tmp = pd.to_datetime(idx, utc=True)
262+
full_schedule.index = tmp.tz_convert(tzinfo)
263+
232264
# Slice to the requested window (inclusive of schedule_end_day).
233265
days = full_schedule.loc[start_day:schedule_end_day].copy()
234266

tests/backtest/backtest_performance_history.csv

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7235,3 +7235,24 @@ timestamp,test_name,data_source,trading_days,execution_time_seconds,git_commit,l
72357235
2026-01-27T19:02:10.083698,test_acceptance_ibkr_mes_futures_acceptance,unknown,,6.407,340ed4e7,4.4.41,,,,,Auto-tracked from test_acceptance_backtests_ci
72367236
2026-01-27T21:02:44.774164,test_acceptance_backdoor_smartlimit,unknown,,210.067,8020e12e,4.4.41,,,,,Auto-tracked from test_acceptance_backtests_ci
72377237
2026-01-27T21:06:26.716069,test_acceptance_spx_short_straddle,unknown,,213.94,8020e12e,4.4.41,,,,,Auto-tracked from test_acceptance_backtests_ci
7238+
2026-02-01T21:17:26.655808,test_crypto_cash_regression_no_fees[price_map0],unknown,,3.302,395805d2,4.4.43,,,,,Auto-tracked from test_crypto_cash_regressions
7239+
2026-02-01T21:17:29.922829,test_crypto_cash_regression_no_fees[price_map1],unknown,,3.15,395805d2,4.4.43,,,,,Auto-tracked from test_crypto_cash_regressions
7240+
2026-02-01T21:17:35.109447,test_crypto_cash_regression_with_fees,unknown,,5.075,395805d2,4.4.43,,,,,Auto-tracked from test_crypto_cash_regressions
7241+
2026-02-01T21:17:35.380215,test_databento_auth_failure_propagates,Databento,,0.155,395805d2,4.4.43,,,,,Auto-tracked from test_databento
7242+
2026-02-01T21:17:47.312363,test_yahoo_finance_dividends,unknown,,11.801,395805d2,4.4.43,,,,,Auto-tracked from test_dividends
7243+
2026-02-01T21:17:47.612178,test_stock_bracket,unknown,,0.18,395805d2,4.4.43,,,,,Auto-tracked from test_example_strategies
7244+
2026-02-01T21:17:47.915589,test_stock_oco,unknown,,0.178,395805d2,4.4.43,,,,,Auto-tracked from test_example_strategies
7245+
2026-02-01T21:17:48.343537,test_stock_buy_and_hold,unknown,,0.294,395805d2,4.4.43,,,,,Auto-tracked from test_example_strategies
7246+
2026-02-01T21:17:49.334631,test_stock_diversified_leverage,unknown,,0.851,395805d2,4.4.43,,,,,Auto-tracked from test_example_strategies
7247+
2026-02-01T21:17:49.668061,test_limit_and_trailing_stops,unknown,,0.18,395805d2,4.4.43,,,,,Auto-tracked from test_example_strategies
7248+
2026-02-01T21:17:57.716977,test_trading_iteration_failure_raises_exception,unknown,,7.774,395805d2,4.4.43,,,,,Auto-tracked from test_failing_backtest
7249+
2026-02-01T21:18:06.142554,test_backtest_classmethod_trading_iteration_failure,unknown,,8.135,395805d2,4.4.43,,,,,Auto-tracked from test_failing_backtest
7250+
2026-02-01T21:18:11.924616,test_multileg_spread_backtest_cash_and_parent_fill,unknown,,4.979,395805d2,4.4.43,,,,,Auto-tracked from test_multileg_backtest
7251+
2026-02-01T21:18:15.753524,test_multileg_parent_limit_order_fills_in_backtest,unknown,,3.664,395805d2,4.4.43,,,,,Auto-tracked from test_multileg_backtest
7252+
2026-02-01T21:18:20.741212,test_pandas_datasource_with_daily_data_in_backtest,unknown,,2.966,395805d2,4.4.43,,,,,Auto-tracked from test_pandas_backtest
7253+
2026-02-01T21:18:23.762486,test_bracket_orders_apply_entry_and_exit_fees,unknown,,2.858,395805d2,4.4.43,,,,,Auto-tracked from test_pandas_backtest
7254+
2026-02-01T21:18:27.280865,test_bracket_positions_remain_bounded,unknown,,3.353,395805d2,4.4.43,,,,,Auto-tracked from test_pandas_backtest
7255+
2026-02-01T21:18:31.517503,test_not_passing_trader_class_into_backtest_creates_generic_trader,unknown,,4.066,395805d2,4.4.43,,,,,Auto-tracked from test_passing_trader_into_backtest
7256+
2026-02-01T21:18:35.813287,test_passing_trader_class_into_backtest_creates_trader_class,unknown,,4.127,395805d2,4.4.43,,,,,Auto-tracked from test_passing_trader_into_backtest
7257+
2026-02-01T21:18:39.472260,test_s3_truncated_cache_forces_refetch,ThetaData,,0.105,395805d2,4.4.43,,,,,Auto-tracked from test_thetadata_resilience
7258+
2026-02-01T21:18:43.490769,test_yahoo_last_price,Yahoo,,3.276,395805d2,4.4.43,,,,,Auto-tracked from test_yahoo

tests/test_helpers.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from zoneinfo import ZoneInfo
55
import pytz
66
import pytest
7+
import pandas as pd
78

89
from lumibot import LUMIBOT_DEFAULT_TIMEZONE
910
from lumibot.tools.helpers import (
@@ -372,3 +373,115 @@ def test_is_market_open_invalid_market():
372373
tz = pytz.timezone("US/Eastern")
373374
dtm = tz.localize(dt.datetime.combine(dt.date(2024, 1, 5), dt.time(10, 30)))
374375
assert is_market_open(dtm, "INVALID") is False
376+
377+
378+
@pytest.mark.parametrize(
379+
"market, tzname",
380+
[
381+
("NYSE", "America/New_York"),
382+
# Using UTC also exhibits the same mismatch prior to the fix
383+
("NYSE", "UTC"),
384+
],
385+
)
386+
def test_get_trading_days_handles_tzaware_index_nyse(market, tzname):
387+
"""
388+
Regression test for tz-aware index vs tz-naive slice bounds in get_trading_days.
389+
390+
Before the fix, this raises:
391+
TypeError: Cannot compare tz-naive and tz-aware datetime-like objects
392+
393+
After the fix, it should return a non-empty schedule for the requested window.
394+
"""
395+
tz = pytz.timezone(tzname)
396+
397+
# Intentionally pass timezone-aware datetimes with times that are not midnight
398+
# so code paths normalize to date-only and then slice the tz-aware index.
399+
start = tz.localize(dt.datetime(2025, 4, 14, 12, 0, 0))
400+
end = tz.localize(dt.datetime(2025, 4, 20, 12, 0, 0))
401+
402+
sched = get_trading_days(market=market, start_date=start, end_date=end, tzinfo=tz)
403+
404+
# After fix: we should get a non-empty DataFrame with DatetimeIndex
405+
assert isinstance(sched, pd.DataFrame)
406+
assert not sched.empty
407+
assert getattr(sched.index, "tz", None) is not None # index should be tz-aware
408+
409+
410+
def test_get_trading_days_handles_tzaware_index_247():
411+
"""
412+
Same regression test for the built-in "24/7" calendar.
413+
414+
Prior to the fix, this also triggers the tz-aware/naive slicing error.
415+
After the fix, it should return daily sessions for the range.
416+
"""
417+
tz = pytz.UTC
418+
start = tz.localize(dt.datetime(2025, 1, 1, 8, 30, 0))
419+
end = tz.localize(dt.datetime(2025, 1, 5, 17, 45, 0))
420+
421+
sched = get_trading_days(market="24/7", start_date=start, end_date=end, tzinfo=tz)
422+
423+
assert isinstance(sched, pd.DataFrame)
424+
assert not sched.empty
425+
# Expect 4 sessions: Jan 1, 2, 3, 4 (inclusive of start day; end is exclusive)
426+
assert len(sched) == 4
427+
assert getattr(sched.index, "tz", None) is not None
428+
429+
430+
def test_date_n_trading_days_from_date_no_tz_mismatch_nyse():
431+
"""
432+
`date_n_trading_days_from_date` delegates to `get_trading_days`. Before the fix,
433+
this call raises a tz-aware/naive mismatch TypeError. After the fix, it should
434+
simply return a `datetime.date`.
435+
"""
436+
tz = pytz.UTC
437+
start_dt = tz.localize(dt.datetime(2025, 7, 1, 12, 0, 0))
438+
439+
result_back = date_n_trading_days_from_date(
440+
n_days=5, start_datetime=start_dt, market="NYSE"
441+
)
442+
result_fwd = date_n_trading_days_from_date(
443+
n_days=-5, start_datetime=start_dt, market="NYSE"
444+
)
445+
446+
assert isinstance(result_back, dt.date)
447+
assert isinstance(result_fwd, dt.date)
448+
449+
450+
def test_get_trading_days_on_tz_mismatch_then_fix(monkeypatch):
451+
"""
452+
Construct a calendar whose schedule has a tz-aware DatetimeIndex (UTC),
453+
while get_trading_days currently builds tz-naive slice bounds.
454+
"""
455+
import pandas as pd
456+
import pytz
457+
458+
class FakeCalendar:
459+
def schedule(self, start_date, end_date, tz=None):
460+
# Build a tz-aware DatetimeIndex to trigger the mismatch
461+
idx = pd.date_range(
462+
start=pd.Timestamp('2025-01-01', tz=pytz.UTC),
463+
end=pd.Timestamp('2025-01-05', tz=pytz.UTC),
464+
freq='D'
465+
)
466+
# Market open/close columns can be naive datetimes; they aren't
467+
# used for the slicing that triggers the error.
468+
opens = pd.date_range('2025-01-01 00:00:00', periods=len(idx), freq='D')
469+
closes = pd.date_range('2025-01-01 23:59:00', periods=len(idx), freq='D')
470+
df = pd.DataFrame({
471+
'market_open': opens,
472+
'market_close': closes,
473+
}, index=idx)
474+
return df
475+
476+
# Monkeypatch pandas_market_calendars.get_calendar used inside helpers
477+
monkeypatch.setattr(helpers_module.mcal, 'get_calendar', lambda market: FakeCalendar())
478+
479+
tz = pytz.UTC
480+
start = tz.localize(dt.datetime(2025, 1, 1, 12, 0, 0))
481+
end = tz.localize(dt.datetime(2025, 1, 4, 12, 0, 0))
482+
483+
sched = get_trading_days(market="FAKE", start_date=start, end_date=end, tzinfo=tz)
484+
485+
assert isinstance(sched, pd.DataFrame)
486+
assert not sched.empty
487+
assert getattr(sched.index, 'tz', None) is not None

0 commit comments

Comments
 (0)