lumibot 4.2.5__py3-none-any.whl → 4.2.9__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.

@@ -7,14 +7,14 @@ lumibot/backtesting/alpha_vantage_backtesting.py,sha256=LR3UNhJrdAKdvadiThVKdKrM
7
7
  lumibot/backtesting/backtesting_broker.py,sha256=C0zn__i0aoyd6u147oizRYO2Akqa5JWs8znl6siLd8Y,76003
8
8
  lumibot/backtesting/ccxt_backtesting.py,sha256=O-RjuNpx5y4f-hKKwwUbrU7hAVkGEegmnvH_9nQWhAo,246
9
9
  lumibot/backtesting/databento_backtesting.py,sha256=zQS6IaamDTQREcjViguk_y4b_HF1xyV3gUrDuHnkUUY,430
10
- lumibot/backtesting/databento_backtesting_pandas.py,sha256=kK6XXmm9veZSt-z1HBKdEzM_LkrVt2-DVfajtkTDdCI,31229
10
+ lumibot/backtesting/databento_backtesting_pandas.py,sha256=xIQ1SnPo47rxIH7hfrgWzhTuVxxG8LDklY3Z8Tu5idU,32440
11
11
  lumibot/backtesting/databento_backtesting_polars.py,sha256=qanSLBsw4aBEFVfMuwSWzTDtaXQlOfcrvp6ERLWkRVo,45786
12
12
  lumibot/backtesting/fix_debug.py,sha256=ETHl-O38xK9JOQC2x8IZVJRCQrR1cokk2Vb4vpGMvb8,1204
13
13
  lumibot/backtesting/interactive_brokers_rest_backtesting.py,sha256=5HJ_sPX0uOUg-rbfOKDjwYVCLiXevlwtdK_3BcUwqXc,6602
14
14
  lumibot/backtesting/pandas_backtesting.py,sha256=m-NvT4o-wFQjaZft6TXULzeZBrskO_7Z-jfy9AIkyAY,388
15
15
  lumibot/backtesting/polygon_backtesting.py,sha256=u9kif_2_7k0P4-KDvbHhaMfSoBVejUUX7fh9H3PCVE0,12350
16
16
  lumibot/backtesting/thetadata_backtesting.py,sha256=Xcz5f-4zTkKgWWcktNzItH2vrr8CysIMQWKKqLwugbA,345
17
- lumibot/backtesting/thetadata_backtesting_pandas.py,sha256=3ltwnxqAw4xXG2VOk14G_vpVHAVdnGl10SsveoMoJF0,51844
17
+ lumibot/backtesting/thetadata_backtesting_pandas.py,sha256=4jIxziBASbmCe5MCmjmvQBeo-5EQ90UTmblRpHrAaDE,51829
18
18
  lumibot/backtesting/yahoo_backtesting.py,sha256=LT2524mGlrUSq1YSRnUqGW4-Xcq4USgRv2EhnV_zfs4,502
19
19
  lumibot/brokers/__init__.py,sha256=MGWKHeH3mqseYRL7u-KX1Jp2x9EaFO4Ol8sfNSxzu1M,404
20
20
  lumibot/brokers/alpaca.py,sha256=VQ17idfqiEFb2JCqqdMGmbvF789L7_PpsCbudiFRzmg,61595
@@ -34,7 +34,7 @@ lumibot/components/configs_helper.py,sha256=e4-2jBmjsIDeMQ9fbQ9j2U6WlHCe2z9bIQtJ
34
34
  lumibot/components/drift_rebalancer_logic.py,sha256=KL1S9Su5gxibGzl2fw_DH8StzXvjFfFl23sOFGHgIZI,28653
35
35
  lumibot/components/grok_helper.py,sha256=vgyPAs63VoNsfZojMoHYRLckb-CFiZkHIlY8HSYZLQs,15382
36
36
  lumibot/components/grok_news_helper.py,sha256=7GFtSuRkBo4-3IcUmC6qhuf7iekoVyyP7q5jyUjAWTc,11007
37
- lumibot/components/options_helper.py,sha256=pUihHFEv5w17pXACE3xw0Tq6tHb9s05fzttlg4hmD7E,64931
37
+ lumibot/components/options_helper.py,sha256=UTMJUrTX9AOq-SPkZR7IFOFc65JQRZDSSrzy6a7ptZY,67371
38
38
  lumibot/components/perplexity_helper.py,sha256=0dhAIXunvUnTKIFFdYbFt8adnSIavKRqXkQ91fjsDPI,31432
39
39
  lumibot/components/quiver_helper.py,sha256=s0Y-tL1y2S1mYlbtlRTcL5R4WV0HYOgiSJ9hT41Hzrw,14380
40
40
  lumibot/components/vix_helper.py,sha256=tsWHFQzOJCjdmSGgmnYIty0K5ua-iAgBiRlyRIpGli0,61940
@@ -104,7 +104,7 @@ lumibot/example_strategies/test_broker_functions.py,sha256=wnVS-M_OtzMgaXVBgshVE
104
104
  lumibot/resources/ThetaTerminal.jar,sha256=K6GeeFcN8-gvyL2x5iq5pzD79KfPJvMK8iiezi3TmNQ,11834389
105
105
  lumibot/resources/conf.yaml,sha256=rjB9-10JP7saZ_edjX5bQDGfuc3amOQTUUUr-UiMpNA,597
