bbstrader 0.3.5__py3-none-any.whl → 0.3.7__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.

Files changed (45) hide show
  1. bbstrader/__init__.py +11 -2
  2. bbstrader/__main__.py +6 -1
  3. bbstrader/apps/_copier.py +43 -40
  4. bbstrader/btengine/backtest.py +33 -28
  5. bbstrader/btengine/data.py +105 -81
  6. bbstrader/btengine/event.py +21 -22
  7. bbstrader/btengine/execution.py +51 -24
  8. bbstrader/btengine/performance.py +23 -12
  9. bbstrader/btengine/portfolio.py +40 -30
  10. bbstrader/btengine/scripts.py +13 -12
  11. bbstrader/btengine/strategy.py +396 -134
  12. bbstrader/compat.py +4 -3
  13. bbstrader/config.py +20 -36
  14. bbstrader/core/data.py +76 -48
  15. bbstrader/core/scripts.py +22 -21
  16. bbstrader/core/utils.py +13 -12
  17. bbstrader/metatrader/account.py +51 -26
  18. bbstrader/metatrader/analysis.py +30 -16
  19. bbstrader/metatrader/copier.py +75 -40
  20. bbstrader/metatrader/trade.py +29 -39
  21. bbstrader/metatrader/utils.py +5 -4
  22. bbstrader/models/nlp.py +83 -66
  23. bbstrader/trading/execution.py +45 -22
  24. bbstrader/tseries.py +158 -166
  25. {bbstrader-0.3.5.dist-info → bbstrader-0.3.7.dist-info}/METADATA +7 -21
  26. bbstrader-0.3.7.dist-info/RECORD +62 -0
  27. bbstrader-0.3.7.dist-info/top_level.txt +3 -0
  28. docs/conf.py +56 -0
  29. tests/__init__.py +0 -0
  30. tests/engine/__init__.py +1 -0
  31. tests/engine/test_backtest.py +58 -0
  32. tests/engine/test_data.py +536 -0
  33. tests/engine/test_events.py +300 -0
  34. tests/engine/test_execution.py +219 -0
  35. tests/engine/test_portfolio.py +308 -0
  36. tests/metatrader/__init__.py +0 -0
  37. tests/metatrader/test_account.py +1769 -0
  38. tests/metatrader/test_rates.py +292 -0
  39. tests/metatrader/test_risk_management.py +700 -0
  40. tests/metatrader/test_trade.py +439 -0
  41. bbstrader-0.3.5.dist-info/RECORD +0 -49
  42. bbstrader-0.3.5.dist-info/top_level.txt +0 -1
  43. {bbstrader-0.3.5.dist-info → bbstrader-0.3.7.dist-info}/WHEEL +0 -0
  44. {bbstrader-0.3.5.dist-info → bbstrader-0.3.7.dist-info}/entry_points.txt +0 -0
  45. {bbstrader-0.3.5.dist-info → bbstrader-0.3.7.dist-info}/licenses/LICENSE +0 -0
docs/conf.py ADDED
@@ -0,0 +1,56 @@
1
+ # Configuration file for the Sphinx documentation builder.
2
+ #
3
+ # For the full list of built-in configuration values, see the documentation:
4
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html
5
+
6
+ # -- Project information -----------------------------------------------------
7
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
8
+ import os
9
+ import sys
10
+ from unittest.mock import MagicMock
11
+ from importlib.metadata import version, PackageNotFoundError
12
+
13
+ os.system('pip install ..')
14
+ sys.path.insert(0, os.path.abspath('../bbstrader'))
15
+
16
+ class Mock(MagicMock):
17
+ @classmethod
18
+ def __getattr__(cls, name):
19
+ return MagicMock()
20
+
21
+ # List the mock modules to avoid import errors
22
+ MOCK_MODULES = ['MetaTrader5', 'talib', 'posix']
23
+ sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
24
+
25
+ project = 'bbstrader'
26
+ copyright = '2023 - 2025, Bertin Balouki SIMYELI'
27
+ author = 'Bertin Balouki SIMYELI'
28
+
29
+ try:
30
+ release = version("bbstrader")
31
+ except PackageNotFoundError:
32
+ release = "unknown"
33
+ version = ".".join(release.split('.')[:2])
34
+
35
+ # -- General configuration ---------------------------------------------------
36
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
37
+
38
+
39
+ extensions = [
40
+ 'sphinx.ext.autodoc',
41
+ 'sphinx.ext.napoleon',
42
+ 'sphinx.ext.viewcode',
43
+ 'sphinx.ext.todo',
44
+ ]
45
+
46
+
47
+ templates_path = ['_templates']
48
+ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
49
+
50
+
51
+
52
+ # -- Options for HTML output -------------------------------------------------
53
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
54
+
55
+ html_theme = 'sphinx_rtd_theme'
56
+ html_static_path = ['_static']
tests/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,58 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock
3
+ from datetime import datetime
4
+ from bbstrader.btengine.backtest import BacktestEngine
5
+
6
+
7
+ class TestBacktestEngine(unittest.TestCase):
8
+ def setUp(self):
9
+ # Create mock classes with minimum expected interface
10
+ self.symbol_list = ['FAKE']
11
+ self.initial_capital = 100000.0
12
+ self.heartbeat = 0.0
13
+ self.start_date = datetime(2020, 1, 1)
14
+
15
+ self.mock_data_handler_cls = MagicMock()
16
+ self.mock_strategy_cls = MagicMock()
17
+ self.mock_execution_handler_cls = MagicMock()
18
+
19
+ # Mock data_handler instance
20
+ self.mock_data_handler = MagicMock()
21
+ self.mock_data_handler.continue_backtest = False
22
+ self.mock_data_handler.get_latest_bar_datetime.return_value = self.start_date
23
+ self.mock_data_handler.update_bars.return_value = None
24
+
25
+ # Strategy and portfolio mock
26
+ self.mock_strategy = MagicMock()
27
+ self.mock_strategy.check_pending_orders.return_value = None
28
+ self.mock_strategy.get_update_from_portfolio.return_value = None
29
+
30
+ self.mock_portfolio = MagicMock()
31
+ self.mock_portfolio.all_holdings = [{"Total": self.initial_capital}]
32
+ self.mock_portfolio.current_positions = {}
33
+ self.mock_portfolio.current_holdings = {}
34
+
35
+ self.mock_execution_handler = MagicMock()
36
+
37
+ # Bind mock return values
38
+ self.mock_data_handler_cls.return_value = self.mock_data_handler
39
+ self.mock_strategy_cls.return_value = self.mock_strategy
40
+ self.mock_execution_handler_cls.return_value = self.mock_execution_handler
41
+
42
+ def test_backtest_engine_runs(self):
43
+ engine = BacktestEngine(
44
+ self.symbol_list,
45
+ self.initial_capital,
46
+ self.heartbeat,
47
+ self.start_date,
48
+ self.mock_data_handler_cls,
49
+ self.mock_execution_handler_cls,
50
+ self.mock_strategy_cls,
51
+ )
52
+ engine.portfolio = self.mock_portfolio
53
+
54
+ result = engine.simulate_trading()
55
+ self.assertTrue(hasattr(result, '__class__'))
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -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)