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.
Files changed (45) hide show
  1. bbstrader/__init__.py +27 -0
  2. bbstrader/__main__.py +92 -0
  3. bbstrader/api/__init__.py +96 -0
  4. bbstrader/api/handlers.py +245 -0
  5. bbstrader/api/metatrader_client.cpython-312-darwin.so +0 -0
  6. bbstrader/api/metatrader_client.pyi +624 -0
  7. bbstrader/assets/bbs_.png +0 -0
  8. bbstrader/assets/bbstrader.ico +0 -0
  9. bbstrader/assets/bbstrader.png +0 -0
  10. bbstrader/assets/qs_metrics_1.png +0 -0
  11. bbstrader/btengine/__init__.py +54 -0
  12. bbstrader/btengine/backtest.py +358 -0
  13. bbstrader/btengine/data.py +737 -0
  14. bbstrader/btengine/event.py +229 -0
  15. bbstrader/btengine/execution.py +287 -0
  16. bbstrader/btengine/performance.py +408 -0
  17. bbstrader/btengine/portfolio.py +393 -0
  18. bbstrader/btengine/strategy.py +588 -0
  19. bbstrader/compat.py +28 -0
  20. bbstrader/config.py +100 -0
  21. bbstrader/core/__init__.py +27 -0
  22. bbstrader/core/data.py +628 -0
  23. bbstrader/core/strategy.py +466 -0
  24. bbstrader/metatrader/__init__.py +48 -0
  25. bbstrader/metatrader/_copier.py +720 -0
  26. bbstrader/metatrader/account.py +865 -0
  27. bbstrader/metatrader/broker.py +418 -0
  28. bbstrader/metatrader/copier.py +1487 -0
  29. bbstrader/metatrader/rates.py +495 -0
  30. bbstrader/metatrader/risk.py +667 -0
  31. bbstrader/metatrader/trade.py +1692 -0
  32. bbstrader/metatrader/utils.py +402 -0
  33. bbstrader/models/__init__.py +39 -0
  34. bbstrader/models/nlp.py +932 -0
  35. bbstrader/models/optimization.py +182 -0
  36. bbstrader/scripts.py +665 -0
  37. bbstrader/trading/__init__.py +33 -0
  38. bbstrader/trading/execution.py +1159 -0
  39. bbstrader/trading/strategy.py +362 -0
  40. bbstrader/trading/utils.py +69 -0
  41. bbstrader-2.0.3.dist-info/METADATA +396 -0
  42. bbstrader-2.0.3.dist-info/RECORD +45 -0
  43. bbstrader-2.0.3.dist-info/WHEEL +5 -0
  44. bbstrader-2.0.3.dist-info/entry_points.txt +3 -0
  45. bbstrader-2.0.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,720 @@
