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,300 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import unittest
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from bbstrader.btengine.event import (
|
|
7
|
+
Event,
|
|
8
|
+
Events,
|
|
9
|
+
FillEvent,
|
|
10
|
+
MarketEvent,
|
|
11
|
+
OrderEvent,
|
|
12
|
+
SignalEvent,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestEventsEnum(unittest.TestCase):
|
|
17
|
+
"""Tests the Events Enum."""
|
|
18
|
+
|
|
19
|
+
def test_enum_values(self):
|
|
20
|
+
self.assertEqual(Events.MARKET.value, "MARKET")
|
|
21
|
+
self.assertEqual(Events.SIGNAL.value, "SIGNAL")
|
|
22
|
+
self.assertEqual(Events.ORDER.value, "ORDER")
|
|
23
|
+
self.assertEqual(Events.FILL.value, "FILL")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestMarketEvent(unittest.TestCase):
|
|
27
|
+
"""Tests the MarketEvent class."""
|
|
28
|
+
|
|
29
|
+
def test_market_event_creation(self):
|
|
30
|
+
"""Test MarketEvent initialization and type."""
|
|
31
|
+
event = MarketEvent()
|
|
32
|
+
self.assertIsInstance(event, Event)
|
|
33
|
+
self.assertEqual(event.type, Events.MARKET)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestSignalEvent(unittest.TestCase):
|
|
37
|
+
"""Tests the SignalEvent class."""
|
|
38
|
+
|
|
39
|
+
def setUp(self):
|
|
40
|
+
"""Set up common data for tests."""
|
|
41
|
+
self.timestamp = datetime(2023, 10, 27, 10, 30, 0)
|
|
42
|
+
self.strategy_id = 1
|
|
43
|
+
self.symbol = "AAPL"
|
|
44
|
+
self.signal_type_long = "LONG"
|
|
45
|
+
self.signal_type_short = "SHORT"
|
|
46
|
+
self.signal_type_exit = "EXIT"
|
|
47
|
+
self.quantity = 150
|
|
48
|
+
self.strength = 1.5
|
|
49
|
+
self.price = 170.50
|
|
50
|
+
self.stoplimit = 168.00
|
|
51
|
+
|
|
52
|
+
def test_signal_event_creation_long(self):
|
|
53
|
+
"""Test SignalEvent initialization with all parameters for LONG."""
|
|
54
|
+
event = SignalEvent(
|
|
55
|
+
strategy_id=self.strategy_id,
|
|
56
|
+
symbol=self.symbol,
|
|
57
|
+
datetime=self.timestamp,
|
|
58
|
+
signal_type=self.signal_type_long,
|
|
59
|
+
quantity=self.quantity,
|
|
60
|
+
strength=self.strength,
|
|
61
|
+
price=self.price,
|
|
62
|
+
stoplimit=self.stoplimit,
|
|
63
|
+
)
|
|
64
|
+
self.assertIsInstance(event, Event)
|
|
65
|
+
self.assertEqual(event.type, Events.SIGNAL)
|
|
66
|
+
self.assertEqual(event.strategy_id, self.strategy_id)
|
|
67
|
+
self.assertEqual(event.symbol, self.symbol)
|
|
68
|
+
self.assertEqual(event.datetime, self.timestamp)
|
|
69
|
+
self.assertEqual(event.signal_type, self.signal_type_long)
|
|
70
|
+
self.assertEqual(event.quantity, self.quantity)
|
|
71
|
+
self.assertEqual(event.strength, self.strength)
|
|
72
|
+
self.assertEqual(event.price, self.price)
|
|
73
|
+
self.assertEqual(event.stoplimit, self.stoplimit)
|
|
74
|
+
|
|
75
|
+
def test_signal_event_creation_short_defaults(self):
|
|
76
|
+
"""Test SignalEvent initialization with defaults for SHORT."""
|
|
77
|
+
event = SignalEvent(
|
|
78
|
+
strategy_id=self.strategy_id,
|
|
79
|
+
symbol=self.symbol,
|
|
80
|
+
datetime=self.timestamp,
|
|
81
|
+
signal_type=self.signal_type_short,
|
|
82
|
+
)
|
|
83
|
+
self.assertIsInstance(event, Event)
|
|
84
|
+
self.assertEqual(event.type, Events.SIGNAL)
|
|
85
|
+
self.assertEqual(event.strategy_id, self.strategy_id)
|
|
86
|
+
self.assertEqual(event.symbol, self.symbol)
|
|
87
|
+
self.assertEqual(event.datetime, self.timestamp)
|
|
88
|
+
self.assertEqual(event.signal_type, self.signal_type_short)
|
|
89
|
+
# Check defaults
|
|
90
|
+
self.assertEqual(event.quantity, 100) # Default quantity
|
|
91
|
+
self.assertEqual(event.strength, 1.0) # Default strength
|
|
92
|
+
self.assertIsNone(event.price) # Default price
|
|
93
|
+
self.assertIsNone(event.stoplimit) # Default stoplimit
|
|
94
|
+
|
|
95
|
+
def test_signal_event_creation_exit(self):
|
|
96
|
+
"""Test SignalEvent initialization for EXIT."""
|
|
97
|
+
event = SignalEvent(
|
|
98
|
+
strategy_id=self.strategy_id,
|
|
99
|
+
symbol=self.symbol,
|
|
100
|
+
datetime=self.timestamp,
|
|
101
|
+
signal_type=self.signal_type_exit,
|
|
102
|
+
quantity=50, # Different quantity for exit
|
|
103
|
+
)
|
|
104
|
+
self.assertIsInstance(event, Event)
|
|
105
|
+
self.assertEqual(event.type, Events.SIGNAL)
|
|
106
|
+
self.assertEqual(event.signal_type, self.signal_type_exit)
|
|
107
|
+
self.assertEqual(event.quantity, 50)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestOrderEvent(unittest.TestCase):
|
|
111
|
+
"""Tests the OrderEvent class."""
|
|
112
|
+
|
|
113
|
+
def setUp(self):
|
|
114
|
+
"""Set up common data for tests."""
|
|
115
|
+
self.symbol = "GOOG"
|
|
116
|
+
self.quantity = 75
|
|
117
|
+
self.price_lmt = 135.00
|
|
118
|
+
self.signal_ref = "Signal_123"
|
|
119
|
+
|
|
120
|
+
def test_order_event_creation_mkt_buy(self):
|
|
121
|
+
"""Test MKT BUY OrderEvent initialization."""
|
|
122
|
+
event = OrderEvent(
|
|
123
|
+
symbol=self.symbol,
|
|
124
|
+
order_type="MKT",
|
|
125
|
+
quantity=self.quantity,
|
|
126
|
+
direction="BUY",
|
|
127
|
+
signal=self.signal_ref,
|
|
128
|
+
)
|
|
129
|
+
self.assertIsInstance(event, Event)
|
|
130
|
+
self.assertEqual(event.type, Events.ORDER)
|
|
131
|
+
self.assertEqual(event.symbol, self.symbol)
|
|
132
|
+
self.assertEqual(event.order_type, "MKT")
|
|
133
|
+
self.assertEqual(event.quantity, self.quantity)
|
|
134
|
+
self.assertEqual(event.direction, "BUY")
|
|
135
|
+
self.assertIsNone(event.price) # Price is None for MKT
|
|
136
|
+
self.assertEqual(event.signal, self.signal_ref)
|
|
137
|
+
|
|
138
|
+
def test_order_event_creation_lmt_sell(self):
|
|
139
|
+
"""Test LMT SELL OrderEvent initialization."""
|
|
140
|
+
event = OrderEvent(
|
|
141
|
+
symbol=self.symbol,
|
|
142
|
+
order_type="LMT",
|
|
143
|
+
quantity=self.quantity,
|
|
144
|
+
direction="SELL",
|
|
145
|
+
price=self.price_lmt,
|
|
146
|
+
signal=self.signal_ref,
|
|
147
|
+
)
|
|
148
|
+
self.assertIsInstance(event, Event)
|
|
149
|
+
self.assertEqual(event.type, Events.ORDER)
|
|
150
|
+
self.assertEqual(event.symbol, self.symbol)
|
|
151
|
+
self.assertEqual(event.order_type, "LMT")
|
|
152
|
+
self.assertEqual(event.quantity, self.quantity)
|
|
153
|
+
self.assertEqual(event.direction, "SELL")
|
|
154
|
+
self.assertEqual(event.price, self.price_lmt)
|
|
155
|
+
self.assertEqual(event.signal, self.signal_ref)
|
|
156
|
+
|
|
157
|
+
def test_print_order(self):
|
|
158
|
+
"""Test the print_order method output."""
|
|
159
|
+
event = OrderEvent(
|
|
160
|
+
symbol=self.symbol,
|
|
161
|
+
order_type="LMT",
|
|
162
|
+
quantity=self.quantity,
|
|
163
|
+
direction="SELL",
|
|
164
|
+
price=self.price_lmt,
|
|
165
|
+
)
|
|
166
|
+
# Redirect stdout to capture print output
|
|
167
|
+
captured_output = io.StringIO()
|
|
168
|
+
try:
|
|
169
|
+
with redirect_stdout(captured_output):
|
|
170
|
+
event.print_order() # Call the *instance* method
|
|
171
|
+
output = captured_output.getvalue().strip()
|
|
172
|
+
expected_output = f"Order: Symbol={self.symbol}, Type=LMT, Quantity={self.quantity}, Direction=SELL, Price={self.price_lmt}"
|
|
173
|
+
self.assertEqual(output, expected_output)
|
|
174
|
+
except AttributeError:
|
|
175
|
+
self.fail(
|
|
176
|
+
"print_order is likely defined inside __init__ and not as a class method."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestFillEvent(unittest.TestCase):
|
|
181
|
+
"""Tests the FillEvent class."""
|
|
182
|
+
|
|
183
|
+
def setUp(self):
|
|
184
|
+
"""Set up common data for tests."""
|
|
185
|
+
self.timestamp = datetime(2023, 10, 27, 10, 35, 15)
|
|
186
|
+
self.symbol = "MSFT"
|
|
187
|
+
self.exchange = "NYSE"
|
|
188
|
+
self.fill_cost = 330.25
|
|
189
|
+
self.order_ref = "Order_456"
|
|
190
|
+
|
|
191
|
+
def create_fill_event(self, quantity, commission=None, direction="BUY"):
|
|
192
|
+
"""Helper method to create a FillEvent."""
|
|
193
|
+
return FillEvent(
|
|
194
|
+
timeindex=self.timestamp,
|
|
195
|
+
symbol=self.symbol,
|
|
196
|
+
exchange=self.exchange,
|
|
197
|
+
quantity=quantity,
|
|
198
|
+
direction=direction,
|
|
199
|
+
fill_cost=self.fill_cost
|
|
200
|
+
* quantity, # Example fill cost based on price * quantity
|
|
201
|
+
commission=commission,
|
|
202
|
+
order=self.order_ref,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def test_fill_event_creation_buy_calculated_commission_small(self):
|
|
206
|
+
"""Test FillEvent BUY with calculated commission (qty <= 500)."""
|
|
207
|
+
quantity = 100
|
|
208
|
+
event = self.create_fill_event(quantity=quantity)
|
|
209
|
+
|
|
210
|
+
self.assertIsInstance(event, Event)
|
|
211
|
+
self.assertEqual(event.type, Events.FILL)
|
|
212
|
+
self.assertEqual(event.timeindex, self.timestamp)
|
|
213
|
+
self.assertEqual(event.symbol, self.symbol)
|
|
214
|
+
self.assertEqual(event.exchange, self.exchange)
|
|
215
|
+
self.assertEqual(event.quantity, quantity)
|
|
216
|
+
self.assertEqual(event.direction, "BUY")
|
|
217
|
+
self.assertEqual(event.fill_cost, self.fill_cost * quantity)
|
|
218
|
+
self.assertEqual(event.order, self.order_ref)
|
|
219
|
+
|
|
220
|
+
# Test commission calculation (<= 500)
|
|
221
|
+
expected_commission = max(1.3, 0.013 * quantity)
|
|
222
|
+
self.assertAlmostEqual(
|
|
223
|
+
event.commission, expected_commission
|
|
224
|
+
) # Use assertAlmostEqual for floats
|
|
225
|
+
self.assertAlmostEqual(event.commission, 1.30) # 0.013 * 100 = 1.3
|
|
226
|
+
|
|
227
|
+
def test_fill_event_creation_sell_calculated_commission_large(self):
|
|
228
|
+
"""Test FillEvent SELL with calculated commission (qty > 500)."""
|
|
229
|
+
quantity = 600
|
|
230
|
+
event = self.create_fill_event(quantity=quantity, direction="SELL")
|
|
231
|
+
|
|
232
|
+
self.assertIsInstance(event, Event)
|
|
233
|
+
self.assertEqual(event.type, Events.FILL)
|
|
234
|
+
self.assertEqual(event.direction, "SELL")
|
|
235
|
+
self.assertEqual(event.quantity, quantity)
|
|
236
|
+
|
|
237
|
+
# Test commission calculation (> 500)
|
|
238
|
+
expected_commission = max(1.3, 0.008 * quantity)
|
|
239
|
+
self.assertAlmostEqual(event.commission, expected_commission)
|
|
240
|
+
self.assertAlmostEqual(event.commission, 4.8) # 0.008 * 600 = 4.8
|
|
241
|
+
|
|
242
|
+
def test_fill_event_creation_calculated_commission_edge_500(self):
|
|
243
|
+
"""Test FillEvent with calculated commission (qty == 500)."""
|
|
244
|
+
quantity = 500
|
|
245
|
+
event = self.create_fill_event(quantity=quantity)
|
|
246
|
+
|
|
247
|
+
# Test commission calculation (<= 500 rule applies)
|
|
248
|
+
expected_commission = max(1.3, 0.013 * quantity)
|
|
249
|
+
self.assertAlmostEqual(event.commission, expected_commission)
|
|
250
|
+
self.assertAlmostEqual(event.commission, 6.5) # 0.013 * 500 = 6.5
|
|
251
|
+
|
|
252
|
+
def test_fill_event_creation_calculated_commission_minimum(self):
|
|
253
|
+
"""Test FillEvent with calculated commission hitting minimum."""
|
|
254
|
+
quantity = 50
|
|
255
|
+
event = self.create_fill_event(quantity=quantity)
|
|
256
|
+
|
|
257
|
+
# Test commission calculation (minimum rule applies)
|
|
258
|
+
expected_commission = max(1.3, 0.013 * quantity) # 0.013 * 50 = 0.65
|
|
259
|
+
self.assertAlmostEqual(event.commission, expected_commission)
|
|
260
|
+
self.assertAlmostEqual(event.commission, 1.3) # max(1.3, 0.65) = 1.3
|
|
261
|
+
|
|
262
|
+
def test_fill_event_creation_provided_commission(self):
|
|
263
|
+
"""Test FillEvent with explicitly provided commission."""
|
|
264
|
+
quantity = 200
|
|
265
|
+
provided_commission = 5.0
|
|
266
|
+
event = self.create_fill_event(
|
|
267
|
+
quantity=quantity, commission=provided_commission
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
self.assertIsInstance(event, Event)
|
|
271
|
+
self.assertEqual(event.type, Events.FILL)
|
|
272
|
+
self.assertEqual(event.quantity, quantity)
|
|
273
|
+
|
|
274
|
+
# Test that provided commission overrides calculation
|
|
275
|
+
self.assertEqual(event.commission, provided_commission)
|
|
276
|
+
|
|
277
|
+
def test_calculate_ib_commission_method(self):
|
|
278
|
+
"""Directly test the commission calculation method."""
|
|
279
|
+
# Create a dummy event just to call the method (attributes don't matter here)
|
|
280
|
+
event_small = FillEvent(datetime.now(), "SYM", "EXCH", 100, "BUY", 1000)
|
|
281
|
+
event_large = FillEvent(datetime.now(), "SYM", "EXCH", 600, "BUY", 6000)
|
|
282
|
+
event_edge = FillEvent(datetime.now(), "SYM", "EXCH", 500, "BUY", 5000)
|
|
283
|
+
event_min = FillEvent(datetime.now(), "SYM", "EXCH", 10, "BUY", 100)
|
|
284
|
+
|
|
285
|
+
self.assertAlmostEqual(
|
|
286
|
+
event_small.calculate_ib_commission(), 1.3
|
|
287
|
+
) # max(1.3, 0.013*100=1.3)
|
|
288
|
+
self.assertAlmostEqual(
|
|
289
|
+
event_large.calculate_ib_commission(), 4.8
|
|
290
|
+
) # max(1.3, 0.008*600=4.8)
|
|
291
|
+
self.assertAlmostEqual(
|
|
292
|
+
event_edge.calculate_ib_commission(), 6.5
|
|
293
|
+
) # max(1.3, 0.013*500=6.5)
|
|
294
|
+
self.assertAlmostEqual(
|
|
295
|
+
event_min.calculate_ib_commission(), 1.3
|
|
296
|
+
) # max(1.3, 0.013*10=0.13)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
unittest.main(argv=[""], exit=False)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from collections import namedtuple
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from queue import Queue
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
from bbstrader.btengine.data import DataHandler
|
|
8
|
+
from bbstrader.btengine.event import FillEvent, OrderEvent
|
|
9
|
+
from bbstrader.btengine.execution import MT5ExecutionHandler, SimExecutionHandler
|
|
10
|
+
from bbstrader.metatrader.utils import SymbolType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestSimExecutionHandler(unittest.TestCase):
|
|
14
|
+
"""
|
|
15
|
+
Tests for the SimExecutionHandler class.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def setUp(self):
|
|
19
|
+
"""Set up test fixtures before each test."""
|
|
20
|
+
self.events_queue = Queue()
|
|
21
|
+
self.mock_data_handler = Mock(spec=DataHandler)
|
|
22
|
+
self.mock_logger = Mock()
|
|
23
|
+
|
|
24
|
+
self.test_time = datetime(2023, 1, 1, 12, 0, 0)
|
|
25
|
+
self.mock_data_handler.get_latest_bar_datetime.return_value = self.test_time
|
|
26
|
+
|
|
27
|
+
self.handler = SimExecutionHandler(
|
|
28
|
+
events=self.events_queue,
|
|
29
|
+
data=self.mock_data_handler,
|
|
30
|
+
logger=self.mock_logger,
|
|
31
|
+
commission=5.0,
|
|
32
|
+
exchange="SIMEX",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def test_execute_order_creates_and_queues_fill_event(self):
|
|
36
|
+
"""
|
|
37
|
+
Tests that SimExecutionHandler correctly creates a FillEvent
|
|
38
|
+
from an OrderEvent and puts it onto the events queue.
|
|
39
|
+
"""
|
|
40
|
+
order_event = OrderEvent(
|
|
41
|
+
symbol="AAPL",
|
|
42
|
+
order_type="MKT",
|
|
43
|
+
quantity=100,
|
|
44
|
+
direction="BUY",
|
|
45
|
+
price=150.0,
|
|
46
|
+
signal="LONG",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.handler.execute_order(order_event)
|
|
50
|
+
|
|
51
|
+
self.mock_data_handler.get_latest_bar_datetime.assert_called_once_with("AAPL")
|
|
52
|
+
|
|
53
|
+
self.assertEqual(self.events_queue.qsize(), 1)
|
|
54
|
+
fill_event = self.events_queue.get()
|
|
55
|
+
|
|
56
|
+
self.assertIsInstance(fill_event, FillEvent)
|
|
57
|
+
self.assertEqual(fill_event.timeindex, self.test_time)
|
|
58
|
+
self.assertEqual(fill_event.symbol, "AAPL")
|
|
59
|
+
self.assertEqual(fill_event.exchange, "SIMEX")
|
|
60
|
+
self.assertEqual(fill_event.quantity, 100)
|
|
61
|
+
self.assertEqual(fill_event.direction, "BUY")
|
|
62
|
+
self.assertIsNone(fill_event.fill_cost)
|
|
63
|
+
self.assertEqual(fill_event.commission, 5.0)
|
|
64
|
+
self.assertEqual(fill_event.order, "LONG")
|
|
65
|
+
|
|
66
|
+
expected_log_msg = (
|
|
67
|
+
"BUY ORDER FILLED: SYMBOL=AAPL, QUANTITY=100, PRICE @150.0 EXCHANGE=SIMEX"
|
|
68
|
+
)
|
|
69
|
+
self.mock_logger.info.assert_called_once_with(
|
|
70
|
+
expected_log_msg, custom_time=self.test_time
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
SymbolInfo = namedtuple(
|
|
75
|
+
"SymbolInfo", ["trade_contract_size", "volume_min", "volume_max"]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestMT5ExecutionHandler(unittest.TestCase):
|
|
80
|
+
"""
|
|
81
|
+
Tests for the MT5ExecutionHandler class.
|
|
82
|
+
The 'Account' class dependency is mocked to avoid any real API calls.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def setUp(self):
|
|
86
|
+
"""Set up test fixtures before each test."""
|
|
87
|
+
self.events_queue = Queue()
|
|
88
|
+
self.mock_data_handler = Mock(spec=DataHandler)
|
|
89
|
+
self.mock_logger = Mock()
|
|
90
|
+
|
|
91
|
+
self.test_time = datetime(2023, 1, 1, 12, 0, 0)
|
|
92
|
+
self.mock_data_handler.get_latest_bar_datetime.return_value = self.test_time
|
|
93
|
+
|
|
94
|
+
self.patcher = patch("bbstrader.btengine.execution.Account")
|
|
95
|
+
MockAccount = self.patcher.start()
|
|
96
|
+
|
|
97
|
+
self.mock_account_instance = MockAccount.return_value
|
|
98
|
+
|
|
99
|
+
self.handler = MT5ExecutionHandler(
|
|
100
|
+
events=self.events_queue,
|
|
101
|
+
data=self.mock_data_handler,
|
|
102
|
+
logger=self.mock_logger,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def tearDown(self):
|
|
106
|
+
"""Stop the patcher after each test."""
|
|
107
|
+
self.patcher.stop()
|
|
108
|
+
|
|
109
|
+
def test_execute_order_us_stock(self):
|
|
110
|
+
"""Tests commission calculation for a US stock."""
|
|
111
|
+
symbol, qty, price = "AAPL", 100, 150.0
|
|
112
|
+
|
|
113
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.STOCKS
|
|
114
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(
|
|
115
|
+
1, 0.01, 1000
|
|
116
|
+
)
|
|
117
|
+
self.mock_account_instance.get_stocks_from_country.return_value = [symbol]
|
|
118
|
+
|
|
119
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
120
|
+
self.handler.execute_order(order_event)
|
|
121
|
+
|
|
122
|
+
fill_event = self.events_queue.get_nowait()
|
|
123
|
+
|
|
124
|
+
expected_commission = max(1.0, 100 * 0.02) # 2.0
|
|
125
|
+
self.assertEqual(fill_event.commission, expected_commission)
|
|
126
|
+
self.assertEqual(fill_event.exchange, "MT5")
|
|
127
|
+
|
|
128
|
+
def test_execute_order_forex(self):
|
|
129
|
+
"""Tests commission calculation for a Forex pair."""
|
|
130
|
+
symbol, qty, price = "EURUSD", 10000, 1.05
|
|
131
|
+
|
|
132
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.FOREX
|
|
133
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(
|
|
134
|
+
100000, 0.01, 1000
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
138
|
+
self.handler.execute_order(order_event)
|
|
139
|
+
|
|
140
|
+
fill_event = self.events_queue.get_nowait()
|
|
141
|
+
|
|
142
|
+
# lot = 10000 * 1.05 / 100000 = 0.105
|
|
143
|
+
# round(0.105, 2) is 0.10 (round half to even)
|
|
144
|
+
# commission = 3.0 * 0.10 = 0.3
|
|
145
|
+
expected_commission = 0.3
|
|
146
|
+
self.assertAlmostEqual(fill_event.commission, expected_commission)
|
|
147
|
+
|
|
148
|
+
def test_execute_order_commodity(self):
|
|
149
|
+
"""Tests commission calculation for a Commodity."""
|
|
150
|
+
symbol, qty, price = "XAUUSD", 10, 1800
|
|
151
|
+
|
|
152
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.COMMODITIES
|
|
153
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(
|
|
154
|
+
100, 0.01, 1000
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
158
|
+
self.handler.execute_order(order_event)
|
|
159
|
+
|
|
160
|
+
fill_event = self.events_queue.get_nowait()
|
|
161
|
+
|
|
162
|
+
expected_commission = 3.0 * 0.1
|
|
163
|
+
self.assertAlmostEqual(fill_event.commission, expected_commission)
|
|
164
|
+
|
|
165
|
+
def test_execute_order_index(self):
|
|
166
|
+
"""Tests commission calculation for an Index."""
|
|
167
|
+
symbol, qty, price = "GER30", 10, 15000
|
|
168
|
+
|
|
169
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.INDICES
|
|
170
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(1, 0.1, 50)
|
|
171
|
+
|
|
172
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
173
|
+
self.handler.execute_order(order_event)
|
|
174
|
+
|
|
175
|
+
fill_event = self.events_queue.get_nowait()
|
|
176
|
+
|
|
177
|
+
expected_commission = 0.25 * 10
|
|
178
|
+
self.assertAlmostEqual(fill_event.commission, expected_commission)
|
|
179
|
+
|
|
180
|
+
def test_lot_capping_at_minimum(self):
|
|
181
|
+
"""Tests that lot size is floored at the symbol's volume_min."""
|
|
182
|
+
symbol, qty, price = "EURUSD", 100, 1.05
|
|
183
|
+
|
|
184
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.FOREX
|
|
185
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(
|
|
186
|
+
100000, 0.01, 1000
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
190
|
+
self.handler.execute_order(order_event)
|
|
191
|
+
|
|
192
|
+
fill_event = self.events_queue.get_nowait()
|
|
193
|
+
|
|
194
|
+
expected_commission = 3.0 * 0.01
|
|
195
|
+
self.assertAlmostEqual(fill_event.commission, expected_commission)
|
|
196
|
+
|
|
197
|
+
def test_custom_commission_overrides_calculation(self):
|
|
198
|
+
"""Tests that a provided commission value overrides the calculated one."""
|
|
199
|
+
handler_with_commission = MT5ExecutionHandler(
|
|
200
|
+
events=self.events_queue, data=self.mock_data_handler, commission=99.99
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
symbol, qty, price = "AAPL", 100, 150.0
|
|
204
|
+
self.mock_account_instance.get_symbol_type.return_value = SymbolType.STOCKS
|
|
205
|
+
self.mock_account_instance.get_symbol_info.return_value = SymbolInfo(
|
|
206
|
+
1, 0.01, 1000
|
|
207
|
+
)
|
|
208
|
+
self.mock_account_instance.get_stocks_from_country.return_value = [symbol]
|
|
209
|
+
|
|
210
|
+
order_event = OrderEvent(symbol, "MKT", qty, "BUY", price, "LONG")
|
|
211
|
+
handler_with_commission.execute_order(order_event)
|
|
212
|
+
|
|
213
|
+
fill_event = self.events_queue.get_nowait()
|
|
214
|
+
|
|
215
|
+
self.assertEqual(fill_event.commission, 99.99)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
unittest.main()
|