lumibot 4.0.23__py3-none-any.whl → 4.1.0__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/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/__pycache__/constants.cpython-312.pyc +0 -0
- lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
- lumibot/backtesting/__init__.py +6 -5
- lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/backtesting_broker.py +209 -9
- lumibot/backtesting/databento_backtesting.py +141 -24
- lumibot/backtesting/thetadata_backtesting.py +63 -42
- lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
- lumibot/brokers/alpaca.py +11 -1
- lumibot/brokers/tradeovate.py +475 -0
- lumibot/components/grok_news_helper.py +284 -0
- lumibot/components/options_helper.py +90 -34
- lumibot/credentials.py +3 -0
- lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
- lumibot/data_sources/data_source_backtesting.py +3 -5
- lumibot/data_sources/databento_data_polars_backtesting.py +194 -48
- lumibot/data_sources/pandas_data.py +6 -3
- lumibot/data_sources/polars_mixin.py +126 -21
- lumibot/data_sources/tradeovate_data.py +80 -0
- lumibot/data_sources/tradier_data.py +2 -1
- lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
- lumibot/entities/asset.py +8 -0
- lumibot/entities/order.py +1 -1
- lumibot/entities/quote.py +14 -0
- lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
- lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
- lumibot/strategies/_strategy.py +95 -27
- lumibot/strategies/strategy.py +5 -6
- lumibot/strategies/strategy_executor.py +2 -2
- lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/tools/databento_helper.py +384 -133
- lumibot/tools/databento_helper_polars.py +218 -156
- lumibot/tools/databento_roll.py +216 -0
- lumibot/tools/lumibot_logger.py +32 -17
- lumibot/tools/polygon_helper.py +65 -0
- lumibot/tools/thetadata_helper.py +588 -70
- lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
- lumibot/traders/trader.py +1 -1
- lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/METADATA +1 -2
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/RECORD +160 -44
- tests/backtest/check_timing_offset.py +198 -0
- tests/backtest/check_volume_spike.py +112 -0
- tests/backtest/comprehensive_comparison.py +166 -0
- tests/backtest/debug_comparison.py +91 -0
- tests/backtest/diagnose_price_difference.py +97 -0
- tests/backtest/direct_api_comparison.py +203 -0
- tests/backtest/profile_thetadata_vs_polygon.py +255 -0
- tests/backtest/root_cause_analysis.py +109 -0
- tests/backtest/test_accuracy_verification.py +244 -0
- tests/backtest/test_daily_data_timestamp_comparison.py +801 -0
- tests/backtest/test_databento.py +4 -0
- tests/backtest/test_databento_comprehensive_trading.py +564 -0
- tests/backtest/test_debug_avg_fill_price.py +112 -0
- tests/backtest/test_dividends.py +8 -3
- tests/backtest/test_example_strategies.py +54 -47
- tests/backtest/test_futures_edge_cases.py +451 -0
- tests/backtest/test_futures_single_trade.py +270 -0
- tests/backtest/test_futures_ultra_simple.py +191 -0
- tests/backtest/test_index_data_verification.py +348 -0
- tests/backtest/test_polygon.py +45 -24
- tests/backtest/test_thetadata.py +246 -60
- tests/backtest/test_thetadata_comprehensive.py +729 -0
- tests/backtest/test_thetadata_vs_polygon.py +557 -0
- tests/backtest/test_yahoo.py +1 -2
- tests/conftest.py +20 -0
- tests/test_backtesting_data_source_env.py +249 -0
- tests/test_backtesting_quiet_logs_complete.py +10 -11
- tests/test_databento_helper.py +73 -86
- tests/test_databento_timezone_fixes.py +21 -4
- tests/test_get_historical_prices.py +6 -6
- tests/test_options_helper.py +162 -40
- tests/test_polygon_helper.py +21 -13
- tests/test_quiet_logs_requirements.py +5 -5
- tests/test_thetadata_helper.py +487 -171
- tests/test_yahoo_data.py +125 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/LICENSE +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/WHEEL +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comparison tests between ThetaData and Polygon data sources.
|
|
3
|
+
|
|
4
|
+
These tests run the same strategy with both data sources and compare:
|
|
5
|
+
- Stock prices
|
|
6
|
+
- Option prices
|
|
7
|
+
- Index prices (SPX, VIX)
|
|
8
|
+
- Option chains
|
|
9
|
+
- Fill prices
|
|
10
|
+
- Portfolio values
|
|
11
|
+
|
|
12
|
+
The goal is to ensure ThetaData produces comparable results to Polygon,
|
|
13
|
+
which we trust as our baseline for data accuracy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import os
|
|
18
|
+
import pytest
|
|
19
|
+
import json
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
from lumibot.backtesting import BacktestingBroker, ThetaDataBacktesting, PolygonDataBacktesting
|
|
22
|
+
from lumibot.entities import Asset
|
|
23
|
+
from lumibot.strategies import Strategy
|
|
24
|
+
from lumibot.traders import Trader
|
|
25
|
+
|
|
26
|
+
# Load environment variables from .env file
|
|
27
|
+
load_dotenv()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detailed_comparison_report(theta_data, polygon_data, data_type):
|
|
31
|
+
"""
|
|
32
|
+
Create a detailed comparison report when prices don't match.
|
|
33
|
+
Any difference requires investigation - there is ZERO tolerance.
|
|
34
|
+
"""
|
|
35
|
+
report = [
|
|
36
|
+
f"\n{'='*80}",
|
|
37
|
+
f"PRICE MISMATCH DETECTED - {data_type.upper()}",
|
|
38
|
+
f"{'='*80}",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for key in theta_data.keys():
|
|
42
|
+
theta_val = theta_data.get(key)
|
|
43
|
+
polygon_val = polygon_data.get(key)
|
|
44
|
+
|
|
45
|
+
if isinstance(theta_val, (int, float)) and isinstance(polygon_val, (int, float)):
|
|
46
|
+
diff = theta_val - polygon_val
|
|
47
|
+
if diff != 0:
|
|
48
|
+
report.append(f"\n{key}:")
|
|
49
|
+
report.append(f" ThetaData: {theta_val}")
|
|
50
|
+
report.append(f" Polygon: {polygon_val}")
|
|
51
|
+
report.append(f" Difference: {diff}")
|
|
52
|
+
report.append(f" Diff %: {(diff/polygon_val*100):.6f}%")
|
|
53
|
+
else:
|
|
54
|
+
report.append(f"\n{key}:")
|
|
55
|
+
report.append(f" ThetaData: {theta_val}")
|
|
56
|
+
report.append(f" Polygon: {polygon_val}")
|
|
57
|
+
|
|
58
|
+
report.append(f"\n{'='*80}")
|
|
59
|
+
report.append("Full ThetaData:")
|
|
60
|
+
report.append(json.dumps(theta_data, indent=2, default=str))
|
|
61
|
+
report.append(f"\n{'='*80}")
|
|
62
|
+
report.append("Full Polygon:")
|
|
63
|
+
report.append(json.dumps(polygon_data, indent=2, default=str))
|
|
64
|
+
report.append(f"\n{'='*80}\n")
|
|
65
|
+
|
|
66
|
+
return "\n".join(report)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ComparisonStrategy(Strategy):
|
|
70
|
+
"""Strategy that collects data points for comparison."""
|
|
71
|
+
|
|
72
|
+
parameters = {
|
|
73
|
+
"symbol": "AMZN",
|
|
74
|
+
"test_type": "stock", # "stock", "option", or "index"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def initialize(self):
|
|
78
|
+
self.sleeptime = "1D"
|
|
79
|
+
self.data_points = {
|
|
80
|
+
"stock_prices": [],
|
|
81
|
+
"option_prices": [],
|
|
82
|
+
"index_prices": [],
|
|
83
|
+
"chains_data": None,
|
|
84
|
+
"fill_prices": [],
|
|
85
|
+
"portfolio_values": [],
|
|
86
|
+
"cash_values": [],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def on_trading_iteration(self):
|
|
90
|
+
test_type = self.parameters.get("test_type", "stock")
|
|
91
|
+
|
|
92
|
+
if test_type == "stock":
|
|
93
|
+
self._test_stock()
|
|
94
|
+
elif test_type == "option":
|
|
95
|
+
self._test_option()
|
|
96
|
+
elif test_type == "index":
|
|
97
|
+
self._test_index()
|
|
98
|
+
|
|
99
|
+
def _test_stock(self):
|
|
100
|
+
"""Test stock data collection."""
|
|
101
|
+
if self.first_iteration:
|
|
102
|
+
symbol = self.parameters["symbol"]
|
|
103
|
+
asset = Asset(symbol, asset_type="stock")
|
|
104
|
+
|
|
105
|
+
# Get price and quote
|
|
106
|
+
price = self.get_last_price(asset)
|
|
107
|
+
quote = self.get_quote(asset)
|
|
108
|
+
dt = self.get_datetime()
|
|
109
|
+
|
|
110
|
+
# Collect detailed information for diagnosis
|
|
111
|
+
quote_dict = {
|
|
112
|
+
"price": quote.price if hasattr(quote, 'price') else None,
|
|
113
|
+
"bid": quote.bid if hasattr(quote, 'bid') else None,
|
|
114
|
+
"ask": quote.ask if hasattr(quote, 'ask') else None,
|
|
115
|
+
"open": quote.open if hasattr(quote, 'open') else None,
|
|
116
|
+
"high": quote.high if hasattr(quote, 'high') else None,
|
|
117
|
+
"low": quote.low if hasattr(quote, 'low') else None,
|
|
118
|
+
"close": quote.close if hasattr(quote, 'close') else None,
|
|
119
|
+
"volume": quote.volume if hasattr(quote, 'volume') else None,
|
|
120
|
+
"bid_size": quote.bid_size if hasattr(quote, 'bid_size') else None,
|
|
121
|
+
"ask_size": quote.ask_size if hasattr(quote, 'ask_size') else None,
|
|
122
|
+
"timestamp": str(quote.timestamp) if hasattr(quote, 'timestamp') else None,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
self.data_points["stock_prices"].append({
|
|
126
|
+
"price": price,
|
|
127
|
+
"quote": quote_dict,
|
|
128
|
+
"datetime": str(dt),
|
|
129
|
+
"datetime_obj": dt,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
# Buy 10 shares to test fill price
|
|
133
|
+
order = self.create_order(asset, 10, "buy")
|
|
134
|
+
self.submit_order(order)
|
|
135
|
+
|
|
136
|
+
def _test_option(self):
|
|
137
|
+
"""Test option data collection."""
|
|
138
|
+
if self.first_iteration:
|
|
139
|
+
symbol = self.parameters["symbol"]
|
|
140
|
+
underlying = Asset(symbol, asset_type="stock")
|
|
141
|
+
|
|
142
|
+
# Get underlying price
|
|
143
|
+
underlying_price = self.get_last_price(underlying)
|
|
144
|
+
|
|
145
|
+
# Get chains
|
|
146
|
+
chains = self.get_chains(underlying)
|
|
147
|
+
self.data_points["chains_data"] = {
|
|
148
|
+
"multiplier": chains.get("Multiplier"),
|
|
149
|
+
"exchange": chains.get("Exchange"),
|
|
150
|
+
"call_expirations_count": len(chains.calls()) if hasattr(chains, 'calls') else len(chains.get("Chains", {}).get("CALL", {})),
|
|
151
|
+
"put_expirations_count": len(chains.puts()) if hasattr(chains, 'puts') else len(chains.get("Chains", {}).get("PUT", {})),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Try to create an option order
|
|
155
|
+
try:
|
|
156
|
+
# Get first available expiration
|
|
157
|
+
if hasattr(chains, 'expirations'):
|
|
158
|
+
expirations = chains.expirations("CALL")
|
|
159
|
+
else:
|
|
160
|
+
call_chains = chains.get("Chains", {}).get("CALL", {})
|
|
161
|
+
expirations = sorted(call_chains.keys())
|
|
162
|
+
|
|
163
|
+
if expirations:
|
|
164
|
+
expiration_str = expirations[0]
|
|
165
|
+
expiration = datetime.datetime.strptime(expiration_str, "%Y-%m-%d").date()
|
|
166
|
+
|
|
167
|
+
# Get strikes for this expiration
|
|
168
|
+
if hasattr(chains, 'strikes'):
|
|
169
|
+
strikes = chains.strikes(expiration_str, "CALL")
|
|
170
|
+
else:
|
|
171
|
+
strikes = chains.get("Chains", {}).get("CALL", {}).get(expiration_str, [])
|
|
172
|
+
|
|
173
|
+
if strikes:
|
|
174
|
+
# Find ATM strike
|
|
175
|
+
strike = min(strikes, key=lambda x: abs(x - underlying_price))
|
|
176
|
+
|
|
177
|
+
option = Asset(
|
|
178
|
+
symbol,
|
|
179
|
+
asset_type="option",
|
|
180
|
+
expiration=expiration,
|
|
181
|
+
strike=strike,
|
|
182
|
+
right="CALL"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
option_price = self.get_last_price(option)
|
|
186
|
+
self.data_points["option_prices"].append({
|
|
187
|
+
"price": option_price,
|
|
188
|
+
"strike": strike,
|
|
189
|
+
"expiration": expiration,
|
|
190
|
+
"datetime": self.get_datetime(),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
# Buy 1 contract
|
|
194
|
+
order = self.create_order(option, 1, "buy")
|
|
195
|
+
self.submit_order(order)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
self.log_message(f"Error creating option order: {e}")
|
|
198
|
+
|
|
199
|
+
def _test_index(self):
|
|
200
|
+
"""Test index data collection."""
|
|
201
|
+
if self.first_iteration:
|
|
202
|
+
index_symbol = self.parameters.get("index_symbol", "SPX")
|
|
203
|
+
asset = Asset(index_symbol, asset_type="index")
|
|
204
|
+
|
|
205
|
+
# Get price and quote
|
|
206
|
+
try:
|
|
207
|
+
price = self.get_last_price(asset)
|
|
208
|
+
quote = self.get_quote(asset)
|
|
209
|
+
|
|
210
|
+
self.data_points["index_prices"].append({
|
|
211
|
+
"symbol": index_symbol,
|
|
212
|
+
"price": price,
|
|
213
|
+
"quote": quote,
|
|
214
|
+
"datetime": self.get_datetime(),
|
|
215
|
+
})
|
|
216
|
+
except Exception as e:
|
|
217
|
+
self.log_message(f"Error getting index price for {index_symbol}: {e}")
|
|
218
|
+
|
|
219
|
+
def on_filled_order(self, position, order, price, quantity, multiplier):
|
|
220
|
+
self.data_points["fill_prices"].append({
|
|
221
|
+
"price": price,
|
|
222
|
+
"quantity": quantity,
|
|
223
|
+
"multiplier": multiplier,
|
|
224
|
+
"asset": str(order.asset),
|
|
225
|
+
"datetime": self.get_datetime(),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
def after_market_closes(self):
|
|
229
|
+
self.data_points["portfolio_values"].append(self.portfolio_value)
|
|
230
|
+
self.data_points["cash_values"].append(self.cash)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_backtest(data_source_class, **params):
|
|
234
|
+
"""
|
|
235
|
+
Helper to run a backtest with a given data source.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
data_source_class: ThetaDataBacktesting or PolygonDataBacktesting
|
|
239
|
+
**params: Parameters for the strategy (symbol, test_type, etc.)
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
dict: Data points collected by the strategy
|
|
243
|
+
"""
|
|
244
|
+
# Use recent dates to avoid Polygon subscription limitations
|
|
245
|
+
# Free tier allows last 2 years of data
|
|
246
|
+
start = datetime.datetime(2024, 8, 1)
|
|
247
|
+
end = datetime.datetime(2024, 8, 2, 23, 59, 59)
|
|
248
|
+
|
|
249
|
+
# Create data source
|
|
250
|
+
if data_source_class == ThetaDataBacktesting:
|
|
251
|
+
data_source = ThetaDataBacktesting(
|
|
252
|
+
datetime_start=start,
|
|
253
|
+
datetime_end=end,
|
|
254
|
+
username=os.environ.get("THETADATA_USERNAME"),
|
|
255
|
+
password=os.environ.get("THETADATA_PASSWORD"),
|
|
256
|
+
)
|
|
257
|
+
else: # PolygonDataBacktesting
|
|
258
|
+
data_source = PolygonDataBacktesting(
|
|
259
|
+
datetime_start=start,
|
|
260
|
+
datetime_end=end,
|
|
261
|
+
api_key=os.environ.get("POLYGON_API_KEY"),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Run backtest
|
|
265
|
+
broker = BacktestingBroker(data_source=data_source)
|
|
266
|
+
strategy = ComparisonStrategy(broker=broker, parameters=params)
|
|
267
|
+
trader = Trader(logfile="", backtest=True)
|
|
268
|
+
trader.add_strategy(strategy)
|
|
269
|
+
trader.run_all(show_plot=False, show_tearsheet=False, show_indicators=False, save_tearsheet=False)
|
|
270
|
+
|
|
271
|
+
return strategy.data_points
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.mark.apitest
|
|
275
|
+
class TestThetaDataVsPolygonComparison:
|
|
276
|
+
"""Comparison tests between ThetaData and Polygon."""
|
|
277
|
+
|
|
278
|
+
def test_stock_price_comparison(self):
|
|
279
|
+
"""
|
|
280
|
+
Compare stock prices between ThetaData and Polygon.
|
|
281
|
+
ZERO TOLERANCE - prices must match exactly or investigation is required.
|
|
282
|
+
"""
|
|
283
|
+
params = {"symbol": "AMZN", "test_type": "stock"}
|
|
284
|
+
|
|
285
|
+
# Run with ThetaData
|
|
286
|
+
theta_data = run_backtest(ThetaDataBacktesting, **params)
|
|
287
|
+
|
|
288
|
+
# Run with Polygon
|
|
289
|
+
polygon_data = run_backtest(PolygonDataBacktesting, **params)
|
|
290
|
+
|
|
291
|
+
# Compare stock prices
|
|
292
|
+
assert len(theta_data["stock_prices"]) > 0, "ThetaData: No stock prices collected"
|
|
293
|
+
assert len(polygon_data["stock_prices"]) > 0, "Polygon: No stock prices collected"
|
|
294
|
+
|
|
295
|
+
theta_info = theta_data["stock_prices"][0]
|
|
296
|
+
polygon_info = polygon_data["stock_prices"][0]
|
|
297
|
+
|
|
298
|
+
theta_price = theta_info["price"]
|
|
299
|
+
polygon_price = polygon_info["price"]
|
|
300
|
+
|
|
301
|
+
# Tolerance: 1 cent for liquid stocks (accounts for SIP feed timing differences)
|
|
302
|
+
tolerance = 0.01
|
|
303
|
+
price_diff = abs(theta_price - polygon_price)
|
|
304
|
+
|
|
305
|
+
if price_diff > tolerance:
|
|
306
|
+
report = detailed_comparison_report(theta_info, polygon_info, "STOCK PRICE")
|
|
307
|
+
print(report)
|
|
308
|
+
pytest.fail(
|
|
309
|
+
f"Stock prices differ by more than ${tolerance}:\n"
|
|
310
|
+
f" ThetaData: ${theta_price}\n"
|
|
311
|
+
f" Polygon: ${polygon_price}\n"
|
|
312
|
+
f" Difference: ${price_diff} (tolerance: ${tolerance})\n"
|
|
313
|
+
f"See detailed report above."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
print(f"✓ Stock prices match within tolerance: ThetaData=${theta_price}, Polygon=${polygon_price}, diff=${price_diff:.4f}")
|
|
317
|
+
|
|
318
|
+
def test_option_price_comparison(self):
|
|
319
|
+
"""
|
|
320
|
+
Compare option prices between ThetaData and Polygon.
|
|
321
|
+
ZERO TOLERANCE - prices must match exactly or investigation is required.
|
|
322
|
+
"""
|
|
323
|
+
params = {"symbol": "AMZN", "test_type": "option"}
|
|
324
|
+
|
|
325
|
+
# Run with ThetaData
|
|
326
|
+
theta_data = run_backtest(ThetaDataBacktesting, **params)
|
|
327
|
+
|
|
328
|
+
# Run with Polygon
|
|
329
|
+
polygon_data = run_backtest(PolygonDataBacktesting, **params)
|
|
330
|
+
|
|
331
|
+
# Compare chains data
|
|
332
|
+
assert theta_data["chains_data"] is not None, "ThetaData: No chains data"
|
|
333
|
+
assert polygon_data["chains_data"] is not None, "Polygon: No chains data"
|
|
334
|
+
|
|
335
|
+
theta_chains = theta_data["chains_data"]
|
|
336
|
+
polygon_chains = polygon_data["chains_data"]
|
|
337
|
+
|
|
338
|
+
# Both should have the same multiplier
|
|
339
|
+
if theta_chains["multiplier"] != polygon_chains["multiplier"]:
|
|
340
|
+
pytest.fail(
|
|
341
|
+
f"Multiplier MISMATCH: ThetaData={theta_chains['multiplier']}, "
|
|
342
|
+
f"Polygon={polygon_chains['multiplier']}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Both should have expirations
|
|
346
|
+
assert theta_chains["call_expirations_count"] > 0, "ThetaData: No CALL expirations"
|
|
347
|
+
assert polygon_chains["call_expirations_count"] > 0, "Polygon: No CALL expirations"
|
|
348
|
+
|
|
349
|
+
print(f"✓ Chains data collected: ThetaData expirations={theta_chains['call_expirations_count']}, "
|
|
350
|
+
f"Polygon expirations={polygon_chains['call_expirations_count']}")
|
|
351
|
+
|
|
352
|
+
# Compare option prices if available
|
|
353
|
+
if theta_data["option_prices"] and polygon_data["option_prices"]:
|
|
354
|
+
theta_info = theta_data["option_prices"][0]
|
|
355
|
+
polygon_info = polygon_data["option_prices"][0]
|
|
356
|
+
|
|
357
|
+
theta_opt_price = theta_info["price"]
|
|
358
|
+
polygon_opt_price = polygon_info["price"]
|
|
359
|
+
|
|
360
|
+
# Tolerance: 5 cents for options (wider spread, less liquid than stocks)
|
|
361
|
+
tolerance = 0.05
|
|
362
|
+
price_diff = abs(theta_opt_price - polygon_opt_price)
|
|
363
|
+
|
|
364
|
+
if price_diff > tolerance:
|
|
365
|
+
report = detailed_comparison_report(theta_info, polygon_info, "OPTION PRICE")
|
|
366
|
+
print(report)
|
|
367
|
+
pytest.fail(
|
|
368
|
+
f"Option prices differ by more than ${tolerance}:\n"
|
|
369
|
+
f" ThetaData: ${theta_opt_price}\n"
|
|
370
|
+
f" Polygon: ${polygon_opt_price}\n"
|
|
371
|
+
f" Difference: ${price_diff} (tolerance: ${tolerance})\n"
|
|
372
|
+
f"See detailed report above."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
print(f"✓ Option prices match within tolerance: ThetaData=${theta_opt_price}, Polygon=${polygon_opt_price}, diff=${price_diff:.4f}")
|
|
376
|
+
|
|
377
|
+
def test_index_price_comparison(self):
|
|
378
|
+
"""
|
|
379
|
+
Tests SPX index data accessibility and timestamp accuracy.
|
|
380
|
+
|
|
381
|
+
NOTE: ThetaData VALUE Indices plan only supports 15-minute intervals.
|
|
382
|
+
This test verifies:
|
|
383
|
+
1. SPX data is accessible
|
|
384
|
+
2. Timestamps are correct (15-min intervals)
|
|
385
|
+
3. OHLC data is consistent
|
|
386
|
+
4. No timestamp offset bugs (like the +1 minute bug we fixed for stocks)
|
|
387
|
+
"""
|
|
388
|
+
username = os.environ.get("THETADATA_USERNAME")
|
|
389
|
+
password = os.environ.get("THETADATA_PASSWORD")
|
|
390
|
+
|
|
391
|
+
from lumibot.tools import thetadata_helper
|
|
392
|
+
|
|
393
|
+
asset = Asset("SPX", asset_type="index")
|
|
394
|
+
|
|
395
|
+
# ThetaData - 15 minute intervals (VALUE plan limitation)
|
|
396
|
+
theta_df = thetadata_helper.get_historical_data(
|
|
397
|
+
asset=asset,
|
|
398
|
+
start_dt=datetime.datetime(2024, 8, 1, 9, 30),
|
|
399
|
+
end_dt=datetime.datetime(2024, 8, 1, 12, 0),
|
|
400
|
+
ivl=900000, # 15 minutes (900,000 ms) - VALUE plan supports this
|
|
401
|
+
username=username,
|
|
402
|
+
password=password,
|
|
403
|
+
datastyle='ohlc'
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Verify data was returned
|
|
407
|
+
if theta_df is None or len(theta_df) == 0:
|
|
408
|
+
pytest.fail("ThetaData SPX data not available - check indices subscription is active")
|
|
409
|
+
|
|
410
|
+
print(f"\n✓ SPX Index Data Verification (15-minute intervals):")
|
|
411
|
+
print(f"{'Time':<25} {'Open':<10} {'High':<10} {'Low':<10} {'Close':<10}")
|
|
412
|
+
print("="*80)
|
|
413
|
+
|
|
414
|
+
# Verify first few bars
|
|
415
|
+
for i in range(min(5, len(theta_df))):
|
|
416
|
+
bar = theta_df.iloc[i]
|
|
417
|
+
timestamp = theta_df.index[i]
|
|
418
|
+
print(f"{str(timestamp):<25} ${bar['open']:<9.2f} ${bar['high']:<9.2f} ${bar['low']:<9.2f} ${bar['close']:<9.2f}")
|
|
419
|
+
|
|
420
|
+
# Verify timestamps are 15 minutes apart
|
|
421
|
+
for i in range(1, min(5, len(theta_df))):
|
|
422
|
+
time_diff = (theta_df.index[i] - theta_df.index[i-1]).total_seconds()
|
|
423
|
+
assert time_diff == 900, f"Bar {i} is {time_diff}s after previous, expected 900s (15 min)"
|
|
424
|
+
|
|
425
|
+
# Verify OHLC consistency
|
|
426
|
+
for i in range(len(theta_df)):
|
|
427
|
+
bar = theta_df.iloc[i]
|
|
428
|
+
assert bar['high'] >= bar['open'], f"Bar {i}: high < open"
|
|
429
|
+
assert bar['high'] >= bar['close'], f"Bar {i}: high < close"
|
|
430
|
+
assert bar['high'] >= bar['low'], f"Bar {i}: high < low"
|
|
431
|
+
assert bar['low'] <= bar['open'], f"Bar {i}: low > open"
|
|
432
|
+
assert bar['low'] <= bar['close'], f"Bar {i}: low > close"
|
|
433
|
+
assert 4000 < bar['close'] < 7000, f"Bar {i}: close ${bar['close']:.2f} outside reasonable range"
|
|
434
|
+
|
|
435
|
+
# Verify first bar starts at 9:30 or 9:29 (depending on timestamp alignment)
|
|
436
|
+
first_time = theta_df.index[0]
|
|
437
|
+
assert first_time.hour == 9, f"First bar hour is {first_time.hour}, expected 9"
|
|
438
|
+
assert 29 <= first_time.minute <= 30, f"First bar minute is {first_time.minute}, expected 29 or 30"
|
|
439
|
+
|
|
440
|
+
print(f"\n✓ SPX index data is accessible and working correctly!")
|
|
441
|
+
print(f" - Got {len(theta_df)} bars of 15-minute data")
|
|
442
|
+
print(f" - Timestamps are 15 minutes apart")
|
|
443
|
+
print(f" - OHLC data is consistent")
|
|
444
|
+
print(f" - First bar at {first_time}")
|
|
445
|
+
print(f" - Price range: ${theta_df['close'].min():.2f} - ${theta_df['close'].max():.2f}")
|
|
446
|
+
|
|
447
|
+
def test_fill_price_comparison(self):
|
|
448
|
+
"""
|
|
449
|
+
Compare fill prices between ThetaData and Polygon.
|
|
450
|
+
ZERO TOLERANCE - prices must match exactly or investigation is required.
|
|
451
|
+
"""
|
|
452
|
+
params = {"symbol": "AMZN", "test_type": "stock"}
|
|
453
|
+
|
|
454
|
+
# Run with ThetaData
|
|
455
|
+
theta_data = run_backtest(ThetaDataBacktesting, **params)
|
|
456
|
+
|
|
457
|
+
# Run with Polygon
|
|
458
|
+
polygon_data = run_backtest(PolygonDataBacktesting, **params)
|
|
459
|
+
|
|
460
|
+
# Compare fill prices
|
|
461
|
+
if theta_data["fill_prices"] and polygon_data["fill_prices"]:
|
|
462
|
+
theta_info = theta_data["fill_prices"][0]
|
|
463
|
+
polygon_info = polygon_data["fill_prices"][0]
|
|
464
|
+
|
|
465
|
+
theta_fill = theta_info["price"]
|
|
466
|
+
polygon_fill = polygon_info["price"]
|
|
467
|
+
|
|
468
|
+
# Tolerance: 1 cent for fill prices
|
|
469
|
+
tolerance = 0.01
|
|
470
|
+
price_diff = abs(theta_fill - polygon_fill)
|
|
471
|
+
|
|
472
|
+
if price_diff > tolerance:
|
|
473
|
+
report = detailed_comparison_report(theta_info, polygon_info, "FILL PRICE")
|
|
474
|
+
print(report)
|
|
475
|
+
pytest.fail(
|
|
476
|
+
f"Fill prices differ by more than ${tolerance}:\n"
|
|
477
|
+
f" ThetaData: ${theta_fill}\n"
|
|
478
|
+
f" Polygon: ${polygon_fill}\n"
|
|
479
|
+
f" Difference: ${price_diff} (tolerance: ${tolerance})\n"
|
|
480
|
+
f"See detailed report above."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
print(f"✓ Fill prices match within tolerance: ThetaData=${theta_fill}, Polygon=${polygon_fill}, diff=${price_diff:.4f}")
|
|
484
|
+
|
|
485
|
+
def test_portfolio_value_comparison(self):
|
|
486
|
+
"""
|
|
487
|
+
Compare portfolio values between ThetaData and Polygon.
|
|
488
|
+
ZERO TOLERANCE - values must match exactly or investigation is required.
|
|
489
|
+
"""
|
|
490
|
+
params = {"symbol": "AMZN", "test_type": "stock"}
|
|
491
|
+
|
|
492
|
+
# Run with ThetaData
|
|
493
|
+
theta_data = run_backtest(ThetaDataBacktesting, **params)
|
|
494
|
+
|
|
495
|
+
# Run with Polygon
|
|
496
|
+
polygon_data = run_backtest(PolygonDataBacktesting, **params)
|
|
497
|
+
|
|
498
|
+
# Compare final portfolio values
|
|
499
|
+
if theta_data["portfolio_values"] and polygon_data["portfolio_values"]:
|
|
500
|
+
theta_pv = theta_data["portfolio_values"][-1]
|
|
501
|
+
polygon_pv = polygon_data["portfolio_values"][-1]
|
|
502
|
+
|
|
503
|
+
# Tolerance: $10 for portfolio value (accounts for compounding small price differences)
|
|
504
|
+
tolerance = 10.0
|
|
505
|
+
pv_diff = abs(theta_pv - polygon_pv)
|
|
506
|
+
|
|
507
|
+
if pv_diff > tolerance:
|
|
508
|
+
theta_info = {"portfolio_value": theta_pv, "all_values": theta_data["portfolio_values"]}
|
|
509
|
+
polygon_info = {"portfolio_value": polygon_pv, "all_values": polygon_data["portfolio_values"]}
|
|
510
|
+
report = detailed_comparison_report(theta_info, polygon_info, "PORTFOLIO VALUE")
|
|
511
|
+
print(report)
|
|
512
|
+
pytest.fail(
|
|
513
|
+
f"Portfolio values differ by more than ${tolerance}:\n"
|
|
514
|
+
f" ThetaData: ${theta_pv}\n"
|
|
515
|
+
f" Polygon: ${polygon_pv}\n"
|
|
516
|
+
f" Difference: ${pv_diff} (tolerance: ${tolerance})\n"
|
|
517
|
+
f"See detailed report above."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
print(f"✓ Portfolio values match within tolerance: ThetaData=${theta_pv}, Polygon=${polygon_pv}, diff=${pv_diff:.2f}")
|
|
521
|
+
|
|
522
|
+
def test_cash_comparison(self):
|
|
523
|
+
"""
|
|
524
|
+
Compare cash values between ThetaData and Polygon.
|
|
525
|
+
ZERO TOLERANCE - values must match exactly or investigation is required.
|
|
526
|
+
"""
|
|
527
|
+
params = {"symbol": "AMZN", "test_type": "stock"}
|
|
528
|
+
|
|
529
|
+
# Run with ThetaData
|
|
530
|
+
theta_data = run_backtest(ThetaDataBacktesting, **params)
|
|
531
|
+
|
|
532
|
+
# Run with Polygon
|
|
533
|
+
polygon_data = run_backtest(PolygonDataBacktesting, **params)
|
|
534
|
+
|
|
535
|
+
# Compare final cash values
|
|
536
|
+
if theta_data["cash_values"] and polygon_data["cash_values"]:
|
|
537
|
+
theta_cash = theta_data["cash_values"][-1]
|
|
538
|
+
polygon_cash = polygon_data["cash_values"][-1]
|
|
539
|
+
|
|
540
|
+
# Tolerance: $10 for cash (mirrors portfolio value tolerance)
|
|
541
|
+
tolerance = 10.0
|
|
542
|
+
cash_diff = abs(theta_cash - polygon_cash)
|
|
543
|
+
|
|
544
|
+
if cash_diff > tolerance:
|
|
545
|
+
theta_info = {"cash": theta_cash, "all_values": theta_data["cash_values"]}
|
|
546
|
+
polygon_info = {"cash": polygon_cash, "all_values": polygon_data["cash_values"]}
|
|
547
|
+
report = detailed_comparison_report(theta_info, polygon_info, "CASH")
|
|
548
|
+
print(report)
|
|
549
|
+
pytest.fail(
|
|
550
|
+
f"Cash values differ by more than ${tolerance}:\n"
|
|
551
|
+
f" ThetaData: ${theta_cash}\n"
|
|
552
|
+
f" Polygon: ${polygon_cash}\n"
|
|
553
|
+
f" Difference: ${cash_diff} (tolerance: ${tolerance})\n"
|
|
554
|
+
f"See detailed report above."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
print(f"✓ Cash values match within tolerance: ThetaData=${theta_cash}, Polygon=${polygon_cash}, diff=${cash_diff:.2f}")
|
tests/backtest/test_yahoo.py
CHANGED
|
@@ -28,7 +28,6 @@ class YahooPriceTest(Strategy):
|
|
|
28
28
|
|
|
29
29
|
class TestYahooBacktestFull:
|
|
30
30
|
|
|
31
|
-
@pytest.mark.xfail(reason="yahoo sucks")
|
|
32
31
|
def test_yahoo_last_price(self):
|
|
33
32
|
"""
|
|
34
33
|
Test the YahooDataBacktesting class by running a backtest and checking that the strategy object is returned
|
|
@@ -62,4 +61,4 @@ class TestYahooBacktestFull:
|
|
|
62
61
|
# Round to 2 decimal places
|
|
63
62
|
last_price = round(last_price, 2)
|
|
64
63
|
|
|
65
|
-
assert last_price ==
|
|
64
|
+
assert last_price == 416.18 # This is the correct price for 2023-11-01 (the open price)
|
tests/conftest.py
CHANGED
|
@@ -7,6 +7,26 @@ import pytest
|
|
|
7
7
|
import gc
|
|
8
8
|
import atexit
|
|
9
9
|
import threading
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
# Load .env file at the very beginning, before any imports
|
|
15
|
+
# This ensures environment variables are available for all tests
|
|
16
|
+
project_root = Path(__file__).parent.parent
|
|
17
|
+
env_file = project_root / ".env"
|
|
18
|
+
if env_file.exists():
|
|
19
|
+
load_dotenv(env_file)
|
|
20
|
+
print(f"Loaded .env file from: {env_file}")
|
|
21
|
+
else:
|
|
22
|
+
print(f"Warning: .env file not found at {env_file}")
|
|
23
|
+
|
|
24
|
+
# Ensure working directory is set to project root for tests
|
|
25
|
+
# This fixes issues with ConfigsHelper and other path-dependent code
|
|
26
|
+
original_cwd = os.getcwd()
|
|
27
|
+
if os.getcwd() != str(project_root):
|
|
28
|
+
os.chdir(project_root)
|
|
29
|
+
print(f"Changed working directory to: {project_root}")
|
|
10
30
|
|
|
11
31
|
|
|
12
32
|
def cleanup_all_schedulers():
|