106
106
  lumibot/strategies/__init__.py,sha256=jEZ95K5hG0f595EXYKWwL2_UsnWWk5Pug361PK2My2E,79
107
- lumibot/strategies/_strategy.py,sha256=wWx2te98kwMTuusioKR24KsKW8Djs3yhOb2nNmvgbiU,111467
107
+ lumibot/strategies/_strategy.py,sha256=3Z2MPz3jYgJdAphaFyoEI5OUs_6ClAJgRCrLWy-b2sg,111718
108
108
  lumibot/strategies/session_manager.py,sha256=Nze6UYNSPlCsf-tyHvtFqUeL44WSNHjwsKrIepvsyCY,12956
109
109
  lumibot/strategies/strategy.py,sha256=toPeL5oIVWmCxBNcfXqIuTCF_EeCfIVj425PrSYImCo,170021
110
110
  lumibot/strategies/strategy_executor.py,sha256=AnmXlKD2eMgKXs3TrD1u8T_Zsn_8GnG5KRcM_Pq-JBQ,70749
@@ -113,8 +113,8 @@ lumibot/tools/alpaca_helpers.py,sha256=nhBS-sv28lZfIQ85szC9El8VHLrCw5a5KbsGOOEjm
113
113
  lumibot/tools/backtest_cache.py,sha256=A-Juzu0swZI_FP4U7cd7ruYTgJYgV8BPf_WJDI-NtrM,9556
114
114
  lumibot/tools/bitunix_helpers.py,sha256=-UzrN3w_Y-Ckvhl7ZBoAcx7sgb6tH0KcpVph1Ovm3gw,25780
115
115
  lumibot/tools/black_scholes.py,sha256=TBjJuDTudvqsbwqSb7-zb4gXsJBCStQFaym8xvePAjw,25428
116
- lumibot/tools/ccxt_data_store.py,sha256=VXLSs0sWcwjRPZzbuEeVPS-3V6D10YnYMfIyoTPTG0U,21225
117
- lumibot/tools/databento_helper.py,sha256=6jCIh1GlEpdU_copJSNTYmvcIKMXCucaAONwmqQjxCE,43466
116
+ lumibot/tools/ccxt_data_store.py,sha256=PlP3MHPHZP7GisEZsk1OUxeWijoPXwiQbsOBTr7jkQI,21227
117
+ lumibot/tools/databento_helper.py,sha256=WBuQCm9Z7WFpde8qdsJEabtpU0ThGsjjPeK7BI2pUVA,43681
118
118
  lumibot/tools/databento_helper_polars.py,sha256=FXvvES_Y-E-IzAmVBtquh1UtQ-eN6i6BEoflcP7y8s0,48674
119
119
  lumibot/tools/databento_roll.py,sha256=48HAw3h6OngCK4UTl9ifpjo-ki8qmB6OoJUrHp0gRmE,6767
120
120
  lumibot/tools/debugers.py,sha256=ga6npFsS9cpKtTXaygh9t2_txCElg3bfzfeqDBvSL8k,485
@@ -132,7 +132,7 @@ lumibot/tools/polygon_helper_async.py,sha256=YHDXa9kmkkn8jh7hToY6GP5etyXS9Tj-uky
132
132
  lumibot/tools/polygon_helper_polars_optimized.py,sha256=NaIZ-5Av-G2McPEKHyJ-x65W72W_Agnz4lRgvXfQp8c,30415
133
133
  lumibot/tools/projectx_helpers.py,sha256=EIemLfbG923T_RBV_i6s6A9xgs7dt0et0oCnhFwdWfA,58299
134
134
  lumibot/tools/schwab_helper.py,sha256=CXnYhgsXOIb5MgmIYOp86aLxsBF9oeVrMGrjwl_GEv0,11768
135
- lumibot/tools/thetadata_helper.py,sha256=Guutp_2QAZe22_r6pftCojpTb3DpDp4Ul_szf6N7X1I,80152
135
+ lumibot/tools/thetadata_helper.py,sha256=F5CdaWZkfJYFrParvN8KhowRWc5cRmPL4TnI9SH-VpI,90111
136
136
  lumibot/tools/types.py,sha256=x-aQBeC6ZTN2-pUyxyo69Q0j5e0c_swdfe06kfrWSVc,1978
137
137
  lumibot/tools/yahoo_helper.py,sha256=htcKKkuktatIckVKfLc_ms0X75mXColysQhrZW244z8,19497
138
138
  lumibot/tools/yahoo_helper_polars_optimized.py,sha256=g9xBN-ReHSW4Aj9EMU_OncBXVS1HpfL8LTHit9ZxFY4,7417
@@ -142,7 +142,7 @@ lumibot/traders/trader.py,sha256=KMif3WoZtnSxA0BzoK3kvkTITNELrDFIortx1BYBv8s,967
142
142
  lumibot/trading_builtins/__init__.py,sha256=vH2QL5zLjL3slfEV1YW-BvQHtEYLCFkIWTZDfh3y8LE,87
143
143
  lumibot/trading_builtins/custom_stream.py,sha256=8_XiPT0JzyXrgnXCXoovGGUrWEfnG4ohIYMPfB_Nook,5264
144
144
  lumibot/trading_builtins/safe_list.py,sha256=IIjZOHSiZYK25A4WBts0oJaZNOJDsjZL65MOSHhE3Ig,1975
