Skip to content

Commit c3aef95

Browse files
authored
Merge pull request #891 from Lumiwealth/feature/databento-gc-roll
Align DataBento futures rolling with COMEX schedule
2 parents f79ddad + 577c6b4 commit c3aef95

File tree

5 files changed

+152
-53
lines changed

5 files changed

+152
-53
lines changed

lumibot/data_sources/databento_data_polars.py

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@
2424
from .data_source import DataSource
2525
from .polars_mixin import PolarsMixin
2626
from lumibot.entities import Asset, Bars, Quote
27-
from lumibot.tools import databento_helper_polars
28-
from lumibot.tools.databento_helper_polars import _ensure_polars_datetime_timezone as _ensure_polars_tz
27+
from lumibot.tools import databento_helper_polars, futures_roll
28+
from lumibot.tools.databento_helper_polars import (
29+
_ensure_polars_datetime_timezone as _ensure_polars_tz,
30+
_format_futures_symbol_for_databento,
31+
_generate_databento_symbol_alternatives,
32+
)
2933
from lumibot.tools.lumibot_logger import get_logger
3034

3135
logger = get_logger(__name__)
@@ -512,38 +516,30 @@ def _warn_stale(self, symbol: str, context: str):
512516

513517
def _resolve_futures_symbol(self, asset: Asset, reference_date: datetime = None) -> str:
514518
"""Resolve asset to specific futures contract symbol"""
515-
if asset.asset_type in [Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE]:
516-
# For continuous futures, resolve to specific contract
517-
if asset.asset_type == Asset.AssetType.CONT_FUTURE:
518-
if hasattr(asset, 'resolve_continuous_futures_contract'):
519-
return asset.resolve_continuous_futures_contract(
520-
reference_date=reference_date,
521-
year_digits=1,
522-
)
523-
524-
# Manual resolution for common futures
525-
symbol = asset.symbol.upper()
526-
month = reference_date.month if reference_date else datetime.now().month
527-
year = reference_date.year if reference_date else datetime.now().year
528-
529-
# Quarterly contracts
530-
if month <= 3:
531-
month_code = 'H'
532-
elif month <= 6:
533-
month_code = 'M'
534-
elif month <= 9:
535-
month_code = 'U'
536-
else:
537-
month_code = 'Z'
538-
539-
year_digit = year % 10
540-
541-
if symbol in ["ES", "NQ", "RTY", "YM", "MES", "MNQ", "MYM", "M2K", "CL", "GC", "SI"]:
542-
return f"{symbol}{month_code}{year_digit}"
543-
519+
if asset.asset_type not in [Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE]:
544520
return asset.symbol
545-
546-
return asset.symbol
521+
522+
ref_dt = reference_date or datetime.now(timezone.utc)
523+
524+
if asset.asset_type == Asset.AssetType.FUTURE and asset.expiration:
525+
return _format_futures_symbol_for_databento(asset, reference_date=reference_date)
526+
527+
if asset.asset_type == Asset.AssetType.CONT_FUTURE:
528+
resolved_contract = futures_roll.resolve_symbol_for_datetime(
529+
asset,
530+
ref_dt,
531+
year_digits=2,
532+
)
533+
else:
534+
temp_asset = Asset(asset.symbol, Asset.AssetType.CONT_FUTURE)
535+
resolved_contract = futures_roll.resolve_symbol_for_datetime(
536+
temp_asset,
537+
ref_dt,
538+
year_digits=2,
539+
)
540+
541+
databento_symbol = _generate_databento_symbol_alternatives(asset.symbol, resolved_contract)
542+
return databento_symbol[0] if databento_symbol else resolved_contract
547543

