bbstrader 0.3.3__py3-none-any.whl → 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bbstrader might be problematic. Click here for more details.

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