145
- lumibot-4.2.5.dist-info/licenses/LICENSE,sha256=fYhGIyxjyNXACgpNQS3xxpxDOaVOWRVxZMCRbsDv8k0,35130
145
+ lumibot-4.2.9.dist-info/licenses/LICENSE,sha256=fYhGIyxjyNXACgpNQS3xxpxDOaVOWRVxZMCRbsDv8k0,35130
146
146
  tests/__init__.py,sha256=3-VoT-nAuqMfwufd4ceN6fXaHl_zCfDCSXJOTp1ywYQ,393
147
147
  tests/conftest.py,sha256=UBw_2fx7r6TZPKus2b1Qxrzmd4bg8EEBnX1vCHUuSVA,3311
148
148
  tests/fixtures.py,sha256=wOHQsh1SGHnXe_PGi6kDWI30CS_Righi7Ig7vwSEKT4,9082
@@ -208,7 +208,7 @@ tests/test_lumibot_logger.py,sha256=76e5yl86NWk4OYtM_be-7yaHnPA-1bO2rBNH-piX8Dk,
208
208
  tests/test_market_infinite_loop_bug.py,sha256=p8Wiq2sdNFYOH54Ij9MkDFWBLTC8V3vbJEphcPq6T8g,16181
209
209
  tests/test_mes_symbols.py,sha256=Av5AM-pogp1STfWZ4A3bA2NmbaA3E9b0Dg449mfk-Ss,3106
210
210
  tests/test_momentum.py,sha256=oakKjgnz5pNQkw0CagbbKKSux0HkOxP_iLW8-Yk0XOo,8492
211
- tests/test_options_helper.py,sha256=NizH8E9ZnzVWsShsHp5X6qBE8FWJ3sS2eE9bCko7y9U,27352
211
+ tests/test_options_helper.py,sha256=wVXafXP4KfL3T-u9dFjJZbGI9YOHApohW-NfQ36yTuk,29185
212
212
  tests/test_options_helper_enhancements.py,sha256=3Ei-vy9IQfg1AnlBqofLNc-kuiQ64vouhJyIuVj3KIg,7324
213
213
  tests/test_order.py,sha256=qN_sqsItl8L-WqSQTF0PUN0s2nxm4HKYOA6gYQ6EBJA,12110
214
214
  tests/test_order_serialization.py,sha256=zcX5fgcYeo0jm6dlIQnUfuH56svTnKRFPtYCSI7xCJw,3710
@@ -226,7 +226,7 @@ tests/test_projectx_helpers.py,sha256=mKFcWZ7C2ve6UXk2LaJWo1E6QwhU09dkIYht1LElQW
226
226
  tests/test_projectx_lifecycle.py,sha256=F7H08wg2b5lYh0vNzKNa5AkQnhCuTrbpPm0YvOZHN6A,3179
227
227
  tests/test_projectx_lifecycle_unit.py,sha256=XqktdtFMS4s5RIF2ay9uZNW9Ij1AQL46OUbgStA9rhA,21926
228
228
  tests/test_projectx_live_flow.py,sha256=vKnb_VDKCOAbmOdQ5TlyQroCNtNE8UFjHKcd-YpIjXg,412
229
- tests/test_projectx_timestep_alias.py,sha256=vMf80illnTrwgl09wzGQ99CiTAVgIt2jSfHZELxxEEA,2042
229
+ tests/test_projectx_timestep_alias.py,sha256=Kcq-q9-pB9h9mBSol_qb_VCztVdLItY8xClroGNf3zo,2043
230
230
  tests/test_projectx_url_mappings.py,sha256=1E0xZe_cZtGiU-fPIfohbvsrrPjawkblR8F2Mvu_Ct8,10039
231
231
  tests/test_quiet_logs_buy_and_hold.py,sha256=GjXGeHBcNrRJoHGWLuc_LkrzcpNVzshiiBkQACczIxI,2681
232
232
  tests/test_quiet_logs_comprehensive.py,sha256=QVkZWLAUnPEb04Ec8qKXvLzdDUkp8alez2mU_Y1Umm0,6068
@@ -234,8 +234,9 @@ tests/test_quiet_logs_functionality.py,sha256=MlOBUICuTy1OXCDifOW05uD7hjnZRsQ2xx
234
234
  tests/test_quiet_logs_requirements.py,sha256=YoUooSVLrFL8TlWPfxEiqxvSj4d8z6-qg58ja4dtOc0,7856
235
235
  tests/test_session_manager.py,sha256=1qygN3aQ2Xe2uh4BMPm0E3V8KXLFNGq5qdL8KkZjef4,11632
236
236
  tests/test_strategy_methods.py,sha256=j9Mhr6nnG1fkiVQXnx7gLjzGbeQmwt0UbJr_4plD36o,12539
237
+ tests/test_strategy_price_guard.py,sha256=3GJdlfROwx6-adsSi8ZBrWaLOy9e-0N6V1eqpikj8e4,1540
237
238
  tests/test_thetadata_backwards_compat.py,sha256=RzNLhNZNJZ2hPkEDyG-T_4mRRXh5XqavK6r-OjfRASQ,3306
238
- tests/test_thetadata_helper.py,sha256=l77ksu70knZBudfrMAOwT9zJ91AxX5UgazqvYSrotqM,59346
239
+ tests/test_thetadata_helper.py,sha256=4EdFHoCTTKELU216ZNYGjfo6Uvq-29kK-TVpFLPWT3M,67263
239
240
  tests/test_thetadata_pandas_verification.py,sha256=MWUecqBY6FGFslWLRo_C5blGbom_unmXCZikAfZXLks,6553