548544
def get_historical_prices(
549545
self,

lumibot/tools/databento_helper.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,22 @@ def _fetch_and_update_futures_multiplier(
784784
logger.info(f"[MULTIPLIER] AFTER update: asset.multiplier = {asset.multiplier}")
785785
else:
786786
logger.error(f"[MULTIPLIER] ✗ Definition missing unit_of_measure_qty field! Fields: {list(definition.keys())}")
787+
788+
if (
789+
asset.asset_type == Asset.AssetType.FUTURE
790+
and getattr(asset, "expiration", None) in (None, "")
791+
):
792+
expiration_value = definition.get('expiration')
793+
if expiration_value:
794+
try:
795+
expiration_ts = pd.to_datetime(expiration_value, utc=True, errors='coerce')
796+
except Exception as exc:
797+
logger.debug(f"[MULTIPLIER] Unable to parse expiration '{expiration_value}' for {asset.symbol}: {exc}")
798+
expiration_ts = None
799+
800+
if expiration_ts is not None and not pd.isna(expiration_ts):
801+
asset.expiration = expiration_ts.date()
802+
logger.debug(f"[MULTIPLIER] ✓ Captured expiration for {asset.symbol}: {asset.expiration}")
787803
else:
788804
logger.error(f"[MULTIPLIER] ✗ Failed to get definition from DataBento for {resolved_symbol}")
789805

lumibot/tools/databento_helper_polars.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,22 @@ def _fetch_and_update_futures_multiplier(
863863
logger.debug(f"[MULTIPLIER] AFTER update: asset.multiplier = {asset.multiplier}")
864864
else:
865865
logger.error(f"[MULTIPLIER] ✗ Definition missing unit_of_measure_qty field! Fields: {list(definition.keys())}")
866+
867+
if (
868+
asset.asset_type == Asset.AssetType.FUTURE
869+
and getattr(asset, "expiration", None) in (None, "")
870+
):
871+
expiration_value = definition.get('expiration')
872+
if expiration_value:
873+
try:
874+
expiration_ts = pd.to_datetime(expiration_value, utc=True, errors='coerce')
875+
except Exception as exc:
876+
logger.debug(f"[MULTIPLIER] Unable to parse expiration '{expiration_value}' for {asset.symbol}: {exc}")
877+
expiration_ts = None
878+
879+
if expiration_ts is not None and not pd.isna(expiration_ts):
880+
asset.expiration = expiration_ts.date()
881+
logger.debug(f"[MULTIPLIER] ✓ Captured expiration for {asset.symbol}: {asset.expiration}")
866882
else:
867883
logger.error(f"[MULTIPLIER] ✗ Failed to get definition from DataBento for {resolved_symbol}")
868884

lumibot/tools/futures_roll.py

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,32 @@
3030
class RollRule:
3131
offset_business_days: int
3232
anchor: str
33+
contract_months: Optional[Tuple[int, ...]] = None
34+
35+
36+
_DEFAULT_CONTRACT_MONTHS: Tuple[int, ...] = (3, 6, 9, 12)
3337

3438

3539
ROLL_RULES: Dict[str, RollRule] = {
36-
symbol: RollRule(offset_business_days=8, anchor="third_friday")
40+
symbol: RollRule(offset_business_days=8, anchor="third_friday", contract_months=_DEFAULT_CONTRACT_MONTHS)
3741
for symbol in {"ES", "MES", "NQ", "MNQ", "YM", "MYM"}
3842
}
3943

44+
ROLL_RULES.update(
45+
{
46+
"GC": RollRule(
47+
offset_business_days=7,
48+
anchor="third_last_business_day",
49+
contract_months=(2, 4, 6, 8, 10, 12),
50+
),
51+
"SI": RollRule(
52+
offset_business_days=7,
53+
anchor="third_last_business_day",
54+
contract_months=(1, 3, 5, 7, 9, 12),
55+
),
56+
}
57+
)
58+
4059
YearMonth = Tuple[int, int]
4160

4261

@@ -72,25 +91,61 @@ def _subtract_business_days(dt: datetime, days: int) -> datetime:
7291
return result
7392

7493

94+
def _third_last_business_day(year: int, month: int) -> datetime:
95+
if month == 12:
96+
next_month = 1
97+
next_year = year + 1
98+
else:
99+
next_month = month + 1
100+
next_year = year
101+
102+
last_day = _to_timezone(datetime(next_year, next_month, 1)) - timedelta(days=1)
103+
104+
remaining = 3
105+
cursor = last_day
106+
while remaining > 0:
107+
if cursor.weekday() < 5:
108+
remaining -= 1
109+
if remaining == 0:
110+
break
111+
cursor -= timedelta(days=1)
112+
return cursor.replace(hour=0, minute=0, second=0, microsecond=0)
113+
114+
75115
def _calculate_roll_trigger(year: int, month: int, rule: RollRule) -> datetime:
76116
if rule.anchor == "third_friday":
77117
anchor = _third_friday(year, month)
118+
elif rule.anchor == "third_last_business_day":
119+
anchor = _third_last_business_day(year, month)
78120
else:
79121
anchor = _to_timezone(datetime(year, month, 15))
80122
if rule.offset_business_days <= 0:
81123
return anchor
82124
return _subtract_business_days(anchor, rule.offset_business_days)
83125

84126

85-
def _advance_quarter(current_month: int, current_year: int) -> YearMonth:
86-
quarter_months = [3, 6, 9, 12]
87-
idx = quarter_months.index(current_month)
88-
next_idx = (idx + 1) % len(quarter_months)
89-
next_month = quarter_months[next_idx]
90-
next_year = current_year + (1 if next_idx == 0 else 0)
127+
def _get_contract_months(rule: Optional[RollRule]) -> Tuple[int, ...]:
128+
if rule and rule.contract_months:
129+
return tuple(sorted(rule.contract_months))
130+
return _DEFAULT_CONTRACT_MONTHS
131+
132+
133+
def _advance_contract(current_month: int, current_year: int, months: Tuple[int, ...]) -> YearMonth:
134+
months_sorted = tuple(sorted(months))
135+
idx = months_sorted.index(current_month)
136+
next_idx = (idx + 1) % len(months_sorted)
137+
next_month = months_sorted[next_idx]
138+
next_year = current_year + (1 if next_idx <= idx else 0)
91139
return next_year, next_month
92140

93141

142+
def _select_contract(year: int, month: int, months: Tuple[int, ...]) -> YearMonth:
143+
for candidate in sorted(months):
144+
if month <= candidate:
145+
return year, candidate
146+
return year + 1, sorted(months)[0]
147+
148+
94149
def _legacy_mid_month(reference_date: datetime) -> YearMonth:
95150
quarter_months = [3, 6, 9, 12]
96151
year = reference_date.year
@@ -118,27 +173,22 @@ def determine_contract_year_month(symbol: str, reference_date: Optional[datetime
118173
ref = _normalize_reference_date(reference_date)
119174
symbol_upper = symbol.upper()
120175
rule = ROLL_RULES.get(symbol_upper)
121-
122-
quarter_months = [3, 6, 9, 12]
123176
year = ref.year
124177
month = ref.month
125178

126179
if rule is None:
127180
return _legacy_mid_month(ref)
128181

129-
if month in quarter_months:
182+
contract_months = _get_contract_months(rule)
183+
184+
if month in contract_months:
130185
target_year, target_month = year, month
131-
roll_point = _calculate_roll_trigger(target_year, target_month, rule)
132-
if ref >= roll_point:
133-
target_year, target_month = _advance_quarter(target_month, target_year)
134186
else:
135-
candidates = [m for m in quarter_months if m > month]
136-
if candidates:
137-
target_month = candidates[0]
138-
target_year = year
139-
else:
140-
target_month = quarter_months[0]
141-
target_year = year + 1
187+
target_year, target_month = _select_contract(year, month, contract_months)
188+
189+
roll_point = _calculate_roll_trigger(target_year, target_month, rule)
190+
if ref >= roll_point:
191+
target_year, target_month = _advance_contract(target_month, target_year, contract_months)
142192

143193
return target_year, target_month
144194

@@ -201,6 +251,7 @@ def build_roll_schedule(asset, start: datetime, end: datetime, year_digits: int
201251

202252
symbol_upper = asset.symbol.upper()
203253
rule = ROLL_RULES.get(symbol_upper)
254+
contract_months = _get_contract_months(rule)
204255

205256
schedule = []
206257
cursor = start

tests/test_futures_roll.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,23 @@ def test_resolve_symbols_for_range_produces_sequential_contracts():
3636

3737
symbols = futures_roll.resolve_symbols_for_range(asset, start, end, year_digits=1)
3838
assert symbols == ["MESU5", "MESZ5", "MESH6"], symbols
39+
40+
41+
def test_comex_gold_rolls_on_third_last_business_day_offset():
42+
asset_symbol = "GC"
43+
44+
year, month = futures_roll.determine_contract_year_month(asset_symbol, _dt(2025, 2, 14))
45+
assert (year, month) == (2025, 2)
46+
47+
# Seven business days before the third last business day of February 2025 is Feb 17
48+
year, month = futures_roll.determine_contract_year_month(asset_symbol, _dt(2025, 2, 17))
49+
assert (year, month) == (2025, 4)
50+
51+
52+
def test_comex_gold_symbol_sequence_uses_even_month_cycle():
53+
asset = Asset("GC", asset_type=Asset.AssetType.CONT_FUTURE)
54+
start = _dt(2025, 1, 1)
55+
end = _dt(2025, 8, 1)
56+
57+
symbols = futures_roll.resolve_symbols_for_range(asset, start, end, year_digits=1)
58+
assert symbols == ["GCG5", "GCJ5", "GCM5", "GCQ5"], symbols

0 commit comments

Comments
 (0)