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.

@@ -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 _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
+ _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
- return asset.symbol
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(
@@ -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
- result = self.broker.close_position(self.name, asset_obj, fraction)
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)
@@ -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 _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)
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
- if month in quarter_months:
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
- 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)
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
- counter = 0
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
- counter += 1
1465
- if counter % 10 == 0:
1466
- logger.info("Waiting for ThetaTerminal connection (attempt %s/%s).", counter, max_retries)
1467
- time.sleep(sleep_interval)
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
- if not connected and counter >= max_retries:
1470
- logger.error("Cannot connect to Theta Data after %s attempts.", counter)
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
- return client, connected
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 response.status_code == 472:
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 response.status_code != 200:
1511
- logger.warning(f"Non-200 status code {response.status_code}: {response.text[:200]}")
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
- response.status_code
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumibot
3
- Version: 4.2.7
3
+ Version: 4.2.10
4
4
  Summary: Backtesting and Trading Library, Made by Lumiwealth
5
5
  Home-page: https://github.com/Lumiwealth/lumibot
6
6
  Author: Robert Grzesik