bbstrader 0.3.5__py3-none-any.whl → 0.3.6__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 bbstrader might be problematic. Click here for more details.
- bbstrader/__init__.py +10 -1
- bbstrader/__main__.py +5 -0
- bbstrader/apps/_copier.py +3 -3
- bbstrader/btengine/strategy.py +113 -38
- bbstrader/metatrader/account.py +51 -26
- bbstrader/metatrader/analysis.py +30 -16
- bbstrader/metatrader/copier.py +75 -40
- bbstrader/metatrader/trade.py +29 -39
- bbstrader/metatrader/utils.py +5 -4
- bbstrader/models/nlp.py +83 -66
- bbstrader/trading/execution.py +39 -22
- bbstrader/tseries.py +103 -127
- {bbstrader-0.3.5.dist-info → bbstrader-0.3.6.dist-info}/METADATA +7 -21
- {bbstrader-0.3.5.dist-info → bbstrader-0.3.6.dist-info}/RECORD +31 -18
- bbstrader-0.3.6.dist-info/top_level.txt +3 -0
- docs/conf.py +56 -0
- tests/__init__.py +0 -0
- tests/engine/__init__.py +1 -0
- tests/engine/test_backtest.py +58 -0
- tests/engine/test_data.py +536 -0
- tests/engine/test_events.py +300 -0
- tests/engine/test_execution.py +219 -0
- tests/engine/test_portfolio.py +307 -0
- tests/metatrader/__init__.py +0 -0
- tests/metatrader/test_account.py +1769 -0
- tests/metatrader/test_rates.py +292 -0
- tests/metatrader/test_risk_management.py +700 -0
- tests/metatrader/test_trade.py +439 -0
- bbstrader-0.3.5.dist-info/top_level.txt +0 -1
- {bbstrader-0.3.5.dist-info → bbstrader-0.3.6.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.5.dist-info → bbstrader-0.3.6.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.5.dist-info → bbstrader-0.3.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from queue import Queue
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from bbstrader.btengine.data import (
|
|
12
|
+
CSVDataHandler,
|
|
13
|
+
EODHDataHandler,
|
|
14
|
+
FMPDataHandler,
|
|
15
|
+
MT5DataHandler,
|
|
16
|
+
YFDataHandler,
|
|
17
|
+
)
|
|
18
|
+
from bbstrader.btengine.event import MarketEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestCSVDataHandler(unittest.TestCase):
|
|
22
|
+
@patch("pandas.read_csv")
|
|
23
|
+
@patch("pandas.DataFrame.to_csv")
|
|
24
|
+
def setUp(self, mock_to_csv, mock_read_csv):
|
|
25
|
+
# Mock CSV content
|
|
26
|
+
date_rng = pd.date_range(start="2023-01-01", periods=5, freq="D")
|
|
27
|
+
df = pd.DataFrame(
|
|
28
|
+
{
|
|
29
|
+
"datetime": date_rng,
|
|
30
|
+
"open": np.random.rand(5),
|
|
31
|
+
"high": np.random.rand(5),
|
|
32
|
+
"low": np.random.rand(5),
|
|
33
|
+
"close": np.random.rand(5),
|
|
34
|
+
"adj_close": np.random.rand(5),
|
|
35
|
+
"volume": np.random.randint(100, 1000, size=5),
|
|
36
|
+
}
|
|
37
|
+
).set_index("datetime")
|
|
38
|
+
mock_read_csv.return_value = df
|
|
39
|
+
|
|
40
|
+
self.events = Queue()
|
|
41
|
+
self.symbol_list = ["AAPL"]
|
|
42
|
+
self.handler = CSVDataHandler(
|
|
43
|
+
self.events, self.symbol_list, csv_dir="/fake/dir"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Manually trigger symbol iterrows generator
|
|
47
|
+
self.handler.symbol_data["AAPL"] = iter(df.iterrows())
|
|
48
|
+
self.handler.latest_symbol_data["AAPL"] = list(df.iterrows())
|
|
49
|
+
|
|
50
|
+
def test_get_latest_bar(self):
|
|
51
|
+
latest = self.handler.get_latest_bar("AAPL")
|
|
52
|
+
self.assertIsInstance(latest, tuple)
|
|
53
|
+
|
|
54
|
+
def test_get_latest_bars(self):
|
|
55
|
+
bars = self.handler.get_latest_bars("AAPL", N=2)
|
|
56
|
+
self.assertEqual(len(bars), 2)
|
|
57
|
+
|
|
58
|
+
def test_get_latest_bar_value(self):
|
|
59
|
+
val = self.handler.get_latest_bar_value("AAPL", "close")
|
|
60
|
+
self.assertIsInstance(val, float)
|
|
61
|
+
|
|
62
|
+
def test_get_latest_bars_values(self):
|
|
63
|
+
vals = self.handler.get_latest_bars_values("AAPL", "close", N=3)
|
|
64
|
+
self.assertEqual(len(vals), 3)
|
|
65
|
+
|
|
66
|
+
def test_update_bars(self):
|
|
67
|
+
self.handler.update_bars()
|
|
68
|
+
self.assertFalse(self.events.empty())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestMT5DataHandler(unittest.TestCase):
|
|
72
|
+
"""
|
|
73
|
+
Tests for the MT5DataHandler class, mocked to run without an MT5 terminal.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def setUp(self):
|
|
77
|
+
"""Set up a temporary directory and a mock events queue for each test."""
|
|
78
|
+
# Create a temporary directory that will be automatically cleaned up
|
|
79
|
+
self.temp_dir_context = tempfile.TemporaryDirectory()
|
|
80
|
+
self.temp_dir_path = Path(self.temp_dir_context.name)
|
|
81
|
+
|
|
82
|
+
# A mock queue to check if events are being put correctly
|
|
83
|
+
self.events = Queue()
|
|
84
|
+
self.symbol_list = ["EURUSD"]
|
|
85
|
+
self.start_dt = datetime(2023, 1, 1)
|
|
86
|
+
self.end_dt = datetime(2023, 1, 5)
|
|
87
|
+
|
|
88
|
+
def tearDown(self):
|
|
89
|
+
"""Clean up the temporary directory after each test."""
|
|
90
|
+
self.temp_dir_context.cleanup()
|
|
91
|
+
|
|
92
|
+
def _create_sample_mt5_df(self):
|
|
93
|
+
"""Helper function to create a realistic mock DataFrame."""
|
|
94
|
+
date_rng = pd.date_range(start="2023-01-01", periods=5, freq="D")
|
|
95
|
+
df = pd.DataFrame(
|
|
96
|
+
{
|
|
97
|
+
"open": np.random.uniform(1.05, 1.06, 5),
|
|
98
|
+
"high": np.random.uniform(1.06, 1.07, 5),
|
|
99
|
+
"low": np.random.uniform(1.04, 1.05, 5),
|
|
100
|
+
"close": np.random.uniform(1.05, 1.06, 5),
|
|
101
|
+
"volume": np.random.randint(10000, 50000, size=5),
|
|
102
|
+
},
|
|
103
|
+
index=date_rng,
|
|
104
|
+
)
|
|
105
|
+
df.index.name = "time" # MT5 data uses 'time' index
|
|
106
|
+
# The handler normalizes column names to lowercase
|
|
107
|
+
return df
|
|
108
|
+
|
|
109
|
+
# We only need to patch the function that makes the external call.
|
|
110
|
+
@patch("bbstrader.btengine.data.download_historical_data")
|
|
111
|
+
def test_initialization_downloads_and_caches_data(self, mock_download):
|
|
112
|
+
"""
|
|
113
|
+
Verify that the handler correctly calls the download function,
|
|
114
|
+
caches the data to a CSV, and loads it.
|
|
115
|
+
"""
|
|
116
|
+
sample_df = self._create_sample_mt5_df()
|
|
117
|
+
mock_download.return_value = sample_df
|
|
118
|
+
time_frame_arg = "D1"
|
|
119
|
+
handler = MT5DataHandler(
|
|
120
|
+
self.events,
|
|
121
|
+
self.symbol_list,
|
|
122
|
+
time_frame=time_frame_arg,
|
|
123
|
+
mt5_start=self.start_dt,
|
|
124
|
+
mt5_end=self.end_dt,
|
|
125
|
+
data_dir=self.temp_dir_path,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 1. Was the download function called with the correct parameters?
|
|
129
|
+
mock_download.assert_called_once_with(
|
|
130
|
+
# Explicitly set arguments
|
|
131
|
+
symbol="EURUSD",
|
|
132
|
+
timeframe=time_frame_arg,
|
|
133
|
+
date_from=self.start_dt,
|
|
134
|
+
date_to=self.end_dt,
|
|
135
|
+
utc=False,
|
|
136
|
+
filter=False,
|
|
137
|
+
fill_na=False,
|
|
138
|
+
lower_colnames=True,
|
|
139
|
+
# Arguments passed via **self.kwargs
|
|
140
|
+
time_frame=time_frame_arg,
|
|
141
|
+
mt5_start=self.start_dt,
|
|
142
|
+
mt5_end=self.end_dt,
|
|
143
|
+
data_dir=self.temp_dir_path,
|
|
144
|
+
backtest=True,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 2. Was a CSV file created in our temporary directory?
|
|
148
|
+
expected_filepath = Path(self.temp_dir_path) / "EURUSD.csv"
|
|
149
|
+
self.assertTrue(expected_filepath.exists())
|
|
150
|
+
|
|
151
|
+
# 3. Was the data loaded correctly into the handler?
|
|
152
|
+
# The data is stored as a generator, so we check by consuming one item.
|
|
153
|
+
self.assertIn("EURUSD", handler.data)
|
|
154
|
+
first_bar_tuple = next(handler.symbol_data["EURUSD"])
|
|
155
|
+
|
|
156
|
+
# The first element of the tuple is the timestamp
|
|
157
|
+
self.assertEqual(first_bar_tuple[0], pd.Timestamp("2023-01-01"))
|
|
158
|
+
# The second is the Series of bar data
|
|
159
|
+
self.assertAlmostEqual(first_bar_tuple[1]["close"], sample_df["close"].iloc[0])
|
|
160
|
+
|
|
161
|
+
@patch("bbstrader.btengine.data.download_historical_data")
|
|
162
|
+
def test_update_bars_and_get_latest_bar(self, mock_download):
|
|
163
|
+
"""
|
|
164
|
+
Verify the full data flow from initialization to updating and retrieving bars.
|
|
165
|
+
"""
|
|
166
|
+
sample_df = self._create_sample_mt5_df()
|
|
167
|
+
mock_download.return_value = sample_df
|
|
168
|
+
|
|
169
|
+
handler = MT5DataHandler(
|
|
170
|
+
self.events, self.symbol_list, data_dir=self.temp_dir_path
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Update bars for the first time
|
|
174
|
+
handler.update_bars()
|
|
175
|
+
|
|
176
|
+
# Assert 1
|
|
177
|
+
self.assertEqual(self.events.qsize(), 1) # A MarketEvent should be in the queue
|
|
178
|
+
self.assertIsInstance(self.events.get(), MarketEvent)
|
|
179
|
+
self.assertEqual(
|
|
180
|
+
handler.get_latest_bar_datetime("EURUSD"), pd.Timestamp("2023-01-01")
|
|
181
|
+
)
|
|
182
|
+
self.assertAlmostEqual(
|
|
183
|
+
handler.get_latest_bar_value("EURUSD", "close"), sample_df["close"].iloc[0]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Update bars for the second time
|
|
187
|
+
handler.update_bars()
|
|
188
|
+
|
|
189
|
+
# Assert 2
|
|
190
|
+
self.assertEqual(
|
|
191
|
+
handler.get_latest_bar_datetime("EURUSD"), pd.Timestamp("2023-01-02")
|
|
192
|
+
)
|
|
193
|
+
latest_bars_df = handler.get_latest_bars("EURUSD", N=2)
|
|
194
|
+
self.assertEqual(len(latest_bars_df), 2)
|
|
195
|
+
self.assertAlmostEqual(
|
|
196
|
+
latest_bars_df.iloc[1]["close"], sample_df["close"].iloc[1]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@patch("bbstrader.btengine.data.download_historical_data")
|
|
200
|
+
def test_download_failure_raises_valueerror(self, mock_download):
|
|
201
|
+
"""
|
|
202
|
+
Verify that if the download function fails, a descriptive ValueError is raised.
|
|
203
|
+
"""
|
|
204
|
+
# Arrange: Configure the mock to raise an error
|
|
205
|
+
mock_download.side_effect = Exception("MT5 Connection Failed")
|
|
206
|
+
|
|
207
|
+
# Act & Assert: Check that instantiating the handler raises the correct error
|
|
208
|
+
with self.assertRaisesRegex(
|
|
209
|
+
ValueError, "Error downloading EURUSD: .*MT5 Connection Failed.*"
|
|
210
|
+
):
|
|
211
|
+
MT5DataHandler(self.events, self.symbol_list, data_dir=self.temp_dir_path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestYFDataHandler(unittest.TestCase):
|
|
215
|
+
"""Tests for the YFDataHandler class."""
|
|
216
|
+
|
|
217
|
+
def setUp(self):
|
|
218
|
+
"""Set up a temporary directory and mock queue for each test."""
|
|
219
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
220
|
+
self.mock_events_queue = MagicMock(spec=Queue)
|
|
221
|
+
self.symbol_list = ["AAPL"]
|
|
222
|
+
self.start_date = "2023-01-01"
|
|
223
|
+
self.end_date = "2023-01-05"
|
|
224
|
+
|
|
225
|
+
def tearDown(self):
|
|
226
|
+
"""Clean up the temporary directory."""
|
|
227
|
+
self.temp_dir.cleanup()
|
|
228
|
+
|
|
229
|
+
def _create_sample_yf_df(self):
|
|
230
|
+
"""Creates a sample DataFrame mimicking yfinance output."""
|
|
231
|
+
dates = pd.to_datetime(["2023-01-03", "2023-01-04"])
|
|
232
|
+
data = {
|
|
233
|
+
"Open": [130.0, 127.0],
|
|
234
|
+
"High": [131.0, 128.0],
|
|
235
|
+
"Low": [129.0, 126.0],
|
|
236
|
+
"Close": [130.5, 127.5],
|
|
237
|
+
"Volume": [100000, 110000],
|
|
238
|
+
}
|
|
239
|
+
df = pd.DataFrame(data, index=dates)
|
|
240
|
+
df.index.name = "Date"
|
|
241
|
+
# YFDataHandler expects 'Adj Close', but it's added if missing.
|
|
242
|
+
# We'll test the case where it's provided.
|
|
243
|
+
df["Adj Close"] = df["Close"]
|
|
244
|
+
return df
|
|
245
|
+
|
|
246
|
+
@patch("bbstrader.btengine.data.yf.download")
|
|
247
|
+
def test_successful_initialization_and_caching(self, mock_yf_download):
|
|
248
|
+
"""Verify successful initialization, download, and caching."""
|
|
249
|
+
sample_df = self._create_sample_yf_df()
|
|
250
|
+
mock_yf_download.return_value = sample_df
|
|
251
|
+
|
|
252
|
+
handler = YFDataHandler(
|
|
253
|
+
self.mock_events_queue,
|
|
254
|
+
self.symbol_list,
|
|
255
|
+
yf_start=self.start_date,
|
|
256
|
+
yf_end=self.end_date,
|
|
257
|
+
data_dir=self.temp_dir.name,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# 1. Verify yf.download was called correctly
|
|
261
|
+
mock_yf_download.assert_called_once_with(
|
|
262
|
+
"AAPL",
|
|
263
|
+
start=self.start_date,
|
|
264
|
+
end=self.end_date,
|
|
265
|
+
multi_level_index=False,
|
|
266
|
+
auto_adjust=True,
|
|
267
|
+
progress=False,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# 2. Verify the CSV file was created
|
|
271
|
+
expected_filepath = Path(self.temp_dir.name) / "AAPL.csv"
|
|
272
|
+
self.assertTrue(expected_filepath.exists())
|
|
273
|
+
|
|
274
|
+
# 3. Verify data is loaded into the handler
|
|
275
|
+
self.assertIn("AAPL", handler.data)
|
|
276
|
+
df_from_csv = pd.read_csv(expected_filepath)
|
|
277
|
+
self.assertEqual(len(df_from_csv), 2)
|
|
278
|
+
self.assertIn(
|
|
279
|
+
"adj_close", df_from_csv.columns
|
|
280
|
+
) # Check for normalized column name
|
|
281
|
+
|
|
282
|
+
@patch("bbstrader.btengine.data.yf.download")
|
|
283
|
+
def test_download_failure_raises_value_error(self, mock_yf_download):
|
|
284
|
+
"""Test that a download exception is handled and raises a ValueError."""
|
|
285
|
+
mock_yf_download.side_effect = Exception("Network Error")
|
|
286
|
+
|
|
287
|
+
with self.assertRaisesRegex(
|
|
288
|
+
ValueError, "Error downloading AAPL: .*Network Error.*"
|
|
289
|
+
):
|
|
290
|
+
YFDataHandler(
|
|
291
|
+
self.mock_events_queue,
|
|
292
|
+
self.symbol_list,
|
|
293
|
+
yf_start=self.start_date,
|
|
294
|
+
data_dir=self.temp_dir.name,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
@patch("bbstrader.btengine.data.yf.download")
|
|
298
|
+
def test_empty_data_raises_value_error(self, mock_yf_download):
|
|
299
|
+
"""Test that empty data from API raises a ValueError."""
|
|
300
|
+
mock_yf_download.return_value = pd.DataFrame()
|
|
301
|
+
|
|
302
|
+
with self.assertRaisesRegex(ValueError, "Error downloading AAPL: 'Close'"):
|
|
303
|
+
YFDataHandler(
|
|
304
|
+
self.mock_events_queue,
|
|
305
|
+
self.symbol_list,
|
|
306
|
+
yf_start=self.start_date,
|
|
307
|
+
data_dir=self.temp_dir.name,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestEODHDataHandler(unittest.TestCase):
|
|
312
|
+
"""Tests for the EODHDataHandler class."""
|
|
313
|
+
|
|
314
|
+
def setUp(self):
|
|
315
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
316
|
+
self.mock_events_queue = MagicMock(spec=Queue)
|
|
317
|
+
self.symbol_list = ["MSFT.US"]
|
|
318
|
+
self.start_date = "2023-01-01"
|
|
319
|
+
self.end_date = "2023-01-05"
|
|
320
|
+
|
|
321
|
+
def tearDown(self):
|
|
322
|
+
self.temp_dir.cleanup()
|
|
323
|
+
|
|
324
|
+
def _create_sample_eodhd_df(self):
|
|
325
|
+
"""Creates a sample DataFrame mimicking EODHD daily output."""
|
|
326
|
+
data = {
|
|
327
|
+
"date": ["2023-01-03", "2023-01-04"],
|
|
328
|
+
"open": [240.0, 235.0],
|
|
329
|
+
"high": [241.0, 236.0],
|
|
330
|
+
"low": [239.0, 234.0],
|
|
331
|
+
"close": [240.5, 235.5],
|
|
332
|
+
"adjusted_close": [240.5, 235.5],
|
|
333
|
+
"volume": [200000, 210000],
|
|
334
|
+
"symbol": ["MSFT.US", "MSFT.US"],
|
|
335
|
+
"interval": ["d", "d"],
|
|
336
|
+
}
|
|
337
|
+
return pd.DataFrame(data).set_index("date")
|
|
338
|
+
|
|
339
|
+
def test_missing_api_key_raises_error(self):
|
|
340
|
+
"""Verify that not providing an API key raises a ValueError."""
|
|
341
|
+
with self.assertRaisesRegex(ValueError, "API key is required"):
|
|
342
|
+
EODHDataHandler(
|
|
343
|
+
self.mock_events_queue,
|
|
344
|
+
self.symbol_list,
|
|
345
|
+
eodhd_start=self.start_date,
|
|
346
|
+
eodhd_api_key=None, # Explicitly set to None
|
|
347
|
+
data_dir=self.temp_dir.name,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@patch("bbstrader.btengine.data.APIClient")
|
|
351
|
+
def test_successful_initialization_daily(self, mock_api_client):
|
|
352
|
+
"""Test successful initialization for daily data."""
|
|
353
|
+
mock_instance = mock_api_client.return_value
|
|
354
|
+
mock_instance.get_historical_data.return_value = self._create_sample_eodhd_df()
|
|
355
|
+
|
|
356
|
+
handler = EODHDataHandler(
|
|
357
|
+
self.mock_events_queue,
|
|
358
|
+
self.symbol_list,
|
|
359
|
+
eodhd_start=self.start_date,
|
|
360
|
+
eodhd_end=self.end_date,
|
|
361
|
+
eodhd_api_key="fake_key",
|
|
362
|
+
data_dir=self.temp_dir.name,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
mock_api_client.assert_called_once_with(api_key="fake_key")
|
|
366
|
+
mock_instance.get_historical_data.assert_called_once_with(
|
|
367
|
+
symbol="MSFT.US",
|
|
368
|
+
interval="d",
|
|
369
|
+
iso8601_start=self.start_date,
|
|
370
|
+
iso8601_end=self.end_date,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
expected_filepath = Path(self.temp_dir.name) / "MSFT.US.csv"
|
|
374
|
+
self.assertTrue(expected_filepath.exists())
|
|
375
|
+
self.assertIn("MSFT.US", handler.data)
|
|
376
|
+
|
|
377
|
+
@patch("bbstrader.btengine.data.APIClient")
|
|
378
|
+
def test_empty_data_raises_value_error(self, mock_api_client):
|
|
379
|
+
"""Test that empty data from the API raises a ValueError."""
|
|
380
|
+
mock_instance = mock_api_client.return_value
|
|
381
|
+
mock_instance.get_historical_data.return_value = pd.DataFrame()
|
|
382
|
+
|
|
383
|
+
with self.assertRaisesRegex(ValueError, "No data found"):
|
|
384
|
+
EODHDataHandler(
|
|
385
|
+
self.mock_events_queue,
|
|
386
|
+
self.symbol_list,
|
|
387
|
+
eodhd_api_key="fake_key",
|
|
388
|
+
data_dir=self.temp_dir.name,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TestFMPDataHandler(unittest.TestCase):
|
|
393
|
+
"""Tests for the FMPDataHandler class."""
|
|
394
|
+
|
|
395
|
+
def setUp(self):
|
|
396
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
397
|
+
self.mock_events_queue = MagicMock(spec=Queue)
|
|
398
|
+
self.symbol_list = ["GOOG"]
|
|
399
|
+
self.start_date = "2023-01-01"
|
|
400
|
+
self.end_date = "2023-01-05"
|
|
401
|
+
|
|
402
|
+
def tearDown(self):
|
|
403
|
+
self.temp_dir.cleanup()
|
|
404
|
+
|
|
405
|
+
def _create_sample_fmp_df(self):
|
|
406
|
+
"""Creates a sample DataFrame mimicking FMP output."""
|
|
407
|
+
dates = pd.period_range("2023-01-03", periods=2, freq="D")
|
|
408
|
+
data = {
|
|
409
|
+
"Open": [90.0, 88.0],
|
|
410
|
+
"High": [91.0, 89.0],
|
|
411
|
+
"Low": [89.0, 87.0],
|
|
412
|
+
"Close": [90.5, 88.5],
|
|
413
|
+
"Volume": [300000, 310000],
|
|
414
|
+
"Dividends": [0, 0],
|
|
415
|
+
"Return": [0.01, -0.01],
|
|
416
|
+
"Volatility": [0.1, 0.1],
|
|
417
|
+
"Excess Return": [0.005, -0.015],
|
|
418
|
+
"Excess Volatility": [0.1, 0.1],
|
|
419
|
+
"Cumulative Return": [1.01, 0.99],
|
|
420
|
+
}
|
|
421
|
+
df = pd.DataFrame(data, index=pd.Index(dates, name="date"))
|
|
422
|
+
return df
|
|
423
|
+
|
|
424
|
+
def test_missing_api_key_raises_error(self):
|
|
425
|
+
"""Verify that not providing an API key raises a ValueError."""
|
|
426
|
+
with self.assertRaisesRegex(ValueError, "API key is required"):
|
|
427
|
+
FMPDataHandler(
|
|
428
|
+
self.mock_events_queue,
|
|
429
|
+
self.symbol_list,
|
|
430
|
+
fmp_start=self.start_date,
|
|
431
|
+
fmp_api_key=None, # Explicitly set to None
|
|
432
|
+
data_dir=self.temp_dir.name,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
@patch("bbstrader.btengine.data.Toolkit")
|
|
436
|
+
def test_successful_initialization_and_formatting(self, mock_toolkit):
|
|
437
|
+
"""Test successful initialization and data formatting."""
|
|
438
|
+
mock_instance = mock_toolkit.return_value
|
|
439
|
+
mock_instance.get_historical_data.return_value = self._create_sample_fmp_df()
|
|
440
|
+
|
|
441
|
+
handler = FMPDataHandler(
|
|
442
|
+
self.mock_events_queue,
|
|
443
|
+
self.symbol_list,
|
|
444
|
+
fmp_start=self.start_date,
|
|
445
|
+
fmp_end=self.end_date,
|
|
446
|
+
fmp_api_key="fake_key",
|
|
447
|
+
data_dir=self.temp_dir.name,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
mock_toolkit.assert_called_once_with(
|
|
451
|
+
"GOOG",
|
|
452
|
+
api_key="fake_key",
|
|
453
|
+
start_date=self.start_date,
|
|
454
|
+
end_date=self.end_date,
|
|
455
|
+
benchmark_ticker=None,
|
|
456
|
+
progress_bar=False,
|
|
457
|
+
)
|
|
458
|
+
mock_instance.get_historical_data.assert_called_once_with(
|
|
459
|
+
period="daily", progress_bar=False
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
expected_filepath = Path(self.temp_dir.name) / "GOOG.csv"
|
|
463
|
+
self.assertTrue(expected_filepath.exists())
|
|
464
|
+
self.assertIn("GOOG", handler.data)
|
|
465
|
+
|
|
466
|
+
# Check that formatting worked by reading the CSV
|
|
467
|
+
df_from_csv = pd.read_csv(expected_filepath)
|
|
468
|
+
self.assertNotIn("Dividends", df_from_csv.columns)
|
|
469
|
+
self.assertNotIn("Return", df_from_csv.columns)
|
|
470
|
+
self.assertIn("adj_close", df_from_csv.columns)
|
|
471
|
+
|
|
472
|
+
@patch("bbstrader.btengine.data.Toolkit")
|
|
473
|
+
def test_empty_data_raises_value_error(self, mock_toolkit):
|
|
474
|
+
"""Test that empty data from the API raises a ValueError."""
|
|
475
|
+
mock_instance = mock_toolkit.return_value
|
|
476
|
+
mock_instance.get_historical_data.return_value = pd.DataFrame()
|
|
477
|
+
|
|
478
|
+
with self.assertRaisesRegex(ValueError, "No data found"):
|
|
479
|
+
FMPDataHandler(
|
|
480
|
+
self.mock_events_queue,
|
|
481
|
+
self.symbol_list,
|
|
482
|
+
fmp_api_key="fake_key",
|
|
483
|
+
data_dir=self.temp_dir.name,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
@patch("bbstrader.btengine.data.Toolkit")
|
|
487
|
+
@patch("bbstrader.btengine.event.MarketEvent")
|
|
488
|
+
def test_data_flow_and_base_handler_methods(self, mock_market_event, mock_toolkit):
|
|
489
|
+
"""Test update_bars and get_latest_* methods from the base class."""
|
|
490
|
+
mock_instance = mock_toolkit.return_value
|
|
491
|
+
sample_df = self._create_sample_fmp_df()
|
|
492
|
+
mock_instance.get_historical_data.return_value = sample_df
|
|
493
|
+
|
|
494
|
+
handler = FMPDataHandler(
|
|
495
|
+
self.mock_events_queue,
|
|
496
|
+
self.symbol_list,
|
|
497
|
+
fmp_api_key="fake_key",
|
|
498
|
+
data_dir=self.temp_dir.name,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# 1. First update
|
|
502
|
+
handler.update_bars()
|
|
503
|
+
|
|
504
|
+
# Check that a MarketEvent was put on the queue
|
|
505
|
+
self.mock_events_queue.put.assert_called_once()
|
|
506
|
+
event_put_on_queue = self.mock_events_queue.put.call_args[0][0]
|
|
507
|
+
self.assertIsInstance(
|
|
508
|
+
event_put_on_queue, MarketEvent
|
|
509
|
+
) # Check against the real class
|
|
510
|
+
|
|
511
|
+
# 2. Check latest bar data
|
|
512
|
+
latest_bar = handler.get_latest_bar("GOOG")[1] # Bar data is the 2nd element
|
|
513
|
+
self.assertAlmostEqual(latest_bar["close"], 90.5)
|
|
514
|
+
|
|
515
|
+
latest_dt = handler.get_latest_bar_datetime("GOOG")
|
|
516
|
+
self.assertEqual(latest_dt, pd.to_datetime("2023-01-03"))
|
|
517
|
+
|
|
518
|
+
latest_val = handler.get_latest_bar_value("GOOG", "high")
|
|
519
|
+
self.assertAlmostEqual(latest_val, 91.0)
|
|
520
|
+
|
|
521
|
+
# 3. Second update
|
|
522
|
+
handler.update_bars()
|
|
523
|
+
|
|
524
|
+
# Check latest bars data (N=2)
|
|
525
|
+
latest_2_bars = handler.get_latest_bars("GOOG", N=2)
|
|
526
|
+
self.assertEqual(len(latest_2_bars), 2)
|
|
527
|
+
self.assertAlmostEqual(latest_2_bars.iloc[1]["close"], 88.5)
|
|
528
|
+
|
|
529
|
+
latest_2_vals = handler.get_latest_bars_values("GOOG", "low", N=2)
|
|
530
|
+
self.assertEqual(len(latest_2_vals), 2)
|
|
531
|
+
self.assertAlmostEqual(latest_2_vals[0], 89.0)
|
|
532
|
+
self.assertAlmostEqual(latest_2_vals[1], 87.0)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
unittest.main(argv=["first-arg-is-ignored"], exit=False)
|