1
+ import multiprocessing
2
+ import os
3
+ import sys
4
+ import tkinter as tk
5
+ import traceback
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from tkinter import filedialog, messagebox, scrolledtext, ttk
9
+ from typing import Any, Dict, List, Union
10
+
11
+ from PIL import Image, ImageTk
12
+
13
+ from bbstrader.metatrader.copier import (
14
+ copier_worker_process,
15
+ get_lots_from_string,
16
+ get_symbols_from_string,
17
+ )
18
+
19
+
20
+ def resource_path(relative_path: str) -> Path:
21
+ """Get absolute path to resource"""
22
+ try:
23
+ base_path = Path(sys._MEIPASS) # type: ignore
24
+ resource_path_obj = base_path / relative_path
25
+ except AttributeError:
26
+ resource_path_obj = resources.files("bbstrader").joinpath(relative_path)
27
+
28
+ return Path(os.fspath(resource_path_obj))
29
+
30
+
31
+ TITLE = "Trade Copier"
32
+ ICON_PATH = resource_path("assets/bbstrader.ico")
33
+ LOGO_PATH = resource_path("assets/bbstrader.png")
34
+
35
+
36
+ class TradeCopierApp:
37
+ copier_processes: List[multiprocessing.Process]
38
+
39
+ def __init__(self, root: tk.Tk) -> None:
40
+ root.title(TITLE)
41
+ root.geometry("1600x900")
42
+ self.root = root
43
+ self.root.columnconfigure(0, weight=1)
44
+ self.root.rowconfigure(0, weight=1)
45
+
46
+ self.main_frame = self.add_main_frame()
47
+ self.main_frame.columnconfigure(0, weight=1)
48
+ self.main_frame.columnconfigure(1, weight=1)
49
+ self.main_frame.rowconfigure(3, weight=1)
50
+
51
+ self.set_style()
52
+ self.add_logo_and_description()
53
+ self.add_source_account_frame(self.main_frame)
54
+ self.add_destination_accounts_frame(self.main_frame)
55
+ self.add_copier_settings(self.main_frame)
56
+
57
+ def set_style(self) -> None:
58
+ self.style = ttk.Style()
59
+ self.style.configure("Bold.TLabelframe.Label", font=("Segoe UI", 15, "bold"))
60
+
61
+ def add_main_frame(self) -> ttk.Frame:
62
+ main_frame = ttk.Frame(self.root, padding="10")
63
+ main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) # type: ignore
64
+
65
+ # Configure the layout
66
+ main_frame.columnconfigure(0, weight=2)
67
+ main_frame.columnconfigure(1, weight=1)
68
+ main_frame.rowconfigure(3, weight=1)
69
+
70
+ # visual/logo frame
71
+ self.visual_frame = ttk.Frame(main_frame)
72
+ self.visual_frame.grid(row=0, column=1, padx=5, pady=5, sticky=(tk.W, tk.E)) # type: ignore
73
+
74
+ # opier settings
75
+ self.right_panel_frame = ttk.Frame(main_frame)
76
+ self.right_panel_frame.grid(
77
+ row=1, column=1, rowspan=2, padx=5, pady=5, sticky="nsew"
78
+ )
79
+ self.right_panel_frame.columnconfigure(0, weight=1)
80
+ self.right_panel_frame.rowconfigure(0, weight=0)
81
+ self.right_panel_frame.rowconfigure(1, weight=1)
82
+
83
+ return main_frame
84
+
85
+ def add_source_account_frame(self, main_frame: ttk.Frame) -> None:
86
+ # Source Account
87
+ source_frame = ttk.LabelFrame(
88
+ main_frame, text="Source Account", style="Bold.TLabelframe"
89
+ )
90
+ source_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N)) # type: ignore
91
+ source_frame.columnconfigure(1, weight=1)
92
+ source_frame.columnconfigure(3, weight=1)
93
+
94
+ ttk.Label(source_frame, text="Login").grid(
95
+ row=0, column=0, sticky=tk.W, padx=5, pady=2
96
+ )
97
+ self.source_login_entry = ttk.Entry(source_frame)
98
+ self.source_login_entry.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
99
+
100
+ ttk.Label(source_frame, text="Password").grid(
101
+ row=1, column=0, sticky=tk.W, padx=5, pady=2
102
+ )
103
+ self.source_password_entry = ttk.Entry(source_frame, show="*")
104
+ self.source_password_entry.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2)
105
+
106
+ ttk.Label(source_frame, text="Server").grid(
107
+ row=2, column=0, sticky=tk.W, padx=5, pady=2
108
+ )
109
+ self.source_server_entry = ttk.Entry(source_frame)
110
+ self.source_server_entry.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=2)
111
+
112
+ ttk.Label(source_frame, text="Path").grid(
113
+ row=3, column=0, sticky=tk.W, padx=5, pady=2
114
+ )
115
+ self.source_path_entry = ttk.Entry(source_frame)
116
+ self.source_path_entry.grid(row=3, column=1, sticky=tk.EW, padx=5, pady=2)
117
+ source_path_browse_button = ttk.Button(
118
+ source_frame,
119
+ text="Browse...",
120
+ command=lambda: self.browse_path(self.source_path_entry),
121
+ )
122
+ source_path_browse_button.grid(row=3, column=2, sticky=tk.W, padx=5, pady=2)
123
+
124
+ right_frame = ttk.Frame(source_frame)
125
+ right_frame.grid(row=0, column=3, rowspan=2, sticky=tk.NW, padx=5, pady=2)
126
+
127
+ # Source ID
128
+ ttk.Label(right_frame, text="Source ID").pack(side=tk.LEFT, padx=(0, 2))
129
+ self.source_id_entry = ttk.Entry(right_frame, width=20)
130
+ self.source_id_entry.pack(side=tk.LEFT)
131
+ self.source_id_entry.insert(0, "0")
132
+
133
+ # Allow copy from others checkbox
134
+ self.allow_copy_var = tk.BooleanVar(value=False)
135
+ self.allow_copy_check = ttk.Checkbutton(
136
+ source_frame, text="Allow Others Sources", variable=self.allow_copy_var
137
+ )
138
+ self.allow_copy_check.grid(row=1, column=3, sticky=tk.W, padx=5, pady=2)
139
+
140
+ def add_destination_accounts_frame(self, main_frame: ttk.Frame) -> None:
141
+ # Destination Accounts Scrollable Area
142
+ self.destinations_outer_frame = ttk.LabelFrame(
143
+ main_frame, text="Destination Accounts", style="Bold.TLabelframe"
144
+ )
145
+ self.destinations_outer_frame.grid(
146
+ row=1,
147
+ column=0,
148
+ padx=5,
149
+ pady=5,
150
+ sticky=(tk.W, tk.E, tk.N, tk.S), # type: ignore
151
+ )
152
+ self.destinations_outer_frame.rowconfigure(0, weight=1)
153
+ self.destinations_outer_frame.columnconfigure(0, weight=1)
154
+
155
+ self.canvas = tk.Canvas(self.destinations_outer_frame)
156
+ self.canvas.grid(row=0, column=0, sticky="nsew")
157
+
158
+ self.scrollbar = ttk.Scrollbar(
159
+ self.destinations_outer_frame, orient="vertical", command=self.canvas.yview
160
+ )
161
+ self.scrollbar.grid(row=0, column=1, sticky="ns")
162
+
163
+ self.canvas.configure(yscrollcommand=self.scrollbar.set)
164
+
165
+ self.scrollable_frame_for_destinations = ttk.Frame(self.canvas)
166
+ self.canvas_window = self.canvas.create_window(
167
+ (0, 0), window=self.scrollable_frame_for_destinations, anchor="nw"
168
+ )
169
+
170
+ self.scrollable_frame_for_destinations.columnconfigure(0, weight=1)
171
+
172
+ def configure_scroll_region(event: tk.Event) -> None:
173
+ self.canvas.configure(scrollregion=self.canvas.bbox("all"))
174
+
175
+ def configure_canvas_window(event: tk.Event) -> None:
176
+ self.canvas.itemconfig(self.canvas_window, width=event.width)
177
+
178
+ self.scrollable_frame_for_destinations.bind(
179
+ "<Configure>", configure_scroll_region
180
+ )
181
+ self.canvas.bind("<Configure>", configure_canvas_window)
182
+
183
+ def _on_mousewheel(event: tk.Event) -> None:
184
+ scroll_val = -1 * (event.delta // 120)
185
+ self.canvas.yview_scroll(scroll_val, "units")
186
+
187
+ self.canvas.bind("<MouseWheel>", _on_mousewheel)
188
+ self.scrollable_frame_for_destinations.bind("<MouseWheel>", _on_mousewheel)
189
+
190
+ self.destination_widgets = []
191
+ self.current_scrollable_content_row = 0
192
+
193
+ self.add_dest_button = ttk.Button(
194
+ self.destinations_outer_frame,
195
+ text="Add Destination",
196
+ command=self.add_destination_account,
197
+ )
198
+ self.add_dest_button.grid(row=1, column=0, columnspan=2, pady=10, sticky=tk.EW)
199
+
200
+ self.add_destination_account()
201
+
202
+ def add_copier_settings(self, main_frame: ttk.Frame) -> None:
203
+ # Copier Settings
204
+ settings_frame = ttk.LabelFrame(
205
+ self.right_panel_frame, text="Copier Settings", style="Bold.TLabelframe"
206
+ )
207
+ settings_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=5) # type: ignore
208
+
209
+ ttk.Label(settings_frame, text="Sleep Time (s)").grid(
210
+ row=0, column=0, sticky=tk.W, padx=5, pady=2
211
+ )
212
+ self.sleeptime_entry = ttk.Entry(settings_frame)
213
+ self.sleeptime_entry.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
214
+ self.sleeptime_entry.insert(0, "0.1")
215
+
216
+ ttk.Label(settings_frame, text="Start Time (HH:MM)").grid(
217
+ row=1, column=0, sticky=tk.W, padx=5, pady=2
218
+ )
219
+ self.start_time_entry = ttk.Entry(settings_frame)
220
+ self.start_time_entry.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2)
221
+
222
+ ttk.Label(settings_frame, text="End Time (HH:MM)").grid(
223
+ row=2, column=0, sticky=tk.W, padx=5, pady=2
224
+ )
225
+ self.end_time_entry = ttk.Entry(settings_frame)
226
+ self.end_time_entry.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=2)
227
+
228
+ # Controls
229
+ controls_frame = ttk.Frame(main_frame)
230
+ controls_frame.grid(row=2, column=0, columnspan=2, pady=10)
231
+
232
+ self.start_button = ttk.Button(
233
+ controls_frame, text="Start Copier", command=self.start_copier
234
+ )
235
+ self.start_button.pack(side=tk.LEFT, padx=5)
236
+
237
+ self.stop_button = ttk.Button(
238
+ controls_frame,
239
+ text="Stop Copier",
240
+ command=self.stop_copier,
241
+ state=tk.DISABLED,
242
+ )
243
+ self.stop_button.pack(side=tk.LEFT, padx=5)
244
+
245
+ # Log Area
246
+ log_frame = ttk.LabelFrame(main_frame, text="Logs", style="Bold.TLabelframe")
247
+ log_frame.grid(
248
+ row=3,
249
+ column=0,
250
+ columnspan=2,
251
+ padx=5,
252
+ pady=5,
253
+ sticky=(tk.W, tk.E, tk.N, tk.S), # type: ignore
254
+ )
255
+
256
+ self.log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=10)
257
+ self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
258
+ self.log_text.configure(state="disabled")
259
+
260
+ def add_logo_and_description(self) -> None:
261
+ image = Image.open(LOGO_PATH)
262
+ image = image.resize((120, 120))
263
+ self.logo_img = ImageTk.PhotoImage(image)
264
+ logo_label = ttk.Label(self.visual_frame, image=self.logo_img)
265
+ logo_label.pack(padx=10, pady=10)
266
+
267
+ # Add custom title/info
268
+ ttk.Label(self.visual_frame, text=TITLE, font=("Segoe UI", 20, "bold")).pack()
269
+ ttk.Label(
270
+ self.visual_frame,
271
+ text="Fast | Reliable | Flexible",
272
+ font=("Segoe UI", 10, "bold"),
273
+ ).pack()
274
+
275
+ def add_destination_account(self) -> None:
276
+ dest_row = len(self.destination_widgets)
277
+ dest_widgets: Dict[str, Union[ttk.Entry, ttk.Combobox]] = {}
278
+
279
+ if dest_row > 0:
280
+ sep = ttk.Separator(
281
+ self.scrollable_frame_for_destinations, orient=tk.HORIZONTAL
282
+ )
283
+ sep.grid(
284
+ row=self.current_scrollable_content_row,
285
+ column=0,
286
+ sticky=tk.EW,
287
+ pady=(10, 5),
288
+ )
289
+ self.current_scrollable_content_row += 1
290
+
291
+ frame = ttk.Frame(self.scrollable_frame_for_destinations)
292
+ frame.grid(
293
+ row=self.current_scrollable_content_row,
294
+ column=0,
295
+ pady=5,
296
+ padx=5,
297
+ sticky=tk.EW,
298
+ )
299
+ self.current_scrollable_content_row += 1
300
+
301
+ frame.columnconfigure(1, weight=1)
302
+ frame.columnconfigure(3, weight=1)
303
+
304
+ ttk.Label(
305
+ frame, text=f"Destination {dest_row + 1}", font=("Segoe UI", 8, "bold")
306
+ ).grid(row=0, column=0, columnspan=5, sticky=tk.W, padx=5, pady=(5, 10))
307
+
308
+ # Row 1: Login and Password
309
+ ttk.Label(frame, text="Login").grid(
310
+ row=1, column=0, sticky=tk.W, padx=5, pady=2
311
+ )
312
+ dest_widgets["login"] = ttk.Entry(frame)
313
+ dest_widgets["login"].grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2)
314
+
315
+ ttk.Label(frame, text="Password").grid(
316
+ row=1, column=2, sticky=tk.W, padx=5, pady=2
317
+ )
318
+ dest_widgets["password"] = ttk.Entry(frame, show="*")
319
+ dest_widgets["password"].grid(
320
+ row=1, column=3, columnspan=2, sticky=tk.EW, padx=5, pady=2
321
+ )
322
+
323
+ # Row 2: Server and Path
324
+ ttk.Label(frame, text="Server").grid(
325
+ row=2, column=0, sticky=tk.W, padx=5, pady=2
326
+ )
327
+ dest_widgets["server"] = ttk.Entry(frame)
328
+ dest_widgets["server"].grid(row=2, column=1, sticky=tk.EW, padx=5, pady=2)
329
+
330
+ ttk.Label(frame, text="Path").grid(row=2, column=2, sticky=tk.W, padx=5, pady=2)
331
+ dest_widgets["path"] = ttk.Entry(frame)
332
+ dest_widgets["path"].grid(row=2, column=3, sticky=tk.EW, padx=5, pady=2)
333
+ dest_path_browse_button = ttk.Button(
334
+ frame,
335
+ text="Browse...",
336
+ command=lambda w=dest_widgets["path"]: self.browse_path(w),
337
+ )
338
+ dest_path_browse_button.grid(row=2, column=4, sticky=tk.W, padx=(0, 5), pady=2)
339
+
340
+ # Row 3: Mode and Value
341
+ ttk.Label(frame, text="Mode").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2)
342
+ dest_widgets["mode"] = ttk.Combobox(
343
+ frame,
344
+ values=[
345
+ "fix",
346
+ "multiply",
347
+ "percentage",
348
+ "dynamic",
349
+ "replicate",
350
+ "specific",
351
+ ],
352
+ width=10,
353
+ )
354
+ dest_widgets["mode"].grid(row=3, column=1, sticky=tk.EW, padx=5, pady=2)
355
+ dest_widgets["mode"].set("fix")
356
+
357
+ ttk.Label(frame, text="Value").grid(
358
+ row=3, column=2, sticky=tk.W, padx=5, pady=2
359
+ )
360
+ dest_widgets["value"] = ttk.Entry(frame)
361
+ dest_widgets["value"].grid(
362
+ row=3, column=3, columnspan=2, sticky=tk.EW, padx=5, pady=2
363
+ )
364
+ dest_widgets["value"].insert(0, "0.01")
365
+
366
+ lots_load_button = ttk.Button(
367
+ frame,
368
+ text="Load...",
369
+ command=lambda w=dest_widgets["value"]: self.browse_lots_file(w),
370
+ )
371
+ lots_load_button.grid(row=3, column=4, sticky=tk.W, padx=(0, 5), pady=2)
372
+
373
+ # Row 4: Copy what and slippage
374
+ ttk.Label(frame, text="Copy What").grid(
375
+ row=4, column=0, sticky=tk.W, padx=5, pady=2
376
+ )
377
+ dest_widgets["copy_what"] = ttk.Combobox(
378
+ frame, values=["all", "orders", "positions"], width=10
379
+ )
380
+ dest_widgets["copy_what"].grid(row=4, column=1, sticky=tk.EW, padx=5, pady=2)
381
+ dest_widgets["copy_what"].set("all")
382
+
383
+ ttk.Label(frame, text="Slippage").grid(
384
+ row=4, column=2, sticky=tk.W, padx=5, pady=2
385
+ )
386
+ dest_widgets["slippage"] = ttk.Entry(frame)
387
+ dest_widgets["slippage"].grid(
388
+ row=4, column=3, columnspan=2, sticky=tk.EW, padx=5, pady=2
389
+ )
390
+ dest_widgets["slippage"].insert(0, "0.1")
391
+
392
+ # Row 5: Symbols
393
+ ttk.Label(frame, text="Symbols").grid(
394
+ row=5, column=0, sticky=tk.W, padx=5, pady=2
395
+ )
396
+ dest_widgets["symbols"] = ttk.Entry(frame)
397
+ dest_widgets["symbols"].grid(row=5, column=1, sticky=tk.EW, padx=5, pady=2)
398
+ dest_widgets["symbols"].insert(0, "all")
399
+
400
+ symbols_load_button = ttk.Button(
401
+ frame,
402
+ text="Load...",
403
+ command=lambda w=dest_widgets["symbols"]: self.browse_symbols_file(w),
404
+ )
405
+ symbols_load_button.grid(row=5, column=2, sticky=tk.W, padx=(0, 5), pady=2)
406
+
407
+ self.destination_widgets.append(dest_widgets)
408
+
409
+ self.add_dest_button.grid_forget()
410
+ self.add_dest_button.grid(row=1, column=0, columnspan=2, pady=10, sticky=tk.EW)
411
+
412
+ def log_message(self, message: str) -> None:
413
+ self.log_text.configure(state="normal")
414
+ self.log_text.insert(tk.END, message + "\n")
415
+ self.log_text.configure(state="disabled")
416
+ self.log_text.see(tk.END)
417
+
418
+ def check_log_queue(self) -> None:
419
+ try:
420
+ while True:
421
+ message = self.log_queue.get_nowait()
422
+ self.log_message(message.strip())
423
+ except Exception: # queue empty
424
+ pass
425
+ finally:
426
+ if (
427
+ hasattr(self, "copier_processes")
428
+ and self.copier_processes
429
+ and any(p.is_alive() for p in self.copier_processes)
430
+ ):
431
+ self.root.after(100, self.check_log_queue)
432
+
433
+ def _handle_symbols(self, symbols_str: str):
434
+ symbols = symbols_str.strip().replace("\n", "").replace('"""', "")
435
+ if symbols in ["all", "*"]:
436
+ return symbols
437
+ else:
438
+ return get_symbols_from_string(symbols)
439
+
440
+ def _validate_inputs(self) -> bool:
441
+ if (
442
+ not self.source_login_entry.get()
443
+ or not self.source_password_entry.get()
444
+ or not self.source_server_entry.get()
445
+ or not self.source_path_entry.get()
446
+ ):
447
+ messagebox.showerror("Error", "Source account details are incomplete.")
448
+ return False
449
+
450
+ for i, dest in enumerate(self.destination_widgets):
451
+ if (
452
+ not dest["login"].get()
453
+ or not dest["password"].get()
454
+ or not dest["server"].get()
455
+ or not dest["path"].get()
456
+ ):
457
+ messagebox.showerror(
458
+ "Error", f"Destination account {i + 1} details are incomplete."
459
+ )
460
+ return False
461
+ if (
462
+ dest["mode"].get() in ["fix", "multiply", "percentage", "specific"]
463
+ and not dest["value"].get()
464
+ ):
465
+ messagebox.showerror(
466
+ "Error",
467
+ f"Value is required for mode '{dest['mode'].get()}' in Destination {i + 1}.",
468
+ )
469
+ return False
470
+ try:
471
+ if dest["value"].get():
472
+ try:
473
+ float(dest["value"].get())
474
+ except ValueError:
475
+ pass
476
+ if dest["slippage"].get():
477
+ float(dest["slippage"].get())
478
+ except ValueError:
479
+ messagebox.showerror(
480
+ "Error",
481
+ f"Invalid Value or Slippage for Destination {i + 1}. "
482
+ "Must be a number (Slippage must be an integer).",
483
+ )
484
+ return False
485
+ try:
486
+ float(self.sleeptime_entry.get())
487
+ except ValueError:
488
+ messagebox.showerror("Error", "Invalid Sleep Time. Must be a number.")
489
+ return False
490
+ # Add more validation for time formats if needed
491
+ return True
492
+
493
+ def start_copier(self) -> None:
494
+ if not self._validate_inputs():
495
+ return
496
+
497
+ source_config: Dict[str, Any] = {
498
+ "login": int(self.source_login_entry.get().strip()),
499
+ "password": self.source_password_entry.get().strip(),
500
+ "server": self.source_server_entry.get().strip(),
501
+ "path": self.source_path_entry.get().strip(),
502
+ "id": int(self.source_id_entry.get().strip()),
503
+ "unique": not self.allow_copy_var.get(),
504
+ }
505
+
506
+ destinations_config: List[Dict[str, Any]] = []
507
+ for dest_widget_map in self.destination_widgets:
508
+ symbols_str = dest_widget_map["symbols"].get().strip()
509
+ dest: Dict[str, Any] = {
510
+ "login": int(dest_widget_map["login"].get().strip()),
511
+ "password": dest_widget_map["password"].get().strip(),
512
+ "server": dest_widget_map["server"].get().strip(),
513
+ "path": dest_widget_map["path"].get().strip(),
514
+ "mode": dest_widget_map["mode"].get().strip(),
515
+ "symbols": self._handle_symbols(symbols_str),
516
+ "copy_what": dest_widget_map["copy_what"].get().strip(),
517
+ }
518
+ if dest_widget_map["value"].get().strip():
519
+ try:
520
+ dest["value"] = float(dest_widget_map["value"].get().strip())
521
+ except ValueError:
522
+ dest["value"] = get_lots_from_string(
523
+ dest_widget_map["value"].get().strip()
524
+ )
525
+ if dest_widget_map["slippage"].get().strip():
526
+ dest["slippage"] = float(dest_widget_map["slippage"].get().strip())
527
+ destinations_config.append(dest)
528
+
529
+ sleeptime = float(self.sleeptime_entry.get())
530
+ start_time = self.start_time_entry.get() or None
531
+ end_time = self.end_time_entry.get() or None
532
+
533
+ self.log_message("Starting Trade Copier...")
534
+
535
+ # Defensive: if a process is running, stop it first
536
+ if hasattr(self, "copier_processes") and any(
537
+ p.is_alive() for p in self.copier_processes
538
+ ):
539
+ self.log_message("Existing copier processes found, stopping them first...")
540
+ self.stop_copier()
541
+
542
+ try:
543
+ # Create shared shutdown event and log queue
544
+ self.shutdown_event = multiprocessing.Event()
545
+ self.log_queue: "multiprocessing.Queue[str]" = multiprocessing.Queue()
546
+ self.copier_processes = []
547
+
548
+ # Spawn one process for each destination
549
+ for dest_config in destinations_config:
550
+ process = multiprocessing.Process(
551
+ target=copier_worker_process,
552
+ args=(
553
+ source_config,
554
+ dest_config,
555
+ sleeptime,
556
+ start_time,
557
+ end_time,
558
+ ),
559
+ kwargs=dict(
560
+ shutdown_event=self.shutdown_event,
561
+ log_queue=self.log_queue,
562
+ ),
563
+ )
564
+ process.start()
565
+ self.copier_processes.append(process)
566
+
567
+ # Start checking the log queue
568
+ self.root.after(100, self.check_log_queue)
569
+
570
+ self.start_button.config(state=tk.DISABLED)
571
+ self.stop_button.config(state=tk.NORMAL)
572
+ self.log_message(
573
+ f"Trade Copier started with {len(self.copier_processes)} processes."
574
+ )
575
+ except Exception as e:
576
+ messagebox.showerror("Error Starting Copier", str(e))
577
+ self.log_message(f"Error starting copier: {e}")
578
+ self.start_button.config(state=tk.NORMAL)
579
+ self.stop_button.config(state=tk.DISABLED)
580
+
581
+ def stop_copier(self) -> None:
582
+ self.log_message("Attempting to stop all Trade Copier processes...")
583
+
584
+ if not hasattr(self, "copier_processes") or not self.copier_processes:
585
+ self.log_message("No copier processes were running.")
586
+ self.start_button.config(state=tk.NORMAL)
587
+ self.stop_button.config(state=tk.DISABLED)
588
+ return
589
+
590
+ # Signal all processes to shut down
591
+ if hasattr(self, "shutdown_event") and self.shutdown_event:
592
+ self.shutdown_event.set()
593
+
594
+ # Join all processes
595
+ for process in self.copier_processes:
596
+ if process.is_alive():
597
+ process.join(timeout=5) # Wait for a graceful exit
598
+ if process.is_alive(): # If it's still running, terminate it
599
+ self.log_message(
600
+ f"Process {process.pid} did not exit gracefully, terminating."
601
+ )
602
+ try:
603
+ process.terminate()
604
+ except Exception:
605
+ pass
606
+
607
+ self.log_message("All Trade Copier processes stopped.")
608
+
609
+ # Final check of the queue
610
+ if hasattr(self, "log_queue") and self.log_queue:
611
+ self.check_log_queue()
612
+
613
+ # Cleanup references
614
+ self.copier_processes = []
615
+ self.shutdown_event = None
616
+ self.log_queue = None # type: ignore
617
+
618
+ self.start_button.config(state=tk.NORMAL)
619
+ self.stop_button.config(state=tk.DISABLED)
620
+
621
+ def browse_path(self, path_entry_widget: ttk.Entry) -> None:
622
+ filetypes = (("Executable files", "*.exe"), ("All files", "*.*"))
623
+ filepath = filedialog.askopenfilename(
624
+ title="Select MetaTrader Terminal Executable", filetypes=filetypes
625
+ )
626
+ if filepath:
627
+ path_entry_widget.delete(0, tk.END)
628
+ path_entry_widget.insert(0, filepath)
629
+
630
+ def browse_symbols_file(self, symbols_entry_widget: ttk.Entry) -> None:
631
+ """
632
+ Opens a file dialog to select a .txt file, reads the content,
633
+ and populates the given symbols_entry_widget with a comma-separated list.
634
+ """
635
+ filetypes = (("Text files", "*.txt"), ("All files", "*.*"))
636
+ filepath = filedialog.askopenfilename(
637
+ title="Select Symbols File", filetypes=filetypes
638
+ )
639
+ if filepath:
640
+ try:
641
+ with open(filepath, "r") as f:
642
+ # Read all lines, strip whitespace from each, filter out empty lines
643
+ lines = [line.strip() for line in f.readlines()]
644
+ # Join the non-empty lines with a comma
645
+ symbols_string = "\n".join(filter(None, lines))
646
+
647
+ # Update the entry widget
648
+ symbols_entry_widget.delete(0, tk.END)
649
+ symbols_entry_widget.insert(0, symbols_string)
650
+ self.log_message(f"Loaded symbols from {filepath}")
651
+ except Exception as e:
652
+ messagebox.showerror(
653
+ "Error Reading File", f"Could not read symbols from file: {e}"
654
+ )
655
+ self.log_message(f"Error loading symbols: {e}")
656
+
657
+ def browse_lots_file(self, lots_entry_widget: ttk.Entry) -> None:
658
+ """
659
+ Opens a file dialog to select a .txt file, reads the content,
660
+ and populates the given lots_entry_widget with the content.
661
+ """
662
+ filetypes = (("Text files", "*.txt"), ("All files", "*.*"))
663
+ filepath = filedialog.askopenfilename(
664
+ title="Select Lots File", filetypes=filetypes
665
+ )
666
+ if filepath:
667
+ try:
668
+ with open(filepath, "r") as f:
669
+ content = f.read().strip()
670
+
671
+ # Update the entry widget
672
+ lots_entry_widget.delete(0, tk.END)
673
+ lots_entry_widget.insert(0, content)
674
+ self.log_message(f"Loaded lots from {filepath}")
675
+ except Exception as e:
676
+ messagebox.showerror(
677
+ "Error Reading File", f"Could not read lots from file: {e}"
678
+ )
679
+ self.log_message(f"Error loading lots: {e}")
680
+
681
+
682
+ def main() -> None:
683
+ """
684
+ Main function to initialize and run the Trade Copier GUI.
685
+ """
686
+ try:
687
+ root = tk.Tk()
688
+ root.iconbitmap(os.path.abspath(ICON_PATH))
689
+ app = TradeCopierApp(root)
690
+
691
+ def on_closing() -> None:
692
+ try:
693
+ if (
694
+ hasattr(app, "copier_process")
695
+ and app.copier_process # type: ignore
696
+ and app.copier_process.is_alive() # type: ignore
697
+ ):
698
+ app.log_message("Window closed, stopping Trade Copier...")
699
+ app.stop_copier()
700
+ except Exception as stop_error:
701
+ app.log_message(f"Error while stopping copier on exit: {stop_error}")
702
+ finally:
703
+ root.quit()
704
+ root.destroy()
705
+
706
+ root.protocol("WM_DELETE_WINDOW", on_closing)
707
+ root.mainloop()
708
+
709
+ except KeyboardInterrupt:
710
+ app.stop_copier()
711
+ sys.exit(0)
712
+ except Exception as e:
713
+ error_details = f"{e}\n\n{traceback.format_exc()}"
714
+ messagebox.showerror("Fatal Error", error_details)
715
+ sys.exit(1)
716
+
717
+
718
+ if __name__ == "__main__":
719
+ multiprocessing.freeze_support()
720
+ main()