bbstrader 2.0.3__cp312-cp312-macosx_11_0_arm64.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.
- bbstrader/__init__.py +27 -0
- bbstrader/__main__.py +92 -0
- bbstrader/api/__init__.py +96 -0
- bbstrader/api/handlers.py +245 -0
- bbstrader/api/metatrader_client.cpython-312-darwin.so +0 -0
- bbstrader/api/metatrader_client.pyi +624 -0
- bbstrader/assets/bbs_.png +0 -0
- bbstrader/assets/bbstrader.ico +0 -0
- bbstrader/assets/bbstrader.png +0 -0
- bbstrader/assets/qs_metrics_1.png +0 -0
- bbstrader/btengine/__init__.py +54 -0
- bbstrader/btengine/backtest.py +358 -0
- bbstrader/btengine/data.py +737 -0
- bbstrader/btengine/event.py +229 -0
- bbstrader/btengine/execution.py +287 -0
- bbstrader/btengine/performance.py +408 -0
- bbstrader/btengine/portfolio.py +393 -0
- bbstrader/btengine/strategy.py +588 -0
- bbstrader/compat.py +28 -0
- bbstrader/config.py +100 -0
- bbstrader/core/__init__.py +27 -0
- bbstrader/core/data.py +628 -0
- bbstrader/core/strategy.py +466 -0
- bbstrader/metatrader/__init__.py +48 -0
- bbstrader/metatrader/_copier.py +720 -0
- bbstrader/metatrader/account.py +865 -0
- bbstrader/metatrader/broker.py +418 -0
- bbstrader/metatrader/copier.py +1487 -0
- bbstrader/metatrader/rates.py +495 -0
- bbstrader/metatrader/risk.py +667 -0
- bbstrader/metatrader/trade.py +1692 -0
- bbstrader/metatrader/utils.py +402 -0
- bbstrader/models/__init__.py +39 -0
- bbstrader/models/nlp.py +932 -0
- bbstrader/models/optimization.py +182 -0
- bbstrader/scripts.py +665 -0
- bbstrader/trading/__init__.py +33 -0
- bbstrader/trading/execution.py +1159 -0
- bbstrader/trading/strategy.py +362 -0
- bbstrader/trading/utils.py +69 -0
- bbstrader-2.0.3.dist-info/METADATA +396 -0
- bbstrader-2.0.3.dist-info/RECORD +45 -0
- bbstrader-2.0.3.dist-info/WHEEL +5 -0
- bbstrader-2.0.3.dist-info/entry_points.txt +3 -0
- bbstrader-2.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import concurrent.futures as cf
|
|
2
|
+
import configparser
|
|
3
|
+
import multiprocessing as mp
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from multiprocessing.synchronize import Event
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from loguru import logger as log
|
|
13
|
+
|
|
14
|
+
from bbstrader.api.metatrader_client import TradeOrder, TradePosition # type: ignore
|
|
15
|
+
from bbstrader.config import BBSTRADER_DIR
|
|
16
|
+
from bbstrader.metatrader.account import Account
|
|
17
|
+
from bbstrader.metatrader.broker import check_mt5_connection
|
|
18
|
+
from bbstrader.metatrader.trade import FILLING_TYPE
|
|
19
|
+
from bbstrader.metatrader.utils import trade_retcode_message
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import MetaTrader5 as Mt5
|
|
23
|
+
except ImportError:
|
|
24
|
+
import bbstrader.compat # noqa: F401
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"TradeCopier",
|
|
29
|
+
"RunCopier",
|
|
30
|
+
"RunMultipleCopier",
|
|
31
|
+
"config_copier",
|
|
32
|
+
"copier_worker_process",
|
|
33
|
+
"get_symbols_from_string",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
log.add(
|
|
37
|
+
f"{BBSTRADER_DIR}/logs/copier.log",
|
|
38
|
+
enqueue=True,
|
|
39
|
+
level="INFO",
|
|
40
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
41
|
+
)
|
|
42
|
+
global logger
|
|
43
|
+
logger = log
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ORDER_TYPE = {
|
|
47
|
+
0: (Mt5.ORDER_TYPE_BUY, "BUY"),
|
|
48
|
+
1: (Mt5.ORDER_TYPE_SELL, "SELL"),
|
|
49
|
+
2: (Mt5.ORDER_TYPE_BUY_LIMIT, "BUY LIMIT"),
|
|
50
|
+
3: (Mt5.ORDER_TYPE_SELL_LIMIT, "SELL LIMIT"),
|
|
51
|
+
4: (Mt5.ORDER_TYPE_BUY_STOP, "BUY STOP"),
|
|
52
|
+
5: (Mt5.ORDER_TYPE_SELL_STOP, "SELL STOP"),
|
|
53
|
+
6: (Mt5.ORDER_TYPE_BUY_STOP_LIMIT, "BUY STOP LIMIT"),
|
|
54
|
+
7: (Mt5.ORDER_TYPE_SELL_STOP_LIMIT, "SELL STOP LIMIT"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
STOP_RETCODES = [
|
|
58
|
+
Mt5.TRADE_RETCODE_TRADE_DISABLED,
|
|
59
|
+
Mt5.TRADE_RETCODE_NO_MONEY,
|
|
60
|
+
Mt5.TRADE_RETCODE_SERVER_DISABLES_AT,
|
|
61
|
+
Mt5.TRADE_RETCODE_CLIENT_DISABLES_AT,
|
|
62
|
+
Mt5.TRADE_RETCODE_ONLY_REAL,
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
RETURN_RETCODE = [
|
|
66
|
+
Mt5.TRADE_RETCODE_MARKET_CLOSED,
|
|
67
|
+
Mt5.TRADE_RETCODE_CONNECTION,
|
|
68
|
+
Mt5.TRADE_RETCODE_LIMIT_ORDERS,
|
|
69
|
+
Mt5.TRADE_RETCODE_LIMIT_VOLUME,
|
|
70
|
+
Mt5.TRADE_RETCODE_LIMIT_POSITIONS,
|
|
71
|
+
Mt5.TRADE_RETCODE_LONG_ONLY,
|
|
72
|
+
Mt5.TRADE_RETCODE_SHORT_ONLY,
|
|
73
|
+
Mt5.TRADE_RETCODE_CLOSE_ONLY,
|
|
74
|
+
Mt5.TRADE_RETCODE_FIFO_CLOSE,
|
|
75
|
+
Mt5.TRADE_RETCODE_INVALID_VOLUME,
|
|
76
|
+
Mt5.TRADE_RETCODE_INVALID_PRICE,
|
|
77
|
+
Mt5.TRADE_RETCODE_INVALID_STOPS,
|
|
78
|
+
Mt5.TRADE_RETCODE_NO_CHANGES,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OrderAction(Enum):
|
|
83
|
+
COPY_NEW = "COPY_NEW"
|
|
84
|
+
MODIFY = "MODIFY"
|
|
85
|
+
CLOSE = "CLOSE"
|
|
86
|
+
SYNC_REMOVE = "SYNC_REMOVE"
|
|
87
|
+
SYNC_ADD = "SYNC_ADD"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
CopyMode = Literal["fix", "multiply", "percentage", "dynamic", "replicate", "specific"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fix_lot(fixed):
|
|
94
|
+
if fixed == 0 or fixed is None:
|
|
95
|
+
raise ValueError("Fixed lot must be a number > 0")
|
|
96
|
+
return fixed
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def multiply_lot(lot, multiplier):
|
|
100
|
+
if multiplier == 0 or multiplier is None:
|
|
101
|
+
raise ValueError("Multiplier lot must be a number > 0")
|
|
102
|
+
return lot * multiplier
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def percentage_lot(lot, percentage):
|
|
106
|
+
if percentage == 0 or percentage is None:
|
|
107
|
+
raise ValueError("Percentage lot must be a number > 0")
|
|
108
|
+
return round(lot * percentage / 100, 2)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def dynamic_lot(source_lot, source_eqty: float, dest_eqty: float):
|
|
112
|
+
try:
|
|
113
|
+
ratio = dest_eqty / source_eqty
|
|
114
|
+
return round(source_lot * ratio, 2)
|
|
115
|
+
except ZeroDivisionError:
|
|
116
|
+
raise ValueError("Source or destination account equity is zero")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def specific_lot(symbol, value) -> float:
|
|
120
|
+
if not isinstance(value, dict):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"Specific lot size must be provided as a dictionary mapping symbols to lot sizes"
|
|
123
|
+
)
|
|
124
|
+
return value.get(symbol, 0.01)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fixed_lot(lot, symbol, destination) -> float:
|
|
128
|
+
def _volume_step(value):
|
|
129
|
+
value_str = str(value)
|
|
130
|
+
if "." in value_str and value_str != "1.0":
|
|
131
|
+
decimal_index = value_str.index(".")
|
|
132
|
+
num_digits = len(value_str) - decimal_index - 1
|
|
133
|
+
return num_digits
|
|
134
|
+
elif value_str == "1.0":
|
|
135
|
+
return 0
|
|
136
|
+
else:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
def _check_lot(lot: float, symbol_info) -> float:
|
|
140
|
+
if lot > symbol_info.volume_max:
|
|
141
|
+
return symbol_info.volume_max / 2
|
|
142
|
+
elif lot < symbol_info.volume_min:
|
|
143
|
+
return symbol_info.volume_min
|
|
144
|
+
return lot
|
|
145
|
+
|
|
146
|
+
s_info = Account(**destination).get_symbol_info(symbol)
|
|
147
|
+
volume_step = s_info.volume_step
|
|
148
|
+
steps = _volume_step(volume_step)
|
|
149
|
+
if float(steps) >= float(1):
|
|
150
|
+
return _check_lot(round(lot, steps), s_info)
|
|
151
|
+
else:
|
|
152
|
+
return _check_lot(round(lot), s_info)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def calculate_copy_lot(
|
|
156
|
+
source_lot,
|
|
157
|
+
symbol: str,
|
|
158
|
+
destination: dict,
|
|
159
|
+
mode: CopyMode = "dynamic",
|
|
160
|
+
value=None,
|
|
161
|
+
source_eqty: float = None,
|
|
162
|
+
dest_eqty: float = None,
|
|
163
|
+
):
|
|
164
|
+
match mode:
|
|
165
|
+
case "replicate":
|
|
166
|
+
return fixed_lot(source_lot, symbol, destination)
|
|
167
|
+
case "fix":
|
|
168
|
+
return fixed_lot(fix_lot(value), symbol, destination)
|
|
169
|
+
case "multiply":
|
|
170
|
+
lot = multiply_lot(source_lot, value)
|
|
171
|
+
return fixed_lot(lot, symbol, destination)
|
|
172
|
+
case "percentage":
|
|
173
|
+
lot = percentage_lot(source_lot, value)
|
|
174
|
+
return fixed_lot(lot, symbol, destination)
|
|
175
|
+
case "dynamic":
|
|
176
|
+
lot = dynamic_lot(source_lot, source_eqty, dest_eqty)
|
|
177
|
+
return fixed_lot(lot, symbol, destination)
|
|
178
|
+
case "specific":
|
|
179
|
+
lot = specific_lot(symbol, value)
|
|
180
|
+
return fixed_lot(lot, symbol, destination)
|
|
181
|
+
case _:
|
|
182
|
+
raise ValueError("Invalid mode selected")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_symbols_from_string(symbols_string: str) -> List[str] | Dict[str, str]:
|
|
186
|
+
if not symbols_string:
|
|
187
|
+
raise ValueError("Input Error", "Tickers string cannot be empty.")
|
|
188
|
+
string = (
|
|
189
|
+
symbols_string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
|
|
190
|
+
)
|
|
191
|
+
if ":" in string and "," in string:
|
|
192
|
+
if string.endswith(","):
|
|
193
|
+
string = string[:-1]
|
|
194
|
+
return dict(item.split(":") for item in string.split(","))
|
|
195
|
+
elif ":" in string and "," not in string:
|
|
196
|
+
raise ValueError("Each key pairs value must be separeted by ','")
|
|
197
|
+
elif "," in string and ":" not in string:
|
|
198
|
+
return string.split(",")
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError("""
|
|
201
|
+
Invalid symbols format.
|
|
202
|
+
You can use comma separated symbols in one line or multiple lines using triple quotes.
|
|
203
|
+
You can also use a dictionary to map source symbols to destination symbols as shown below.
|
|
204
|
+
Or if you want to copy all symbols, use "all" or "*".
|
|
205
|
+
|
|
206
|
+
symbols = EURUSD,GBPUSD,USDJPY (comma separated)
|
|
207
|
+
symbols = EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i (dictionary)
|
|
208
|
+
symbols = all (copy all symbols)
|
|
209
|
+
symbols = * (copy all symbols) """)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_lots_from_string(lots_string: str) -> Dict[str, float]:
|
|
213
|
+
if not lots_string:
|
|
214
|
+
raise ValueError("Input Error", "Lots string cannot be empty.")
|
|
215
|
+
string = lots_string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
|
|
216
|
+
if ":" in string and "," in string:
|
|
217
|
+
if string.endswith(","):
|
|
218
|
+
string = string[:-1]
|
|
219
|
+
lot_dict = {}
|
|
220
|
+
for item in string.split(","):
|
|
221
|
+
key, value = item.split(":")
|
|
222
|
+
lot_dict[key] = float(value)
|
|
223
|
+
return lot_dict
|
|
224
|
+
else:
|
|
225
|
+
raise ValueError("""
|
|
226
|
+
Invalid lots format.
|
|
227
|
+
You must use a dictionary to map symbols to lot sizes as shown below.
|
|
228
|
+
|
|
229
|
+
lots = EURUSD:0.1, GBPUSD:0.2, USDJPY:0.15 (dictionary)
|
|
230
|
+
""")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_copy_symbols(destination: dict, source: dict) -> List[str] | Dict[str, str]:
|
|
234
|
+
symbols = destination.get("symbols", "all")
|
|
235
|
+
if symbols == "all" or symbols == "*":
|
|
236
|
+
src_account = Account(**source)
|
|
237
|
+
src_symbols = src_account.get_symbols()
|
|
238
|
+
dest_account = Account(**destination)
|
|
239
|
+
dest_symbols = dest_account.get_symbols()
|
|
240
|
+
for s in src_symbols:
|
|
241
|
+
if s not in dest_symbols:
|
|
242
|
+
err_msg = (
|
|
243
|
+
f"To use 'all' or '*', Source account@{src_account.number} "
|
|
244
|
+
f"and destination account@{dest_account.number} "
|
|
245
|
+
f"must be the same type and have the same symbols"
|
|
246
|
+
f"If not Use a dictionary to map source symbols to destination symbols "
|
|
247
|
+
f"(e.g., EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i"
|
|
248
|
+
f"Where EURUSD.s is the source symbols and EURUSD_i is the corresponding symbol"
|
|
249
|
+
)
|
|
250
|
+
raise ValueError(err_msg)
|
|
251
|
+
return dest_symbols
|
|
252
|
+
elif isinstance(symbols, (list, dict)):
|
|
253
|
+
return symbols
|
|
254
|
+
elif isinstance(symbols, str):
|
|
255
|
+
return get_symbols_from_string(symbols)
|
|
256
|
+
else:
|
|
257
|
+
raise ValueError("Invalide symbols provided")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TradeCopier(object):
|
|
261
|
+
"""
|
|
262
|
+
``TradeCopier`` responsible for copying trading orders and positions from a source account to multiple destination accounts.
|
|
263
|
+
|
|
264
|
+
This class facilitates the synchronization of trades between a source account and multiple destination accounts.
|
|
265
|
+
It handles copying new orders, modifying existing orders, updating and closing positions based on updates from the source account.
|
|
266
|
+
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
__slots__ = (
|
|
270
|
+
"source",
|
|
271
|
+
"source_id",
|
|
272
|
+
"source_isunique",
|
|
273
|
+
"destinations",
|
|
274
|
+
"errors",
|
|
275
|
+
"sleeptime",
|
|
276
|
+
"start_time",
|
|
277
|
+
"end_time",
|
|
278
|
+
"shutdown_event",
|
|
279
|
+
"custom_logger",
|
|
280
|
+
"log_queue",
|
|
281
|
+
"_last_session",
|
|
282
|
+
"_running",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
source: Dict
|
|
286
|
+
source_id: int
|
|
287
|
+
source_isunique: bool
|
|
288
|
+
destinations: List[dict]
|
|
289
|
+
shutdown_event: Event
|
|
290
|
+
log_queue: mp.Queue
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
source: Dict,
|
|
295
|
+
destinations: List[dict],
|
|
296
|
+
/,
|
|
297
|
+
sleeptime: float = 0.1,
|
|
298
|
+
start_time: str = None,
|
|
299
|
+
end_time: str = None,
|
|
300
|
+
*,
|
|
301
|
+
custom_logger=None,
|
|
302
|
+
shutdown_event=None,
|
|
303
|
+
log_queue=None,
|
|
304
|
+
):
|
|
305
|
+
"""
|
|
306
|
+
Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
source (dict):
|
|
310
|
+
A dictionary containing the connection details for the source trading account. This dictionary
|
|
311
|
+
**must** include all parameters required to successfully connect to the source account.
|
|
312
|
+
Refer to the ``bbstrader.metatrader.check_mt5_connection`` function for a comprehensive list
|
|
313
|
+
of required keys and their expected values. Common parameters include, but are not limited to
|
|
314
|
+
|
|
315
|
+
- `login`: The account login ID (integer).
|
|
316
|
+
- `password`: The account password (string).
|
|
317
|
+
- `server`: The server address (string), e.g., "Broker-Demo".
|
|
318
|
+
- `path`: The path to the MetaTrader 5 installation directory (string).
|
|
319
|
+
- `portable`: A boolean indicating whether to open MetaTrader 5 installation in portable mode.
|
|
320
|
+
- `id`: A unique identifier for all trades opened buy the source source account.
|
|
321
|
+
This Must be a positive number greater than 0 and less than 2^32 / 2.
|
|
322
|
+
- `unique`: A boolean indication whehter to allow destination accounts to copy from other sources.
|
|
323
|
+
If Set to True, all destination accounts won't be allow to accept trades from other accounts even
|
|
324
|
+
manually opened positions or orders will be removed.
|
|
325
|
+
|
|
326
|
+
destinations (List[dict]):
|
|
327
|
+
A list of dictionaries, where each dictionary represents a destination trading account to which
|
|
328
|
+
trades will be copied. Each destination dictionary **must** contain the following keys
|
|
329
|
+
|
|
330
|
+
- Authentication details (e.g., `login`, `password`, `server`)
|
|
331
|
+
Identical in structure and requirements to the `source` dictionary,
|
|
332
|
+
ensuring a connection can be established to the destination account.
|
|
333
|
+
Refer to ``bbstrader.metatrader.check_mt5_connection``.
|
|
334
|
+
|
|
335
|
+
- `symbols` (Union[List[str], Dict[str, str], str])
|
|
336
|
+
Specifies which symbols should be copied from the source
|
|
337
|
+
account to this destination account. Possible values include
|
|
338
|
+
`List[str]` A list of strings, where each string is a symbol to be copied.
|
|
339
|
+
The same symbol will be traded on the destination account. Example `["EURUSD", "GBPUSD"]`
|
|
340
|
+
`Dict[str, str]` A dictionary mapping source symbols to destination symbols.
|
|
341
|
+
This allows for trading a different symbol on the destination account than the one traded on the source.
|
|
342
|
+
Example `{"EURUSD": "EURUSD_i", "GBPUSD": "GBPUSD_i"}`.
|
|
343
|
+
`"all"` or `"*"` Indicates that all symbols traded on the source account should be
|
|
344
|
+
copied to this destination account, using the same symbol name.
|
|
345
|
+
|
|
346
|
+
- `mode` (str) The risk management mode to use. Valid options are
|
|
347
|
+
`"fix"` Use a fixed lot size. The `value` key must specify the fixed lot size.
|
|
348
|
+
`"multiply"` Multiply the source account's lot size by a factor.
|
|
349
|
+
The `value` key must specify the multiplier.
|
|
350
|
+
`"percentage"` Trade a percentage of the source account's lot size.
|
|
351
|
+
The `value` key must specify the percentage (as a decimal, e.g., 50 for 50%).
|
|
352
|
+
`"dynamic"` Calculate the lot size dynamically based on account equity and risk parameters.
|
|
353
|
+
The `value` key is ignored.
|
|
354
|
+
`"replicate"` Copy the exact lot size from the source account. The `value` key is ignored.
|
|
355
|
+
`"specific"` Use a specific lot size defined in the `value` key for each symbol.
|
|
356
|
+
|
|
357
|
+
- `value` (float or dict, optional) A numerical value or dict used in conjunction with the selected `mode`.
|
|
358
|
+
Its meaning depends on the chosen `mode` (see above). Required for "fix", "multiply", specific
|
|
359
|
+
and "percentage" modes; optional for "dynamic".
|
|
360
|
+
|
|
361
|
+
- `slippage` (float, optional) The maximum allowed slippage in percentage when opening trades on the destination account,
|
|
362
|
+
defaults to 0.1% (0.1), if the slippage exceeds this value, the trade will not be copied.
|
|
363
|
+
|
|
364
|
+
- `comment` (str, optional) An optional comment to be added to trades opened on the destination account,
|
|
365
|
+
defaults to an empty string.
|
|
366
|
+
|
|
367
|
+
- ``copy_what`` (str, optional)
|
|
368
|
+
Specifies what to copy from the source account to the destination accounts. Valid options are
|
|
369
|
+
`"orders"` Copy only orders from the source account to the destination accounts.
|
|
370
|
+
`"positions"` Copy only positions from the source account to the destination accounts.
|
|
371
|
+
`"all"` Copy both orders and positions from the source account to the destination accounts.
|
|
372
|
+
Defaults to `"all"`.
|
|
373
|
+
|
|
374
|
+
sleeptime (float, optional):
|
|
375
|
+
The time interval in seconds between each iteration of the trade copying process.
|
|
376
|
+
Defaults to 0.1 seconds. It can be useful if you know the frequency of new trades on the source account.
|
|
377
|
+
|
|
378
|
+
start_time (str, optional): The time (HH:MM) from which the copier start copying from the source.
|
|
379
|
+
end_time (str, optional): The time (HH:MM) from which the copier stop copying from the source.
|
|
380
|
+
custom_logger (Any, Optional): Used to set a cutum logger (default is ``loguru.logger``)
|
|
381
|
+
shutdown_event (Any, Otional): Use to terminate the copy process when runs in a custum environment like web App or GUI.
|
|
382
|
+
log_queue (multiprocessing.Queue, Optional): Use to send log to an external program, usefule in GUI apps
|
|
383
|
+
|
|
384
|
+
Note:
|
|
385
|
+
The source account and the destination accounts must be connected to different MetaTrader 5 platforms.
|
|
386
|
+
you can copy the initial installation of MetaTrader 5 to a different directory and rename it to create a new instance
|
|
387
|
+
Then you can connect destination accounts to the new instance while the source account is connected to the original instance.
|
|
388
|
+
"""
|
|
389
|
+
self.source = source
|
|
390
|
+
self.source_id = source.get("id", 0)
|
|
391
|
+
self.source_isunique = source.get("unique", True)
|
|
392
|
+
self.destinations = destinations
|
|
393
|
+
self.sleeptime = sleeptime
|
|
394
|
+
self.start_time = start_time
|
|
395
|
+
self.end_time = end_time
|
|
396
|
+
self.errors = set()
|
|
397
|
+
self.log_queue = log_queue
|
|
398
|
+
self._add_logger(custom_logger)
|
|
399
|
+
self._validate_source()
|
|
400
|
+
self.shutdown_event = (
|
|
401
|
+
shutdown_event if shutdown_event is not None else mp.Event()
|
|
402
|
+
)
|
|
403
|
+
self._last_session = datetime.now().date()
|
|
404
|
+
self._running = True
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def running(self):
|
|
408
|
+
"""Check if the Trade Copier is running."""
|
|
409
|
+
return self._running
|
|
410
|
+
|
|
411
|
+
def _add_logger(self, custom_logger):
|
|
412
|
+
if custom_logger:
|
|
413
|
+
global logger
|
|
414
|
+
logger = custom_logger
|
|
415
|
+
|
|
416
|
+
def log_message(
|
|
417
|
+
self, message, type: Literal["info", "error", "debug", "warning"] = "info"
|
|
418
|
+
):
|
|
419
|
+
logger.trace
|
|
420
|
+
if self.log_queue:
|
|
421
|
+
try:
|
|
422
|
+
now = datetime.now()
|
|
423
|
+
formatted = (
|
|
424
|
+
now.strftime("%Y-%m-%d %H:%M:%S.")
|
|
425
|
+
+ f"{int(now.microsecond / 1000):03d}"
|
|
426
|
+
)
|
|
427
|
+
space = len("exception") # longest log name
|
|
428
|
+
self.log_queue.put(
|
|
429
|
+
f"{formatted} |{type.upper()} {' ' * (space - len(type))} | - {message}"
|
|
430
|
+
)
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
else:
|
|
434
|
+
getattr(logger, type)(message)
|
|
435
|
+
|
|
436
|
+
def log_error(self, e, symbol=None):
|
|
437
|
+
if datetime.now().date() > self._last_session:
|
|
438
|
+
self._last_session = datetime.now().date()
|
|
439
|
+
self.errors.clear()
|
|
440
|
+
error_msg = repr(e)
|
|
441
|
+
if error_msg not in self.errors:
|
|
442
|
+
self.errors.add(error_msg)
|
|
443
|
+
add_msg = f", SYMBOL={symbol}" if symbol else ""
|
|
444
|
+
message = f"Error encountered: {error_msg}{add_msg}"
|
|
445
|
+
self.log_message(message, type="error")
|
|
446
|
+
|
|
447
|
+
def _validate_source(self):
|
|
448
|
+
if not self.source_isunique:
|
|
449
|
+
try:
|
|
450
|
+
assert self.source_id >= 1
|
|
451
|
+
except AssertionError:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
"Non Unique source account must have a valide ID , (e.g., source['id'] = 1234)"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _get_magic(self, ticket: int) -> int:
|
|
457
|
+
return int(str(self.source_id) + str(ticket)) if self.source_id >= 1 else ticket
|
|
458
|
+
|
|
459
|
+
def _select_symbol(self, symbol: str, destination: dict):
|
|
460
|
+
selected = Mt5.symbol_select(symbol, True)
|
|
461
|
+
if not selected:
|
|
462
|
+
self.log_message(
|
|
463
|
+
f"Failed to select {destination.get('login')}::{symbol}, error code = {Mt5.last_error()}",
|
|
464
|
+
type="error",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def source_orders(self, symbol=None):
|
|
468
|
+
check_mt5_connection(**self.source)
|
|
469
|
+
return Account(**self.source).get_orders(symbol=symbol)
|
|
470
|
+
|
|
471
|
+
def source_positions(self, symbol=None):
|
|
472
|
+
check_mt5_connection(**self.source)
|
|
473
|
+
return Account(**self.source).get_positions(symbol=symbol)
|
|
474
|
+
|
|
475
|
+
def destination_orders(self, destination: dict, symbol=None):
|
|
476
|
+
check_mt5_connection(**destination)
|
|
477
|
+
return Account(**destination).get_orders(symbol=symbol)
|
|
478
|
+
|
|
479
|
+
def destination_positions(self, destination: dict, symbol=None):
|
|
480
|
+
check_mt5_connection(**destination)
|
|
481
|
+
return Account(**destination).get_positions(symbol=symbol)
|
|
482
|
+
|
|
483
|
+
def get_copy_symbol(self, symbol, destination: dict = None, type="destination"):
|
|
484
|
+
symbols = get_copy_symbols(destination, self.source)
|
|
485
|
+
if isinstance(symbols, list):
|
|
486
|
+
if symbol in symbols:
|
|
487
|
+
return symbol
|
|
488
|
+
if isinstance(symbols, dict):
|
|
489
|
+
if type == "destination":
|
|
490
|
+
if symbol in symbols.keys():
|
|
491
|
+
return symbols[symbol]
|
|
492
|
+
if type == "source":
|
|
493
|
+
for k, v in symbols.items():
|
|
494
|
+
if v == symbol:
|
|
495
|
+
return k
|
|
496
|
+
raise ValueError(f"Symbol {symbol} not found in {type} account")
|
|
497
|
+
|
|
498
|
+
def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
|
|
499
|
+
if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
|
|
500
|
+
return (
|
|
501
|
+
source.sl != dest.sl
|
|
502
|
+
or source.tp != dest.tp
|
|
503
|
+
or source.price_open != dest.price_open
|
|
504
|
+
or source.price_stoplimit != dest.price_stoplimit
|
|
505
|
+
)
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
def isposition_modified(self, source: TradePosition, dest: TradePosition):
|
|
509
|
+
if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
|
|
510
|
+
return source.sl != dest.sl or source.tp != dest.tp
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
def slippage(self, source: TradeOrder | TradePosition, destination: dict) -> bool:
|
|
514
|
+
slippage = destination.get("slippage", 0.1)
|
|
515
|
+
if slippage is None:
|
|
516
|
+
return False
|
|
517
|
+
if hasattr(source, "profit"):
|
|
518
|
+
if source.type in [0, 1] and source.profit < 0:
|
|
519
|
+
return False
|
|
520
|
+
delta = ((source.price_current - source.price_open) / source.price_open) * 100
|
|
521
|
+
if source.type in [0, 3, 4, 6] and delta > slippage:
|
|
522
|
+
return True
|
|
523
|
+
if source.type in [1, 2, 5, 7] and delta < -slippage:
|
|
524
|
+
return True
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
def iscopy_time(self):
|
|
528
|
+
if self.start_time is None or self.end_time is None:
|
|
529
|
+
return True
|
|
530
|
+
else:
|
|
531
|
+
start_time = datetime.strptime(self.start_time, "%H:%M").time()
|
|
532
|
+
end_time = datetime.strptime(self.end_time, "%H:%M").time()
|
|
533
|
+
if start_time <= datetime.now().time() <= end_time:
|
|
534
|
+
return True
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
def _update_filling_type(self, request, result):
|
|
538
|
+
new_result = result
|
|
539
|
+
if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL:
|
|
540
|
+
for fill in FILLING_TYPE:
|
|
541
|
+
request["type_filling"] = fill
|
|
542
|
+
new_result = Mt5.order_send(request)
|
|
543
|
+
if new_result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
544
|
+
break
|
|
545
|
+
return new_result
|
|
546
|
+
|
|
547
|
+
def handle_retcode(self, retcode) -> int:
|
|
548
|
+
if retcode in STOP_RETCODES:
|
|
549
|
+
msg = trade_retcode_message(retcode)
|
|
550
|
+
self.log_error(f"Critical Error on @{self.source['login']}: {msg} ")
|
|
551
|
+
self.stop()
|
|
552
|
+
if retcode in RETURN_RETCODE:
|
|
553
|
+
return 1
|
|
554
|
+
|
|
555
|
+
def copy_new_trade(self, trade: TradeOrder | TradePosition, destination: dict):
|
|
556
|
+
if not self.iscopy_time():
|
|
557
|
+
return
|
|
558
|
+
check_mt5_connection(**destination)
|
|
559
|
+
symbol = self.get_copy_symbol(trade.symbol, destination)
|
|
560
|
+
self._select_symbol(symbol, destination)
|
|
561
|
+
|
|
562
|
+
volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
|
|
563
|
+
lot = calculate_copy_lot(
|
|
564
|
+
volume,
|
|
565
|
+
symbol,
|
|
566
|
+
destination,
|
|
567
|
+
mode=destination.get("mode", "fix"),
|
|
568
|
+
value=destination.get("value", 0.01),
|
|
569
|
+
source_eqty=Account(**self.source).get_account_info().margin_free,
|
|
570
|
+
dest_eqty=Account(**destination).get_account_info().margin_free,
|
|
571
|
+
)
|
|
572
|
+
trade_action = (
|
|
573
|
+
Mt5.TRADE_ACTION_DEAL if trade.type in [0, 1] else Mt5.TRADE_ACTION_PENDING
|
|
574
|
+
)
|
|
575
|
+
tick = Mt5.symbol_info_tick(symbol)
|
|
576
|
+
price = tick.bid if trade.type == 0 else tick.ask
|
|
577
|
+
try:
|
|
578
|
+
request = dict(
|
|
579
|
+
symbol=symbol,
|
|
580
|
+
action=trade_action,
|
|
581
|
+
volume=lot,
|
|
582
|
+
price=price,
|
|
583
|
+
sl=trade.sl,
|
|
584
|
+
tp=trade.tp,
|
|
585
|
+
type=ORDER_TYPE[trade.type][0],
|
|
586
|
+
magic=self._get_magic(trade.ticket),
|
|
587
|
+
deviation=Mt5.symbol_info(symbol).spread,
|
|
588
|
+
comment=destination.get("comment", trade.comment + "#bbstrader"),
|
|
589
|
+
type_time=Mt5.ORDER_TIME_GTC,
|
|
590
|
+
type_filling=Mt5.ORDER_FILLING_FOK,
|
|
591
|
+
)
|
|
592
|
+
if trade.type not in [0, 1]:
|
|
593
|
+
request["price"] = trade.price_open
|
|
594
|
+
|
|
595
|
+
if trade.type in [6, 7]:
|
|
596
|
+
request["stoplimit"] = trade.price_stoplimit
|
|
597
|
+
|
|
598
|
+
result = Mt5.order_send(request)
|
|
599
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
600
|
+
result = self._update_filling_type(request, result)
|
|
601
|
+
action = ORDER_TYPE[trade.type][1]
|
|
602
|
+
copy_action = "Position" if trade.type in [0, 1] else "Order"
|
|
603
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
604
|
+
self.log_message(
|
|
605
|
+
f"Copy {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
606
|
+
f"to @{destination.get('login')}::{symbol}",
|
|
607
|
+
)
|
|
608
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
609
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
610
|
+
return
|
|
611
|
+
self.log_message(
|
|
612
|
+
f"Error copying {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
613
|
+
f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
|
|
614
|
+
type="error",
|
|
615
|
+
)
|
|
616
|
+
except Exception as e:
|
|
617
|
+
self.log_error(e, symbol=symbol)
|
|
618
|
+
|
|
619
|
+
def copy_new_order(self, order: TradeOrder, destination: dict):
|
|
620
|
+
self.copy_new_trade(order, destination)
|
|
621
|
+
|
|
622
|
+
def modify_order(self, ticket, symbol, source_order: TradeOrder, destination: dict):
|
|
623
|
+
check_mt5_connection(**destination)
|
|
624
|
+
self._select_symbol(symbol, destination)
|
|
625
|
+
request = {
|
|
626
|
+
"action": Mt5.TRADE_ACTION_MODIFY,
|
|
627
|
+
"order": ticket,
|
|
628
|
+
"symbol": symbol,
|
|
629
|
+
"price": source_order.price_open,
|
|
630
|
+
"sl": source_order.sl,
|
|
631
|
+
"tp": source_order.tp,
|
|
632
|
+
"stoplimit": source_order.price_stoplimit,
|
|
633
|
+
}
|
|
634
|
+
result = Mt5.order_send(request)
|
|
635
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
636
|
+
result = self._update_filling_type(request, result)
|
|
637
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
638
|
+
self.log_message(
|
|
639
|
+
f"Modify {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
640
|
+
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
641
|
+
)
|
|
642
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
643
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
644
|
+
return
|
|
645
|
+
self.log_message(
|
|
646
|
+
f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
|
|
647
|
+
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
|
|
648
|
+
type="error",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def remove_order(self, src_symbol, order: TradeOrder, destination: dict):
|
|
652
|
+
check_mt5_connection(**destination)
|
|
653
|
+
self._select_symbol(order.symbol, destination)
|
|
654
|
+
request = {
|
|
655
|
+
"action": Mt5.TRADE_ACTION_REMOVE,
|
|
656
|
+
"order": order.ticket,
|
|
657
|
+
}
|
|
658
|
+
result = Mt5.order_send(request)
|
|
659
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
660
|
+
result = self._update_filling_type(request, result)
|
|
661
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
662
|
+
self.log_message(
|
|
663
|
+
f"Close {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
664
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
665
|
+
)
|
|
666
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
667
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
668
|
+
return
|
|
669
|
+
self.log_message(
|
|
670
|
+
f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
671
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
|
|
672
|
+
type="error",
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def copy_new_position(self, position: TradePosition, destination: dict):
|
|
676
|
+
self.copy_new_trade(position, destination)
|
|
677
|
+
|
|
678
|
+
def modify_position(
|
|
679
|
+
self, ticket, symbol, source_pos: TradePosition, destination: dict
|
|
680
|
+
):
|
|
681
|
+
check_mt5_connection(**destination)
|
|
682
|
+
self._select_symbol(symbol, destination)
|
|
683
|
+
request = {
|
|
684
|
+
"action": Mt5.TRADE_ACTION_SLTP,
|
|
685
|
+
"position": ticket,
|
|
686
|
+
"symbol": symbol,
|
|
687
|
+
"sl": source_pos.sl,
|
|
688
|
+
"tp": source_pos.tp,
|
|
689
|
+
}
|
|
690
|
+
result = Mt5.order_send(request)
|
|
691
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
692
|
+
result = self._update_filling_type(request, result)
|
|
693
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
694
|
+
self.log_message(
|
|
695
|
+
f"Modify {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
696
|
+
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
697
|
+
)
|
|
698
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
699
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
700
|
+
return
|
|
701
|
+
self.log_message(
|
|
702
|
+
f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
703
|
+
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
|
|
704
|
+
type="error",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def remove_position(self, src_symbol, position: TradePosition, destination: dict):
|
|
708
|
+
check_mt5_connection(**destination)
|
|
709
|
+
self._select_symbol(position.symbol, destination)
|
|
710
|
+
position_type = (
|
|
711
|
+
Mt5.ORDER_TYPE_SELL if position.type == 0 else Mt5.ORDER_TYPE_BUY
|
|
712
|
+
)
|
|
713
|
+
request = {
|
|
714
|
+
"action": Mt5.TRADE_ACTION_DEAL,
|
|
715
|
+
"symbol": position.symbol,
|
|
716
|
+
"volume": position.volume,
|
|
717
|
+
"type": position_type,
|
|
718
|
+
"position": position.ticket,
|
|
719
|
+
"price": position.price_current,
|
|
720
|
+
"deviation": int(Mt5.symbol_info(position.symbol).spread),
|
|
721
|
+
"type_time": Mt5.ORDER_TIME_GTC,
|
|
722
|
+
"type_filling": Mt5.ORDER_FILLING_FOK,
|
|
723
|
+
}
|
|
724
|
+
result = Mt5.order_send(request)
|
|
725
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
726
|
+
result = self._update_filling_type(request, result)
|
|
727
|
+
|
|
728
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
729
|
+
self.log_message(
|
|
730
|
+
f"Close {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
|
|
731
|
+
f"on @{destination.get('login')}::{position.symbol}, "
|
|
732
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
733
|
+
)
|
|
734
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
735
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
736
|
+
return
|
|
737
|
+
self.log_message(
|
|
738
|
+
f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
|
|
739
|
+
f"on @{destination.get('login')}::{position.symbol}, "
|
|
740
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
|
|
741
|
+
type="error",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def filter_positions_and_orders(self, pos_or_orders, symbols=None):
|
|
745
|
+
if symbols is None:
|
|
746
|
+
return pos_or_orders
|
|
747
|
+
elif isinstance(symbols, list):
|
|
748
|
+
return [pos for pos in pos_or_orders if pos.symbol in symbols]
|
|
749
|
+
elif isinstance(symbols, dict):
|
|
750
|
+
return [
|
|
751
|
+
pos
|
|
752
|
+
for pos in pos_or_orders
|
|
753
|
+
if pos.symbol in symbols.keys() or pos.symbol in symbols.values()
|
|
754
|
+
]
|
|
755
|
+
|
|
756
|
+
def get_positions(
|
|
757
|
+
self, destination: dict
|
|
758
|
+
) -> Tuple[List[TradePosition], List[TradePosition]]:
|
|
759
|
+
source_positions = self.source_positions() or []
|
|
760
|
+
dest_symbols = get_copy_symbols(destination, self.source)
|
|
761
|
+
dest_positions = self.destination_positions(destination) or []
|
|
762
|
+
source_positions = self.filter_positions_and_orders(
|
|
763
|
+
source_positions, symbols=dest_symbols
|
|
764
|
+
)
|
|
765
|
+
dest_positions = self.filter_positions_and_orders(
|
|
766
|
+
dest_positions, symbols=dest_symbols
|
|
767
|
+
)
|
|
768
|
+
return source_positions, dest_positions
|
|
769
|
+
|
|
770
|
+
def get_orders(
|
|
771
|
+
self, destination: dict
|
|
772
|
+
) -> Tuple[List[TradeOrder], List[TradeOrder]]:
|
|
773
|
+
source_orders = self.source_orders() or []
|
|
774
|
+
dest_symbols = get_copy_symbols(destination, self.source)
|
|
775
|
+
dest_orders = self.destination_orders(destination) or []
|
|
776
|
+
source_orders = self.filter_positions_and_orders(
|
|
777
|
+
source_orders, symbols=dest_symbols
|
|
778
|
+
)
|
|
779
|
+
dest_orders = self.filter_positions_and_orders(
|
|
780
|
+
dest_orders, symbols=dest_symbols
|
|
781
|
+
)
|
|
782
|
+
return source_orders, dest_orders
|
|
783
|
+
|
|
784
|
+
def _copy_what(self, destination):
|
|
785
|
+
return destination.get("copy_what", "all")
|
|
786
|
+
|
|
787
|
+
def _isvalide_magic(self, magic):
|
|
788
|
+
ticket = str(magic)
|
|
789
|
+
id = str(self.source_id)
|
|
790
|
+
return (
|
|
791
|
+
ticket != id
|
|
792
|
+
and ticket.startswith(id)
|
|
793
|
+
and ticket[: len(id)] == id
|
|
794
|
+
and int(ticket[: len(id)]) == self.source_id
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def _get_new_orders(
|
|
798
|
+
self, source_orders, destination_orders, destination
|
|
799
|
+
) -> List[Tuple]:
|
|
800
|
+
actions = []
|
|
801
|
+
dest_ids = {order.magic for order in destination_orders}
|
|
802
|
+
for source_order in source_orders:
|
|
803
|
+
if self._get_magic(source_order.ticket) not in dest_ids:
|
|
804
|
+
if not self.slippage(source_order, destination):
|
|
805
|
+
actions.append((OrderAction.COPY_NEW, source_order, destination))
|
|
806
|
+
return actions
|
|
807
|
+
|
|
808
|
+
def _get_modified_orders(
|
|
809
|
+
self, source_orders, destination_orders, destination
|
|
810
|
+
) -> List[Tuple]:
|
|
811
|
+
actions = []
|
|
812
|
+
dest_order_map = {order.magic: order for order in destination_orders}
|
|
813
|
+
|
|
814
|
+
for source_order in source_orders:
|
|
815
|
+
magic_id = self._get_magic(source_order.ticket)
|
|
816
|
+
if magic_id in dest_order_map:
|
|
817
|
+
destination_order = dest_order_map[magic_id]
|
|
818
|
+
if self.isorder_modified(source_order, destination_order):
|
|
819
|
+
ticket = destination_order.ticket
|
|
820
|
+
symbol = destination_order.symbol
|
|
821
|
+
actions.append(
|
|
822
|
+
(OrderAction.MODIFY, ticket, symbol, source_order, destination)
|
|
823
|
+
)
|
|
824
|
+
return actions
|
|
825
|
+
|
|
826
|
+
def _get_closed_orders(
|
|
827
|
+
self, source_orders, destination_orders, destination
|
|
828
|
+
) -> List[Tuple]:
|
|
829
|
+
actions = []
|
|
830
|
+
source_ids = {self._get_magic(order.ticket) for order in source_orders}
|
|
831
|
+
for destination_order in destination_orders:
|
|
832
|
+
if destination_order.magic not in source_ids:
|
|
833
|
+
if self.source_isunique or self._isvalide_magic(
|
|
834
|
+
destination_order.magic
|
|
835
|
+
):
|
|
836
|
+
src_symbol = self.get_copy_symbol(
|
|
837
|
+
destination_order.symbol, destination, type="source"
|
|
838
|
+
)
|
|
839
|
+
actions.append(
|
|
840
|
+
(OrderAction.CLOSE, src_symbol, destination_order, destination)
|
|
841
|
+
)
|
|
842
|
+
return actions
|
|
843
|
+
|
|
844
|
+
def _get_orders_to_sync(
|
|
845
|
+
self, source_orders, destination_positions, destination
|
|
846
|
+
) -> List[Tuple]:
|
|
847
|
+
actions = []
|
|
848
|
+
source_order_map = {
|
|
849
|
+
self._get_magic(order.ticket): order for order in source_orders
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for dest_pos in destination_positions:
|
|
853
|
+
if dest_pos.magic in source_order_map:
|
|
854
|
+
source_order = source_order_map[dest_pos.magic]
|
|
855
|
+
actions.append(
|
|
856
|
+
(
|
|
857
|
+
OrderAction.SYNC_REMOVE,
|
|
858
|
+
source_order.symbol,
|
|
859
|
+
dest_pos,
|
|
860
|
+
destination,
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
if not self.slippage(source_order, destination):
|
|
864
|
+
actions.append((OrderAction.SYNC_ADD, source_order, destination))
|
|
865
|
+
return actions
|
|
866
|
+
|
|
867
|
+
def _execute_order_action(self, action_item: Tuple):
|
|
868
|
+
action_type, *args = action_item
|
|
869
|
+
try:
|
|
870
|
+
if action_type == OrderAction.COPY_NEW:
|
|
871
|
+
self.copy_new_order(*args)
|
|
872
|
+
elif action_type == OrderAction.MODIFY:
|
|
873
|
+
self.modify_order(*args)
|
|
874
|
+
elif action_type == OrderAction.CLOSE:
|
|
875
|
+
self.remove_order(*args)
|
|
876
|
+
elif action_type == OrderAction.SYNC_REMOVE:
|
|
877
|
+
self.remove_position(*args)
|
|
878
|
+
elif action_type == OrderAction.SYNC_ADD:
|
|
879
|
+
self.copy_new_order(*args)
|
|
880
|
+
else:
|
|
881
|
+
self.log_message(f"Warning: Unknown action type '{action_type.value}'")
|
|
882
|
+
except Exception as e:
|
|
883
|
+
self.log_error(
|
|
884
|
+
f"Error executing action {action_type.value} with args {args}: {e}"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
def process_all_orders(self, destination, max_workers=10):
|
|
888
|
+
source_orders, destination_orders = self.get_orders(destination)
|
|
889
|
+
_, destination_positions = self.get_positions(destination)
|
|
890
|
+
|
|
891
|
+
orders_actions = []
|
|
892
|
+
orders_actions.extend(
|
|
893
|
+
self._get_new_orders(source_orders, destination_orders, destination)
|
|
894
|
+
)
|
|
895
|
+
orders_actions.extend(
|
|
896
|
+
self._get_modified_orders(source_orders, destination_orders, destination)
|
|
897
|
+
)
|
|
898
|
+
orders_actions.extend(
|
|
899
|
+
self._get_closed_orders(source_orders, destination_orders, destination)
|
|
900
|
+
)
|
|
901
|
+
orders_actions.extend(
|
|
902
|
+
self._get_orders_to_sync(source_orders, destination_positions, destination)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
if not orders_actions:
|
|
906
|
+
return
|
|
907
|
+
with cf.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
908
|
+
list(executor.map(self._execute_order_action, orders_actions))
|
|
909
|
+
|
|
910
|
+
def _get_new_positions(
|
|
911
|
+
self, source_positions, destination_positions, destination
|
|
912
|
+
) -> List[Tuple]:
|
|
913
|
+
actions = []
|
|
914
|
+
dest_ids = {pos.magic for pos in destination_positions}
|
|
915
|
+
for source_pos in source_positions:
|
|
916
|
+
if self._get_magic(source_pos.ticket) not in dest_ids:
|
|
917
|
+
if not self.slippage(source_pos, destination):
|
|
918
|
+
actions.append((OrderAction.COPY_NEW, source_pos, destination))
|
|
919
|
+
return actions
|
|
920
|
+
|
|
921
|
+
def _get_modified_positions(
|
|
922
|
+
self, source_positions, destination_positions, destination
|
|
923
|
+
) -> List[Tuple]:
|
|
924
|
+
actions = []
|
|
925
|
+
dest_pos_map = {pos.magic: pos for pos in destination_positions}
|
|
926
|
+
|
|
927
|
+
for source_pos in source_positions:
|
|
928
|
+
magic_id = self._get_magic(source_pos.ticket)
|
|
929
|
+
if magic_id in dest_pos_map:
|
|
930
|
+
dest_pos = dest_pos_map[magic_id]
|
|
931
|
+
if self.isposition_modified(source_pos, dest_pos):
|
|
932
|
+
actions.append(
|
|
933
|
+
(
|
|
934
|
+
OrderAction.MODIFY,
|
|
935
|
+
dest_pos.ticket,
|
|
936
|
+
dest_pos.symbol,
|
|
937
|
+
source_pos,
|
|
938
|
+
destination,
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
return actions
|
|
942
|
+
|
|
943
|
+
def _get_closed_positions(
|
|
944
|
+
self, source_positions, destination_positions, destination
|
|
945
|
+
) -> List[Tuple]:
|
|
946
|
+
actions = []
|
|
947
|
+
source_ids = {self._get_magic(pos.ticket) for pos in source_positions}
|
|
948
|
+
for dest_pos in destination_positions:
|
|
949
|
+
if dest_pos.magic not in source_ids:
|
|
950
|
+
if self.source_isunique or self._isvalide_magic(dest_pos.magic):
|
|
951
|
+
src_symbol = self.get_copy_symbol(
|
|
952
|
+
dest_pos.symbol, destination, type="source"
|
|
953
|
+
)
|
|
954
|
+
actions.append(
|
|
955
|
+
(OrderAction.CLOSE, src_symbol, dest_pos, destination)
|
|
956
|
+
)
|
|
957
|
+
return actions
|
|
958
|
+
|
|
959
|
+
def _get_positions_to_sync(
|
|
960
|
+
self, source_positions, destination_orders, destination
|
|
961
|
+
) -> List[Tuple]:
|
|
962
|
+
actions = []
|
|
963
|
+
dest_order_map = {order.magic: order for order in destination_orders}
|
|
964
|
+
|
|
965
|
+
for source_pos in source_positions:
|
|
966
|
+
magic_id = self._get_magic(source_pos.ticket)
|
|
967
|
+
if magic_id in dest_order_map:
|
|
968
|
+
dest_order = dest_order_map[magic_id]
|
|
969
|
+
# Action 1: Always remove the corresponding order
|
|
970
|
+
actions.append(
|
|
971
|
+
(
|
|
972
|
+
OrderAction.SYNC_REMOVE,
|
|
973
|
+
source_pos.symbol,
|
|
974
|
+
dest_order,
|
|
975
|
+
destination,
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# Action 2: Potentially copy a new position
|
|
980
|
+
if self._copy_what(destination) in ["all", "positions"]:
|
|
981
|
+
if not self.slippage(source_pos, destination):
|
|
982
|
+
actions.append((OrderAction.SYNC_ADD, source_pos, destination))
|
|
983
|
+
return actions
|
|
984
|
+
|
|
985
|
+
def _execute_position_action(self, action_item: Tuple):
|
|
986
|
+
"""A single worker task that executes one action for either Orders or Positions."""
|
|
987
|
+
action_type, *args = action_item
|
|
988
|
+
try:
|
|
989
|
+
if action_type == OrderAction.COPY_NEW:
|
|
990
|
+
self.copy_new_position(*args)
|
|
991
|
+
elif action_type == OrderAction.MODIFY:
|
|
992
|
+
self.modify_position(*args)
|
|
993
|
+
elif action_type == OrderAction.CLOSE:
|
|
994
|
+
self.remove_position(*args)
|
|
995
|
+
elif action_type == OrderAction.SYNC_REMOVE:
|
|
996
|
+
self.remove_order(*args)
|
|
997
|
+
elif action_type == OrderAction.SYNC_ADD:
|
|
998
|
+
self.copy_new_position(*args)
|
|
999
|
+
else:
|
|
1000
|
+
self.log_message(f"Warning: Unknown action type '{action_type.value}'")
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
self.log_error(
|
|
1003
|
+
f"Error executing action {action_type.value} with args {args}: {e}"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
def process_all_positions(self, destination, max_workers=20):
|
|
1007
|
+
source_positions, destination_positions = self.get_positions(destination)
|
|
1008
|
+
_, destination_orders = self.get_orders(destination)
|
|
1009
|
+
|
|
1010
|
+
positions_actions = []
|
|
1011
|
+
positions_actions.extend(
|
|
1012
|
+
self._get_new_positions(
|
|
1013
|
+
source_positions, destination_positions, destination
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
positions_actions.extend(
|
|
1017
|
+
self._get_modified_positions(
|
|
1018
|
+
source_positions, destination_positions, destination
|
|
1019
|
+
)
|
|
1020
|
+
)
|
|
1021
|
+
positions_actions.extend(
|
|
1022
|
+
self._get_closed_positions(
|
|
1023
|
+
source_positions, destination_positions, destination
|
|
1024
|
+
)
|
|
1025
|
+
)
|
|
1026
|
+
positions_actions.extend(
|
|
1027
|
+
self._get_positions_to_sync(
|
|
1028
|
+
source_positions, destination_orders, destination
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
if not positions_actions:
|
|
1033
|
+
return
|
|
1034
|
+
with cf.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1035
|
+
list(executor.map(self._execute_position_action, positions_actions))
|
|
1036
|
+
|
|
1037
|
+
def copy_orders(self, destination: dict):
|
|
1038
|
+
if self._copy_what(destination) not in ["all", "orders"]:
|
|
1039
|
+
return
|
|
1040
|
+
check_mt5_connection(**destination)
|
|
1041
|
+
self.process_all_orders(destination)
|
|
1042
|
+
|
|
1043
|
+
def copy_positions(self, destination: dict):
|
|
1044
|
+
if self._copy_what(destination) not in ["all", "positions"]:
|
|
1045
|
+
return
|
|
1046
|
+
check_mt5_connection(**destination)
|
|
1047
|
+
self.process_all_positions(destination)
|
|
1048
|
+
|
|
1049
|
+
def start_copy_process(self, destination: dict):
|
|
1050
|
+
"""
|
|
1051
|
+
Worker process: copies orders and positions concurrently for a single destination account.
|
|
1052
|
+
"""
|
|
1053
|
+
if destination.get("path") == self.source.get("path"):
|
|
1054
|
+
self.log_message(
|
|
1055
|
+
f"Source and destination accounts are on the same MetaTrader 5 "
|
|
1056
|
+
f"installation ({self.source.get('path')}), which is not allowed."
|
|
1057
|
+
)
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
self.log_message(
|
|
1061
|
+
f"Copy process started for source @{self.source.get('login')} "
|
|
1062
|
+
f"and destination @{destination.get('login')}"
|
|
1063
|
+
)
|
|
1064
|
+
while not self.shutdown_event.is_set():
|
|
1065
|
+
try:
|
|
1066
|
+
self.copy_positions(destination)
|
|
1067
|
+
self.copy_orders(destination)
|
|
1068
|
+
except KeyboardInterrupt:
|
|
1069
|
+
self.log_message(
|
|
1070
|
+
"KeyboardInterrupt received, stopping the Trade Copier..."
|
|
1071
|
+
)
|
|
1072
|
+
self.stop()
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
self.log_error(f"An error occurred during the sync cycle: {e}")
|
|
1075
|
+
time.sleep(self.sleeptime)
|
|
1076
|
+
|
|
1077
|
+
self.log_message(
|
|
1078
|
+
f"Process exiting for destination @{destination.get('login')} due to shutdown event."
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
def run(self):
|
|
1082
|
+
"""
|
|
1083
|
+
Entry point: Starts a dedicated worker thread for EACH destination account to run concurrently.
|
|
1084
|
+
"""
|
|
1085
|
+
self.log_message(
|
|
1086
|
+
f"Main Copier instance starting for source @{self.source.get('login')}."
|
|
1087
|
+
)
|
|
1088
|
+
self.log_message(
|
|
1089
|
+
f"Found {len(self.destinations)} destination accounts to process in parallel."
|
|
1090
|
+
)
|
|
1091
|
+
if len(set([d.get("path") for d in self.destinations])) < len(
|
|
1092
|
+
self.destinations
|
|
1093
|
+
):
|
|
1094
|
+
self.log_message(
|
|
1095
|
+
"Two or more destination accounts have the same Terminal path, which is not allowed.",
|
|
1096
|
+
type="error",
|
|
1097
|
+
)
|
|
1098
|
+
return
|
|
1099
|
+
|
|
1100
|
+
worker_threads = []
|
|
1101
|
+
|
|
1102
|
+
for destination in self.destinations:
|
|
1103
|
+
self.log_message(
|
|
1104
|
+
f"Creating worker thread for destination @{destination.get('login')}"
|
|
1105
|
+
)
|
|
1106
|
+
try:
|
|
1107
|
+
thread = threading.Thread(
|
|
1108
|
+
target=self.start_copy_process,
|
|
1109
|
+
args=(destination,),
|
|
1110
|
+
name=f"Worker-{destination.get('login')}",
|
|
1111
|
+
)
|
|
1112
|
+
worker_threads.append(thread)
|
|
1113
|
+
thread.start()
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
self.log_error(
|
|
1116
|
+
f"Error executing thread Worker-{destination.get('login')} : {e}"
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
self.log_message(f"All {len(worker_threads)} worker threads have been started.")
|
|
1120
|
+
try:
|
|
1121
|
+
while not self.shutdown_event.is_set():
|
|
1122
|
+
time.sleep(1)
|
|
1123
|
+
except KeyboardInterrupt:
|
|
1124
|
+
self.log_message(
|
|
1125
|
+
"\nKeyboardInterrupt detected by main thread. Initiating shutdown..."
|
|
1126
|
+
)
|
|
1127
|
+
finally:
|
|
1128
|
+
self.stop()
|
|
1129
|
+
self.log_message("Waiting for all worker threads to complete...")
|
|
1130
|
+
for thread in worker_threads:
|
|
1131
|
+
thread.join()
|
|
1132
|
+
|
|
1133
|
+
self.log_message("All worker threads have shut down. Copier exiting.")
|
|
1134
|
+
|
|
1135
|
+
def stop(self):
|
|
1136
|
+
"""
|
|
1137
|
+
Stop the Trade Copier gracefully by setting the shutdown event.
|
|
1138
|
+
"""
|
|
1139
|
+
if self._running:
|
|
1140
|
+
self.log_message(
|
|
1141
|
+
f"Signaling stop for Trade Copier on source account @{self.source.get('login')}..."
|
|
1142
|
+
)
|
|
1143
|
+
self._running = False
|
|
1144
|
+
self.shutdown_event.set()
|
|
1145
|
+
self.log_message("Trade Copier stopped successfully.")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def copier_worker_process(
|
|
1149
|
+
source_config: dict,
|
|
1150
|
+
destination_config: dict,
|
|
1151
|
+
sleeptime: float,
|
|
1152
|
+
start_time: str,
|
|
1153
|
+
end_time: str,
|
|
1154
|
+
/,
|
|
1155
|
+
custom_logger=None,
|
|
1156
|
+
shutdown_event=None,
|
|
1157
|
+
log_queue=None,
|
|
1158
|
+
):
|
|
1159
|
+
"""A top-level worker function for handling a single source-to-destination copy task.
|
|
1160
|
+
|
|
1161
|
+
This function is the cornerstone of the robust, multi-process architecture. It is
|
|
1162
|
+
designed to be the `target` of a `multiprocessing.Process`. By being a top-level
|
|
1163
|
+
function, it avoids pickling issues on Windows and ensures that each copy task
|
|
1164
|
+
runs in a completely isolated process.
|
|
1165
|
+
|
|
1166
|
+
A controller (like a GUI or a master script) should spawn one process with this
|
|
1167
|
+
target for each destination account it needs to manage.
|
|
1168
|
+
|
|
1169
|
+
Args:
|
|
1170
|
+
source_config (dict): Configuration dictionary for the source account.
|
|
1171
|
+
Must contain 'login', 'password', 'server', and 'path'.
|
|
1172
|
+
destination_config (dict): Configuration dictionary for a *single*
|
|
1173
|
+
destination account.
|
|
1174
|
+
sleeptime (float): The time in seconds to wait between copy cycles.
|
|
1175
|
+
start_time (str): The time of day to start copying (e.g., "08:00").
|
|
1176
|
+
end_time (str): The time of day to stop copying (e.g., "22:00").
|
|
1177
|
+
custom_logger: An optional custom logger instance.
|
|
1178
|
+
shutdown_event (multiprocessing.Event): An event object that, when set,
|
|
1179
|
+
will signal this process to terminate gracefully.
|
|
1180
|
+
log_queue (multiprocessing.Queue): A queue for sending log messages back
|
|
1181
|
+
to the parent process in a thread-safe manner.
|
|
1182
|
+
"""
|
|
1183
|
+
copier = TradeCopier(
|
|
1184
|
+
source_config,
|
|
1185
|
+
[destination_config],
|
|
1186
|
+
sleeptime=sleeptime,
|
|
1187
|
+
start_time=start_time,
|
|
1188
|
+
end_time=end_time,
|
|
1189
|
+
custom_logger=custom_logger,
|
|
1190
|
+
shutdown_event=shutdown_event,
|
|
1191
|
+
log_queue=log_queue,
|
|
1192
|
+
)
|
|
1193
|
+
copier.start_copy_process(destination_config)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def RunCopier(
|
|
1197
|
+
source: dict,
|
|
1198
|
+
destinations: list,
|
|
1199
|
+
sleeptime: float,
|
|
1200
|
+
start_time: str,
|
|
1201
|
+
end_time: str,
|
|
1202
|
+
/,
|
|
1203
|
+
custom_logger=None,
|
|
1204
|
+
shutdown_event=None,
|
|
1205
|
+
log_queue=None,
|
|
1206
|
+
):
|
|
1207
|
+
"""
|
|
1208
|
+
Initialize and run a TradeCopier instance in a single process.
|
|
1209
|
+
|
|
1210
|
+
This function serves as a straightforward wrapper to start a copying session
|
|
1211
|
+
that handles one source account and one or more destination accounts
|
|
1212
|
+
sequentially within the same thread. It does not create any new processes itself.
|
|
1213
|
+
|
|
1214
|
+
Use Cases
|
|
1215
|
+
---------
|
|
1216
|
+
* Simpler, command-line based use cases.
|
|
1217
|
+
* Scenarios where parallelism is not required.
|
|
1218
|
+
* As the target for ``RunMultipleCopier``, where each process handles a
|
|
1219
|
+
full source-to-destinations session.
|
|
1220
|
+
|
|
1221
|
+
Parameters
|
|
1222
|
+
----------
|
|
1223
|
+
source : dict
|
|
1224
|
+
Configuration dictionary for the source account.
|
|
1225
|
+
destinations : list
|
|
1226
|
+
A list of configuration dictionaries, one for each
|
|
1227
|
+
destination account to be processed sequentially.
|
|
1228
|
+
sleeptime : float
|
|
1229
|
+
The time in seconds to wait after completing a full
|
|
1230
|
+
cycle through all destinations.
|
|
1231
|
+
start_time : str
|
|
1232
|
+
The time of day to start copying (e.g., ``"08:00"``).
|
|
1233
|
+
end_time : str
|
|
1234
|
+
The time of day to stop copying (e.g., ``"22:00"``).
|
|
1235
|
+
custom_logger : logging.Logger, optional
|
|
1236
|
+
An optional custom logger instance.
|
|
1237
|
+
shutdown_event : multiprocessing.Event, optional
|
|
1238
|
+
An event to signal shutdown.
|
|
1239
|
+
log_queue : multiprocessing.Queue, optional
|
|
1240
|
+
A queue for log messages.
|
|
1241
|
+
|
|
1242
|
+
Returns
|
|
1243
|
+
-------
|
|
1244
|
+
None
|
|
1245
|
+
Runs until stopped via ``shutdown_event`` or external interruption.
|
|
1246
|
+
"""
|
|
1247
|
+
copier = TradeCopier(
|
|
1248
|
+
source,
|
|
1249
|
+
destinations,
|
|
1250
|
+
sleeptime=sleeptime,
|
|
1251
|
+
start_time=start_time,
|
|
1252
|
+
end_time=end_time,
|
|
1253
|
+
custom_logger=custom_logger,
|
|
1254
|
+
shutdown_event=shutdown_event,
|
|
1255
|
+
log_queue=log_queue,
|
|
1256
|
+
)
|
|
1257
|
+
copier.run()
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def RunMultipleCopier(
|
|
1261
|
+
accounts: List[dict],
|
|
1262
|
+
sleeptime: float = 0.01,
|
|
1263
|
+
start_delay: float = 1.0,
|
|
1264
|
+
start_time: str = None,
|
|
1265
|
+
end_time: str = None,
|
|
1266
|
+
shutdown_event=None,
|
|
1267
|
+
custom_logger=None,
|
|
1268
|
+
log_queue=None,
|
|
1269
|
+
):
|
|
1270
|
+
"""
|
|
1271
|
+
Manage multiple, independent trade copying sessions in parallel.
|
|
1272
|
+
|
|
1273
|
+
This function acts as a high-level manager that takes a list of account
|
|
1274
|
+
setups and creates a separate, dedicated process for each one. Each process
|
|
1275
|
+
is responsible for copying from one source account to its associated list of
|
|
1276
|
+
destination accounts.
|
|
1277
|
+
|
|
1278
|
+
The parallelism occurs at the **source account level**. Within each spawned
|
|
1279
|
+
process, the destinations for that source are handled sequentially by
|
|
1280
|
+
``RunCopier``.
|
|
1281
|
+
|
|
1282
|
+
Example
|
|
1283
|
+
-------
|
|
1284
|
+
An example ``accounts`` structure:
|
|
1285
|
+
|
|
1286
|
+
.. code-block:: python
|
|
1287
|
+
|
|
1288
|
+
accounts = [
|
|
1289
|
+
{"source": {...}, "destinations": [{...}, {...}]}, # -> Process 1
|
|
1290
|
+
{"source": {...}, "destinations": [{...}]} # -> Process 2
|
|
1291
|
+
]
|
|
1292
|
+
|
|
1293
|
+
Parameters
|
|
1294
|
+
----------
|
|
1295
|
+
accounts : list of dict
|
|
1296
|
+
A list of account configurations. Each item must be a dictionary with
|
|
1297
|
+
a ``source`` key and a ``destinations`` key.
|
|
1298
|
+
sleeptime : float, optional
|
|
1299
|
+
The sleep time passed down to each ``RunCopier`` process.
|
|
1300
|
+
start_delay : float, optional
|
|
1301
|
+
A delay in seconds between starting each new process.
|
|
1302
|
+
Helps prevent resource contention by staggering the initialization of
|
|
1303
|
+
multiple MetaTrader 5 terminals.
|
|
1304
|
+
start_time : str, optional
|
|
1305
|
+
The start time passed down to each ``RunCopier`` process.
|
|
1306
|
+
end_time : str, optional
|
|
1307
|
+
The end time passed down to each ``RunCopier`` process.
|
|
1308
|
+
shutdown_event : multiprocessing.Event, optional
|
|
1309
|
+
An event to signal shutdown to all child processes.
|
|
1310
|
+
custom_logger : logging.Logger, optional
|
|
1311
|
+
An optional custom logger instance.
|
|
1312
|
+
log_queue : multiprocessing.Queue, optional
|
|
1313
|
+
A queue for aggregating log messages from all child processes.
|
|
1314
|
+
|
|
1315
|
+
Returns
|
|
1316
|
+
-------
|
|
1317
|
+
None
|
|
1318
|
+
Runs until stopped via ``shutdown_event`` or external interruption.
|
|
1319
|
+
"""
|
|
1320
|
+
processes = []
|
|
1321
|
+
|
|
1322
|
+
for account in accounts:
|
|
1323
|
+
source = account.get("source")
|
|
1324
|
+
destinations = account.get("destinations")
|
|
1325
|
+
|
|
1326
|
+
if not source or not destinations:
|
|
1327
|
+
logger.warning("Skipping account due to missing source or destinations.")
|
|
1328
|
+
continue
|
|
1329
|
+
paths = set([source.get("path")] + [dest.get("path") for dest in destinations])
|
|
1330
|
+
if len(paths) == 1 and len(destinations) >= 1:
|
|
1331
|
+
logger.warning(
|
|
1332
|
+
"Skipping account: source and destination cannot share the same MetaTrader 5 terminal path."
|
|
1333
|
+
)
|
|
1334
|
+
continue
|
|
1335
|
+
logger.info(f"Starting process for source account @{source.get('login')}")
|
|
1336
|
+
process = mp.Process(
|
|
1337
|
+
target=RunCopier,
|
|
1338
|
+
args=(
|
|
1339
|
+
source,
|
|
1340
|
+
destinations,
|
|
1341
|
+
sleeptime,
|
|
1342
|
+
start_time,
|
|
1343
|
+
end_time,
|
|
1344
|
+
),
|
|
1345
|
+
kwargs=dict(
|
|
1346
|
+
custom_logger=custom_logger,
|
|
1347
|
+
shutdown_event=shutdown_event,
|
|
1348
|
+
log_queue=log_queue,
|
|
1349
|
+
),
|
|
1350
|
+
)
|
|
1351
|
+
processes.append(process)
|
|
1352
|
+
process.start()
|
|
1353
|
+
|
|
1354
|
+
if start_delay:
|
|
1355
|
+
time.sleep(start_delay)
|
|
1356
|
+
|
|
1357
|
+
for process in processes:
|
|
1358
|
+
process.join()
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def auto_convert(value: str) -> Union[bool, None, int, float, str]:
|
|
1362
|
+
"""Convert string values to appropriate data types"""
|
|
1363
|
+
if value.lower() in {"true", "false"}: # Boolean
|
|
1364
|
+
return value.lower() == "true"
|
|
1365
|
+
elif value.lower() in {"none", "null"}: # None
|
|
1366
|
+
return None
|
|
1367
|
+
elif value.isdigit():
|
|
1368
|
+
return int(value)
|
|
1369
|
+
try:
|
|
1370
|
+
return float(value)
|
|
1371
|
+
except ValueError:
|
|
1372
|
+
return value
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def dict_from_ini(
|
|
1376
|
+
file_path: str, sections: Optional[Union[str, List[str]]] = None
|
|
1377
|
+
) -> Dict[str, Any]:
|
|
1378
|
+
"""Reads an INI file and converts it to a dictionary with proper data types.
|
|
1379
|
+
Args:
|
|
1380
|
+
file_path: Path to the INI file to read.
|
|
1381
|
+
sections: Optional list of sections to read from the INI file.
|
|
1382
|
+
Returns:
|
|
1383
|
+
A dictionary containing the INI file contents with proper data types.
|
|
1384
|
+
"""
|
|
1385
|
+
try:
|
|
1386
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
1387
|
+
config.read(file_path)
|
|
1388
|
+
except Exception:
|
|
1389
|
+
raise
|
|
1390
|
+
ini_dict: Dict[str, Any] = {}
|
|
1391
|
+
for section in config.sections():
|
|
1392
|
+
ini_dict[section] = {
|
|
1393
|
+
key: auto_convert(value) for key, value in config.items(section)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if isinstance(sections, str):
|
|
1397
|
+
try:
|
|
1398
|
+
return ini_dict[sections]
|
|
1399
|
+
except KeyError:
|
|
1400
|
+
raise KeyError(f"{sections} not found in the {file_path} file")
|
|
1401
|
+
if isinstance(sections, list):
|
|
1402
|
+
sect_dict: Dict[str, Any] = {}
|
|
1403
|
+
for section in sections:
|
|
1404
|
+
try:
|
|
1405
|
+
sect_dict[section] = ini_dict[section]
|
|
1406
|
+
except KeyError:
|
|
1407
|
+
raise KeyError(f"{section} not found in the {file_path} file")
|
|
1408
|
+
return sect_dict
|
|
1409
|
+
return ini_dict
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _parse_symbols(section):
|
|
1413
|
+
symbols: str = section.get("symbols")
|
|
1414
|
+
symbols = symbols.strip().replace("\n", " ").replace('"""', "")
|
|
1415
|
+
if symbols in ["all", "*"]:
|
|
1416
|
+
section["symbols"] = symbols
|
|
1417
|
+
else:
|
|
1418
|
+
symbols = get_symbols_from_string(symbols)
|
|
1419
|
+
section["symbols"] = symbols
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _parse_lots(section):
|
|
1423
|
+
lots = section.get("value")
|
|
1424
|
+
if not lots:
|
|
1425
|
+
raise ValueError("Lot size value must be specified for the selected mode")
|
|
1426
|
+
lots = get_lots_from_string(lots) if isinstance(lots, str) else lots
|
|
1427
|
+
section["value"] = lots
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def config_copier(
|
|
1431
|
+
source_section: str = None,
|
|
1432
|
+
dest_sections: str | List[str] = None,
|
|
1433
|
+
inifile: str | Path = None,
|
|
1434
|
+
) -> Tuple[dict, List[dict]]:
|
|
1435
|
+
"""
|
|
1436
|
+
Read the configuration file and return the source and destination account details.
|
|
1437
|
+
|
|
1438
|
+
Args:
|
|
1439
|
+
inifile (str | Path): The path to the INI configuration file.
|
|
1440
|
+
source_section (str): The section name of the source account, defaults to "SOURCE".
|
|
1441
|
+
dest_sections (str | List[str]): The section name(s) of the destination account(s).
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
Tuple[dict, List[dict]]: A tuple containing the source account and a list of destination accounts.
|
|
1445
|
+
|
|
1446
|
+
Example:
|
|
1447
|
+
```python
|
|
1448
|
+
from pathlib import Path
|
|
1449
|
+
config_file = ~/.bbstrader/copier/copier.ini
|
|
1450
|
+
source, destinations = config_copier(config_file, "SOURCE", ["DEST1", "DEST2"])
|
|
1451
|
+
```
|
|
1452
|
+
"""
|
|
1453
|
+
|
|
1454
|
+
if not inifile:
|
|
1455
|
+
inifile = Path().home() / ".bbstrader" / "copier" / "copier.ini"
|
|
1456
|
+
if not inifile.exists() or not inifile.is_file():
|
|
1457
|
+
raise FileNotFoundError(f"{inifile} not found")
|
|
1458
|
+
|
|
1459
|
+
if not source_section:
|
|
1460
|
+
source_section = "SOURCE"
|
|
1461
|
+
|
|
1462
|
+
config = dict_from_ini(inifile)
|
|
1463
|
+
try:
|
|
1464
|
+
source = config.pop(source_section)
|
|
1465
|
+
except KeyError:
|
|
1466
|
+
raise ValueError(f"Source section {source_section} not found in {inifile}")
|
|
1467
|
+
dest_sections = dest_sections or config.keys()
|
|
1468
|
+
if not dest_sections:
|
|
1469
|
+
raise ValueError("No destination sections found in the configuration file")
|
|
1470
|
+
|
|
1471
|
+
destinations = []
|
|
1472
|
+
|
|
1473
|
+
if isinstance(dest_sections, str):
|
|
1474
|
+
dest_sections = [dest_sections]
|
|
1475
|
+
|
|
1476
|
+
for dest_section in dest_sections:
|
|
1477
|
+
try:
|
|
1478
|
+
section = config[dest_section]
|
|
1479
|
+
except KeyError:
|
|
1480
|
+
raise ValueError(
|
|
1481
|
+
f"Destination section {dest_section} not found in {inifile}"
|
|
1482
|
+
)
|
|
1483
|
+
_parse_symbols(section)
|
|
1484
|
+
_parse_lots(section)
|
|
1485
|
+
destinations.append(section)
|
|
1486
|
+
|
|
1487
|
+
return source, destinations
|