lumibot 4.1.1__py3-none-any.whl → 4.1.3__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/strategies/_strategy.py +29 -8
- lumibot/tools/databento_helper.py +32 -9
- lumibot/tools/databento_helper_polars.py +12 -5
- lumibot/tools/thetadata_helper.py +15 -10
- {lumibot-4.1.1.dist-info → lumibot-4.1.3.dist-info}/METADATA +16 -5
- {lumibot-4.1.1.dist-info → lumibot-4.1.3.dist-info}/RECORD +16 -15
- {lumibot-4.1.1.dist-info → lumibot-4.1.3.dist-info}/WHEEL +1 -1
- tests/backtest/test_databento_comprehensive_trading.py +62 -83
- tests/backtest/test_databento_parity.py +103 -0
- tests/backtest/test_futures_edge_cases.py +93 -60
- tests/test_backtesting_data_source_env.py +44 -10
- tests/test_databento_backtesting.py +44 -0
- tests/test_databento_helper.py +6 -1
- {lumibot-4.1.1.data/data → lumibot/resources}/ThetaTerminal.jar +0 -0
- {lumibot-4.1.1.dist-info → lumibot-4.1.3.dist-info/licenses}/LICENSE +0 -0
- {lumibot-4.1.1.dist-info → lumibot-4.1.3.dist-info}/top_level.txt +0 -0
lumibot/strategies/_strategy.py
CHANGED
|
@@ -1264,9 +1264,24 @@ class _Strategy:
|
|
|
1264
1264
|
if show_indicators is None:
|
|
1265
1265
|
show_indicators = SHOW_INDICATORS
|
|
1266
1266
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1267
|
+
from lumibot.credentials import BACKTESTING_DATA_SOURCE as _DEFAULT_BACKTESTING_DATA_SOURCE
|
|
1268
|
+
|
|
1269
|
+
# Determine whether an environment override exists. When BACKTESTING_DATA_SOURCE
|
|
1270
|
+
# is set (and not blank/\"none\"), it should take precedence even if a
|
|
1271
|
+
# datasource_class argument was provided.
|
|
1272
|
+
env_override_raw = os.environ.get("BACKTESTING_DATA_SOURCE")
|
|
1273
|
+
env_override_name = None
|
|
1274
|
+
|
|
1275
|
+
if env_override_raw is not None:
|
|
1276
|
+
trimmed = env_override_raw.strip()
|
|
1277
|
+
if trimmed and trimmed.lower() != "none":
|
|
1278
|
+
env_override_name = trimmed.lower()
|
|
1279
|
+
elif datasource_class is None:
|
|
1280
|
+
# No override provided and no class in code – fall back to the default
|
|
1281
|
+
# configured in credentials (ThetaData unless the project overrides it).
|
|
1282
|
+
env_override_name = _DEFAULT_BACKTESTING_DATA_SOURCE.lower()
|
|
1283
|
+
|
|
1284
|
+
if env_override_name is not None:
|
|
1270
1285
|
from lumibot.backtesting import (
|
|
1271
1286
|
PolygonDataBacktesting,
|
|
1272
1287
|
ThetaDataBacktesting,
|
|
@@ -1285,18 +1300,24 @@ class _Strategy:
|
|
|
1285
1300
|
"databento": DataBentoDataBacktesting,
|
|
1286
1301
|
}
|
|
1287
1302
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1303
|
+
if env_override_name not in datasource_map:
|
|
1304
|
+
label = env_override_raw or _DEFAULT_BACKTESTING_DATA_SOURCE
|
|
1290
1305
|
raise ValueError(
|
|
1291
|
-
f"Unknown BACKTESTING_DATA_SOURCE: '{
|
|
1306
|
+
f"Unknown BACKTESTING_DATA_SOURCE: '{label}'. "
|
|
1292
1307
|
f"Valid options: {list(datasource_map.keys())}"
|
|
1293
1308
|
)
|
|
1294
1309
|
|
|
1295
|
-
datasource_class = datasource_map[
|
|
1310
|
+
datasource_class = datasource_map[env_override_name]
|
|
1311
|
+
label = env_override_raw or _DEFAULT_BACKTESTING_DATA_SOURCE
|
|
1296
1312
|
get_logger(__name__).info(colored(
|
|
1297
|
-
f"
|
|
1313
|
+
f"Using BACKTESTING_DATA_SOURCE setting for backtest data: {label}",
|
|
1298
1314
|
"green"
|
|
1299
1315
|
))
|
|
1316
|
+
elif datasource_class is None:
|
|
1317
|
+
raise ValueError(
|
|
1318
|
+
"No backtesting data source provided. Set BACKTESTING_DATA_SOURCE in the environment "
|
|
1319
|
+
"or pass datasource_class when calling backtest()."
|
|
1320
|
+
)
|
|
1300
1321
|
|
|
1301
1322
|
# Make sure polygon_api_key is set if using PolygonDataBacktesting
|
|
1302
1323
|
polygon_api_key = polygon_api_key if polygon_api_key is not None else POLYGON_API_KEY
|
|
@@ -593,13 +593,29 @@ def _filter_front_month_rows_pandas(
|
|
|
593
593
|
if df.empty or "symbol" not in df.columns or schedule is None:
|
|
594
594
|
return df
|
|
595
595
|
|
|
596
|
+
index_tz = getattr(df.index, "tz", None)
|
|
597
|
+
|
|
598
|
+
def _align(ts: datetime | pd.Timestamp | None) -> pd.Timestamp | None:
|
|
599
|
+
if ts is None:
|
|
600
|
+
return None
|
|
601
|
+
ts_pd = pd.Timestamp(ts)
|
|
602
|
+
if index_tz is None:
|
|
603
|
+
return ts_pd.tz_localize(None) if ts_pd.tz is not None else ts_pd
|
|
604
|
+
if ts_pd.tz is None:
|
|
605
|
+
ts_pd = ts_pd.tz_localize(index_tz)
|
|
606
|
+
else:
|
|
607
|
+
ts_pd = ts_pd.tz_convert(index_tz)
|
|
608
|
+
return ts_pd
|
|
609
|
+
|
|
596
610
|
mask = pd.Series(False, index=df.index)
|
|
597
611
|
for symbol, start_dt, end_dt in schedule:
|
|
598
612
|
cond = df["symbol"] == symbol
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if
|
|
602
|
-
cond &= df.index
|
|
613
|
+
start_aligned = _align(start_dt)
|
|
614
|
+
end_aligned = _align(end_dt)
|
|
615
|
+
if start_aligned is not None:
|
|
616
|
+
cond &= df.index >= start_aligned
|
|
617
|
+
if end_aligned is not None:
|
|
618
|
+
cond &= df.index < end_aligned
|
|
603
619
|
mask |= cond
|
|
604
620
|
|
|
605
621
|
filtered = df.loc[mask]
|
|
@@ -783,15 +799,22 @@ def get_price_data_from_databento(
|
|
|
783
799
|
start_naive = start.replace(tzinfo=None) if start.tzinfo is not None else start
|
|
784
800
|
end_naive = end.replace(tzinfo=None) if end.tzinfo is not None else end
|
|
785
801
|
|
|
786
|
-
|
|
802
|
+
roll_asset = asset
|
|
803
|
+
if asset.asset_type == Asset.AssetType.FUTURE and not asset.expiration:
|
|
804
|
+
roll_asset = Asset(asset.symbol, Asset.AssetType.CONT_FUTURE)
|
|
805
|
+
|
|
806
|
+
if roll_asset.asset_type == Asset.AssetType.CONT_FUTURE:
|
|
787
807
|
schedule_start = start
|
|
788
|
-
symbols = databento_roll.resolve_symbols_for_range(
|
|
789
|
-
front_symbol = databento_roll.resolve_symbol_for_datetime(
|
|
808
|
+
symbols = databento_roll.resolve_symbols_for_range(roll_asset, schedule_start, end)
|
|
809
|
+
front_symbol = databento_roll.resolve_symbol_for_datetime(roll_asset, reference_date or start)
|
|
790
810
|
if front_symbol not in symbols:
|
|
791
811
|
symbols.insert(0, front_symbol)
|
|
792
812
|
else:
|
|
793
813
|
schedule_start = start
|
|
794
|
-
front_symbol = _format_futures_symbol_for_databento(
|
|
814
|
+
front_symbol = _format_futures_symbol_for_databento(
|
|
815
|
+
asset,
|
|
816
|
+
reference_date=reference_date or start,
|
|
817
|
+
)
|
|
795
818
|
symbols = [front_symbol]
|
|
796
819
|
|
|
797
820
|
# Ensure multiplier is populated using the first contract.
|
|
@@ -904,7 +927,7 @@ def get_price_data_from_databento(
|
|
|
904
927
|
return definition
|
|
905
928
|
|
|
906
929
|
schedule = databento_roll.build_roll_schedule(
|
|
907
|
-
|
|
930
|
+
roll_asset,
|
|
908
931
|
schedule_start,
|
|
909
932
|
end,
|
|
910
933
|
definition_provider=get_definition,
|
|
@@ -929,10 +929,14 @@ def get_price_data_from_databento_polars(
|
|
|
929
929
|
start_naive = start.replace(tzinfo=None) if start.tzinfo is not None else start
|
|
930
930
|
end_naive = end.replace(tzinfo=None) if end.tzinfo is not None else end
|
|
931
931
|
|
|
932
|
-
|
|
932
|
+
roll_asset = asset
|
|
933
|
+
if asset.asset_type == Asset.AssetType.FUTURE and not asset.expiration:
|
|
934
|
+
roll_asset = Asset(asset.symbol, Asset.AssetType.CONT_FUTURE)
|
|
935
|
+
|
|
936
|
+
if roll_asset.asset_type == Asset.AssetType.CONT_FUTURE:
|
|
933
937
|
schedule_start = start
|
|
934
|
-
symbols_to_fetch = databento_roll.resolve_symbols_for_range(
|
|
935
|
-
front_symbol = databento_roll.resolve_symbol_for_datetime(
|
|
938
|
+
symbols_to_fetch = databento_roll.resolve_symbols_for_range(roll_asset, schedule_start, end)
|
|
939
|
+
front_symbol = databento_roll.resolve_symbol_for_datetime(roll_asset, reference_date or start)
|
|
936
940
|
if front_symbol not in symbols_to_fetch:
|
|
937
941
|
symbols_to_fetch.insert(0, front_symbol)
|
|
938
942
|
logger.info(
|
|
@@ -941,7 +945,10 @@ def get_price_data_from_databento_polars(
|
|
|
941
945
|
)
|
|
942
946
|
else:
|
|
943
947
|
schedule_start = start
|
|
944
|
-
front_symbol = _format_futures_symbol_for_databento(
|
|
948
|
+
front_symbol = _format_futures_symbol_for_databento(
|
|
949
|
+
asset,
|
|
950
|
+
reference_date=reference_date or start,
|
|
951
|
+
)
|
|
945
952
|
symbols_to_fetch = [front_symbol]
|
|
946
953
|
|
|
947
954
|
# Fetch and cache futures multiplier from DataBento if needed (after symbol resolution)
|
|
@@ -1092,7 +1099,7 @@ def get_price_data_from_databento_polars(
|
|
|
1092
1099
|
return definition
|
|
1093
1100
|
|
|
1094
1101
|
schedule = databento_roll.build_roll_schedule(
|
|
1095
|
-
|
|
1102
|
+
roll_asset,
|
|
1096
1103
|
schedule_start,
|
|
1097
1104
|
end,
|
|
1098
1105
|
definition_provider=get_definition,
|
|
@@ -498,20 +498,25 @@ def start_theta_data_client(username: str, password: str):
|
|
|
498
498
|
logger.info("ThetaTerminal.jar not found, copying from lumibot package...")
|
|
499
499
|
import shutil as shutil_copy
|
|
500
500
|
|
|
501
|
-
|
|
502
|
-
|
|
501
|
+
package_root = Path(__file__).resolve().parent.parent
|
|
502
|
+
candidate_paths = [
|
|
503
|
+
package_root / "resources" / "ThetaTerminal.jar",
|
|
504
|
+
package_root.parent / "ThetaTerminal.jar", # legacy location fallback
|
|
505
|
+
]
|
|
503
506
|
|
|
504
|
-
if
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
logger.info(f"Successfully copied ThetaTerminal.jar to {jar_file}")
|
|
508
|
-
else:
|
|
507
|
+
lumibot_jar = next((path for path in candidate_paths if path.exists()), None)
|
|
508
|
+
|
|
509
|
+
if lumibot_jar is None:
|
|
509
510
|
raise FileNotFoundError(
|
|
510
|
-
|
|
511
|
-
f"
|
|
512
|
-
f"or manually place
|
|
511
|
+
"ThetaTerminal.jar not bundled with lumibot installation. "
|
|
512
|
+
f"Searched: {', '.join(str(path) for path in candidate_paths)}. "
|
|
513
|
+
f"Please reinstall lumibot or manually place the jar at {jar_file}"
|
|
513
514
|
)
|
|
514
515
|
|
|
516
|
+
logger.info(f"Copying ThetaTerminal.jar from {lumibot_jar} to {jar_file}")
|
|
517
|
+
shutil_copy.copy2(lumibot_jar, jar_file)
|
|
518
|
+
logger.info(f"Successfully copied ThetaTerminal.jar to {jar_file}")
|
|
519
|
+
|
|
515
520
|
if not jar_file.exists():
|
|
516
521
|
raise FileNotFoundError(f"ThetaTerminal.jar not found at {jar_file}")
|
|
517
522
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: lumibot
|
|
3
|
-
Version: 4.1.
|
|
3
|
+
Version: 4.1.3
|
|
4
4
|
Summary: Backtesting and Trading Library, Made by Lumiwealth
|
|
5
5
|
Home-page: https://github.com/Lumiwealth/lumibot
|
|
6
6
|
Author: Robert Grzesik
|
|
@@ -13,7 +13,7 @@ Description-Content-Type: text/markdown
|
|
|
13
13
|
License-File: LICENSE
|
|
14
14
|
Requires-Dist: polygon-api-client>=1.13.3
|
|
15
15
|
Requires-Dist: alpaca-py>=0.42.0
|
|
16
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: alpha_vantage
|
|
17
17
|
Requires-Dist: ibapi==9.81.1.post1
|
|
18
18
|
Requires-Dist: yfinance>=0.2.61
|
|
19
19
|
Requires-Dist: matplotlib>=3.3.3
|
|
@@ -21,7 +21,7 @@ Requires-Dist: quandl
|
|
|
21
21
|
Requires-Dist: numpy>=1.20.0
|
|
22
22
|
Requires-Dist: pandas>=2.2.0
|
|
23
23
|
Requires-Dist: polars>=1.32.3
|
|
24
|
-
Requires-Dist:
|
|
24
|
+
Requires-Dist: pandas_market_calendars>=5.1.0
|
|
25
25
|
Requires-Dist: pandas-ta-classic>=0.3.14b0
|
|
26
26
|
Requires-Dist: plotly>=5.18.0
|
|
27
27
|
Requires-Dist: sqlalchemy
|
|
@@ -40,7 +40,7 @@ Requires-Dist: tqdm
|
|
|
40
40
|
Requires-Dist: lumiwealth-tradier>=0.1.17
|
|
41
41
|
Requires-Dist: pytz
|
|
42
42
|
Requires-Dist: psycopg2-binary
|
|
43
|
-
Requires-Dist:
|
|
43
|
+
Requires-Dist: exchange_calendars>=4.6.0
|
|
44
44
|
Requires-Dist: duckdb
|
|
45
45
|
Requires-Dist: tabulate
|
|
46
46
|
Requires-Dist: databento>=0.42.0
|
|
@@ -51,6 +51,17 @@ Requires-Dist: schwab-py>=1.5.0
|
|
|
51
51
|
Requires-Dist: Flask>=2.3
|
|
52
52
|
Requires-Dist: free-proxy
|
|
53
53
|
Requires-Dist: requests-oauthlib
|
|
54
|
+
Dynamic: author
|
|
55
|
+
Dynamic: author-email
|
|
56
|
+
Dynamic: classifier
|
|
57
|
+
Dynamic: description
|
|
58
|
+
Dynamic: description-content-type
|
|
59
|
+
Dynamic: home-page
|
|
60
|
+
Dynamic: license
|
|
61
|
+
Dynamic: license-file
|
|
62
|
+
Dynamic: requires-dist
|
|
63
|
+
Dynamic: requires-python
|
|
64
|
+
Dynamic: summary
|
|
54
65
|
|
|
55
66
|
[](https://github.com/Lumiwealth/lumibot/actions/workflows/cicd.yaml)
|
|
56
67
|
[](https://github.com/Lumiwealth/lumibot/actions/workflows/cicd.yaml)
|
|
@@ -156,9 +156,10 @@ lumibot/example_strategies/strangle.py,sha256=naYiJLcjKu9yb_06WOMAUg8t-mFEo_F0BS
|
|
|
156
156
|
lumibot/example_strategies/test_broker_functions.py,sha256=wnVS-M_OtzMgaXVBgshVEqXKGEnHVzVL_O4x5qR86cM,4443
|
|
157
157
|
lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc,sha256=vawGE5mjtFUCyn6dqWSmZM2ij5pVT6YF4Cjn3KE0N-s,227
|
|
158
158
|
lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc,sha256=Jvch4BJBA5IYmQx86yf8dDpWYFtV5ETkzrLFSxQoGkc,5452
|
|
159
|
+
lumibot/resources/ThetaTerminal.jar,sha256=K6GeeFcN8-gvyL2x5iq5pzD79KfPJvMK8iiezi3TmNQ,11834389
|
|
159
160
|
lumibot/resources/conf.yaml,sha256=rjB9-10JP7saZ_edjX5bQDGfuc3amOQTUUUr-UiMpNA,597
|
|
160
161
|
lumibot/strategies/__init__.py,sha256=jEZ95K5hG0f595EXYKWwL2_UsnWWk5Pug361PK2My2E,79
|
|
161
|
-
lumibot/strategies/_strategy.py,sha256=
|
|
162
|
+
lumibot/strategies/_strategy.py,sha256=KI4nhQX4qrr0Dp4G0uN-njn03y3xoJDM762O-ru41Vg,110745
|
|
162
163
|
lumibot/strategies/session_manager.py,sha256=Nze6UYNSPlCsf-tyHvtFqUeL44WSNHjwsKrIepvsyCY,12956
|
|
163
164
|
lumibot/strategies/strategy.py,sha256=i-jAt2a1DXSiKT1gMQ5vXkbLb29x9vAFZl9zehbfJ1s,169301
|
|
164
165
|
lumibot/strategies/strategy_executor.py,sha256=IrHwDOu5s3gG65dz7FL-0kllWy7COci7JFyB4iiPUrg,70801
|
|
@@ -171,8 +172,8 @@ lumibot/tools/alpaca_helpers.py,sha256=nhBS-sv28lZfIQ85szC9El8VHLrCw5a5KbsGOOEjm
|
|
|
171
172
|
lumibot/tools/bitunix_helpers.py,sha256=-UzrN3w_Y-Ckvhl7ZBoAcx7sgb6tH0KcpVph1Ovm3gw,25780
|
|
172
173
|
lumibot/tools/black_scholes.py,sha256=TBjJuDTudvqsbwqSb7-zb4gXsJBCStQFaym8xvePAjw,25428
|
|
173
174
|
lumibot/tools/ccxt_data_store.py,sha256=VXLSs0sWcwjRPZzbuEeVPS-3V6D10YnYMfIyoTPTG0U,21225
|
|
174
|
-
lumibot/tools/databento_helper.py,sha256=
|
|
175
|
-
lumibot/tools/databento_helper_polars.py,sha256=
|
|
175
|
+
lumibot/tools/databento_helper.py,sha256=XBCAxc_GJ_oOTnHIsc5XSsRGmwymTqosLUbaKpIwaBw,43807
|
|
176
|
+
lumibot/tools/databento_helper_polars.py,sha256=4Nx4rUC-zY7zF25J__HRf5PXyZQGSGuX7zfkllvuWIo,53418
|
|
176
177
|
lumibot/tools/databento_roll.py,sha256=48HAw3h6OngCK4UTl9ifpjo-ki8qmB6OoJUrHp0gRmE,6767
|
|
177
178
|
lumibot/tools/debugers.py,sha256=ga6npFsS9cpKtTXaygh9t2_txCElg3bfzfeqDBvSL8k,485
|
|
178
179
|
lumibot/tools/decorators.py,sha256=gokLv6s37C1cnbnFSVOUc4RaVJ5aMTU2C344Vvi3ycs,2275
|
|
@@ -187,7 +188,7 @@ lumibot/tools/polygon_helper_async.py,sha256=YHDXa9kmkkn8jh7hToY6GP5etyXS9Tj-uky
|
|
|
187
188
|
lumibot/tools/polygon_helper_polars_optimized.py,sha256=NaIZ-5Av-G2McPEKHyJ-x65W72W_Agnz4lRgvXfQp8c,30415
|
|
188
189
|
lumibot/tools/projectx_helpers.py,sha256=EIemLfbG923T_RBV_i6s6A9xgs7dt0et0oCnhFwdWfA,58299
|
|
189
190
|
lumibot/tools/schwab_helper.py,sha256=CXnYhgsXOIb5MgmIYOp86aLxsBF9oeVrMGrjwl_GEv0,11768
|
|
190
|
-
lumibot/tools/thetadata_helper.py,sha256=
|
|
191
|
+
lumibot/tools/thetadata_helper.py,sha256=dLWBCGlNGv97HZVcj8hFPA0gnfDk-AAWUl88Ravr99c,42905
|
|
191
192
|
lumibot/tools/types.py,sha256=x-aQBeC6ZTN2-pUyxyo69Q0j5e0c_swdfe06kfrWSVc,1978
|
|
192
193
|
lumibot/tools/yahoo_helper.py,sha256=htcKKkuktatIckVKfLc_ms0X75mXColysQhrZW244z8,19497
|
|
193
194
|
lumibot/tools/yahoo_helper_polars_optimized.py,sha256=g9xBN-ReHSW4Aj9EMU_OncBXVS1HpfL8LTHit9ZxFY4,7417
|
|
@@ -224,7 +225,7 @@ lumibot/trading_builtins/safe_list.py,sha256=IIjZOHSiZYK25A4WBts0oJaZNOJDsjZL65M
|
|
|
224
225
|
lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc,sha256=ksqDHG5HzxBeh3sDNn2NjEhYtj3dI6TvuQoe03VAItg,345
|
|
225
226
|
lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc,sha256=w9EEoPd4LTBKmS8x6x-umicO1GwzaHlZnAv7MC2A78o,7397
|
|
226
227
|
lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc,sha256=2MQnqSCnMHHVu_gMK-3xBVSdHFyhxGR7_UrNdOvb4So,4875
|
|
227
|
-
lumibot-4.1.
|
|
228
|
+
lumibot-4.1.3.dist-info/licenses/LICENSE,sha256=fYhGIyxjyNXACgpNQS3xxpxDOaVOWRVxZMCRbsDv8k0,35130
|
|
228
229
|
tests/__init__.py,sha256=3-VoT-nAuqMfwufd4ceN6fXaHl_zCfDCSXJOTp1ywYQ,393
|
|
229
230
|
tests/conftest.py,sha256=UBw_2fx7r6TZPKus2b1Qxrzmd4bg8EEBnX1vCHUuSVA,3311
|
|
230
231
|
tests/fixtures.py,sha256=wOHQsh1SGHnXe_PGi6kDWI30CS_Righi7Ig7vwSEKT4,9082
|
|
@@ -243,7 +244,7 @@ tests/test_backtesting_broker.py,sha256=rxZGH5cgiWLmNGdI3k9fti3Fp9IOSohq8xD2E3L2
|
|
|
243
244
|
tests/test_backtesting_broker_await_close.py,sha256=WbehY7E4Qet3_Mo7lpfgjmhtI9pnJPIt9mkFI15Dzho,7545
|
|
244
245
|
tests/test_backtesting_broker_time_advance.py,sha256=FCv0nKG8BQlEjNft7kmQYm9M2CsLIZ0b7mWCllOHQxc,6378
|
|
245
246
|
tests/test_backtesting_crypto_cash_unit.py,sha256=4EO9jVajdZNV0M7zSyp4gpR_msZFoM4x5tb-6g-mHO8,11399
|
|
246
|
-
tests/test_backtesting_data_source_env.py,sha256=
|
|
247
|
+
tests/test_backtesting_data_source_env.py,sha256=MQaKNSbRzl5yXqFkMbBkt3C63stQtJJ8RE4jypVEv1U,12114
|
|
247
248
|
tests/test_backtesting_flow_control.py,sha256=pBqW-fa-HnZq0apUBltalGMM-vNJ_2A5W2SoJzMK8Mg,7208
|
|
248
249
|
tests/test_backtesting_multileg_unit.py,sha256=h1DPfVuYXXx-uq6KtUjr6_nasZuXPm_5gFat1XxCKIo,6456
|
|
249
250
|
tests/test_backtesting_quiet_logs_complete.py,sha256=x-GfOiqkiUu8pYKCzB0UUacn13Nx_cPRth7_jmPY2Y8,14155
|
|
@@ -266,10 +267,10 @@ tests/test_continuous_futures_resolution.py,sha256=1fob1-GF7QZrxiMK1EKcI2-LDP6bz
|
|
|
266
267
|
tests/test_data_source.py,sha256=MLZBZbUYQjt9FC9zrZ6ALL0ElHjByno6x2ls9XrYQH0,1991
|
|
267
268
|
tests/test_databento_asset_validation.py,sha256=bKSY_vWUb7IoVFfyI7Lk88DMjEgRcSw5dNPmKOc9-AI,5193
|
|
268
269
|
tests/test_databento_auto_expiry_integration.py,sha256=uPAyYTMTzEqppUUZx99G8pvnvAp7QQDuknfU6_IQJA8,11886
|
|
269
|
-
tests/test_databento_backtesting.py,sha256=
|
|
270
|
+
tests/test_databento_backtesting.py,sha256=LqcuHgjivmHIcEcCjZNSPDdD7epCwdaKtj6Q6Q4KTF0,20671
|
|
270
271
|
tests/test_databento_backtesting_polars.py,sha256=V9Z7-WG2TNKpHQVF5vGJSIdgUsuhJwlopZV66FREWA0,8097
|
|
271
272
|
tests/test_databento_data.py,sha256=HakjDileGpicQc_OXeX7l8ncIDn3FtxP8ymKA6TQp8o,18860
|
|
272
|
-
tests/test_databento_helper.py,sha256=
|
|
273
|
+
tests/test_databento_helper.py,sha256=Oeojl4xTZi37JewuX0n-7ML8EQcLTbl5Jc4ugtQrYb8,43611
|
|
273
274
|
tests/test_databento_live.py,sha256=tbg2C9cyW45OYY7dKYlPMNZpgsN_sjs4yk1GR23OL6o,15648
|
|
274
275
|
tests/test_databento_timezone_fixes.py,sha256=NsfND7yTdKH2ddiYYhO6kU3m41V7se7C4_zTvqKOGv0,11562
|
|
275
276
|
tests/test_drift_rebalancer.py,sha256=AUuEd3WIunfx3gwVdLVtq8jOHlz65UeqpO4adY1xfcs,105289
|
|
@@ -336,12 +337,13 @@ tests/backtest/test_buy_hold_quiet_logs_full_run.py,sha256=LDiR8wsEwIASPnO_bUMid
|
|
|
336
337
|
tests/backtest/test_crypto_cash_regressions.py,sha256=-f0wjb-9nXpggS30N4zomYl098Qu-tfvfWwhlkoxPMM,6077
|
|
337
338
|
tests/backtest/test_daily_data_timestamp_comparison.py,sha256=DeMLH0hwl0zEybJF0YTyBgfO8p96DGLahiBCwVQKjA4,35910
|
|
338
339
|
tests/backtest/test_databento.py,sha256=L7UIN0IjxdVzE65ujBvsoKyQhY4KHTctIOeOeWvVNBY,7485
|
|
339
|
-
tests/backtest/test_databento_comprehensive_trading.py,sha256=
|
|
340
|
+
tests/backtest/test_databento_comprehensive_trading.py,sha256=ZqZm6yOc9qAPSpjPLRIE2EZTr3jQPCtlmeKSLN9vOQw,21990
|
|
341
|
+
tests/backtest/test_databento_parity.py,sha256=YGF3L7dv4CMfJISj1KrObBWXneK3xwjp7Iw4mVtR32k,3481
|
|
340
342
|
tests/backtest/test_debug_avg_fill_price.py,sha256=W4pgvCkAwmqlK82yzlw7u-s8VXe-dIQjPu-BVQkwJvg,3520
|
|
341
343
|
tests/backtest/test_dividends.py,sha256=tDEnK4IcqHPHYPXAfUlApEBOf-iMROSl3aCap0BX1nI,9970
|
|
342
344
|
tests/backtest/test_example_strategies.py,sha256=mSRRONtKdd7x0DUiuqCZD3YXLiPS8kAGn5wT1f_gDlk,15482
|
|
343
345
|
tests/backtest/test_failing_backtest.py,sha256=jBkm_3Yq-TrzezAQM7XEAn3424lzG6Mu5agnTJQCo6E,5460
|
|
344
|
-
tests/backtest/test_futures_edge_cases.py,sha256=
|
|
346
|
+
tests/backtest/test_futures_edge_cases.py,sha256=If97ybqmaGw3lxK8hMSNFGaRM3ovDphNdyCqLhGmFrE,18314
|
|
345
347
|
tests/backtest/test_futures_single_trade.py,sha256=seGe-mOkoFnCY58HT-iaWktPo76-WG0xgxPkOiL6BOg,10838
|
|
346
348
|
tests/backtest/test_futures_ultra_simple.py,sha256=UWGhuhwALCpez-yJxmbke5pfW6vHLDsHbsezwezU934,6875
|
|
347
349
|
tests/backtest/test_index_data_verification.py,sha256=oCM_xRMylyDkUS0uDXCyj4KVKs_GG24lcqCvQq1Exvw,13737
|
|
@@ -354,8 +356,7 @@ tests/backtest/test_thetadata.py,sha256=xWYfC9C4EhbMDb29qyZWHO3sSWaLIPzzvcMbHCt5
|
|
|
354
356
|
tests/backtest/test_thetadata_comprehensive.py,sha256=-gN3xLJcJtlB-k4vlaK82DCZDGDmr0LNZZDzn-aN3l4,26120
|
|
355
357
|
tests/backtest/test_thetadata_vs_polygon.py,sha256=dZqsrOx3u3cz-1onIO6o5BDRjI1ey7U9vIkZupfXoig,22831
|
|
356
358
|
tests/backtest/test_yahoo.py,sha256=uHb9aK3uHYEvA7MI_y1dbKm7mjHrErlxU7TJOgVdzs8,1966
|
|
357
|
-
lumibot-4.1.
|
|
358
|
-
lumibot-4.1.
|
|
359
|
-
lumibot-4.1.
|
|
360
|
-
lumibot-4.1.
|
|
361
|
-
lumibot-4.1.1.dist-info/RECORD,,
|
|
359
|
+
lumibot-4.1.3.dist-info/METADATA,sha256=bqCXhktv5-RaCGiktkddUfpmjDUfBKhzS5josA6A1Sk,11721
|
|
360
|
+
lumibot-4.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
361
|
+
lumibot-4.1.3.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
|
|
362
|
+
lumibot-4.1.3.dist-info/RECORD,,
|
|
@@ -215,6 +215,14 @@ class TestDatabentoComprehensiveTrading:
|
|
|
215
215
|
|
|
216
216
|
print(f"\n Instruments traded: {list(trades_by_instrument.keys())}")
|
|
217
217
|
|
|
218
|
+
snapshots_by_symbol = {}
|
|
219
|
+
for snap in strat.snapshots:
|
|
220
|
+
symbol = snap.get("current_asset")
|
|
221
|
+
if symbol:
|
|
222
|
+
snapshots_by_symbol.setdefault(symbol, []).append(snap)
|
|
223
|
+
|
|
224
|
+
fee_amount = float(fee.flat_fee)
|
|
225
|
+
|
|
218
226
|
# Analyze each instrument's trades
|
|
219
227
|
for symbol, trades in trades_by_instrument.items():
|
|
220
228
|
print(f"\n" + "-"*80)
|
|
@@ -249,16 +257,45 @@ class TestDatabentoComprehensiveTrading:
|
|
|
249
257
|
assert actual_asset.multiplier == expected_multiplier, \
|
|
250
258
|
f"{symbol} asset.multiplier should be {expected_multiplier}, got {actual_asset.multiplier}"
|
|
251
259
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
symbol_snapshots = snapshots_by_symbol.get(symbol, [])
|
|
261
|
+
entry_snapshot = next((s for s in symbol_snapshots if s.get("phase") == "BUY"), None)
|
|
262
|
+
sell_snapshot = next((s for s in symbol_snapshots if s.get("phase") == "SELL"), None)
|
|
263
|
+
hold_snapshots = [s for s in symbol_snapshots if s.get("phase") == "HOLD"]
|
|
264
|
+
|
|
265
|
+
assert entry_snapshot is not None, f"No entry snapshot recorded for {symbol}"
|
|
266
|
+
assert sell_snapshot is not None, f"No sell snapshot recorded for {symbol}"
|
|
267
|
+
|
|
268
|
+
cash_before_entry = float(entry_snapshot["cash"])
|
|
269
|
+
entry_cash_after = float(entry["cash_after"])
|
|
270
|
+
margin_deposit = cash_before_entry - entry_cash_after - fee_amount
|
|
271
|
+
expected_margin_total = expected_margin * float(entry["quantity"])
|
|
272
|
+
|
|
273
|
+
print(f"\nCASH / MARGIN STATE:")
|
|
274
|
+
print(f" Cash before entry: ${cash_before_entry:,.2f}")
|
|
275
|
+
print(f" Cash after entry: ${entry_cash_after:,.2f}")
|
|
276
|
+
print(f" Margin captured: ${margin_deposit:,.2f} (expected ${expected_margin_total:,.2f})")
|
|
277
|
+
assert pytest.approx(margin_deposit, abs=0.01) == expected_margin_total, (
|
|
278
|
+
f"{symbol} margin mismatch: expected ${expected_margin_total:,.2f}, "
|
|
279
|
+
f"got ${margin_deposit:,.2f}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Verify mark-to-market during hold period is exact
|
|
283
|
+
for snap in hold_snapshots:
|
|
284
|
+
price = snap.get("price")
|
|
285
|
+
if price is None:
|
|
286
|
+
continue
|
|
287
|
+
unrealized = (price - entry["price"]) * float(entry["quantity"]) * expected_multiplier
|
|
288
|
+
expected_portfolio = entry_cash_after + margin_deposit + unrealized
|
|
289
|
+
assert pytest.approx(expected_portfolio, abs=0.01) == float(snap["portfolio"]), (
|
|
290
|
+
f"{symbol} mark-to-market mismatch at {snap['datetime']}: "
|
|
291
|
+
f"expected ${expected_portfolio:,.2f}, got ${snap['portfolio']:,.2f}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Snapshot immediately before exit should have identical cash to post-entry state
|
|
295
|
+
assert pytest.approx(float(sell_snapshot["cash"]), abs=0.01) == entry_cash_after, (
|
|
296
|
+
f"{symbol} cash prior to exit changed unexpectedly: "
|
|
297
|
+
f"{sell_snapshot['cash']} vs {entry_cash_after}"
|
|
298
|
+
)
|
|
262
299
|
|
|
263
300
|
if len(exits) > 0 and len(entries) > 0:
|
|
264
301
|
entry = entries[0]
|
|
@@ -283,79 +320,21 @@ class TestDatabentoComprehensiveTrading:
|
|
|
283
320
|
print(f" Price change: ${price_change:.2f}")
|
|
284
321
|
print(f" Expected P&L: ${expected_pnl:.2f} (change × qty × {expected_multiplier})")
|
|
285
322
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
print(f" Difference from expected: ${pnl_diff:.2f}")
|
|
302
|
-
|
|
303
|
-
# Allow generous tolerance for fees, rounding, and concurrent trades
|
|
304
|
-
# For small P&L, allow larger percentage; for large P&L, allow smaller percentage
|
|
305
|
-
tolerance = max(abs(expected_pnl) * 0.5, 500)
|
|
306
|
-
# For this comprehensive test with multiple concurrent trades, just verify it's reasonable
|
|
307
|
-
# (exact match is tested in simpler single-trade tests)
|
|
308
|
-
if pnl_diff < tolerance:
|
|
309
|
-
print(f" ✓ Portfolio change matches expected P&L within tolerance")
|
|
310
|
-
else:
|
|
311
|
-
print(f" ⚠ Portfolio change differs (may be due to concurrent trades)")
|
|
312
|
-
|
|
313
|
-
# CRITICAL: Verify unrealized P&L during HOLD periods
|
|
314
|
-
# This catches bugs in portfolio value calculation (multiplier applied to unrealized P&L)
|
|
315
|
-
print(f"\n" + "-"*80)
|
|
316
|
-
print("VERIFYING UNREALIZED P&L DURING HOLD PERIODS")
|
|
317
|
-
print("-"*80)
|
|
318
|
-
|
|
319
|
-
for symbol in trades_by_instrument.keys():
|
|
320
|
-
# Find snapshots where we're holding this position
|
|
321
|
-
holding_snapshots = [s for s in strat.snapshots if s['position_qty'] > 0 and s.get('current_asset') == symbol]
|
|
322
|
-
|
|
323
|
-
if len(holding_snapshots) >= 2:
|
|
324
|
-
# Check a couple of snapshots during the hold
|
|
325
|
-
snap = holding_snapshots[len(holding_snapshots)//2] # middle of hold period
|
|
326
|
-
|
|
327
|
-
# Get the entry trade for this position
|
|
328
|
-
entries = [t for t in trades_by_instrument[symbol] if "buy" in str(t["side"]).lower()]
|
|
329
|
-
if entries:
|
|
330
|
-
entry = entries[0]
|
|
331
|
-
entry_price = entry['price']
|
|
332
|
-
quantity = entry['quantity']
|
|
333
|
-
current_price = snap['price']
|
|
334
|
-
expected_mult = CONTRACT_SPECS.get(symbol, {}).get("multiplier", 1)
|
|
335
|
-
expected_margin = CONTRACT_SPECS.get(symbol, {}).get("margin", 1000)
|
|
336
|
-
|
|
337
|
-
# Calculate expected portfolio value
|
|
338
|
-
cash = snap['cash']
|
|
339
|
-
margin_tied_up = quantity * expected_margin
|
|
340
|
-
unrealized_pnl = (current_price - entry_price) * quantity * expected_mult
|
|
341
|
-
expected_portfolio = cash + margin_tied_up + unrealized_pnl
|
|
342
|
-
actual_portfolio = snap['portfolio']
|
|
343
|
-
|
|
344
|
-
print(f"\n{symbol} during HOLD (snapshot {strat.snapshots.index(snap)}):")
|
|
345
|
-
print(f" Entry: ${entry_price:.2f} × {quantity} contracts")
|
|
346
|
-
print(f" Current: ${current_price:.2f}")
|
|
347
|
-
print(f" Cash: ${cash:,.2f}")
|
|
348
|
-
print(f" Margin: ${margin_tied_up:,.2f}")
|
|
349
|
-
print(f" Unrealized P&L: ${unrealized_pnl:,.2f} = (${current_price:.2f} - ${entry_price:.2f}) × {quantity} × {expected_mult}")
|
|
350
|
-
print(f" Expected portfolio: ${expected_portfolio:,.2f}")
|
|
351
|
-
print(f" Actual portfolio: ${actual_portfolio:,.2f}")
|
|
352
|
-
print(f" Difference: ${abs(actual_portfolio - expected_portfolio):,.2f}")
|
|
353
|
-
|
|
354
|
-
# This tolerance should catch multiplier bugs (5x error would be huge)
|
|
355
|
-
tolerance = max(abs(expected_portfolio) * 0.02, 100) # 2% or $100
|
|
356
|
-
assert abs(actual_portfolio - expected_portfolio) < tolerance, \
|
|
357
|
-
f"{symbol} portfolio value incorrect during hold: expected ${expected_portfolio:,.2f}, got ${actual_portfolio:,.2f}"
|
|
358
|
-
print(f" ✓ Portfolio value matches expected (within ${tolerance:.2f})")
|
|
323
|
+
cash_before_entry = float(snapshots_by_symbol[symbol][0]["cash"])
|
|
324
|
+
expected_cash_after_exit = (
|
|
325
|
+
cash_before_entry
|
|
326
|
+
- fee_amount # entry fee
|
|
327
|
+
- fee_amount # exit fee
|
|
328
|
+
+ expected_pnl
|
|
329
|
+
)
|
|
330
|
+
print(f"\nCASH RECONCILIATION:")
|
|
331
|
+
print(f" Expected cash after exit: ${expected_cash_after_exit:,.2f}")
|
|
332
|
+
actual_cash_after_exit = float(exit_trade["cash_after"])
|
|
333
|
+
print(f" Actual cash after exit: ${actual_cash_after_exit:,.2f}")
|
|
334
|
+
assert pytest.approx(expected_cash_after_exit, abs=0.01) == actual_cash_after_exit, (
|
|
335
|
+
f"{symbol} cash after exit mismatch: expected ${expected_cash_after_exit:,.2f}, "
|
|
336
|
+
f"got ${actual_cash_after_exit:,.2f}"
|
|
337
|
+
)
|
|
359
338
|
|
|
360
339
|
print(f"\n" + "="*80)
|
|
361
340
|
print("✓ ALL INSTRUMENTS VERIFIED")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Parity checks between DataBento pandas and polars backends."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pytest
|
|
10
|
+
import pytz
|
|
11
|
+
|
|
12
|
+
from lumibot.backtesting.databento_backtesting import DataBentoDataBacktesting as DataBentoPandas
|
|
13
|
+
from lumibot.data_sources.databento_data_polars_backtesting import DataBentoDataPolarsBacktesting
|
|
14
|
+
from lumibot.entities import Asset
|
|
15
|
+
from lumibot.credentials import DATABENTO_CONFIG
|
|
16
|
+
from lumibot.tools import databento_helper, databento_helper_polars
|
|
17
|
+
|
|
18
|
+
DATABENTO_API_KEY = DATABENTO_CONFIG.get("API_KEY")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _clear_databento_caches():
|
|
22
|
+
for cache_dir in (
|
|
23
|
+
databento_helper.LUMIBOT_DATABENTO_CACHE_FOLDER,
|
|
24
|
+
databento_helper_polars.LUMIBOT_DATABENTO_CACHE_FOLDER,
|
|
25
|
+
):
|
|
26
|
+
path = Path(cache_dir)
|
|
27
|
+
if path.exists():
|
|
28
|
+
shutil.rmtree(path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.apitest
|
|
32
|
+
@pytest.mark.skipif(
|
|
33
|
+
not DATABENTO_API_KEY or DATABENTO_API_KEY == '<your key here>',
|
|
34
|
+
reason="This test requires a Databento API key",
|
|
35
|
+
)
|
|
36
|
+
def test_databento_price_parity():
|
|
37
|
+
"""Ensure pandas and polars backends deliver identical prices."""
|
|
38
|
+
|
|
39
|
+
_clear_databento_caches()
|
|
40
|
+
|
|
41
|
+
tz = pytz.timezone("America/New_York")
|
|
42
|
+
start = tz.localize(datetime(2025, 9, 15, 0, 0))
|
|
43
|
+
end = tz.localize(datetime(2025, 9, 29, 23, 59))
|
|
44
|
+
asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
|
|
45
|
+
|
|
46
|
+
pandas_ds = DataBentoPandas(
|
|
47
|
+
datetime_start=start,
|
|
48
|
+
datetime_end=end,
|
|
49
|
+
api_key=DATABENTO_API_KEY,
|
|
50
|
+
show_progress_bar=False,
|
|
51
|
+
)
|
|
52
|
+
polars_ds = DataBentoDataPolarsBacktesting(
|
|
53
|
+
datetime_start=start,
|
|
54
|
+
datetime_end=end,
|
|
55
|
+
api_key=DATABENTO_API_KEY,
|
|
56
|
+
show_progress_bar=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Prime caches
|
|
60
|
+
pandas_bars = pandas_ds.get_historical_prices(asset, 500, timestep="minute").df.sort_index()
|
|
61
|
+
polars_bars = polars_ds.get_historical_prices(asset, 500, timestep="minute").df.sort_index()
|
|
62
|
+
|
|
63
|
+
candidate_columns = ["open", "high", "low", "close", "volume", "vwap"]
|
|
64
|
+
common_columns = [col for col in candidate_columns if col in pandas_bars.columns and col in polars_bars.columns]
|
|
65
|
+
assert common_columns, "No shared OHLCV columns between pandas and polars DataFrames"
|
|
66
|
+
|
|
67
|
+
aligned_pandas = pandas_bars[common_columns].copy()
|
|
68
|
+
aligned_polars = polars_bars[common_columns].copy()
|
|
69
|
+
|
|
70
|
+
for col in common_columns:
|
|
71
|
+
dtype_left = aligned_pandas[col].dtype
|
|
72
|
+
dtype_right = aligned_polars[col].dtype
|
|
73
|
+
if dtype_left != dtype_right:
|
|
74
|
+
target_dtype = np.promote_types(dtype_left, dtype_right)
|
|
75
|
+
aligned_pandas[col] = aligned_pandas[col].astype(target_dtype)
|
|
76
|
+
aligned_polars[col] = aligned_polars[col].astype(target_dtype)
|
|
77
|
+
|
|
78
|
+
pd.testing.assert_frame_equal(
|
|
79
|
+
aligned_pandas,
|
|
80
|
+
aligned_polars,
|
|
81
|
+
check_exact=True,
|
|
82
|
+
check_index_type=True,
|
|
83
|
+
check_column_type=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
checkpoints = [
|
|
87
|
+
(0, 0),
|
|
88
|
+
(3, 40),
|
|
89
|
+
(4, 0),
|
|
90
|
+
(7, 35),
|
|
91
|
+
(11, 5),
|
|
92
|
+
(14, 5),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
for hour, minute in checkpoints:
|
|
96
|
+
current_dt = tz.localize(datetime(2025, 9, 15, hour, minute))
|
|
97
|
+
pandas_ds._datetime = current_dt
|
|
98
|
+
polars_ds._datetime = current_dt
|
|
99
|
+
pandas_price = pandas_ds.get_last_price(asset)
|
|
100
|
+
polars_price = polars_ds.get_last_price(asset)
|
|
101
|
+
assert pandas_price == polars_price, (
|
|
102
|
+
f"Mismatch at {current_dt}: pandas={pandas_price}, polars={polars_price}"
|
|
103
|
+
)
|
|
@@ -31,6 +31,8 @@ DATABENTO_API_KEY = DATABENTO_CONFIG.get("API_KEY")
|
|
|
31
31
|
# Contract specs
|
|
32
32
|
MES_MULTIPLIER = 5
|
|
33
33
|
ES_MULTIPLIER = 50
|
|
34
|
+
MES_MARGIN = 1300
|
|
35
|
+
ES_MARGIN = 13000
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def _clear_polars_cache():
|
|
@@ -211,6 +213,8 @@ class TestFuturesEdgeCases:
|
|
|
211
213
|
|
|
212
214
|
broker = BacktestingBroker(data_source=data_source)
|
|
213
215
|
fee = TradingFee(flat_fee=0.50)
|
|
216
|
+
fee_amount = float(fee.flat_fee)
|
|
217
|
+
fee_amount = float(fee.flat_fee)
|
|
214
218
|
|
|
215
219
|
strat = ShortSellingStrategy(
|
|
216
220
|
broker=broker,
|
|
@@ -252,7 +256,7 @@ class TestFuturesEdgeCases:
|
|
|
252
256
|
assert trade['multiplier'] == MES_MULTIPLIER, \
|
|
253
257
|
f"MES multiplier should be {MES_MULTIPLIER}, got {trade['multiplier']}"
|
|
254
258
|
|
|
255
|
-
# If we have both entry and exit, verify P&L
|
|
259
|
+
# If we have both entry and exit, verify P&L and cash bookkeeping exactly
|
|
256
260
|
if len(strat.trades) >= 2:
|
|
257
261
|
entry = strat.trades[0] # Sell to open
|
|
258
262
|
exit_trade = strat.trades[1] # Buy to cover
|
|
@@ -261,10 +265,19 @@ class TestFuturesEdgeCases:
|
|
|
261
265
|
print("P&L VERIFICATION (SHORT TRADE)")
|
|
262
266
|
print("-"*80)
|
|
263
267
|
|
|
264
|
-
|
|
268
|
+
entry_snapshot = strat.snapshots[0]
|
|
269
|
+
holding_snapshots = [s for s in strat.snapshots if s['position_qty'] < 0]
|
|
270
|
+
assert holding_snapshots, "No holding snapshots recorded for short position"
|
|
271
|
+
sell_snapshot = holding_snapshots[-1] # still short immediately before closing
|
|
272
|
+
final_snapshot = next((s for s in strat.snapshots if s['position_qty'] == 0 and s['iteration'] > sell_snapshot['iteration']), strat.snapshots[-1])
|
|
273
|
+
|
|
274
|
+
margin_deposit = float(entry_snapshot['cash']) - float(entry['cash_after']) - fee_amount
|
|
275
|
+
print(f" Margin captured: ${margin_deposit:.2f}")
|
|
276
|
+
assert pytest.approx(margin_deposit, abs=0.01) == 1300.0, f"Expected $1,300 margin, got ${margin_deposit:.2f}"
|
|
277
|
+
|
|
265
278
|
entry_price = entry['price']
|
|
266
279
|
exit_price = exit_trade['price']
|
|
267
|
-
price_change = entry_price - exit_price #
|
|
280
|
+
price_change = entry_price - exit_price # inverted for short
|
|
268
281
|
expected_pnl = price_change * MES_MULTIPLIER
|
|
269
282
|
|
|
270
283
|
print(f" Entry (sell): ${entry_price:.2f}")
|
|
@@ -272,23 +285,34 @@ class TestFuturesEdgeCases:
|
|
|
272
285
|
print(f" Price change (entry - exit): ${price_change:.2f}")
|
|
273
286
|
print(f" Expected P&L: ${expected_pnl:.2f} (inverted for short)")
|
|
274
287
|
|
|
275
|
-
#
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
# Mark-to-market validation during the hold window
|
|
289
|
+
for snap in holding_snapshots[:-1]:
|
|
290
|
+
current_price = snap['price']
|
|
291
|
+
if current_price is None:
|
|
292
|
+
continue
|
|
293
|
+
unrealized = (entry_price - current_price) * MES_MULTIPLIER
|
|
294
|
+
expected_portfolio = float(entry['cash_after']) + margin_deposit + unrealized
|
|
295
|
+
assert pytest.approx(expected_portfolio, abs=0.01) == float(snap['portfolio']), (
|
|
296
|
+
f"Short unrealized P&L mismatch at {snap['datetime']}: "
|
|
297
|
+
f"expected ${expected_portfolio:,.2f}, got ${snap['portfolio']:,.2f}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
assert pytest.approx(float(sell_snapshot['cash']), abs=0.01) == float(entry['cash_after']), \
|
|
301
|
+
"Cash should not drift while holding the short position"
|
|
302
|
+
|
|
303
|
+
# Final cash should equal initial cash minus fees plus realized P&L
|
|
304
|
+
starting_cash = float(entry_snapshot['cash'])
|
|
305
|
+
expected_final_cash = starting_cash - 2 * fee_amount + expected_pnl
|
|
306
|
+
print(f"\n Expected final cash: ${expected_final_cash:,.2f}")
|
|
307
|
+
actual_final_cash = float(final_snapshot['cash'])
|
|
308
|
+
print(f" Actual final cash: ${actual_final_cash:,.2f}")
|
|
309
|
+
assert pytest.approx(expected_final_cash, abs=0.01) == actual_final_cash, \
|
|
310
|
+
f"Final cash mismatch: expected ${expected_final_cash:,.2f}, got ${actual_final_cash:,.2f}"
|
|
311
|
+
|
|
312
|
+
assert pytest.approx(expected_final_cash, abs=0.01) == float(exit_trade['cash_after']), \
|
|
313
|
+
"Cash reported in exit trade callback should match final ledger"
|
|
314
|
+
|
|
315
|
+
print(f"\n✓ PASS: Short selling P&L and cash bookkeeping are exact")
|
|
292
316
|
|
|
293
317
|
print("\n" + "="*80)
|
|
294
318
|
print("✓ SHORT SELLING TEST PASSED")
|
|
@@ -383,54 +407,63 @@ class TestFuturesEdgeCases:
|
|
|
383
407
|
assert trade['multiplier'] == ES_MULTIPLIER, \
|
|
384
408
|
f"ES multiplier should be {ES_MULTIPLIER}, got {trade['multiplier']}"
|
|
385
409
|
|
|
386
|
-
|
|
410
|
+
fee_amount = float(fee.flat_fee)
|
|
411
|
+
mes_entry = mes_trades[0]
|
|
412
|
+
mes_exit = mes_trades[1]
|
|
413
|
+
es_entry = es_trades[0]
|
|
414
|
+
es_exit = es_trades[1]
|
|
415
|
+
|
|
416
|
+
mes_entry_snapshot = next(s for s in strat.snapshots if s['datetime'] == mes_entry['datetime'])
|
|
417
|
+
es_entry_snapshot = next(s for s in strat.snapshots if s['datetime'] == es_entry['datetime'])
|
|
418
|
+
|
|
419
|
+
mes_margin = float(mes_entry_snapshot['cash']) - float(mes_entry['cash_after']) - fee_amount
|
|
420
|
+
es_margin = float(es_entry_snapshot['cash']) - float(es_entry['cash_after']) - fee_amount
|
|
421
|
+
|
|
422
|
+
assert pytest.approx(mes_margin, abs=0.01) == MES_MARGIN, \
|
|
423
|
+
f"MES margin should be ${MES_MARGIN}, got ${mes_margin:.2f}"
|
|
424
|
+
assert pytest.approx(es_margin, abs=0.01) == ES_MARGIN, \
|
|
425
|
+
f"ES margin should be ${ES_MARGIN}, got ${es_margin:.2f}"
|
|
426
|
+
|
|
387
427
|
print("\n" + "-"*80)
|
|
388
|
-
print("
|
|
428
|
+
print("MARK-TO-MARKET VERIFICATION")
|
|
389
429
|
print("-"*80)
|
|
390
430
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
mes_exit = mes_trades[1]
|
|
394
|
-
mes_pnl = (mes_exit['price'] - mes_entry['price']) * MES_MULTIPLIER
|
|
431
|
+
for snap in strat.snapshots:
|
|
432
|
+
expected_portfolio = float(snap['cash'])
|
|
395
433
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
print(f" P&L: ${mes_pnl:.2f}")
|
|
434
|
+
if snap['mes_qty'] != 0 and snap.get('mes_price') is not None:
|
|
435
|
+
expected_portfolio += abs(snap['mes_qty']) * MES_MARGIN
|
|
436
|
+
expected_portfolio += (snap['mes_price'] - mes_entry['price']) * snap['mes_qty'] * MES_MULTIPLIER
|
|
400
437
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
es_pnl = (es_exit['price'] - es_entry['price']) * ES_MULTIPLIER
|
|
438
|
+
if snap['es_qty'] != 0 and snap.get('es_price') is not None:
|
|
439
|
+
expected_portfolio += abs(snap['es_qty']) * ES_MARGIN
|
|
440
|
+
expected_portfolio += (snap['es_price'] - es_entry['price']) * snap['es_qty'] * ES_MULTIPLIER
|
|
405
441
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
442
|
+
assert pytest.approx(expected_portfolio, abs=0.01) == float(snap['portfolio']), (
|
|
443
|
+
f"Portfolio mismatch at {snap['datetime']}: "
|
|
444
|
+
f"expected ${expected_portfolio:,.2f}, got ${snap['portfolio']:,.2f}"
|
|
445
|
+
)
|
|
410
446
|
|
|
411
|
-
|
|
447
|
+
mes_pnl = (mes_exit['price'] - mes_entry['price']) * MES_MULTIPLIER
|
|
448
|
+
es_pnl = (es_exit['price'] - es_entry['price']) * ES_MULTIPLIER
|
|
412
449
|
total_pnl = mes_pnl + es_pnl
|
|
413
|
-
total_fees = 4
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
print(f"
|
|
430
|
-
|
|
431
|
-
# Allow tolerance
|
|
432
|
-
assert cash_diff < 200, f"Cash difference too large: ${cash_diff:.2f}"
|
|
433
|
-
print(f"\n✓ PASS: Multiple simultaneous positions tracked correctly")
|
|
450
|
+
total_fees = 4 * fee_amount
|
|
451
|
+
|
|
452
|
+
starting_cash = float(strat.snapshots[0]['cash'])
|
|
453
|
+
expected_final_cash = starting_cash - total_fees + total_pnl
|
|
454
|
+
final_cash = float(strat.snapshots[-1]['cash'])
|
|
455
|
+
|
|
456
|
+
print(f"\nTotal realised P&L: ${total_pnl:.2f}")
|
|
457
|
+
print(f"Total fees: ${total_fees:.2f}")
|
|
458
|
+
print(f"Expected final cash: ${expected_final_cash:,.2f}")
|
|
459
|
+
print(f"Actual final cash: ${final_cash:,.2f}")
|
|
460
|
+
|
|
461
|
+
assert pytest.approx(expected_final_cash, abs=0.01) == final_cash, \
|
|
462
|
+
f"Final cash mismatch: expected ${expected_final_cash:,.2f}, got ${final_cash:,.2f}"
|
|
463
|
+
assert pytest.approx(expected_final_cash, abs=0.01) == float(es_exit['cash_after']), \
|
|
464
|
+
"Exit trade cash should match ledger final cash"
|
|
465
|
+
|
|
466
|
+
print(f"\n✓ PASS: Multiple simultaneous positions tracked with exact accounting")
|
|
434
467
|
|
|
435
468
|
# Verify we had both positions at the same time (iteration 3-4)
|
|
436
469
|
snapshot_with_both = None
|
|
@@ -98,7 +98,7 @@ class TestBacktestingDataSourceEnv:
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
# Verify the log message shows polygon was selected
|
|
101
|
-
assert any("
|
|
101
|
+
assert any("Using BACKTESTING_DATA_SOURCE setting for backtest data: polygon" in record.message
|
|
102
102
|
for record in caplog.records)
|
|
103
103
|
|
|
104
104
|
def test_auto_select_thetadata_case_insensitive(self, clean_environment, restore_theta_credentials, caplog):
|
|
@@ -130,7 +130,7 @@ class TestBacktestingDataSourceEnv:
|
|
|
130
130
|
pass
|
|
131
131
|
|
|
132
132
|
# Verify the log message shows thetadata was selected OR check for ThetaData error
|
|
133
|
-
thetadata_selected = any("
|
|
133
|
+
thetadata_selected = any("Using BACKTESTING_DATA_SOURCE setting for backtest data: THETADATA" in record.message
|
|
134
134
|
for record in caplog.records)
|
|
135
135
|
thetadata_attempted = any("Cannot connect to Theta Data" in record.message or "ThetaData" in record.message
|
|
136
136
|
for record in caplog.records)
|
|
@@ -183,8 +183,11 @@ class TestBacktestingDataSourceEnv:
|
|
|
183
183
|
show_indicators=False,
|
|
184
184
|
)
|
|
185
185
|
|
|
186
|
-
def
|
|
187
|
-
"""Test that
|
|
186
|
+
def test_env_override_wins_over_explicit_datasource(self, clean_environment, restore_theta_credentials, caplog):
|
|
187
|
+
"""Test that BACKTESTING_DATA_SOURCE env var takes precedence over explicit datasource_class."""
|
|
188
|
+
import logging
|
|
189
|
+
caplog.set_level(logging.INFO, logger='lumibot.strategies._strategy')
|
|
190
|
+
|
|
188
191
|
with patch.dict(os.environ, {'BACKTESTING_DATA_SOURCE': 'polygon'}):
|
|
189
192
|
# Re-import credentials to pick up env change
|
|
190
193
|
from importlib import reload
|
|
@@ -205,9 +208,36 @@ class TestBacktestingDataSourceEnv:
|
|
|
205
208
|
show_progress_bar=False,
|
|
206
209
|
)
|
|
207
210
|
|
|
208
|
-
# Verify the
|
|
209
|
-
assert
|
|
210
|
-
|
|
211
|
+
# Verify the env override message was logged (env var wins)
|
|
212
|
+
assert any("Using BACKTESTING_DATA_SOURCE setting for backtest data: polygon" in record.message
|
|
213
|
+
for record in caplog.records)
|
|
214
|
+
|
|
215
|
+
def test_explicit_datasource_used_when_env_none(self, clean_environment, restore_theta_credentials, caplog):
|
|
216
|
+
"""Test that setting BACKTESTING_DATA_SOURCE to 'none' defers to the explicit datasource_class."""
|
|
217
|
+
import logging
|
|
218
|
+
caplog.set_level(logging.INFO, logger='lumibot.strategies._strategy')
|
|
219
|
+
|
|
220
|
+
with patch.dict(os.environ, {'BACKTESTING_DATA_SOURCE': 'none'}):
|
|
221
|
+
from importlib import reload
|
|
222
|
+
import lumibot.credentials
|
|
223
|
+
reload(lumibot.credentials)
|
|
224
|
+
|
|
225
|
+
backtesting_start = datetime(2023, 1, 1)
|
|
226
|
+
backtesting_end = datetime(2023, 1, 10) # Shorter backtest for speed
|
|
227
|
+
|
|
228
|
+
SimpleTestStrategy.run_backtest(
|
|
229
|
+
YahooDataBacktesting,
|
|
230
|
+
backtesting_start=backtesting_start,
|
|
231
|
+
backtesting_end=backtesting_end,
|
|
232
|
+
show_plot=False,
|
|
233
|
+
show_tearsheet=False,
|
|
234
|
+
show_indicators=False,
|
|
235
|
+
show_progress_bar=False,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Confirm no override occurred
|
|
239
|
+
assert not any("Using BACKTESTING_DATA_SOURCE setting for backtest data" in record.message
|
|
240
|
+
for record in caplog.records)
|
|
211
241
|
|
|
212
242
|
def test_default_thetadata_when_no_env_set(self, clean_environment, restore_theta_credentials, caplog):
|
|
213
243
|
"""Test that ThetaData is the default when BACKTESTING_DATA_SOURCE is not set."""
|
|
@@ -240,9 +270,13 @@ class TestBacktestingDataSourceEnv:
|
|
|
240
270
|
# Expected to fail with test credentials - that's okay
|
|
241
271
|
pass
|
|
242
272
|
|
|
243
|
-
# Verify ThetaData was attempted (
|
|
244
|
-
assert any(
|
|
245
|
-
|
|
273
|
+
# Verify ThetaData was attempted (look for override message or Theta-specific logs)
|
|
274
|
+
assert any(
|
|
275
|
+
"Using BACKTESTING_DATA_SOURCE setting for backtest data: ThetaData" in record.message
|
|
276
|
+
or "Cannot connect to Theta Data" in record.message
|
|
277
|
+
or "ThetaData" in record.message
|
|
278
|
+
for record in caplog.records
|
|
279
|
+
), "ThetaData was not used as default"
|
|
246
280
|
|
|
247
281
|
|
|
248
282
|
if __name__ == "__main__":
|
|
@@ -2,6 +2,7 @@ import unittest
|
|
|
2
2
|
from unittest.mock import Mock, patch, MagicMock
|
|
3
3
|
from datetime import datetime, timedelta
|
|
4
4
|
import pandas as pd
|
|
5
|
+
import pytz
|
|
5
6
|
|
|
6
7
|
from lumibot.backtesting.databento_backtesting import DataBentoDataBacktesting
|
|
7
8
|
from lumibot.entities import Asset, Data
|
|
@@ -224,6 +225,49 @@ class TestDataBentoDataBacktesting(unittest.TestCase):
|
|
|
224
225
|
self.assertEqual(result, 4250.75)
|
|
225
226
|
mock_get_last_price.assert_called_once()
|
|
226
227
|
|
|
228
|
+
@patch('lumibot.tools.databento_helper.DATABENTO_AVAILABLE', True)
|
|
229
|
+
@patch('lumibot.tools.databento_helper.get_last_price_from_databento')
|
|
230
|
+
def test_get_last_price_uses_current_bar_when_available(self, mock_get_last_price):
|
|
231
|
+
"""
|
|
232
|
+
Ensure we reuse the latest completed bar from cache (rather than hitting the API)
|
|
233
|
+
when no earlier bar exists in the window.
|
|
234
|
+
"""
|
|
235
|
+
ny = pytz.timezone("America/New_York")
|
|
236
|
+
current_dt = ny.localize(datetime(2025, 1, 1, 9, 30))
|
|
237
|
+
|
|
238
|
+
backtester = DataBentoDataBacktesting(
|
|
239
|
+
datetime_start=self.start_date,
|
|
240
|
+
datetime_end=self.end_date,
|
|
241
|
+
api_key=self.api_key
|
|
242
|
+
)
|
|
243
|
+
backtester._datetime = current_dt
|
|
244
|
+
|
|
245
|
+
# Seed cached data with a single bar at the current timestamp
|
|
246
|
+
test_df = pd.DataFrame(
|
|
247
|
+
{
|
|
248
|
+
"open": [100.0],
|
|
249
|
+
"high": [101.0],
|
|
250
|
+
"low": [99.5],
|
|
251
|
+
"close": [100.5],
|
|
252
|
+
"volume": [1000],
|
|
253
|
+
},
|
|
254
|
+
index=pd.DatetimeIndex([current_dt]),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
data = Data(
|
|
258
|
+
self.test_asset,
|
|
259
|
+
df=test_df,
|
|
260
|
+
timestep="minute",
|
|
261
|
+
quote=Asset("USD", "forex"),
|
|
262
|
+
)
|
|
263
|
+
search_asset = (self.test_asset, Asset("USD", "forex"))
|
|
264
|
+
backtester.pandas_data[search_asset] = data
|
|
265
|
+
|
|
266
|
+
price = backtester.get_last_price(asset=self.test_asset)
|
|
267
|
+
|
|
268
|
+
self.assertEqual(price, 100.5)
|
|
269
|
+
mock_get_last_price.assert_not_called()
|
|
270
|
+
|
|
227
271
|
@patch('lumibot.tools.databento_helper.DATABENTO_AVAILABLE', True)
|
|
228
272
|
def test_get_chains(self):
|
|
229
273
|
"""Test options chains retrieval (should return empty dict)"""
|
tests/test_databento_helper.py
CHANGED
|
@@ -49,7 +49,12 @@ class TestDataBentoHelper(unittest.TestCase):
|
|
|
49
49
|
self.assertRegex(result, r"ES[FGHJKMNQUVXZ]\d", "Should auto-resolve to contract format like ESZ5")
|
|
50
50
|
|
|
51
51
|
# Test specific contract with expiration (March 2025 = H25)
|
|
52
|
-
|
|
52
|
+
specific_future = Asset(
|
|
53
|
+
symbol="ES",
|
|
54
|
+
asset_type="future",
|
|
55
|
+
expiration=datetime(2025, 3, 15).date()
|
|
56
|
+
)
|
|
57
|
+
result = databento_helper._format_futures_symbol_for_databento(specific_future)
|
|
53
58
|
self.assertEqual(result, "ESH25") # March 2025 = H25
|
|
54
59
|
|
|
55
60
|
# Test another month (December 2024 = Z24)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|