lumibot 4.2.7__py3-none-any.whl → 4.2.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of lumibot might be problematic. Click here for more details.
- lumibot/brokers/broker.py +33 -0
- lumibot/brokers/tradovate.py +556 -10
- lumibot/data_sources/databento_data_polars.py +35 -34
- lumibot/entities/asset.py +11 -0
- lumibot/strategies/strategy.py +46 -1
- lumibot/tools/databento_helper.py +16 -0
- lumibot/tools/databento_helper_polars.py +41 -1
- lumibot/tools/futures_roll.py +71 -20
- lumibot/tools/thetadata_helper.py +103 -37
- {lumibot-4.2.7.dist-info → lumibot-4.2.10.dist-info}/METADATA +1 -1
- {lumibot-4.2.7.dist-info → lumibot-4.2.10.dist-info}/RECORD +18 -17
- tests/test_futures_roll.py +20 -0
- tests/test_strategy_close_position.py +83 -0
- tests/test_thetadata_helper.py +121 -4
- tests/test_tradovate.py +293 -0
- {lumibot-4.2.7.dist-info → lumibot-4.2.10.dist-info}/WHEEL +0 -0
- {lumibot-4.2.7.dist-info → lumibot-4.2.10.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.7.dist-info → lumibot-4.2.10.dist-info}/top_level.txt +0 -0
|
@@ -24,8 +24,13 @@ except ImportError: # pragma: no cover - optional dependency
|
|
|
24
24
|
from .data_source import DataSource
|
|
25
25
|
from .polars_mixin import PolarsMixin
|
|
26
26
|
from lumibot.entities import Asset, Bars, Quote
|
|
27
|
-
from lumibot.tools import databento_helper_polars
|
|
28
|
-
from lumibot.tools.databento_helper_polars import
|
|
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
|
+
_ensure_polars_datetime_precision as _ensure_polars_precision,
|
|
31
|
+
_format_futures_symbol_for_databento,
|
|
32
|
+
_generate_databento_symbol_alternatives,
|
|
33
|
+
)
|
|
29
34
|
from lumibot.tools.lumibot_logger import get_logger
|
|
30
35
|
|
|
31
36
|
logger = get_logger(__name__)
|
|
@@ -455,6 +460,7 @@ class DataBentoDataPolars(PolarsMixin, DataSource):
|
|
|
455
460
|
|
|
456
461
|
df = pl.DataFrame(tail_bars).sort('datetime')
|
|
457
462
|
df = _ensure_polars_tz(df)
|
|
463
|
+
df = _ensure_polars_precision(df)
|
|
458
464
|
logger.debug(f"[DATABENTO][LIVE] Collected {len(df)} tail bars after {after_dt}")
|
|
459
465
|
return df
|
|
460
466
|
|
|
@@ -512,38 +518,30 @@ class DataBentoDataPolars(PolarsMixin, DataSource):
|
|
|
512
518
|
|
|
513
519
|
def _resolve_futures_symbol(self, asset: Asset, reference_date: datetime = None) -> str:
|
|
514
520
|
"""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
|
-
|
|
521
|
+
if asset.asset_type not in [Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE]:
|
|
544
522
|
return asset.symbol
|
|
545
|
-
|
|
546
|
-
|
|
523
|
+
|
|
524
|
+
ref_dt = reference_date or datetime.now(timezone.utc)
|
|
525
|
+
|
|
526
|
+
if asset.asset_type == Asset.AssetType.FUTURE and asset.expiration:
|
|
527
|
+
return _format_futures_symbol_for_databento(asset, reference_date=reference_date)
|
|
528
|
+
|
|
529
|
+
if asset.asset_type == Asset.AssetType.CONT_FUTURE:
|
|
530
|
+
resolved_contract = futures_roll.resolve_symbol_for_datetime(
|
|
531
|
+
asset,
|
|
532
|
+
ref_dt,
|
|
533
|
+
year_digits=2,
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
temp_asset = Asset(asset.symbol, Asset.AssetType.CONT_FUTURE)
|
|
537
|
+
resolved_contract = futures_roll.resolve_symbol_for_datetime(
|
|
538
|
+
temp_asset,
|
|
539
|
+
ref_dt,
|
|
540
|
+
year_digits=2,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
databento_symbol = _generate_databento_symbol_alternatives(asset.symbol, resolved_contract)
|
|
544
|
+
return databento_symbol[0] if databento_symbol else resolved_contract
|
|
547
545
|
|
|
548
546
|
def get_historical_prices(
|
|
549
547
|
self,
|
|
@@ -633,6 +631,8 @@ class DataBentoDataPolars(PolarsMixin, DataSource):
|
|
|
633
631
|
|
|
634
632
|
df = _ensure_polars_tz(df)
|
|
635
633
|
tail_df = _ensure_polars_tz(tail_df)
|
|
634
|
+
df = _ensure_polars_precision(df)
|
|
635
|
+
tail_df = _ensure_polars_precision(tail_df)
|
|
636
636
|
|
|
637
637
|
# Only keep columns that exist in both dataframes
|
|
638
638
|
common_columns = [col for col in df.columns if col in tail_df.columns]
|
|
@@ -678,9 +678,10 @@ class DataBentoDataPolars(PolarsMixin, DataSource):
|
|
|
678
678
|
except Exception as e:
|
|
679
679
|
logger.warning(f"[DATABENTO][MERGE] Failed to merge live tail: {e}")
|
|
680
680
|
|
|
681
|
-
# Trim to requested length
|
|
681
|
+
# Trim to requested length and normalize datetime metadata
|
|
682
682
|
df = df.tail(length)
|
|
683
683
|
df = _ensure_polars_tz(df)
|
|
684
|
+
df = _ensure_polars_precision(df)
|
|
684
685
|
return Bars(
|
|
685
686
|
df=df,
|
|
686
687
|
source=self.SOURCE,
|
lumibot/entities/asset.py
CHANGED
|
@@ -702,6 +702,17 @@ class Asset:
|
|
|
702
702
|
if c not in seen:
|
|
703
703
|
seen.add(c)
|
|
704
704
|
unique.append(c)
|
|
705
|
+
|
|
706
|
+
# Ensure the resolved primary contract appears first so downstream
|
|
707
|
+
# consumers try the preferred contract before fallbacks.
|
|
708
|
+
try:
|
|
709
|
+
primary = self.resolve_continuous_futures_contract(reference_date)
|
|
710
|
+
except ValueError:
|
|
711
|
+
primary = None
|
|
712
|
+
if primary and primary in unique:
|
|
713
|
+
unique.remove(primary)
|
|
714
|
+
unique.insert(0, primary)
|
|
715
|
+
|
|
705
716
|
return unique
|
|
706
717
|
|
|
707
718
|
def _determine_continuous_contract_components(
|
lumibot/strategies/strategy.py
CHANGED
|
@@ -1830,6 +1830,21 @@ class Strategy(_Strategy):
|
|
|
1830
1830
|
>>> self.cancel_open_orders()
|
|
1831
1831
|
|
|
1832
1832
|
"""
|
|
1833
|
+
try:
|
|
1834
|
+
tracked_orders = self.broker.get_tracked_orders(self.name)
|
|
1835
|
+
active_orders = [order for order in tracked_orders if order.is_active()]
|
|
1836
|
+
order_ids = [
|
|
1837
|
+
getattr(order, "identifier", None)
|
|
1838
|
+
or getattr(order, "id", None)
|
|
1839
|
+
or getattr(order, "order_id", None)
|
|
1840
|
+
for order in active_orders
|
|
1841
|
+
]
|
|
1842
|
+
self.log_message(
|
|
1843
|
+
f"cancel_open_orders -> active={len(active_orders)} ids={order_ids}",
|
|
1844
|
+
color="yellow",
|
|
1845
|
+
)
|
|
1846
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
1847
|
+
self.logger.exception("Failed to enumerate open orders before cancellation: %s", exc)
|
|
1833
1848
|
return self.broker.cancel_open_orders(self.name)
|
|
1834
1849
|
|
|
1835
1850
|
def modify_order(self, order: Order, limit_price: Union[float, None] = None, stop_price: Union[float, None] = None):
|
|
@@ -1917,7 +1932,37 @@ class Strategy(_Strategy):
|
|
|
1917
1932
|
- If no open position exists, this method does nothing.
|
|
1918
1933
|
"""
|
|
1919
1934
|
asset_obj = self._sanitize_user_asset(asset)
|
|
1920
|
-
|
|
1935
|
+
|
|
1936
|
+
try:
|
|
1937
|
+
position = self.get_position(asset_obj)
|
|
1938
|
+
qty = getattr(position, "quantity", 0) if position else 0
|
|
1939
|
+
self.log_message(
|
|
1940
|
+
f"close_position -> asset={asset_obj.symbol if hasattr(asset_obj, 'symbol') else asset_obj}, "
|
|
1941
|
+
f"fraction={fraction}, current_qty={qty}",
|
|
1942
|
+
color="yellow",
|
|
1943
|
+
)
|
|
1944
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
1945
|
+
self.logger.exception("Unable to inspect position prior to close_position: %s", exc)
|
|
1946
|
+
|
|
1947
|
+
target_asset = getattr(position, "asset", asset_obj) if position is not None else asset_obj
|
|
1948
|
+
if target_asset is not asset_obj:
|
|
1949
|
+
try:
|
|
1950
|
+
self.log_message(
|
|
1951
|
+
f"close_position -> resolved continuous asset {asset_obj.symbol if hasattr(asset_obj, 'symbol') else asset_obj} "
|
|
1952
|
+
f"to contract {target_asset.symbol if hasattr(target_asset, 'symbol') else target_asset}",
|
|
1953
|
+
color="yellow",
|
|
1954
|
+
)
|
|
1955
|
+
except Exception: # pragma: no cover - best effort logging
|
|
1956
|
+
pass
|
|
1957
|
+
result = self.broker.close_position(self.name, target_asset, fraction)
|
|
1958
|
+
if result is None:
|
|
1959
|
+
self.log_message("close_position -> broker returned None (no order submitted)", color="yellow")
|
|
1960
|
+
else:
|
|
1961
|
+
order_id = getattr(result, "identifier", None) or getattr(result, "id", None) or getattr(result, "order_id", None)
|
|
1962
|
+
self.log_message(
|
|
1963
|
+
f"close_position -> broker submitted order {order_id} (type={type(result).__name__})",
|
|
1964
|
+
color="yellow",
|
|
1965
|
+
)
|
|
1921
1966
|
if result is not None:
|
|
1922
1967
|
return result
|
|
1923
1968
|
|
|
@@ -784,6 +784,22 @@ def _fetch_and_update_futures_multiplier(
|
|
|
784
784
|
logger.info(f"[MULTIPLIER] AFTER update: asset.multiplier = {asset.multiplier}")
|
|
785
785
|
else:
|
|
786
786
|
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}")
|
|
787
803
|
else:
|
|
788
804
|
logger.error(f"[MULTIPLIER] ✗ Failed to get definition from DataBento for {resolved_symbol}")
|
|
789
805
|
|
|
@@ -863,6 +863,22 @@ def _fetch_and_update_futures_multiplier(
|
|
|
863
863
|
logger.debug(f"[MULTIPLIER] AFTER update: asset.multiplier = {asset.multiplier}")
|
|
864
864
|
else:
|
|
865
865
|
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}")
|
|
866
882
|
else:
|
|
867
883
|
logger.error(f"[MULTIPLIER] ✗ Failed to get definition from DataBento for {resolved_symbol}")
|
|
868
884
|
|
|
@@ -1046,8 +1062,10 @@ def get_price_data_from_databento(
|
|
|
1046
1062
|
logger.warning(f"No datetime column found after reset_index, using first column: {first_col}")
|
|
1047
1063
|
combined_reset = combined_reset.rename(columns={first_col: 'datetime'})
|
|
1048
1064
|
|
|
1049
|
-
# Convert to polars
|
|
1065
|
+
# Convert to polars and normalize datetime metadata
|
|
1050
1066
|
combined_polars = pl.from_pandas(combined_reset)
|
|
1067
|
+
combined_polars = _ensure_polars_datetime_timezone(combined_polars)
|
|
1068
|
+
combined_polars = _ensure_polars_datetime_precision(combined_polars)
|
|
1051
1069
|
|
|
1052
1070
|
return combined_polars
|
|
1053
1071
|
|
|
@@ -1239,6 +1257,8 @@ def _generate_databento_symbol_alternatives(base_symbol: str, resolved_contract:
|
|
|
1239
1257
|
# Fallback for unexpected contract format - use original contract
|
|
1240
1258
|
logger.warning(f"Unexpected contract format: {resolved_contract}, using as-is")
|
|
1241
1259
|
return [resolved_contract]
|
|
1260
|
+
|
|
1261
|
+
|
|
1242
1262
|
def _ensure_polars_datetime_timezone(df: pl.DataFrame, column: str = "datetime") -> pl.DataFrame:
|
|
1243
1263
|
"""Ensure the specified datetime column is timezone-aware (defaults to UTC)."""
|
|
1244
1264
|
if column not in df.columns:
|
|
@@ -1251,6 +1271,26 @@ def _ensure_polars_datetime_timezone(df: pl.DataFrame, column: str = "datetime")
|
|
|
1251
1271
|
return df
|
|
1252
1272
|
|
|
1253
1273
|
|
|
1274
|
+
def _ensure_polars_datetime_precision(
|
|
1275
|
+
df: pl.DataFrame,
|
|
1276
|
+
column: str = "datetime",
|
|
1277
|
+
time_unit: str = "ns",
|
|
1278
|
+
) -> pl.DataFrame:
|
|
1279
|
+
"""Normalize the precision of a datetime column (default nanosecond)."""
|
|
1280
|
+
if column not in df.columns:
|
|
1281
|
+
return df
|
|
1282
|
+
col_dtype = df.schema.get(column)
|
|
1283
|
+
if not isinstance(col_dtype, pl.Datetime):
|
|
1284
|
+
return df
|
|
1285
|
+
target_dtype = pl.Datetime(
|
|
1286
|
+
time_unit=time_unit,
|
|
1287
|
+
time_zone=col_dtype.time_zone or "UTC",
|
|
1288
|
+
)
|
|
1289
|
+
if col_dtype == target_dtype:
|
|
1290
|
+
return df
|
|
1291
|
+
return df.with_columns(pl.col(column).cast(target_dtype))
|
|
1292
|
+
|
|
1293
|
+
|
|
1254
1294
|
def get_price_data_from_databento_polars(*args, **kwargs):
|
|
1255
1295
|
"""Compatibility helper that forces polars return type."""
|
|
1256
1296
|
kwargs.setdefault("return_polars", True)
|
lumibot/tools/futures_roll.py
CHANGED
|
@@ -30,13 +30,32 @@ _FUTURES_MONTH_CODES: Dict[int, str] = {
|
|
|
30
30
|
class RollRule:
|
|
31
31
|
offset_business_days: int
|
|
32
32
|
anchor: str
|
|
33
|
+
contract_months: Optional[Tuple[int, ...]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DEFAULT_CONTRACT_MONTHS: Tuple[int, ...] = (3, 6, 9, 12)
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
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)
|
|
37
41
|
for symbol in {"ES", "MES", "NQ", "MNQ", "YM", "MYM"}
|
|
38
42
|
}
|
|
39
43
|
|
|
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
|
+
|
|
40
59
|
YearMonth = Tuple[int, int]
|
|
41
60
|
|
|
42
61
|
|
|
@@ -72,9 +91,32 @@ def _subtract_business_days(dt: datetime, days: int) -> datetime:
|
|
|
72
91
|
return result
|
|
73
92
|
|
|
74
93
|
|
|
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
|
+
|
|
75
115
|
def _calculate_roll_trigger(year: int, month: int, rule: RollRule) -> datetime:
|
|
76
116
|
if rule.anchor == "third_friday":
|
|
77
117
|
anchor = _third_friday(year, month)
|
|
118
|
+
elif rule.anchor == "third_last_business_day":
|
|
119
|
+
anchor = _third_last_business_day(year, month)
|
|
78
120
|
else:
|
|
79
121
|
anchor = _to_timezone(datetime(year, month, 15))
|
|
80
122
|
if rule.offset_business_days <= 0:
|
|
@@ -82,15 +124,28 @@ def _calculate_roll_trigger(year: int, month: int, rule: RollRule) -> datetime:
|
|
|
82
124
|
return _subtract_business_days(anchor, rule.offset_business_days)
|
|
83
125
|
|
|
84
126
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
|
91
139
|
return next_year, next_month
|
|
92
140
|
|
|
93
141
|
|
|
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
|
+
|
|
94
149
|
def _legacy_mid_month(reference_date: datetime) -> YearMonth:
|
|
95
150
|
quarter_months = [3, 6, 9, 12]
|
|
96
151
|
year = reference_date.year
|
|
@@ -118,27 +173,22 @@ def determine_contract_year_month(symbol: str, reference_date: Optional[datetime
|
|
|
118
173
|
ref = _normalize_reference_date(reference_date)
|
|
119
174
|
symbol_upper = symbol.upper()
|
|
120
175
|
rule = ROLL_RULES.get(symbol_upper)
|
|
121
|
-
|
|
122
|
-
quarter_months = [3, 6, 9, 12]
|
|
123
176
|
year = ref.year
|
|
124
177
|
month = ref.month
|
|
125
178
|
|
|
126
179
|
if rule is None:
|
|
127
180
|
return _legacy_mid_month(ref)
|
|
128
181
|
|
|
129
|
-
|
|
182
|
+
contract_months = _get_contract_months(rule)
|
|
183
|
+
|
|
184
|
+
if month in contract_months:
|
|
130
185
|
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)
|
|
134
186
|
else:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|
|
142
192
|
|
|
143
193
|
return target_year, target_month
|
|
144
194
|
|
|
@@ -201,6 +251,7 @@ def build_roll_schedule(asset, start: datetime, end: datetime, year_digits: int
|
|
|
201
251
|
|
|
202
252
|
symbol_upper = asset.symbol.upper()
|
|
203
253
|
rule = ROLL_RULES.get(symbol_upper)
|
|
254
|
+
contract_months = _get_contract_months(rule)
|
|
204
255
|
|
|
205
256
|
schedule = []
|
|
206
257
|
cursor = start
|
|
@@ -26,6 +26,7 @@ CONNECTION_RETRY_SLEEP = 1.0
|
|
|
26
26
|
CONNECTION_MAX_RETRIES = 60
|
|
27
27
|
BOOT_GRACE_PERIOD = 5.0
|
|
28
28
|
MAX_RESTART_ATTEMPTS = 3
|
|
29
|
+
MAX_TERMINAL_RESTART_CYCLES = 3
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
def _resolve_asset_folder(asset_obj: Asset) -> str:
|
|
@@ -43,6 +44,12 @@ THETA_DATA_PROCESS = None
|
|
|
43
44
|
THETA_DATA_PID = None
|
|
44
45
|
THETA_DATA_LOG_HANDLE = None
|
|
45
46
|
|
|
47
|
+
|
|
48
|
+
class ThetaDataConnectionError(RuntimeError):
|
|
49
|
+
"""Raised when ThetaTerminal cannot reconnect to Theta Data after multiple restarts."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
46
53
|
def reset_connection_diagnostics():
|
|
47
54
|
"""Reset ThetaData connection counters (useful for tests)."""
|
|
48
55
|
CONNECTION_DIAGNOSTICS.update({
|
|
@@ -50,6 +57,7 @@ def reset_connection_diagnostics():
|
|
|
50
57
|
"start_terminal_calls": 0,
|
|
51
58
|
"network_requests": 0,
|
|
52
59
|
"placeholder_writes": 0,
|
|
60
|
+
"terminal_restarts": 0,
|
|
53
61
|
})
|
|
54
62
|
|
|
55
63
|
|
|
@@ -198,6 +206,7 @@ CONNECTION_DIAGNOSTICS = {
|
|
|
198
206
|
"start_terminal_calls": 0,
|
|
199
207
|
"network_requests": 0,
|
|
200
208
|
"placeholder_writes": 0,
|
|
209
|
+
"terminal_restarts": 0,
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
|
|
@@ -1395,7 +1404,6 @@ def check_connection(username: str, password: str, wait_for_connection: bool = F
|
|
|
1395
1404
|
|
|
1396
1405
|
max_retries = CONNECTION_MAX_RETRIES
|
|
1397
1406
|
sleep_interval = CONNECTION_RETRY_SLEEP
|
|
1398
|
-
restart_attempts = 0
|
|
1399
1407
|
client = None
|
|
1400
1408
|
|
|
1401
1409
|
def probe_status() -> Optional[str]:
|
|
@@ -1435,47 +1443,72 @@ def check_connection(username: str, password: str, wait_for_connection: bool = F
|
|
|
1435
1443
|
logger.debug("ThetaTerminal running but not yet CONNECTED; waiting for status.")
|
|
1436
1444
|
return check_connection(username=username, password=password, wait_for_connection=True)
|
|
1437
1445
|
|
|
1438
|
-
|
|
1439
|
-
connected = False
|
|
1440
|
-
|
|
1441
|
-
while counter < max_retries:
|
|
1442
|
-
status_text = probe_status()
|
|
1443
|
-
if status_text == "CONNECTED":
|
|
1444
|
-
if counter:
|
|
1445
|
-
logger.info("ThetaTerminal connected after %s attempt(s).", counter + 1)
|
|
1446
|
-
connected = True
|
|
1447
|
-
break
|
|
1448
|
-
elif status_text == "DISCONNECTED":
|
|
1449
|
-
logger.debug("ThetaTerminal reports DISCONNECTED; will retry.")
|
|
1450
|
-
elif status_text is not None:
|
|
1451
|
-
logger.debug(f"ThetaTerminal returned unexpected status: {status_text}")
|
|
1452
|
-
|
|
1453
|
-
if not is_process_alive():
|
|
1454
|
-
if restart_attempts >= MAX_RESTART_ATTEMPTS:
|
|
1455
|
-
logger.error("ThetaTerminal not running after %s restart attempts.", restart_attempts)
|
|
1456
|
-
break
|
|
1457
|
-
restart_attempts += 1
|
|
1458
|
-
logger.warning("ThetaTerminal process is not running (restart #%s).", restart_attempts)
|
|
1459
|
-
client = start_theta_data_client(username=username, password=password)
|
|
1460
|
-
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1461
|
-
counter = 0
|
|
1462
|
-
continue
|
|
1446
|
+
total_restart_cycles = 0
|
|
1463
1447
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1448
|
+
while True:
|
|
1449
|
+
counter = 0
|
|
1450
|
+
restart_attempts = 0
|
|
1451
|
+
|
|
1452
|
+
while counter < max_retries:
|
|
1453
|
+
status_text = probe_status()
|
|
1454
|
+
if status_text == "CONNECTED":
|
|
1455
|
+
if counter or total_restart_cycles:
|
|
1456
|
+
logger.info(
|
|
1457
|
+
"ThetaTerminal connected after %s attempt(s) (restart cycles=%s).",
|
|
1458
|
+
counter + 1,
|
|
1459
|
+
total_restart_cycles,
|
|
1460
|
+
)
|
|
1461
|
+
return client, True
|
|
1462
|
+
elif status_text == "DISCONNECTED":
|
|
1463
|
+
logger.debug("ThetaTerminal reports DISCONNECTED; will retry.")
|
|
1464
|
+
elif status_text is not None:
|
|
1465
|
+
logger.debug(f"ThetaTerminal returned unexpected status: {status_text}")
|
|
1466
|
+
|
|
1467
|
+
if not is_process_alive():
|
|
1468
|
+
if restart_attempts >= MAX_RESTART_ATTEMPTS:
|
|
1469
|
+
logger.error("ThetaTerminal not running after %s restart attempts.", restart_attempts)
|
|
1470
|
+
break
|
|
1471
|
+
restart_attempts += 1
|
|
1472
|
+
logger.warning("ThetaTerminal process is not running (restart #%s).", restart_attempts)
|
|
1473
|
+
client = start_theta_data_client(username=username, password=password)
|
|
1474
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1475
|
+
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1476
|
+
counter = 0
|
|
1477
|
+
continue
|
|
1468
1478
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1479
|
+
counter += 1
|
|
1480
|
+
if counter % 10 == 0:
|
|
1481
|
+
logger.info("Waiting for ThetaTerminal connection (attempt %s/%s).", counter, max_retries)
|
|
1482
|
+
time.sleep(sleep_interval)
|
|
1483
|
+
|
|
1484
|
+
total_restart_cycles += 1
|
|
1485
|
+
if total_restart_cycles > MAX_TERMINAL_RESTART_CYCLES:
|
|
1486
|
+
logger.error(
|
|
1487
|
+
"Unable to connect to Theta Data after %s restart cycle(s) (%s attempts each).",
|
|
1488
|
+
MAX_TERMINAL_RESTART_CYCLES,
|
|
1489
|
+
max_retries,
|
|
1490
|
+
)
|
|
1491
|
+
raise ThetaDataConnectionError(
|
|
1492
|
+
f"Unable to connect to Theta Data after {MAX_TERMINAL_RESTART_CYCLES} restart cycle(s)."
|
|
1493
|
+
)
|
|
1471
1494
|
|
|
1472
|
-
|
|
1495
|
+
logger.warning(
|
|
1496
|
+
"ThetaTerminal still disconnected after %s attempts; restarting (cycle %s/%s).",
|
|
1497
|
+
max_retries,
|
|
1498
|
+
total_restart_cycles,
|
|
1499
|
+
MAX_TERMINAL_RESTART_CYCLES,
|
|
1500
|
+
)
|
|
1501
|
+
client = start_theta_data_client(username=username, password=password)
|
|
1502
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1503
|
+
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1473
1504
|
|
|
1474
1505
|
|
|
1475
1506
|
def get_request(url: str, headers: dict, querystring: dict, username: str, password: str):
|
|
1476
1507
|
all_responses = []
|
|
1477
1508
|
next_page_url = None
|
|
1478
1509
|
page_count = 0
|
|
1510
|
+
consecutive_disconnects = 0
|
|
1511
|
+
restart_budget = 3
|
|
1479
1512
|
|
|
1480
1513
|
# Lightweight liveness probe before issuing the request
|
|
1481
1514
|
check_connection(username=username, password=password, wait_for_connection=False)
|
|
@@ -1498,25 +1531,52 @@ def get_request(url: str, headers: dict, querystring: dict, username: str, passw
|
|
|
1498
1531
|
)
|
|
1499
1532
|
|
|
1500
1533
|
response = requests.get(request_url, headers=headers, params=request_params)
|
|
1534
|
+
status_code = response.status_code
|
|
1501
1535
|
# Status code 472 means "No data" - this is valid, return None
|
|
1502
|
-
if
|
|
1536
|
+
if status_code == 472:
|
|
1503
1537
|
logger.warning(f"No data available for request: {response.text[:200]}")
|
|
1504
1538
|
# DEBUG-LOG: API response - no data
|
|
1505
1539
|
logger.debug(
|
|
1506
1540
|
"[THETA][DEBUG][API][RESPONSE] status=472 result=NO_DATA"
|
|
1507
1541
|
)
|
|
1542
|
+
consecutive_disconnects = 0
|
|
1508
1543
|
return None
|
|
1544
|
+
elif status_code == 474:
|
|
1545
|
+
consecutive_disconnects += 1
|
|
1546
|
+
logger.warning("Received 474 from Theta Data (attempt %s): %s", counter + 1, response.text[:200])
|
|
1547
|
+
if consecutive_disconnects >= 2:
|
|
1548
|
+
if restart_budget <= 0:
|
|
1549
|
+
logger.error("Restart budget exhausted after repeated 474 responses.")
|
|
1550
|
+
raise ValueError("Cannot connect to Theta Data!")
|
|
1551
|
+
logger.warning(
|
|
1552
|
+
"Restarting ThetaTerminal after %s consecutive 474 responses (restart budget remaining %s).",
|
|
1553
|
+
consecutive_disconnects,
|
|
1554
|
+
restart_budget - 1,
|
|
1555
|
+
)
|
|
1556
|
+
restart_budget -= 1
|
|
1557
|
+
start_theta_data_client(username=username, password=password)
|
|
1558
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1559
|
+
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1560
|
+
time.sleep(max(BOOT_GRACE_PERIOD, CONNECTION_RETRY_SLEEP))
|
|
1561
|
+
consecutive_disconnects = 0
|
|
1562
|
+
counter = 0
|
|
1563
|
+
else:
|
|
1564
|
+
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1565
|
+
time.sleep(CONNECTION_RETRY_SLEEP)
|
|
1566
|
+
continue
|
|
1509
1567
|
# If status code is not 200, then we are not connected
|
|
1510
|
-
elif
|
|
1511
|
-
logger.warning(f"Non-200 status code {
|
|
1568
|
+
elif status_code != 200:
|
|
1569
|
+
logger.warning(f"Non-200 status code {status_code}: {response.text[:200]}")
|
|
1512
1570
|
# DEBUG-LOG: API response - error
|
|
1513
1571
|
logger.debug(
|
|
1514
1572
|
"[THETA][DEBUG][API][RESPONSE] status=%d result=ERROR",
|
|
1515
|
-
|
|
1573
|
+
status_code
|
|
1516
1574
|
)
|
|
1517
1575
|
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1576
|
+
consecutive_disconnects = 0
|
|
1518
1577
|
else:
|
|
1519
1578
|
json_resp = response.json()
|
|
1579
|
+
consecutive_disconnects = 0
|
|
1520
1580
|
|
|
1521
1581
|
# DEBUG-LOG: API response - success
|
|
1522
1582
|
response_rows = len(json_resp.get("response", [])) if isinstance(json_resp.get("response"), list) else 0
|
|
@@ -1542,6 +1602,12 @@ def get_request(url: str, headers: dict, querystring: dict, username: str, passw
|
|
|
1542
1602
|
else:
|
|
1543
1603
|
break
|
|
1544
1604
|
|
|
1605
|
+
except ThetaDataConnectionError as exc:
|
|
1606
|
+
logger.error("Theta Data connection failed after supervised restarts: %s", exc)
|
|
1607
|
+
raise
|
|
1608
|
+
except ValueError:
|
|
1609
|
+
# Preserve deliberate ValueError signals (e.g., ThetaData error_type responses)
|
|
1610
|
+
raise
|
|
1545
1611
|
except Exception as e:
|
|
1546
1612
|
logger.warning(f"Exception during request (attempt {counter + 1}): {e}")
|
|
1547
1613
|
check_connection(username=username, password=password, wait_for_connection=True)
|