240
241
  tests/test_tradier.py,sha256=iCEM2FTxJSzJ2oLNaRqSx05XaX_DCiMzLx1aEYPANko,33280
241
242
  tests/test_tradier_data.py,sha256=1jTxDzQtzaC42CQJVXMRMElBwExy1mVci3NFfKjjVH0,13363
@@ -281,7 +282,7 @@ tests/backtest/test_thetadata.py,sha256=xWYfC9C4EhbMDb29qyZWHO3sSWaLIPzzvcMbHCt5
281
282
  tests/backtest/test_thetadata_comprehensive.py,sha256=-gN3xLJcJtlB-k4vlaK82DCZDGDmr0LNZZDzn-aN3l4,26120
282
283
  tests/backtest/test_thetadata_vs_polygon.py,sha256=dZqsrOx3u3cz-1onIO6o5BDRjI1ey7U9vIkZupfXoig,22831
283
284
  tests/backtest/test_yahoo.py,sha256=2FguUTUMC9_A20eqxnZ17rN3tT9n6hyvJHaL98QKpqY,3443
284
- lumibot-4.2.5.dist-info/METADATA,sha256=lK1f_O0TcWv79IUVPehgpO2s_dQE1jrsj69_LCqeKM8,12092
285
- lumibot-4.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
286
- lumibot-4.2.5.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
287
- lumibot-4.2.5.dist-info/RECORD,,
285
+ lumibot-4.2.9.dist-info/METADATA,sha256=Bis7WU6RY3Wp7YXHsNa7-xklQzQcXRi5zcJzZhC2j0w,12093
286
+ lumibot-4.2.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
287
+ lumibot-4.2.9.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
288
+ lumibot-4.2.9.dist-info/RECORD,,
@@ -11,7 +11,7 @@ import pytest
11
11
  # Add the lumibot path
12
12
  sys.path.insert(0, '/Users/robertgrzesik/Documents/Development/lumivest_bot_server/strategies/lumibot')
13
13
 
14
- from lumibot.components.options_helper import OptionsHelper
14
+ from lumibot.components.options_helper import OptionsHelper, OptionMarketEvaluation
15
15
  from lumibot.entities import Asset
16
16
  from lumibot.entities.chains import OptionsDataFormatError, normalize_option_chains
17
17
  from lumibot.brokers.broker import Broker
@@ -311,7 +311,7 @@ class TestOptionsHelper(unittest.TestCase):
311
311
  result = self.options_helper.get_expiration_on_or_after_date(target, expiries, "call")
312
312
  self.assertEqual(result, _date(2024, 1, 9))
313
313
 
314
- def test_get_expiration_on_or_after_date_uses_latest_when_needed(self):
314
+ def test_get_expiration_on_or_after_date_returns_none_when_no_future_available(self):
315
315
  from datetime import date as _date
316
316
 
