bbstrader 0.2.92__py3-none-any.whl → 0.2.94__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/__ini__.py +20 -20
- bbstrader/__main__.py +50 -50
- bbstrader/btengine/__init__.py +54 -54
- bbstrader/btengine/data.py +11 -9
- bbstrader/btengine/scripts.py +157 -157
- bbstrader/compat.py +19 -19
- bbstrader/config.py +137 -137
- bbstrader/core/data.py +22 -22
- bbstrader/core/utils.py +146 -146
- bbstrader/metatrader/__init__.py +6 -6
- bbstrader/metatrader/account.py +1516 -1516
- bbstrader/metatrader/copier.py +750 -735
- bbstrader/metatrader/rates.py +584 -584
- bbstrader/metatrader/risk.py +749 -748
- bbstrader/metatrader/scripts.py +81 -81
- bbstrader/metatrader/trade.py +1836 -1826
- bbstrader/metatrader/utils.py +645 -645
- bbstrader/models/__init__.py +10 -10
- bbstrader/models/factors.py +312 -312
- bbstrader/models/ml.py +1272 -1265
- bbstrader/models/optimization.py +182 -182
- bbstrader/models/portfolio.py +223 -223
- bbstrader/models/risk.py +398 -398
- bbstrader/trading/__init__.py +11 -11
- bbstrader/trading/execution.py +846 -842
- bbstrader/trading/script.py +155 -155
- bbstrader/trading/scripts.py +69 -69
- bbstrader/trading/strategies.py +860 -860
- bbstrader/tseries.py +1842 -1842
- {bbstrader-0.2.92.dist-info → bbstrader-0.2.94.dist-info}/LICENSE +21 -21
- {bbstrader-0.2.92.dist-info → bbstrader-0.2.94.dist-info}/METADATA +188 -187
- bbstrader-0.2.94.dist-info/RECORD +44 -0
- {bbstrader-0.2.92.dist-info → bbstrader-0.2.94.dist-info}/WHEEL +1 -1
- bbstrader-0.2.92.dist-info/RECORD +0 -44
- {bbstrader-0.2.92.dist-info → bbstrader-0.2.94.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.2.92.dist-info → bbstrader-0.2.94.dist-info}/top_level.txt +0 -0
bbstrader/metatrader/copier.py
CHANGED
|
@@ -1,735 +1,750 @@
|
|
|
1
|
-
import multiprocessing
|
|
2
|
-
import time
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Dict, List, Literal, Tuple
|
|
6
|
-
|
|
7
|
-
from loguru import logger
|
|
8
|
-
|
|
9
|
-
from bbstrader.config import BBSTRADER_DIR
|
|
10
|
-
from bbstrader.metatrader.account import Account, check_mt5_connection
|
|
11
|
-
from bbstrader.metatrader.trade import Trade
|
|
12
|
-
from bbstrader.metatrader.utils import TradeOrder, TradePosition, trade_retcode_message
|
|
13
|
-
|
|
14
|
-
try:
|
|
15
|
-
import MetaTrader5 as Mt5
|
|
16
|
-
except ImportError:
|
|
17
|
-
import bbstrader.compat # noqa: F401
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
__all__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
logger.add(
|
|
24
|
-
f"{BBSTRADER_DIR}/logs/copier.log",
|
|
25
|
-
enqueue=True,
|
|
26
|
-
level="INFO",
|
|
27
|
-
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def fix_lot(fixed):
|
|
32
|
-
if fixed == 0 or fixed is None:
|
|
33
|
-
raise ValueError("Fixed lot must be a number")
|
|
34
|
-
return fixed
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def multiply_lot(lot, multiplier):
|
|
38
|
-
if multiplier == 0 or multiplier is None:
|
|
39
|
-
raise ValueError("Multiplier lot must be a number")
|
|
40
|
-
return lot * multiplier
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def percentage_lot(lot, percentage):
|
|
44
|
-
if percentage == 0 or percentage is None:
|
|
45
|
-
raise ValueError("Percentage lot must be a number")
|
|
46
|
-
return round(lot * percentage / 100, 2)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def dynamic_lot(source_lot, source_eqty: float, dest_eqty: float):
|
|
50
|
-
if source_eqty == 0 or dest_eqty == 0:
|
|
51
|
-
raise ValueError("Source or destination account equity is zero")
|
|
52
|
-
try:
|
|
53
|
-
ratio = dest_eqty / source_eqty
|
|
54
|
-
return round(source_lot * ratio, 2)
|
|
55
|
-
except ZeroDivisionError:
|
|
56
|
-
raise ValueError("Source or destination account equity is zero")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def fixed_lot(lot, symbol, destination) -> float:
|
|
60
|
-
def _volume_step(value):
|
|
61
|
-
value_str = str(value)
|
|
62
|
-
if "." in value_str and value_str != "1.0":
|
|
63
|
-
decimal_index = value_str.index(".")
|
|
64
|
-
num_digits = len(value_str) - decimal_index - 1
|
|
65
|
-
return num_digits
|
|
66
|
-
elif value_str == "1.0":
|
|
67
|
-
return 0
|
|
68
|
-
else:
|
|
69
|
-
return 0
|
|
70
|
-
|
|
71
|
-
def _check_lot(lot: float, symbol_info) -> float:
|
|
72
|
-
if lot > symbol_info.volume_max:
|
|
73
|
-
return symbol_info.volume_max / 2
|
|
74
|
-
elif lot < symbol_info.volume_min:
|
|
75
|
-
return symbol_info.volume_min
|
|
76
|
-
return lot
|
|
77
|
-
|
|
78
|
-
s_info = Account(**destination).get_symbol_info(symbol)
|
|
79
|
-
volume_step = s_info.volume_step
|
|
80
|
-
steps = _volume_step(volume_step)
|
|
81
|
-
if float(steps) >= float(1):
|
|
82
|
-
return _check_lot(round(lot, steps), s_info)
|
|
83
|
-
else:
|
|
84
|
-
return _check_lot(round(lot), s_info)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Mode = Literal["fix", "multiply", "percentage", "dynamic", "replicate"]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def calculate_copy_lot(
|
|
91
|
-
source_lot,
|
|
92
|
-
symbol: str,
|
|
93
|
-
destination: dict,
|
|
94
|
-
mode: Mode = "dynamic",
|
|
95
|
-
value=None,
|
|
96
|
-
source_eqty: float = None,
|
|
97
|
-
dest_eqty: float = None,
|
|
98
|
-
):
|
|
99
|
-
if mode == "replicate":
|
|
100
|
-
return fixed_lot(source_lot, symbol, destination)
|
|
101
|
-
elif mode == "fix":
|
|
102
|
-
return fixed_lot(fix_lot(value), symbol, destination)
|
|
103
|
-
elif mode == "multiply":
|
|
104
|
-
lot = multiply_lot(source_lot, value)
|
|
105
|
-
return fixed_lot(lot, symbol, destination)
|
|
106
|
-
elif mode == "percentage":
|
|
107
|
-
lot = percentage_lot(source_lot, value)
|
|
108
|
-
return fixed_lot(lot, symbol, destination)
|
|
109
|
-
elif mode == "dynamic":
|
|
110
|
-
lot = dynamic_lot(source_lot, source_eqty, dest_eqty)
|
|
111
|
-
return fixed_lot(lot, symbol, destination)
|
|
112
|
-
else:
|
|
113
|
-
raise ValueError("Invalid mode selected")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def get_copy_symbols(destination: dict = None):
|
|
117
|
-
symbols = destination.get("symbols", "all")
|
|
118
|
-
account = Account(**destination)
|
|
119
|
-
if symbols == "all" or symbols == "*":
|
|
120
|
-
return account.get_symbols()
|
|
121
|
-
elif isinstance(symbols, (list, dict)):
|
|
122
|
-
return symbols
|
|
123
|
-
elif isinstance(symbols, str):
|
|
124
|
-
if "," in symbols:
|
|
125
|
-
return symbols.split(",")
|
|
126
|
-
if " " in symbols:
|
|
127
|
-
return symbols.split()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def get_copy_symbol(
|
|
131
|
-
symbols = get_copy_symbols(destination)
|
|
132
|
-
if isinstance(symbols, list):
|
|
133
|
-
if
|
|
134
|
-
return
|
|
135
|
-
if isinstance(symbols, dict):
|
|
136
|
-
if
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
`Dict[str, str]
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
`
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
`"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`"
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
`
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
self.
|
|
244
|
-
self.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
self.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return False
|
|
281
|
-
|
|
282
|
-
def
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
logger.
|
|
354
|
-
f"
|
|
355
|
-
f"to @{destination.get('login')}
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
logger.
|
|
392
|
-
f"
|
|
393
|
-
f"SOURCE=@{self.source.get('login')}
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
logger.
|
|
406
|
-
f"
|
|
407
|
-
f"SOURCE=@{self.source.get('login')}
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
logger.
|
|
435
|
-
f"
|
|
436
|
-
f"SOURCE=@{self.source.get('login')}
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
logger.
|
|
449
|
-
f"
|
|
450
|
-
f"SOURCE=@{self.source.get('login')}
|
|
451
|
-
)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if
|
|
538
|
-
self.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
for
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1
|
+
import multiprocessing
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Literal, Tuple
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from bbstrader.config import BBSTRADER_DIR
|
|
10
|
+
from bbstrader.metatrader.account import Account, check_mt5_connection
|
|
11
|
+
from bbstrader.metatrader.trade import Trade
|
|
12
|
+
from bbstrader.metatrader.utils import TradeOrder, TradePosition, trade_retcode_message
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import MetaTrader5 as Mt5
|
|
16
|
+
except ImportError:
|
|
17
|
+
import bbstrader.compat # noqa: F401
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger.add(
|
|
24
|
+
f"{BBSTRADER_DIR}/logs/copier.log",
|
|
25
|
+
enqueue=True,
|
|
26
|
+
level="INFO",
|
|
27
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fix_lot(fixed):
|
|
32
|
+
if fixed == 0 or fixed is None:
|
|
33
|
+
raise ValueError("Fixed lot must be a number")
|
|
34
|
+
return fixed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def multiply_lot(lot, multiplier):
|
|
38
|
+
if multiplier == 0 or multiplier is None:
|
|
39
|
+
raise ValueError("Multiplier lot must be a number")
|
|
40
|
+
return lot * multiplier
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def percentage_lot(lot, percentage):
|
|
44
|
+
if percentage == 0 or percentage is None:
|
|
45
|
+
raise ValueError("Percentage lot must be a number")
|
|
46
|
+
return round(lot * percentage / 100, 2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dynamic_lot(source_lot, source_eqty: float, dest_eqty: float):
|
|
50
|
+
if source_eqty == 0 or dest_eqty == 0:
|
|
51
|
+
raise ValueError("Source or destination account equity is zero")
|
|
52
|
+
try:
|
|
53
|
+
ratio = dest_eqty / source_eqty
|
|
54
|
+
return round(source_lot * ratio, 2)
|
|
55
|
+
except ZeroDivisionError:
|
|
56
|
+
raise ValueError("Source or destination account equity is zero")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fixed_lot(lot, symbol, destination) -> float:
|
|
60
|
+
def _volume_step(value):
|
|
61
|
+
value_str = str(value)
|
|
62
|
+
if "." in value_str and value_str != "1.0":
|
|
63
|
+
decimal_index = value_str.index(".")
|
|
64
|
+
num_digits = len(value_str) - decimal_index - 1
|
|
65
|
+
return num_digits
|
|
66
|
+
elif value_str == "1.0":
|
|
67
|
+
return 0
|
|
68
|
+
else:
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
def _check_lot(lot: float, symbol_info) -> float:
|
|
72
|
+
if lot > symbol_info.volume_max:
|
|
73
|
+
return symbol_info.volume_max / 2
|
|
74
|
+
elif lot < symbol_info.volume_min:
|
|
75
|
+
return symbol_info.volume_min
|
|
76
|
+
return lot
|
|
77
|
+
|
|
78
|
+
s_info = Account(**destination).get_symbol_info(symbol)
|
|
79
|
+
volume_step = s_info.volume_step
|
|
80
|
+
steps = _volume_step(volume_step)
|
|
81
|
+
if float(steps) >= float(1):
|
|
82
|
+
return _check_lot(round(lot, steps), s_info)
|
|
83
|
+
else:
|
|
84
|
+
return _check_lot(round(lot), s_info)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Mode = Literal["fix", "multiply", "percentage", "dynamic", "replicate"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def calculate_copy_lot(
|
|
91
|
+
source_lot,
|
|
92
|
+
symbol: str,
|
|
93
|
+
destination: dict,
|
|
94
|
+
mode: Mode = "dynamic",
|
|
95
|
+
value=None,
|
|
96
|
+
source_eqty: float = None,
|
|
97
|
+
dest_eqty: float = None,
|
|
98
|
+
):
|
|
99
|
+
if mode == "replicate":
|
|
100
|
+
return fixed_lot(source_lot, symbol, destination)
|
|
101
|
+
elif mode == "fix":
|
|
102
|
+
return fixed_lot(fix_lot(value), symbol, destination)
|
|
103
|
+
elif mode == "multiply":
|
|
104
|
+
lot = multiply_lot(source_lot, value)
|
|
105
|
+
return fixed_lot(lot, symbol, destination)
|
|
106
|
+
elif mode == "percentage":
|
|
107
|
+
lot = percentage_lot(source_lot, value)
|
|
108
|
+
return fixed_lot(lot, symbol, destination)
|
|
109
|
+
elif mode == "dynamic":
|
|
110
|
+
lot = dynamic_lot(source_lot, source_eqty, dest_eqty)
|
|
111
|
+
return fixed_lot(lot, symbol, destination)
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError("Invalid mode selected")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_copy_symbols(destination: dict = None):
|
|
117
|
+
symbols = destination.get("symbols", "all")
|
|
118
|
+
account = Account(**destination)
|
|
119
|
+
if symbols == "all" or symbols == "*":
|
|
120
|
+
return account.get_symbols()
|
|
121
|
+
elif isinstance(symbols, (list, dict)):
|
|
122
|
+
return symbols
|
|
123
|
+
elif isinstance(symbols, str):
|
|
124
|
+
if "," in symbols:
|
|
125
|
+
return symbols.split(",")
|
|
126
|
+
if " " in symbols:
|
|
127
|
+
return symbols.split()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_copy_symbol(symbol, destination: dict = None, type="destination"):
|
|
131
|
+
symbols = get_copy_symbols(destination)
|
|
132
|
+
if isinstance(symbols, list):
|
|
133
|
+
if symbol in symbols:
|
|
134
|
+
return symbol
|
|
135
|
+
if isinstance(symbols, dict):
|
|
136
|
+
if type == "destination":
|
|
137
|
+
if symbol in symbols.keys():
|
|
138
|
+
return symbols[symbol]
|
|
139
|
+
if type == "source":
|
|
140
|
+
for k, v in symbols.items():
|
|
141
|
+
if v == symbol:
|
|
142
|
+
return k
|
|
143
|
+
raise ValueError(f"Symbol {symbol} not found in {type} account")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TradeCopier(object):
|
|
147
|
+
"""
|
|
148
|
+
``TradeCopier`` responsible for copying trading orders and positions from a source account to multiple destination accounts.
|
|
149
|
+
|
|
150
|
+
This class facilitates the synchronization of trades between a source account and multiple destination accounts.
|
|
151
|
+
It handles copying new orders, modifying existing orders, updating and closing positions based on updates from the source account.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
__slots__ = (
|
|
156
|
+
"source",
|
|
157
|
+
"destinations",
|
|
158
|
+
"errors",
|
|
159
|
+
"sleeptime",
|
|
160
|
+
"start_time",
|
|
161
|
+
"end_time",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
source: Dict,
|
|
167
|
+
destinations: List[dict],
|
|
168
|
+
sleeptime: float = 0.1,
|
|
169
|
+
start_time: str = None,
|
|
170
|
+
end_time: str = None,
|
|
171
|
+
):
|
|
172
|
+
"""
|
|
173
|
+
Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
source (dict):
|
|
177
|
+
A dictionary containing the connection details for the source trading account. This dictionary
|
|
178
|
+
**must** include all parameters required to successfully connect to the source account.
|
|
179
|
+
Refer to the ``bbstrader.metatrader.check_mt5_connection`` function for a comprehensive list
|
|
180
|
+
of required keys and their expected values. Common parameters include, but are not limited to
|
|
181
|
+
|
|
182
|
+
- `login`: The account login ID (integer).
|
|
183
|
+
- `password`: The account password (string).
|
|
184
|
+
- `server`: The server address (string), e.g., "Broker-Demo".
|
|
185
|
+
- `path`: The path to the MetaTrader 5 installation directory (string).
|
|
186
|
+
- ``portable``: A boolean indicating whether to open MetaTrader 5 installation in portable mode.
|
|
187
|
+
|
|
188
|
+
destinations (List[dict]):
|
|
189
|
+
A list of dictionaries, where each dictionary represents a destination trading account to which
|
|
190
|
+
trades will be copied. Each destination dictionary **must** contain the following keys
|
|
191
|
+
|
|
192
|
+
- Authentication details (e.g., `login`, `password`, `server`)
|
|
193
|
+
Identical in structure and requirements to the `source` dictionary,
|
|
194
|
+
ensuring a connection can be established to the destination account.
|
|
195
|
+
Refer to ``bbstrader.metatrader.check_mt5_connection``.
|
|
196
|
+
|
|
197
|
+
- `symbols` (Union[List[str], Dict[str, str], str])
|
|
198
|
+
Specifies which symbols should be copied from the source
|
|
199
|
+
account to this destination account. Possible values include
|
|
200
|
+
`List[str]` A list of strings, where each string is a symbol to be copied.
|
|
201
|
+
The same symbol will be traded on the destination account. Example `["EURUSD", "GBPUSD"]`
|
|
202
|
+
`Dict[str, str]` A dictionary mapping source symbols to destination symbols.
|
|
203
|
+
This allows for trading a different symbol on the destination account than the one traded on the source.
|
|
204
|
+
Example `{"EURUSD": "EURUSD_i", "GBPUSD": "GBPUSD_i"}`.
|
|
205
|
+
`"all"` or `"*"` Indicates that all symbols traded on the source account should be
|
|
206
|
+
copied to this destination account, using the same symbol name.
|
|
207
|
+
|
|
208
|
+
- `mode` (str) The risk management mode to use. Valid options are
|
|
209
|
+
`"fix"` Use a fixed lot size. The `value` key must specify the fixed lot size.
|
|
210
|
+
`"multiply"` Multiply the source account's lot size by a factor.
|
|
211
|
+
The `value` key must specify the multiplier.
|
|
212
|
+
`"percentage"` Trade a percentage of the source account's lot size.
|
|
213
|
+
The `value` key must specify the percentage (as a decimal, e.g., 50 for 50%).
|
|
214
|
+
`"dynamic"` Calculate the lot size dynamically based on account equity and risk parameters.
|
|
215
|
+
The `value` key is ignored.
|
|
216
|
+
`"replicate"` Copy the exact lot size from the source account. The `value` key is ignored.
|
|
217
|
+
|
|
218
|
+
- `value` (float, optional) A numerical value used in conjunction with the selected `mode`.
|
|
219
|
+
Its meaning depends on the chosen `mode` (see above). Required for "fix", "multiply",
|
|
220
|
+
and "percentage" modes; optional for "dynamic".
|
|
221
|
+
|
|
222
|
+
- `slippage` (float, optional) The maximum allowed slippage in percentage when opening trades on the destination account,
|
|
223
|
+
defaults to 0.1% (0.1), if the slippage exceeds this value, the trade will not be copied.
|
|
224
|
+
|
|
225
|
+
- `comment` (str, optional) An optional comment to be added to trades opened on the destination account,
|
|
226
|
+
defaults to an empty string.
|
|
227
|
+
|
|
228
|
+
- ``copy_what`` (str, optional)
|
|
229
|
+
Specifies what to copy from the source account to the destination accounts. Valid options are
|
|
230
|
+
`"orders"` Copy only orders from the source account to the destination accounts.
|
|
231
|
+
`"positions"` Copy only positions from the source account to the destination accounts.
|
|
232
|
+
`"all"` Copy both orders and positions from the source account to the destination accounts.
|
|
233
|
+
Defaults to `"all"`.
|
|
234
|
+
|
|
235
|
+
sleeptime (float, optional):
|
|
236
|
+
The time interval in seconds between each iteration of the trade copying process.
|
|
237
|
+
Defaults to 0.1 seconds. It can be useful if you know the frequency of new trades on the source account.
|
|
238
|
+
Note:
|
|
239
|
+
The source account and the destination accounts must be connected to different MetaTrader 5 platforms.
|
|
240
|
+
you can copy the initial installation of MetaTrader 5 to a different directory and rename it to create a new instance
|
|
241
|
+
Then you can connect destination accounts to the new instance while the source account is connected to the original instance.
|
|
242
|
+
"""
|
|
243
|
+
self.source = source
|
|
244
|
+
self.destinations = destinations
|
|
245
|
+
self.sleeptime = sleeptime
|
|
246
|
+
self.start_time = start_time
|
|
247
|
+
self.end_time = end_time
|
|
248
|
+
self.errors = set()
|
|
249
|
+
self._add_copy()
|
|
250
|
+
|
|
251
|
+
def _add_copy(self):
|
|
252
|
+
self.source["copy"] = True
|
|
253
|
+
for destination in self.destinations:
|
|
254
|
+
destination["copy"] = True
|
|
255
|
+
|
|
256
|
+
def source_orders(self, symbol=None):
|
|
257
|
+
check_mt5_connection(**self.source)
|
|
258
|
+
return Account(**self.source).get_orders(symbol=symbol)
|
|
259
|
+
|
|
260
|
+
def source_positions(self, symbol=None):
|
|
261
|
+
check_mt5_connection(**self.source)
|
|
262
|
+
return Account(**self.source).get_positions(symbol=symbol)
|
|
263
|
+
|
|
264
|
+
def destination_orders(self, destination: dict, symbol=None):
|
|
265
|
+
check_mt5_connection(**destination)
|
|
266
|
+
return Account(**destination).get_orders(symbol=symbol)
|
|
267
|
+
|
|
268
|
+
def destination_positions(self, destination: dict, symbol=None):
|
|
269
|
+
check_mt5_connection(**destination)
|
|
270
|
+
return Account(**destination).get_positions(symbol=symbol)
|
|
271
|
+
|
|
272
|
+
def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
|
|
273
|
+
if source.type == dest.type and source.ticket == dest.magic:
|
|
274
|
+
return (
|
|
275
|
+
source.sl != dest.sl
|
|
276
|
+
or source.tp != dest.tp
|
|
277
|
+
or source.price_open != dest.price_open
|
|
278
|
+
or source.price_stoplimit != dest.price_stoplimit
|
|
279
|
+
)
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
def isposition_modified(self, source: TradePosition, dest: TradePosition):
|
|
283
|
+
if source.type == dest.type and source.ticket == dest.magic:
|
|
284
|
+
return source.sl != dest.sl or source.tp != dest.tp
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
def slippage(self, source: TradeOrder | TradePosition, destination: dict) -> bool:
|
|
288
|
+
slippage = destination.get("slippage", 0.1)
|
|
289
|
+
if slippage is None:
|
|
290
|
+
return False
|
|
291
|
+
if hasattr(source, "profit"):
|
|
292
|
+
if source.type in [0, 1] and source.profit < 0:
|
|
293
|
+
return False
|
|
294
|
+
delta = ((source.price_current - source.price_open) / source.price_open) * 100
|
|
295
|
+
if source.type in [0, 3, 4, 6] and delta > slippage:
|
|
296
|
+
return True
|
|
297
|
+
if source.type in [1, 2, 5, 7] and delta < -slippage:
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
def iscopy_time(self):
|
|
302
|
+
if self.start_time is None or self.end_time is None:
|
|
303
|
+
return True
|
|
304
|
+
else:
|
|
305
|
+
start_hour, start_minutes = self.start_time.split(":")
|
|
306
|
+
end_hour, end_minutes = self.end_time.split(":")
|
|
307
|
+
if int(start_hour) < datetime.now().hour < int(end_hour):
|
|
308
|
+
return True
|
|
309
|
+
elif datetime.now().hour == int(start_hour):
|
|
310
|
+
if datetime.now().minute >= int(start_minutes):
|
|
311
|
+
return True
|
|
312
|
+
elif datetime.now().hour == int(end_hour):
|
|
313
|
+
if datetime.now().minute < int(end_minutes):
|
|
314
|
+
return True
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
def copy_new_trade(
|
|
318
|
+
self, trade: TradeOrder | TradePosition, action_type: dict, destination: dict
|
|
319
|
+
):
|
|
320
|
+
if not self.iscopy_time():
|
|
321
|
+
return
|
|
322
|
+
check_mt5_connection(**destination)
|
|
323
|
+
volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
|
|
324
|
+
symbol = get_copy_symbol(trade.symbol, destination)
|
|
325
|
+
lot = calculate_copy_lot(
|
|
326
|
+
volume,
|
|
327
|
+
symbol,
|
|
328
|
+
destination,
|
|
329
|
+
mode=destination.get("mode", "fix"),
|
|
330
|
+
value=destination.get("value", 0.01),
|
|
331
|
+
source_eqty=Account(**self.source).get_account_info().margin_free,
|
|
332
|
+
dest_eqty=Account(**destination).get_account_info().margin_free,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
trade_instance = Trade(symbol=symbol, **destination, max_risk=100.0)
|
|
336
|
+
try:
|
|
337
|
+
action = action_type[trade.type]
|
|
338
|
+
except KeyError:
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
if trade_instance.open_position(
|
|
342
|
+
action,
|
|
343
|
+
volume=lot,
|
|
344
|
+
sl=trade.sl,
|
|
345
|
+
tp=trade.tp,
|
|
346
|
+
id=trade.ticket,
|
|
347
|
+
symbol=symbol,
|
|
348
|
+
mm=trade.sl != 0 and trade.tp != 0,
|
|
349
|
+
price=trade.price_open if trade.type not in [0, 1] else None,
|
|
350
|
+
stoplimit=trade.price_stoplimit if trade.type in [6, 7] else None,
|
|
351
|
+
comment=destination.get("comment", trade.comment + "#Copied"),
|
|
352
|
+
):
|
|
353
|
+
logger.info(
|
|
354
|
+
f"Copy {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
355
|
+
f"to @{destination.get('login')}::{symbol}"
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
logger.error(
|
|
359
|
+
f"Error copying {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
360
|
+
f"to @{destination.get('login')}::{symbol}"
|
|
361
|
+
)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
self.log_error(e, symbol=symbol)
|
|
364
|
+
|
|
365
|
+
def copy_new_order(self, order: TradeOrder, destination: dict):
|
|
366
|
+
action_type = {
|
|
367
|
+
2: "BLMT",
|
|
368
|
+
3: "SLMT",
|
|
369
|
+
4: "BSTP",
|
|
370
|
+
5: "SSTP",
|
|
371
|
+
6: "BSTPLMT",
|
|
372
|
+
7: "SSTPLMT",
|
|
373
|
+
}
|
|
374
|
+
self.copy_new_trade(order, action_type, destination)
|
|
375
|
+
|
|
376
|
+
def modify_order(self, ticket, symbol, source_order: TradeOrder, destination: dict):
|
|
377
|
+
check_mt5_connection(**destination)
|
|
378
|
+
account = Account(**destination)
|
|
379
|
+
request = {
|
|
380
|
+
"action": Mt5.TRADE_ACTION_MODIFY,
|
|
381
|
+
"order": ticket,
|
|
382
|
+
"symbol": symbol,
|
|
383
|
+
"price": source_order.price_open,
|
|
384
|
+
"sl": source_order.sl,
|
|
385
|
+
"tp": source_order.tp,
|
|
386
|
+
"stoplimit": source_order.price_stoplimit,
|
|
387
|
+
}
|
|
388
|
+
result = account.send_order(request)
|
|
389
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
390
|
+
msg = trade_retcode_message(result.retcode)
|
|
391
|
+
logger.error(
|
|
392
|
+
f"Error modifying Order #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
|
|
393
|
+
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
394
|
+
)
|
|
395
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
396
|
+
logger.info(
|
|
397
|
+
f"Modify Order #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
398
|
+
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def remove_order(self, src_symbol, order: TradeOrder, destination: dict):
|
|
402
|
+
check_mt5_connection(**destination)
|
|
403
|
+
trade = Trade(symbol=order.symbol, **destination)
|
|
404
|
+
if trade.close_order(order.ticket, id=order.magic):
|
|
405
|
+
logger.info(
|
|
406
|
+
f"Close Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
407
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
logger.error(
|
|
411
|
+
f"Error closing Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
412
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def copy_new_position(self, position: TradePosition, destination: dict):
|
|
416
|
+
action_type = {0: "BMKT", 1: "SMKT"}
|
|
417
|
+
self.copy_new_trade(position, action_type, destination)
|
|
418
|
+
|
|
419
|
+
def modify_position(
|
|
420
|
+
self, ticket, symbol, source_pos: TradePosition, destination: dict
|
|
421
|
+
):
|
|
422
|
+
check_mt5_connection(**destination)
|
|
423
|
+
account = Account(**destination)
|
|
424
|
+
request = {
|
|
425
|
+
"action": Mt5.TRADE_ACTION_SLTP,
|
|
426
|
+
"position": ticket,
|
|
427
|
+
"symbol": symbol,
|
|
428
|
+
"sl": source_pos.sl,
|
|
429
|
+
"tp": source_pos.tp,
|
|
430
|
+
}
|
|
431
|
+
result = account.send_order(request)
|
|
432
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
433
|
+
msg = trade_retcode_message(result.retcode)
|
|
434
|
+
logger.error(
|
|
435
|
+
f"Error modifying Position #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
|
|
436
|
+
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
437
|
+
)
|
|
438
|
+
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
439
|
+
logger.info(
|
|
440
|
+
f"Modify Position #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
441
|
+
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def remove_position(self, src_symbol, position: TradePosition, destination: dict):
|
|
445
|
+
check_mt5_connection(**destination)
|
|
446
|
+
trade = Trade(symbol=position.symbol, **destination)
|
|
447
|
+
if trade.close_position(position.ticket, id=position.magic):
|
|
448
|
+
logger.info(
|
|
449
|
+
f"Close Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
|
|
450
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
logger.error(
|
|
454
|
+
f"Error closing Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
|
|
455
|
+
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def filter_positions_and_orders(self, pos_or_orders, symbols=None):
|
|
459
|
+
if symbols is None:
|
|
460
|
+
return pos_or_orders
|
|
461
|
+
elif isinstance(symbols, list):
|
|
462
|
+
return [pos for pos in pos_or_orders if pos.symbol in symbols]
|
|
463
|
+
elif isinstance(symbols, dict):
|
|
464
|
+
return [
|
|
465
|
+
pos
|
|
466
|
+
for pos in pos_or_orders
|
|
467
|
+
if pos.symbol in symbols.keys() or pos.symbol in symbols.values()
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
def get_positions(self, destination: dict):
|
|
471
|
+
source_positions = self.source_positions() or []
|
|
472
|
+
dest_symbols = get_copy_symbols(destination)
|
|
473
|
+
dest_positions = self.destination_positions(destination) or []
|
|
474
|
+
source_positions = self.filter_positions_and_orders(
|
|
475
|
+
source_positions, symbols=dest_symbols
|
|
476
|
+
)
|
|
477
|
+
dest_positions = self.filter_positions_and_orders(
|
|
478
|
+
dest_positions, symbols=dest_symbols
|
|
479
|
+
)
|
|
480
|
+
return source_positions, dest_positions
|
|
481
|
+
|
|
482
|
+
def get_orders(self, destination: dict):
|
|
483
|
+
source_orders = self.source_orders() or []
|
|
484
|
+
dest_symbols = get_copy_symbols(destination)
|
|
485
|
+
dest_orders = self.destination_orders(destination) or []
|
|
486
|
+
source_orders = self.filter_positions_and_orders(
|
|
487
|
+
source_orders, symbols=dest_symbols
|
|
488
|
+
)
|
|
489
|
+
dest_orders = self.filter_positions_and_orders(
|
|
490
|
+
dest_orders, symbols=dest_symbols
|
|
491
|
+
)
|
|
492
|
+
return source_orders, dest_orders
|
|
493
|
+
|
|
494
|
+
def copy_orders(self, destination: dict):
|
|
495
|
+
assert destination.get("copy", False), "Destination account not set to copy"
|
|
496
|
+
what = destination.get("copy_what", "all")
|
|
497
|
+
if what not in ["all", "orders"]:
|
|
498
|
+
return
|
|
499
|
+
check_mt5_connection(**destination)
|
|
500
|
+
source_orders, destination_orders = self.get_orders(destination)
|
|
501
|
+
# Check for new orders
|
|
502
|
+
dest_ids = [order.magic for order in destination_orders]
|
|
503
|
+
for source_order in source_orders:
|
|
504
|
+
if source_order.ticket not in dest_ids:
|
|
505
|
+
if not self.slippage(source_order, destination):
|
|
506
|
+
self.copy_new_order(source_order, destination)
|
|
507
|
+
|
|
508
|
+
# Check for modified orders
|
|
509
|
+
source_orders, destination_orders = self.get_orders(destination)
|
|
510
|
+
for source_order in source_orders:
|
|
511
|
+
for destination_order in destination_orders:
|
|
512
|
+
if source_order.ticket == destination_order.magic:
|
|
513
|
+
if self.isorder_modified(source_order, destination_order):
|
|
514
|
+
ticket = destination_order.ticket
|
|
515
|
+
symbol = destination_order.symbol
|
|
516
|
+
self.modify_order(ticket, symbol, source_order, destination)
|
|
517
|
+
# Check for closed orders
|
|
518
|
+
source_orders, destination_orders = self.get_orders(destination)
|
|
519
|
+
source_ids = [order.ticket for order in source_orders]
|
|
520
|
+
for destination_order in destination_orders:
|
|
521
|
+
if destination_order.magic not in source_ids:
|
|
522
|
+
src_symbol = get_copy_symbol(
|
|
523
|
+
destination_order.symbol, destination, type="source"
|
|
524
|
+
)
|
|
525
|
+
self.remove_order(src_symbol, destination_order, destination)
|
|
526
|
+
|
|
527
|
+
# Check if order are triggered on source account
|
|
528
|
+
# and not on destination account or vice versa
|
|
529
|
+
source_positions, _ = self.get_positions(destination)
|
|
530
|
+
_, destination_orders = self.get_orders(destination)
|
|
531
|
+
for source_position in source_positions:
|
|
532
|
+
for destination_order in destination_orders:
|
|
533
|
+
if source_position.ticket == destination_order.magic:
|
|
534
|
+
self.remove_order(
|
|
535
|
+
source_position.symbol, destination_order, destination
|
|
536
|
+
)
|
|
537
|
+
if what in ["all", "positions"]:
|
|
538
|
+
if not self.slippage(source_position, destination):
|
|
539
|
+
self.copy_new_position(source_position, destination)
|
|
540
|
+
|
|
541
|
+
_, destination_positions = self.get_positions(destination)
|
|
542
|
+
source_orders, _ = self.get_orders(destination)
|
|
543
|
+
for destination_position in destination_positions:
|
|
544
|
+
for source_order in source_orders:
|
|
545
|
+
if destination_position.magic == source_order.ticket:
|
|
546
|
+
self.remove_position(
|
|
547
|
+
source_order.symbol, destination_position, destination
|
|
548
|
+
)
|
|
549
|
+
if not self.slippage(source_order, destination):
|
|
550
|
+
self.copy_new_order(source_order, destination)
|
|
551
|
+
Mt5.shutdown()
|
|
552
|
+
|
|
553
|
+
def copy_positions(self, destination: dict):
|
|
554
|
+
assert destination.get("copy", False), "Destination account not set to copy"
|
|
555
|
+
what = destination.get("copy_what", "all")
|
|
556
|
+
if what not in ["all", "positions"]:
|
|
557
|
+
return
|
|
558
|
+
check_mt5_connection(**destination)
|
|
559
|
+
source_positions, destination_positions = self.get_positions(destination)
|
|
560
|
+
|
|
561
|
+
# Check for new positions
|
|
562
|
+
dest_ids = [pos.magic for pos in destination_positions]
|
|
563
|
+
for source_position in source_positions:
|
|
564
|
+
if source_position.ticket not in dest_ids:
|
|
565
|
+
if not self.slippage(source_position, destination):
|
|
566
|
+
self.copy_new_position(source_position, destination)
|
|
567
|
+
|
|
568
|
+
# Check for modified positions
|
|
569
|
+
source_positions, destination_positions = self.get_positions(destination)
|
|
570
|
+
for source_position in source_positions:
|
|
571
|
+
for destination_position in destination_positions:
|
|
572
|
+
if source_position.ticket == destination_position.magic:
|
|
573
|
+
if self.isposition_modified(source_position, destination_position):
|
|
574
|
+
ticket = destination_position.ticket
|
|
575
|
+
symbol = destination_position.symbol
|
|
576
|
+
self.modify_position(
|
|
577
|
+
ticket, symbol, source_position, destination
|
|
578
|
+
)
|
|
579
|
+
# Check for closed positions
|
|
580
|
+
source_positions, destination_positions = self.get_positions(destination)
|
|
581
|
+
source_ids = [pos.ticket for pos in source_positions]
|
|
582
|
+
for destination_position in destination_positions:
|
|
583
|
+
if destination_position.magic not in source_ids:
|
|
584
|
+
src_symbol = get_copy_symbol(
|
|
585
|
+
destination_position.symbol, destination, type="source"
|
|
586
|
+
)
|
|
587
|
+
self.remove_position(src_symbol, destination_position, destination)
|
|
588
|
+
Mt5.shutdown()
|
|
589
|
+
|
|
590
|
+
def log_error(self, e, symbol=None):
|
|
591
|
+
error_msg = repr(e)
|
|
592
|
+
if error_msg not in self.errors:
|
|
593
|
+
self.errors.add(error_msg)
|
|
594
|
+
add_msg = f"SYMBOL={symbol}" if symbol else ""
|
|
595
|
+
logger.error(f"Error encountered: {error_msg}, {add_msg}")
|
|
596
|
+
|
|
597
|
+
def run(self):
|
|
598
|
+
logger.info("Trade Copier Running ...")
|
|
599
|
+
logger.info(f"Source Account: {self.source.get('login')}")
|
|
600
|
+
while True:
|
|
601
|
+
try:
|
|
602
|
+
for destination in self.destinations:
|
|
603
|
+
if destination.get("path") == self.source.get("path"):
|
|
604
|
+
err_msg = "Source and destination accounts are on the same \
|
|
605
|
+
MetaTrader 5 installation which is not allowed."
|
|
606
|
+
logger.error(err_msg)
|
|
607
|
+
continue
|
|
608
|
+
self.copy_orders(destination)
|
|
609
|
+
self.copy_positions(destination)
|
|
610
|
+
except Exception as e:
|
|
611
|
+
self.log_error(e)
|
|
612
|
+
except KeyboardInterrupt:
|
|
613
|
+
break
|
|
614
|
+
time.sleep(self.sleeptime)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def RunCopier(
|
|
618
|
+
source: dict, destinations: list, sleeptime: float, start_time: str, end_time: str
|
|
619
|
+
):
|
|
620
|
+
copier = TradeCopier(source, destinations, sleeptime, start_time, end_time)
|
|
621
|
+
copier.run()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def RunMultipleCopier(
|
|
625
|
+
accounts: List[dict],
|
|
626
|
+
sleeptime: float = 0.1,
|
|
627
|
+
start_delay: float = 1.0,
|
|
628
|
+
start_time: str = None,
|
|
629
|
+
end_time: str = None,
|
|
630
|
+
):
|
|
631
|
+
processes = []
|
|
632
|
+
|
|
633
|
+
for account in accounts:
|
|
634
|
+
source = account.get("source")
|
|
635
|
+
destinations = account.get("destinations")
|
|
636
|
+
|
|
637
|
+
if not source or not destinations:
|
|
638
|
+
logger.warning("Skipping account due to missing source or destinations.")
|
|
639
|
+
continue
|
|
640
|
+
paths = set([source.get("path")] + [dest.get("path") for dest in destinations])
|
|
641
|
+
if len(paths) == 1:
|
|
642
|
+
logger.warning(
|
|
643
|
+
"Skipping account due to same MetaTrader 5 installation path."
|
|
644
|
+
)
|
|
645
|
+
continue
|
|
646
|
+
logger.info(f"Starting process for source account @{source.get('login')}")
|
|
647
|
+
|
|
648
|
+
process = multiprocessing.Process(
|
|
649
|
+
target=RunCopier,
|
|
650
|
+
args=(source, destinations, sleeptime, start_time, end_time),
|
|
651
|
+
)
|
|
652
|
+
processes.append(process)
|
|
653
|
+
process.start()
|
|
654
|
+
|
|
655
|
+
if start_delay:
|
|
656
|
+
time.sleep(start_delay)
|
|
657
|
+
|
|
658
|
+
# Wait for all processes to complete
|
|
659
|
+
for process in processes:
|
|
660
|
+
process.join()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def config_copier(
|
|
664
|
+
source_section: str = None,
|
|
665
|
+
dest_sections: str | List[str] = None,
|
|
666
|
+
inifile: str | Path = None,
|
|
667
|
+
) -> Tuple[dict, List[dict]]:
|
|
668
|
+
"""
|
|
669
|
+
Read the configuration file and return the source and destination account details.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
inifile (str | Path): The path to the INI configuration file.
|
|
673
|
+
source_section (str): The section name of the source account, defaults to "SOURCE".
|
|
674
|
+
dest_sections (str | List[str]): The section name(s) of the destination account(s).
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Tuple[dict, List[dict]]: A tuple containing the source account and a list of destination accounts.
|
|
678
|
+
|
|
679
|
+
Example:
|
|
680
|
+
```python
|
|
681
|
+
from pathlib import Path
|
|
682
|
+
config_file = ~/.bbstrader/copier/copier.ini
|
|
683
|
+
source, destinations = config_copier(config_file, "SOURCE", ["DEST1", "DEST2"])
|
|
684
|
+
```
|
|
685
|
+
"""
|
|
686
|
+
from bbstrader.core.utils import dict_from_ini
|
|
687
|
+
|
|
688
|
+
def strtodict(string: str) -> dict:
|
|
689
|
+
string = string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
|
|
690
|
+
return dict(item.split(":") for item in string.split(","))
|
|
691
|
+
|
|
692
|
+
if not inifile:
|
|
693
|
+
inifile = Path().home() / ".bbstrader" / "copier" / "copier.ini"
|
|
694
|
+
if not inifile.exists() or not inifile.is_file():
|
|
695
|
+
raise FileNotFoundError(f"{inifile} not found")
|
|
696
|
+
|
|
697
|
+
if not source_section:
|
|
698
|
+
source_section = "SOURCE"
|
|
699
|
+
|
|
700
|
+
config = dict_from_ini(inifile)
|
|
701
|
+
try:
|
|
702
|
+
source = config.pop(source_section)
|
|
703
|
+
except KeyError:
|
|
704
|
+
raise ValueError(f"Source section {source_section} not found in {inifile}")
|
|
705
|
+
dest_sections = dest_sections or config.keys()
|
|
706
|
+
if not dest_sections:
|
|
707
|
+
raise ValueError("No destination sections found in the configuration file")
|
|
708
|
+
|
|
709
|
+
destinations = []
|
|
710
|
+
|
|
711
|
+
if isinstance(dest_sections, str):
|
|
712
|
+
dest_sections = [dest_sections]
|
|
713
|
+
|
|
714
|
+
for dest_section in dest_sections:
|
|
715
|
+
try:
|
|
716
|
+
section = config[dest_section]
|
|
717
|
+
except KeyError:
|
|
718
|
+
raise ValueError(
|
|
719
|
+
f"Destination section {dest_section} not found in {inifile}"
|
|
720
|
+
)
|
|
721
|
+
symbols: str = section.get("symbols")
|
|
722
|
+
symbols = symbols.strip().replace("\n", " ").replace('"""', "")
|
|
723
|
+
if symbols in ["all", "*"]:
|
|
724
|
+
section["symbols"] = symbols
|
|
725
|
+
elif ":" in symbols:
|
|
726
|
+
symbols = strtodict(symbols)
|
|
727
|
+
section["symbols"] = symbols
|
|
728
|
+
elif " " in symbols and "," not in symbols:
|
|
729
|
+
symbols = symbols.split()
|
|
730
|
+
section["symbols"] = symbols
|
|
731
|
+
elif "," in symbols:
|
|
732
|
+
symbols = symbols.replace(" ", "").split(",")
|
|
733
|
+
section["symbols"] = symbols
|
|
734
|
+
else:
|
|
735
|
+
err_msg = """
|
|
736
|
+
Invalid symbols format.
|
|
737
|
+
You can use space or comma separated symbols in one line or multiple lines using triple quotes.
|
|
738
|
+
You can also use a dictionary to map source symbols to destination symbols as shown below.
|
|
739
|
+
Or if you want to copy all symbols, use "all" or "*".
|
|
740
|
+
|
|
741
|
+
symbols = EURUSD, GBPUSD, USDJPY (space separated)
|
|
742
|
+
symbols = EURUSD,GBPUSD,USDJPY (comma separated)
|
|
743
|
+
symbols = EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i (dictionary)
|
|
744
|
+
symbols = all (copy all symbols)
|
|
745
|
+
symbols = * (copy all symbols) """
|
|
746
|
+
raise ValueError(err_msg)
|
|
747
|
+
|
|
748
|
+
destinations.append(section)
|
|
749
|
+
|
|
750
|
+
return source, destinations
|