bbstrader 0.3.4__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/compat.py +18 -10
- bbstrader/config.py +0 -16
- bbstrader/core/scripts.py +4 -3
- bbstrader/metatrader/account.py +51 -26
- bbstrader/metatrader/analysis.py +30 -16
- bbstrader/metatrader/copier.py +136 -58
- bbstrader/metatrader/trade.py +39 -45
- bbstrader/metatrader/utils.py +5 -4
- bbstrader/models/factors.py +17 -13
- bbstrader/models/ml.py +96 -49
- bbstrader/models/nlp.py +83 -66
- bbstrader/trading/execution.py +39 -22
- bbstrader/tseries.py +103 -127
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/METADATA +29 -46
- bbstrader-0.3.6.dist-info/RECORD +62 -0
- 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.4.dist-info/RECORD +0 -49
- bbstrader-0.3.4.dist-info/top_level.txt +0 -1
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from queue import Queue
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from bbstrader.btengine.event import FillEvent, MarketEvent, OrderEvent, SignalEvent
|
|
11
|
+
from bbstrader.btengine.portfolio import Portfolio
|
|
12
|
+
|
|
13
|
+
warnings.filterwarnings("ignore")
|
|
14
|
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MockDataHandler:
|
|
18
|
+
"""A mock DataHandler to control market data during tests."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, symbol_list, initial_data):
|
|
21
|
+
self.symbol_list = symbol_list
|
|
22
|
+
self.latest_symbol_data = initial_data
|
|
23
|
+
|
|
24
|
+
def get_latest_bar_datetime(self, symbol):
|
|
25
|
+
return self.latest_symbol_data[symbol]["datetime"]
|
|
26
|
+
|
|
27
|
+
def get_latest_bar_value(self, symbol, val_type):
|
|
28
|
+
# val_type can be 'adj_close' or 'close'
|
|
29
|
+
return self.latest_symbol_data[symbol]["price"]
|
|
30
|
+
|
|
31
|
+
def update_bar(self, symbol, dt, price):
|
|
32
|
+
self.latest_symbol_data[symbol]["datetime"] = dt
|
|
33
|
+
self.latest_symbol_data[symbol]["price"] = price
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# PYTEST FIXTURES
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def basic_portfolio():
|
|
39
|
+
"""Fixture to create a fresh Portfolio instance for each test."""
|
|
40
|
+
symbol_list = ["AAPL", "GOOG"]
|
|
41
|
+
start_date = datetime(2023, 1, 1)
|
|
42
|
+
initial_capital = 100000.0
|
|
43
|
+
initial_data = {
|
|
44
|
+
"AAPL": {"datetime": start_date, "price": 150.0},
|
|
45
|
+
"GOOG": {"datetime": start_date, "price": 100.0},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
events_queue = Queue()
|
|
49
|
+
mock_bars = MockDataHandler(symbol_list, initial_data)
|
|
50
|
+
|
|
51
|
+
portfolio = Portfolio(
|
|
52
|
+
bars=mock_bars,
|
|
53
|
+
events=events_queue,
|
|
54
|
+
start_date=start_date,
|
|
55
|
+
initial_capital=initial_capital,
|
|
56
|
+
print_stats=False, # Disable printing/plotting during tests
|
|
57
|
+
)
|
|
58
|
+
return portfolio
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- TEST CASES ---
|
|
62
|
+
def test_initialization(basic_portfolio):
|
|
63
|
+
"""Tests that the portfolio is initialized with the correct state."""
|
|
64
|
+
p = basic_portfolio
|
|
65
|
+
assert p.initial_capital == 100000.0
|
|
66
|
+
assert p.symbol_list == ["AAPL", "GOOG"]
|
|
67
|
+
assert p.current_positions == {"AAPL": 0, "GOOG": 0}
|
|
68
|
+
|
|
69
|
+
expected_holdings = {
|
|
70
|
+
"AAPL": 0.0,
|
|
71
|
+
"GOOG": 0.0,
|
|
72
|
+
"Cash": 100000.0,
|
|
73
|
+
"Commission": 0.0,
|
|
74
|
+
"Total": 100000.0,
|
|
75
|
+
}
|
|
76
|
+
assert p.current_holdings == expected_holdings
|
|
77
|
+
|
|
78
|
+
assert len(p.all_positions) == 1
|
|
79
|
+
initial_positions = p.all_positions[0]
|
|
80
|
+
assert initial_positions["Datetime"] == datetime(2023, 1, 1)
|
|
81
|
+
|
|
82
|
+
assert initial_positions["AAPL"] == 0
|
|
83
|
+
assert initial_positions["GOOG"] == 0
|
|
84
|
+
assert "Total" not in initial_positions
|
|
85
|
+
|
|
86
|
+
assert len(p.all_holdings) == 1
|
|
87
|
+
assert p.all_holdings[0]["Total"] == 100000.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_tf_mapping_and_initialization_error():
|
|
91
|
+
"""Tests the timeframe mapping and ensures invalid timeframes raise errors."""
|
|
92
|
+
mock_bars = MagicMock()
|
|
93
|
+
mock_bars.symbol_list = ["DUMMY"]
|
|
94
|
+
|
|
95
|
+
# Test a valid timeframe
|
|
96
|
+
p = Portfolio(
|
|
97
|
+
mock_bars, Queue(), datetime.now(), time_frame="5m", session_duration=6.5
|
|
98
|
+
)
|
|
99
|
+
assert p.tf == int(252 * (60 / 5) * 6.5)
|
|
100
|
+
|
|
101
|
+
# Test another valid timeframe
|
|
102
|
+
p_d1 = Portfolio(mock_bars, Queue(), datetime.now(), time_frame="D1")
|
|
103
|
+
assert p_d1.tf == 252
|
|
104
|
+
|
|
105
|
+
# Test that an unsupported timeframe raises a ValueError
|
|
106
|
+
with pytest.raises(ValueError, match="Timeframe not supported"):
|
|
107
|
+
Portfolio(mock_bars, Queue(), datetime.now(), time_frame="UnsupportedTF")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_update_timeindex(basic_portfolio):
|
|
111
|
+
"""Tests that portfolio history is correctly updated on a new market event."""
|
|
112
|
+
p = basic_portfolio
|
|
113
|
+
new_date = datetime(2023, 1, 2)
|
|
114
|
+
p.bars.update_bar("AAPL", new_date, 155.0)
|
|
115
|
+
p.bars.update_bar("GOOG", new_date, 102.0)
|
|
116
|
+
|
|
117
|
+
# Give the portfolio a position to make the test more meaningful
|
|
118
|
+
p.current_positions["AAPL"] = 10
|
|
119
|
+
|
|
120
|
+
p.update_timeindex(MarketEvent())
|
|
121
|
+
|
|
122
|
+
assert len(p.all_positions) == 2
|
|
123
|
+
assert p.all_positions[1]["Datetime"] == new_date
|
|
124
|
+
assert p.all_positions[1]["AAPL"] == 10 # Records position from previous bar
|
|
125
|
+
|
|
126
|
+
assert len(p.all_holdings) == 2
|
|
127
|
+
market_value = 10 * 155.0
|
|
128
|
+
expected_total = 100000.0 + market_value
|
|
129
|
+
assert p.all_holdings[1]["Total"] == expected_total
|
|
130
|
+
assert p.all_holdings[1]["AAPL"] == market_value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_update_fill_buy(basic_portfolio):
|
|
134
|
+
"""Tests that a BUY FillEvent correctly updates positions and holdings."""
|
|
135
|
+
p = basic_portfolio
|
|
136
|
+
fill_event = FillEvent(
|
|
137
|
+
datetime.now(), "AAPL", "TEST_EXCHANGE", 10, "BUY", None, commission=5.0
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
p.update_fill(fill_event)
|
|
141
|
+
|
|
142
|
+
assert p.current_positions["AAPL"] == 10
|
|
143
|
+
cost = 10 * 150.0
|
|
144
|
+
assert p.current_holdings["Cash"] == 100000.0 - cost - 5.0
|
|
145
|
+
assert p.current_holdings["Commission"] == 5.0
|
|
146
|
+
assert p.current_holdings["AAPL"] == cost
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_update_fill_sell_short(basic_portfolio):
|
|
150
|
+
"""Tests that a SELL (short) FillEvent correctly updates state."""
|
|
151
|
+
p = basic_portfolio
|
|
152
|
+
fill_event = FillEvent(
|
|
153
|
+
datetime.now(), "GOOG", "TEST_EXCHANGE", 20, "SELL", None, commission=7.0
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
p.update_fill(fill_event)
|
|
157
|
+
|
|
158
|
+
assert p.current_positions["GOOG"] == -20
|
|
159
|
+
proceeds = 20 * 100.0
|
|
160
|
+
assert p.current_holdings["Cash"] == 100000.0 + proceeds - 7.0
|
|
161
|
+
assert p.current_holdings["GOOG"] == -proceeds
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize(
|
|
165
|
+
"signal_type, initial_pos, expected_direction, expected_quantity",
|
|
166
|
+
[
|
|
167
|
+
("LONG", 0, "BUY", 50),
|
|
168
|
+
("SHORT", 0, "SELL", 30),
|
|
169
|
+
("EXIT", 100, "SELL", 100),
|
|
170
|
+
("EXIT", -75, "BUY", 75),
|
|
171
|
+
("EXIT", 0, None, 0), # No order if exiting from a flat position
|
|
172
|
+
("LONG", 10, "BUY", 50), # New LONG signal ignores existing long position
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
def test_generate_order(
|
|
176
|
+
basic_portfolio, signal_type, initial_pos, expected_direction, expected_quantity
|
|
177
|
+
):
|
|
178
|
+
"""Tests order generation logic for various signal types and positions."""
|
|
179
|
+
p = basic_portfolio
|
|
180
|
+
p.current_positions["AAPL"] = initial_pos
|
|
181
|
+
|
|
182
|
+
quantity = 50 if signal_type == "LONG" else 30
|
|
183
|
+
signal = SignalEvent(
|
|
184
|
+
1, "AAPL", datetime.now(), signal_type, quantity=quantity, strength=1.0
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
order = p.generate_order(signal)
|
|
188
|
+
|
|
189
|
+
if expected_direction is None:
|
|
190
|
+
assert order is None
|
|
191
|
+
else:
|
|
192
|
+
assert isinstance(order, OrderEvent)
|
|
193
|
+
assert order.direction == expected_direction
|
|
194
|
+
assert order.quantity == expected_quantity
|
|
195
|
+
assert order.order_type == "MKT"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_update_signal_puts_order_on_queue(basic_portfolio):
|
|
199
|
+
"""Tests that update_signal correctly generates and queues an order."""
|
|
200
|
+
p = basic_portfolio
|
|
201
|
+
signal = SignalEvent(1, "GOOG", datetime.now(), "LONG", quantity=100, strength=0.5)
|
|
202
|
+
|
|
203
|
+
assert p.events.qsize() == 0
|
|
204
|
+
p.update_signal(signal)
|
|
205
|
+
assert p.events.qsize() == 1
|
|
206
|
+
|
|
207
|
+
order = p.events.get()
|
|
208
|
+
assert isinstance(order, OrderEvent)
|
|
209
|
+
assert order.symbol == "GOOG"
|
|
210
|
+
assert order.direction == "BUY"
|
|
211
|
+
assert order.quantity == 50 # 100 * 0.5
|
|
212
|
+
|
|
213
|
+
@pytest.mark.filterwarnings("ignore")
|
|
214
|
+
@patch("bbstrader.btengine.performance.plt.show")
|
|
215
|
+
@patch("bbstrader.btengine.portfolio.plot_performance")
|
|
216
|
+
@patch("bbstrader.btengine.portfolio.plot_returns_and_dd")
|
|
217
|
+
@patch("bbstrader.btengine.portfolio.plot_monthly_yearly_returns")
|
|
218
|
+
@patch("bbstrader.btengine.portfolio.show_qs_stats")
|
|
219
|
+
@patch("bbstrader.btengine.portfolio.qs.plots.monthly_heatmap")
|
|
220
|
+
@patch("pandas.DataFrame.to_csv")
|
|
221
|
+
def test_output_summary_stats(
|
|
222
|
+
mock_to_csv,
|
|
223
|
+
mock_qs_heatmap,
|
|
224
|
+
mock_show_qs,
|
|
225
|
+
mock_plot_monthly,
|
|
226
|
+
mock_plot_ret_dd,
|
|
227
|
+
mock_plot_perf,
|
|
228
|
+
mock_plt_show,
|
|
229
|
+
basic_portfolio,
|
|
230
|
+
):
|
|
231
|
+
"""Tests performance calculation and that reporting functions are called without side effects."""
|
|
232
|
+
p = basic_portfolio
|
|
233
|
+
p.print_stats = True
|
|
234
|
+
p.strategy_name = "Test Strategy"
|
|
235
|
+
p.output_dir = "test_results"
|
|
236
|
+
|
|
237
|
+
# Manually create a simple history for the equity curve
|
|
238
|
+
tz = pd.Timestamp.utcnow().tzinfo
|
|
239
|
+
p.all_holdings = [
|
|
240
|
+
{
|
|
241
|
+
"Datetime": datetime(2023, 1, 1, tzinfo=tz),
|
|
242
|
+
"Total": 100000.0,
|
|
243
|
+
"Commission": 0.0,
|
|
244
|
+
"Cash": 100000.0,
|
|
245
|
+
"AAPL": 0,
|
|
246
|
+
"GOOG": 0,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"Datetime": datetime(2023, 1, 2, tzinfo=tz),
|
|
250
|
+
"Total": 101000.0,
|
|
251
|
+
"Commission": 0.0,
|
|
252
|
+
"Cash": 100000.0,
|
|
253
|
+
"AAPL": 0,
|
|
254
|
+
"GOOG": 0,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"Datetime": datetime(2023, 1, 3, tzinfo=tz),
|
|
258
|
+
"Total": 100500.0,
|
|
259
|
+
"Commission": 0.0,
|
|
260
|
+
"Cash": 100000.0,
|
|
261
|
+
"AAPL": 0,
|
|
262
|
+
"GOOG": 0,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"Datetime": datetime(2023, 1, 4, tzinfo=tz),
|
|
266
|
+
"Total": 102000.0,
|
|
267
|
+
"Commission": 0.0,
|
|
268
|
+
"Cash": 100000.0,
|
|
269
|
+
"AAPL": 0,
|
|
270
|
+
"GOOG": 0,
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
p.create_equity_curve_dataframe()
|
|
275
|
+
|
|
276
|
+
p.equity_curve["Returns"] = p.equity_curve["Returns"].fillna(0.0)
|
|
277
|
+
p.equity_curve["Equity Curve"] = p.equity_curve["Equity Curve"].fillna(1.0)
|
|
278
|
+
|
|
279
|
+
p.equity_curve["Drawdown"] = 0.0
|
|
280
|
+
|
|
281
|
+
# Call the method under test
|
|
282
|
+
stats = p.output_summary_stats()
|
|
283
|
+
|
|
284
|
+
# Assertions
|
|
285
|
+
stats_dict = dict(stats)
|
|
286
|
+
assert stats_dict["Total Return"] == "2.00%"
|
|
287
|
+
assert "Sharpe Ratio" in stats_dict
|
|
288
|
+
assert "Max Drawdown" in stats_dict
|
|
289
|
+
|
|
290
|
+
# Verify that our mocked (and problematic) functions were called
|
|
291
|
+
mock_plot_perf.assert_called_once()
|
|
292
|
+
mock_plot_ret_dd.assert_called_once()
|
|
293
|
+
mock_plot_monthly.assert_called_once()
|
|
294
|
+
mock_qs_heatmap.assert_called_once()
|
|
295
|
+
mock_show_qs.assert_called_once()
|
|
296
|
+
mock_to_csv.assert_called_once()
|
|
297
|
+
|
|
298
|
+
# Verify plots are never actually shown
|
|
299
|
+
mock_plt_show.assert_not_called()
|
|
300
|
+
|
|
301
|
+
# 4. Verify the path for the saved CSV file
|
|
302
|
+
call_args, _ = mock_to_csv.call_args
|
|
303
|
+
file_path = call_args[0]
|
|
304
|
+
assert isinstance(file_path, Path)
|
|
305
|
+
assert "test_results" in str(file_path)
|
|
306
|
+
assert "Test_Strategy" in str(file_path)
|
|
307
|
+
assert str(file_path).endswith("_equities.csv")
|
|
File without changes
|