317
317
  expiries = {
@@ -325,7 +325,7 @@ class TestOptionsHelper(unittest.TestCase):
325
325
 
326
326
  target = _date(2024, 2, 1)
327
327
  result = self.options_helper.get_expiration_on_or_after_date(target, expiries, "call")
328
- self.assertEqual(result, _date(2024, 1, 9))
328
+ self.assertIsNone(result)
329
329
 
330
330
  def test_chains_backward_compatibility_string_access(self):
331
331
  """Test that existing code using string keys still works."""
@@ -676,6 +676,48 @@ class TestOptionsHelper(unittest.TestCase):
676
676
  self.assertIsNone(evaluation.sell_price)
677
677
  self.assertFalse(evaluation.used_last_price_fallback)
678
678
 
679
+ def test_evaluate_option_market_rejects_non_finite_quotes(self):
680
+ """NaN or infinite quotes are treated as missing to prevent crashes."""
681
+ option_asset = Asset(
682
+ "TEST",
683
+ asset_type=Asset.AssetType.OPTION,
684
+ expiration=date.today() + timedelta(days=7),
685
+ strike=200,
686
+ right="call",
687
+ underlying_asset=Asset("TEST", asset_type=Asset.AssetType.STOCK),
688
+ )
689
+
690
+ self.mock_strategy.get_quote.return_value = Mock(bid=float("nan"), ask=float("inf"))
691
+ self.mock_strategy.get_last_price.return_value = float("nan")
692
+ self.mock_strategy.broker.data_source.option_quote_fallback_allowed = True
693
+
694
+ evaluation = self.options_helper.evaluate_option_market(option_asset, max_spread_pct=0.25)
695
+
696
+ self.assertIsNone(evaluation.buy_price)
697
+ self.assertIn("bid_non_finite", evaluation.data_quality_flags)
698
+ self.assertIn("ask_non_finite", evaluation.data_quality_flags)
699
+ self.assertTrue(evaluation.missing_bid_ask)
700
+ self.assertFalse(OptionsHelper.has_actionable_price(evaluation))
701
+
702
+ def test_has_actionable_price_requires_positive_finite_value(self):
703
+ """has_actionable_price returns False for zero or negative values."""
704
+ evaluation = OptionMarketEvaluation(
705
+ bid=0.5,
706
+ ask=0.6,
707
+ last_price=0.55,
708
+ spread_pct=0.18,
709
+ has_bid_ask=True,
710
+ spread_too_wide=False,
711
+ missing_bid_ask=False,
712
+ missing_last_price=False,
713
+ buy_price=0.0,
714
+ sell_price=0.4,
715
+ used_last_price_fallback=False,
716
+ max_spread_pct=0.25,
717
+ data_quality_flags=["buy_price_non_positive"],
718
+ )
719
+ self.assertFalse(OptionsHelper.has_actionable_price(evaluation))
720
+
679
721
  if __name__ == "__main__":
680
722
  print("🧪 Running enhanced options helper tests...")
681
723
  unittest.main(verbosity=2)
@@ -9,7 +9,7 @@ from lumibot.data_sources.projectx_data import ProjectXData
9
9
  class DummyClient:
10
10
  def history_retrieve_bars(self, contract_id, start_datetime, end_datetime, unit, unit_number, limit, include_partial_bar, live, is_est):
11
11
  # Return a minimal DataFrame resembling expected structure
12
- idx = pd.date_range(end=end_datetime, periods=unit_number, freq='T')
12
+ idx = pd.date_range(end=end_datetime, periods=unit_number, freq='min')
13
13
  data = {
14
14
  'open': [1.0]*len(idx),
15
15
  'high': [1.1]*len(idx),
@@ -51,4 +51,3 @@ def test_projectx_get_bars_accepts_timestep_alias(projectx):
51
51
  bars_map2 = projectx.get_bars(asset, 1, timestep='minute')
52
52
  bars2 = list(bars_map2.values())[0]
53
53
  assert bars2 is not None and not bars2.df.empty
54
-
@@ -0,0 +1,50 @@
1
+ import math
2
+ from lumibot.components.options_helper import OptionsHelper, OptionMarketEvaluation
3
+
4
+
5
+ class _GuardedStrategy:
6
+ def __init__(self):
7
+ self.logged = []
8
+ self.options_helper = OptionsHelper(self)
9
+
10
+ def log_message(self, message, color="white"):
11
+ self.logged.append((color, message))
12
+
13
+ def get_cash(self):
14
+ return 10_000.0
15
+
16
+ def size_position(self, evaluation: OptionMarketEvaluation):
17
+ if evaluation.spread_too_wide or not self.options_helper.has_actionable_price(evaluation):
18
+ self.log_message(
19
+ f"Skipping trade due to invalid quotes (flags={evaluation.data_quality_flags}).",
20
+ color="yellow",
21
+ )
22
+ return 0
23
+
24
+ buy_price = float(evaluation.buy_price)
25
+ budget = self.get_cash() * 0.1
26
+ return math.floor(budget / (buy_price * 100.0))
27
+
28
+
29
+ def test_strategy_guard_skips_non_finite_prices():
30
+ strategy = _GuardedStrategy()
31
+ evaluation = OptionMarketEvaluation(
32
+ bid=None,
33
+ ask=None,
34
+ last_price=None,
35
+ spread_pct=None,
36
+ has_bid_ask=False,
37
+ spread_too_wide=False,
38
+ missing_bid_ask=True,
39
+ missing_last_price=True,
40
+ buy_price=float("nan"),
41
+ sell_price=None,
42
+ used_last_price_fallback=False,
43
+ max_spread_pct=None,
44
+ data_quality_flags=["buy_price_non_finite"],
45
+ )
46
+
47
+ contracts = strategy.size_position(evaluation)
48
+
49
+ assert contracts == 0
50
+ assert any("invalid quotes" in msg for _, msg in strategy.logged)
@@ -138,7 +138,11 @@ def test_get_price_data_partial_cache_hit(mock_build_cache_filename, mock_load_c
138
138
  assert df is not None
139
139
  assert len(df) == 10 # Combined cached and fetched data
140
140
  mock_get_historical_data.assert_called_once()
141
- pd.testing.assert_frame_equal(df, updated_data.drop(columns="missing"))
141
+ pd.testing.assert_frame_equal(
142
+ df,
143
+ updated_data.drop(columns="missing"),
144
+ check_dtype=False,
145
+ )
142
146
  mock_update_cache.assert_called_once()
143
147
 
144
148
 
@@ -452,7 +456,7 @@ def test_get_price_data_invokes_remote_cache_manager(tmp_path, monkeypatch):
452
456
 
453
457
  df = pd.DataFrame(
454
458
  {
455
- "datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="T", tz=pytz.UTC),
459
+ "datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="min", tz=pytz.UTC),
456
460
  "open": [100.0, 101.0],
457
461
  "high": [101.0, 102.0],
458
462
  "low": [99.5, 100.5],
@@ -1330,83 +1334,276 @@ class TestThetaDataProcessHealthCheck:
1330
1334
  assert thetadata_helper.is_process_alive() is True, "New process should be alive"
1331
1335
 
1332
1336
 
1333
- @pytest.mark.apitest
1334
1337
  class TestThetaDataChainsCaching:
1335
- """Test option chain caching matches Polygon pattern - ZERO TOLERANCE."""
1338
+ """Unit coverage for historical chain caching and normalization."""
1339
+
1340
+ def test_chains_cached_basic_structure(self, tmp_path, monkeypatch):
1341
+ asset = Asset("TEST", asset_type="stock")
1342
+ test_date = date(2024, 11, 7)
1343
+
1344
+ sample_chain = {
1345
+ "Multiplier": 100,
1346
+ "Exchange": "SMART",
1347
+ "Chains": {
1348
+ "CALL": {"2024-11-15": [100.0, 105.0]},
1349
+ "PUT": {"2024-11-15": [90.0, 95.0]},
1350
+ },
1351
+ }
1336
1352
 
1337
- def test_chains_cached_basic_structure(self):
1338
- """Test chain caching returns correct structure."""
1339
- username = os.environ.get("THETADATA_USERNAME")
1340
- password = os.environ.get("THETADATA_PASSWORD")
1353
+ calls = []
1341
1354
 
1342
- asset = Asset("SPY", asset_type="stock")
1343
- test_date = date(2025, 9, 15)
1355
+ def fake_builder(**kwargs):
1356
+ calls.append(kwargs)
1357
+ return sample_chain
1344
1358
 
1345
- chains = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1359
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
1360
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1346
1361
 
1347
- assert chains is not None, "Chains should not be None"
1348
- assert "Multiplier" in chains, "Missing Multiplier"
1349
- assert chains["Multiplier"] == 100, f"Multiplier should be 100, got {chains['Multiplier']}"
1350
- assert "Exchange" in chains, "Missing Exchange"
1351
- assert "Chains" in chains, "Missing Chains"
1352
- assert "CALL" in chains["Chains"], "Missing CALL chains"
1353
- assert "PUT" in chains["Chains"], "Missing PUT chains"
1362
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1354
1363
 
1355
- # Verify at least one expiration exists
1356
- assert len(chains["Chains"]["CALL"]) > 0, "Should have at least one CALL expiration"
1357
- assert len(chains["Chains"]["PUT"]) > 0, "Should have at least one PUT expiration"
1364
+ assert result == sample_chain
1365
+ assert len(calls) == 1
1366
+ builder_call = calls[0]
1367
+ assert builder_call["asset"] == asset
1368
+ assert builder_call["as_of_date"] == test_date
1358
1369
 
1359
- print(f"✓ Chain structure valid: {len(chains['Chains']['CALL'])} expirations")
1370
+ def test_chains_cache_reuse(self, tmp_path, monkeypatch):
1371
+ asset = Asset("REUSE", asset_type="stock")
1372
+ test_date = date(2024, 11, 8)
1360
1373
 
1361
- def test_chains_cache_reuse(self):
1362
- """Test that second call reuses cached data (no API call)."""
1363
- import time
1364
- from pathlib import Path
1365
- from lumibot.constants import LUMIBOT_CACHE_FOLDER
1374
+ sample_chain = {
1375
+ "Multiplier": 100,
1376
+ "Exchange": "SMART",
1377
+ "Chains": {"CALL": {"2024-11-22": [110.0]}, "PUT": {"2024-11-22": [95.0]}},
1378
+ }
1366
1379
 
1367
- username = os.environ.get("THETADATA_USERNAME")
1368
- password = os.environ.get("THETADATA_PASSWORD")
1380
+ call_count = {"total": 0}
1369
1381
 
1370
- asset = Asset("AAPL", asset_type="stock")
1371
- test_date = date(2025, 9, 15)
1382
+ def fake_builder(**kwargs):
1383
+ call_count["total"] += 1
1384
+ return sample_chain
1372
1385
 
1373
- # CLEAR CACHE to ensure first call downloads fresh data
1374
- # This prevents cache pollution from previous tests in the suite
1375
- # Chains are stored in: LUMIBOT_CACHE_FOLDER / "thetadata" / "option" / "option_chains"
1376
- chain_folder = Path(LUMIBOT_CACHE_FOLDER) / "thetadata" / "option" / "option_chains"
1377
- if chain_folder.exists():
1378
- # Delete all AAPL chain cache files
1379
- for cache_file in chain_folder.glob("AAPL_*.parquet"):
1380
- try:
1381
- cache_file.unlink()
1382
- except Exception:
1383
- pass
1384
-
1385
- # Restart ThetaData Terminal to ensure fresh connection after cache clearing
1386
- # This is necessary because cache clearing may interfere with active connections
1387
- thetadata_helper.start_theta_data_client(username, password)
1388
- time.sleep(3) # Give Terminal time to fully connect
1386
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
1387
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1388
+
1389
+ first = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1390
+ second = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1391
+
1392
+ assert first == sample_chain
1393
+ assert second == sample_chain
1394
+ assert call_count["total"] == 1, "Builder should only run once due to cache reuse"
1395
+
1396
+ def test_chain_cache_respects_recent_file(self, tmp_path, monkeypatch):
1397
+ asset = Asset("RECENT", asset_type="stock")
1398
+ test_date = date(2024, 11, 30)
1399
+
1400
+ sample_chain = {
1401
+ "Multiplier": 100,
1402
+ "Exchange": "SMART",
1403
+ "Chains": {"CALL": {"2024-12-06": [120.0]}, "PUT": {"2024-12-06": [80.0]}},
1404
+ }
1405
+
1406
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1407
+
1408
+ cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
1409
+ cache_folder.mkdir(parents=True, exist_ok=True)
1410
+
1411
+ cache_file = cache_folder / f"{asset.symbol}_{test_date.isoformat()}.parquet"
1412
+ pd.DataFrame({"data": [sample_chain]}).to_parquet(cache_file, compression="snappy", engine="pyarrow")
1413
+
1414
+ # Builder should not be invoked because cache hit satisfies tolerance window
1415
+ def fail_builder(**kwargs):
1416
+ raise AssertionError("build_historical_chain should not be called when cache is fresh")
1417
+
1418
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fail_builder)
1419
+
1420
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1421
+ assert result == sample_chain
1422
+
1423
+ def test_chains_cached_handles_none_builder(self, tmp_path, monkeypatch, caplog):
1424
+ asset = Asset("NONE", asset_type="stock")
1425
+ test_date = date(2024, 11, 28)
1426
+
1427
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", lambda **kwargs: None)
1428
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1429
+ monkeypatch.delenv("BACKTESTING_QUIET_LOGS", raising=False)
1430
+ caplog.set_level(logging.WARNING, logger="lumibot.tools.thetadata_helper")
1431
+
1432
+ with caplog.at_level(logging.WARNING, logger="lumibot.tools.thetadata_helper"):
1433
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1434
+
1435
+ cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
1436
+ assert not cache_folder.exists() or not list(cache_folder.glob("*.parquet"))
1437
+
1438
+ assert result == {
1439
+ "Multiplier": 100,
1440
+ "Exchange": "SMART",
1441
+ "Chains": {"CALL": {}, "PUT": {}},
1442
+ }
1443
+ assert "ThetaData returned no option data" in caplog.text
1444
+
1445
+
1446
+ def test_build_historical_chain_parses_quote_payload(monkeypatch):
1447
+ asset = Asset("CVNA", asset_type="stock")
1448
+ as_of_date = date(2024, 11, 7)
1449
+ as_of_int = int(as_of_date.strftime("%Y%m%d"))
1450
+
1451
+ def fake_get_request(url, headers, querystring, username, password):
1452
+ if url.endswith("/v2/list/expirations"):
1453
+ return {
1454
+ "header": {"format": ["date"]},
1455
+ "response": [[20241115], [20241205], [20250124]],
1456
+ }
1457
+ if url.endswith("/v2/list/strikes"):
1458
+ exp = querystring["exp"]
1459
+ if exp == "20241115":
1460
+ return {
1461
+ "header": {"format": ["strike"]},
1462
+ "response": [[100000], [105000]],
1463
+ }
1464
+ if exp == "20241205":
1465
+ return {
1466
+ "header": {"format": ["strike"]},
1467
+ "response": [[110000]],
1468
+ }
1469
+ return {
1470
+ "header": {"format": ["strike"]},
1471
+ "response": [[120000]],
1472
+ }
1473
+ if url.endswith("/list/dates/option/quote"):
1474
+ exp = querystring["exp"]
1475
+ if exp == "20241115":
1476
+ return {
1477
+ "header": {"format": None, "error_type": "null"},
1478
+ "response": [as_of_int, as_of_int + 1],
1479
+ }
1480
+ return {
1481
+ "header": {"format": None, "error_type": "NO_DATA"},
1482
+ "response": [],
1483
+ }
1484
+ raise AssertionError(f"Unexpected URL {url}")
1485
+
1486
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1487
+
1488
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1489
+
1490
+ assert result["Multiplier"] == 100
1491
+ assert set(result["Chains"].keys()) == {"CALL", "PUT"}
1492
+ assert list(result["Chains"]["CALL"].keys()) == ["2024-11-15"]
1493
+ assert result["Chains"]["CALL"]["2024-11-15"] == [100.0, 105.0]
1494
+ assert result["Chains"]["PUT"]["2024-11-15"] == [100.0, 105.0]
1495
+
1496
+
1497
+ def test_build_historical_chain_returns_none_when_no_dates(monkeypatch, caplog):
1498
+ asset = Asset("NONE", asset_type="stock")
1499
+ as_of_date = date(2024, 11, 28)
1500
+
1501
+ as_of_int = int(as_of_date.strftime("%Y%m%d"))
1502
+
1503
+ def fake_get_request(url, headers, querystring, username, password):
1504
+ if url.endswith("/v2/list/expirations"):
1505
+ return {"header": {"format": ["date"]}, "response": [[20241129], [20241206]]}
1506
+ if url.endswith("/v2/list/strikes"):
1507
+ return {"header": {"format": ["strike"]}, "response": [[150000], [155000]]}
1508
+ if url.endswith("/list/dates/option/quote"):
1509
+ return {"header": {"format": None, "error_type": "NO_DATA"}, "response": []}
1510
+ raise AssertionError(f"Unexpected URL {url}")
1511
+
1512
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1513
+ monkeypatch.delenv("BACKTESTING_QUIET_LOGS", raising=False)
1514
+ caplog.set_level(logging.WARNING, logger="lumibot.tools.thetadata_helper")
1515
+
1516
+ with caplog.at_level(logging.WARNING, logger="lumibot.tools.thetadata_helper"):
1517
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1518
+
1519
+ assert result is None
1520
+ assert f"No expirations with data found for {asset.symbol}" in caplog.text
1521
+
1522
+ def test_build_historical_chain_empty_response(monkeypatch, caplog):
1523
+ asset = Asset("EMPTY", asset_type="stock")
1524
+ as_of_date = date(2024, 11, 9)
1525
+
1526
+ def fake_get_request(url, headers, querystring, username, password):
1527
+ if url.endswith("/v2/list/expirations"):
1528
+ return {"header": {"format": ["date"]}, "response": []}
1529
+ raise AssertionError("Unexpected call after empty expirations")
1530
+
1531
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1532
+ monkeypatch.delenv("BACKTESTING_QUIET_LOGS", raising=False)
1533
+ caplog.set_level(logging.WARNING, logger="lumibot.tools.thetadata_helper")
1534
+
1535
+ with caplog.at_level(logging.WARNING, logger="lumibot.tools.thetadata_helper"):
1536
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1537
+
1538
+ assert result is None
1539
+ assert "returned no expirations" in caplog.text
1540
+
1541
+
1542
+ class TestThetaDataConnectionSupervision:
1543
+
1544
+ def setup_method(self):
1545
+ thetadata_helper.reset_connection_diagnostics()
1546
+
1547
+ def test_check_connection_recovers_after_restart(self, monkeypatch):
1548
+ statuses = iter(["DISCONNECTED", "DISCONNECTED", "CONNECTED"])
1549
+
1550
+ class FakeResponse:
1551
+ def __init__(self, text):
1552
+ self.text = text
1553
+
1554
+ def fake_get(url, timeout):
1555
+ try:
1556
+ text = next(statuses)
1557
+ except StopIteration:
1558
+ text = "CONNECTED"
1559
+ return FakeResponse(text)
1560
+
1561
+ start_calls = []
1562
+
1563
+ def fake_start(username, password):
1564
+ start_calls.append((username, password))
1565
+ return object()
1566
+
1567
+ monkeypatch.setattr(thetadata_helper.requests, "get", fake_get)
1568
+ monkeypatch.setattr(thetadata_helper, "start_theta_data_client", fake_start)
1569
+ monkeypatch.setattr(thetadata_helper, "is_process_alive", lambda: True)
1570
+ monkeypatch.setattr(thetadata_helper, "CONNECTION_MAX_RETRIES", 2, raising=False)
1571
+ monkeypatch.setattr(thetadata_helper, "MAX_TERMINAL_RESTART_CYCLES", 2, raising=False)
1572
+ monkeypatch.setattr(thetadata_helper, "BOOT_GRACE_PERIOD", 0, raising=False)
1573
+ monkeypatch.setattr(thetadata_helper, "CONNECTION_RETRY_SLEEP", 0, raising=False)
1574
+ monkeypatch.setattr(thetadata_helper.time, "sleep", lambda *args, **kwargs: None)
1575
+
1576
+ client, connected = thetadata_helper.check_connection("user", "pass", wait_for_connection=True)
1389
1577
 
1390
- # Verify connection is established
1391
- _, connected = thetadata_helper.check_connection(username, password)
1392
- assert connected, "ThetaData Terminal failed to connect"
1578
+ assert connected is True
1579
+ assert len(start_calls) == 1
1580
+ assert thetadata_helper.CONNECTION_DIAGNOSTICS["terminal_restarts"] >= 1
1393
1581
 
1394
- # First call - downloads (now guaranteed to be fresh)
1395
- start1 = time.time()
1396
- chains1 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1397
- time1 = time.time() - start1
1582
+ def test_check_connection_raises_after_restart_cycles(self, monkeypatch):
1583
+ statuses = iter(["DISCONNECTED"] * 10)
1398
1584
 
1399
- # Second call - should use cache
1400
- start2 = time.time()
1401
- chains2 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1402
- time2 = time.time() - start2
1585
+ class FakeResponse:
1586
+ def __init__(self, text):
1587
+ self.text = text
1403
1588
 
1404
- # Verify same data
1405
- assert chains1 == chains2, "Cached chains should match original"
1589
+ def fake_get(url, timeout):
1590
+ try:
1591
+ text = next(statuses)
1592
+ except StopIteration:
1593
+ text = "DISCONNECTED"
1594
+ return FakeResponse(text)
1406
1595
 
1407
- # Second call should be MUCH faster (cached)
1408
- assert time2 < time1 * 0.1, f"Cache not working: time1={time1:.2f}s, time2={time2:.2f}s (should be 10x faster)"
1409
- print(f" Cache speedup: {time1/time2:.1f}x faster ({time1:.2f}s -> {time2:.4f}s)")
1596
+ monkeypatch.setattr(thetadata_helper.requests, "get", fake_get)
1597
+ monkeypatch.setattr(thetadata_helper, "start_theta_data_client", lambda *args, **kwargs: object())
1598
+ monkeypatch.setattr(thetadata_helper, "is_process_alive", lambda: True)
1599
+ monkeypatch.setattr(thetadata_helper, "CONNECTION_MAX_RETRIES", 1, raising=False)
1600
+ monkeypatch.setattr(thetadata_helper, "MAX_TERMINAL_RESTART_CYCLES", 1, raising=False)
1601
+ monkeypatch.setattr(thetadata_helper, "BOOT_GRACE_PERIOD", 0, raising=False)
1602
+ monkeypatch.setattr(thetadata_helper, "CONNECTION_RETRY_SLEEP", 0, raising=False)
1603
+ monkeypatch.setattr(thetadata_helper.time, "sleep", lambda *args, **kwargs: None)
1604
+
1605
+ with pytest.raises(thetadata_helper.ThetaDataConnectionError):
1606
+ thetadata_helper.check_connection("user", "pass", wait_for_connection=True)
1410
1607
 
1411
1608
 
1412
1609
  def test_finalize_day_frame_handles_dst_fallback():