bbstrader 0.3.3__py3-none-any.whl → 0.3.4__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/__main__.py +10 -2
- bbstrader/apps/__init__.py +0 -0
- bbstrader/apps/_copier.py +664 -0
- bbstrader/btengine/strategy.py +163 -90
- bbstrader/core/utils.py +5 -3
- bbstrader/metatrader/account.py +169 -29
- bbstrader/metatrader/analysis.py +7 -5
- bbstrader/metatrader/copier.py +4 -0
- bbstrader/metatrader/scripts.py +15 -2
- bbstrader/metatrader/utils.py +64 -0
- bbstrader/models/ml.py +8 -5
- bbstrader/trading/execution.py +9 -8
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/METADATA +4 -4
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/RECORD +18 -16
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.4.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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()
|