From ac38fe43c588edd5533652e9f362ad526d018187 Mon Sep 17 00:00:00 2001 From: Ibrahim Elsayed Date: Tue, 10 Feb 2026 18:13:34 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Add=20Phase=206:=20GUI=20refactor=20?= =?UTF-8?q?=E2=80=94=20extract=20pt=5Fhub.py=20monolith=20into=20modular?= =?UTF-8?q?=20hub=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 5,236-line pt_hub.py into focused modules under src/powertrader/hub/: - theme.py (15 lines): Dark theme color constants - utils.py (219 lines): Shared utilities, DEFAULT_SETTINGS, formatting helpers - components/wrap_frame.py: CSS flex-wrap equivalent for Tkinter - components/signal_tile.py: Neural signal level visualization widget - components/candle_fetcher.py: KuCoin market data with cache - components/candle_chart.py: Candlestick chart with neural overlays - components/account_chart.py: Account value line graph with trade markers - process_manager.py (427 lines): Subprocess lifecycle management (decoupled from Tk) - dialogs/settings_dialog.py (654 lines): Settings dialog with Binance API wizard - app.py (1,903 lines): Slim orchestrator importing all extracted modules Original pt_hub.py preserved as backward-compatible entry point. All 528 tests pass. GUI looks and works identically. Co-Authored-By: Claude Opus 4.6 --- scripts/run_hub.py | 3 +- src/powertrader/hub/__init__.py | 1 + src/powertrader/hub/app.py | 1903 +++++++++++++++++ src/powertrader/hub/components/__init__.py | 15 + .../hub/components/account_chart.py | 298 +++ .../hub/components/candle_chart.py | 429 ++++ .../hub/components/candle_fetcher.py | 90 + src/powertrader/hub/components/signal_tile.py | 181 ++ src/powertrader/hub/components/wrap_frame.py | 84 + src/powertrader/hub/dialogs/__init__.py | 1 + .../hub/dialogs/settings_dialog.py | 654 ++++++ src/powertrader/hub/process_manager.py | 427 ++++ src/powertrader/hub/theme.py | 15 + src/powertrader/hub/utils.py | 219 ++ 14 files changed, 4319 insertions(+), 1 deletion(-) create mode 100644 src/powertrader/hub/app.py create mode 100644 src/powertrader/hub/components/__init__.py create mode 100644 src/powertrader/hub/components/account_chart.py create mode 100644 src/powertrader/hub/components/candle_chart.py create mode 100644 src/powertrader/hub/components/candle_fetcher.py create mode 100644 src/powertrader/hub/components/signal_tile.py create mode 100644 src/powertrader/hub/components/wrap_frame.py create mode 100644 src/powertrader/hub/dialogs/__init__.py create mode 100644 src/powertrader/hub/dialogs/settings_dialog.py create mode 100644 src/powertrader/hub/process_manager.py create mode 100644 src/powertrader/hub/theme.py create mode 100644 src/powertrader/hub/utils.py diff --git a/scripts/run_hub.py b/scripts/run_hub.py index e9a439043..b8bcce542 100644 --- a/scripts/run_hub.py +++ b/scripts/run_hub.py @@ -3,7 +3,8 @@ def main() -> None: - raise NotImplementedError("Hub runner not yet migrated — use pt_hub.py directly.") + from powertrader.hub.app import main as hub_main + hub_main() if __name__ == "__main__": diff --git a/src/powertrader/hub/__init__.py b/src/powertrader/hub/__init__.py index e69de29bb..26ab23ae8 100644 --- a/src/powertrader/hub/__init__.py +++ b/src/powertrader/hub/__init__.py @@ -0,0 +1 @@ +"""PowerTrader Hub — GUI control center.""" diff --git a/src/powertrader/hub/app.py b/src/powertrader/hub/app.py new file mode 100644 index 000000000..f7486262f --- /dev/null +++ b/src/powertrader/hub/app.py @@ -0,0 +1,1903 @@ +"""PowerTrader Hub — Tkinter GUI orchestrator. + +This is the refactored version of the monolithic pt_hub.py. It imports +extracted modules for theme, utilities, widgets, process management, and +the settings dialog, then wires them together with the layout, refresh +loop, and event handlers that are tightly coupled to the Tk instance. +""" + +from __future__ import annotations + +import json +import os +import queue +import shutil +import time +import tkinter as tk +import tkinter.font as tkfont +from tkinter import ttk, messagebox +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from powertrader.hub.theme import ( + DARK_ACCENT, + DARK_ACCENT2, + DARK_BG, + DARK_BG2, + DARK_BORDER, + DARK_FG, + DARK_MUTED, + DARK_PANEL, + DARK_PANEL2, + DARK_SELECT_BG, + DARK_SELECT_FG, +) +from powertrader.hub.utils import ( + DEFAULT_SETTINGS, + SETTINGS_FILE, + build_coin_folders, + ensure_dir, + fmt_money, + fmt_pct, + fmt_price, + now_str, + read_int_from_file, + read_trade_history_jsonl, + safe_read_json, + safe_write_json, +) +from powertrader.hub.components import ( + AccountValueChart, + CandleChart, + CandleFetcher, + NeuralSignalTile, + WrapFrame, +) +from powertrader.hub.process_manager import ProcessManager +from powertrader.hub.dialogs.settings_dialog import SettingsDialog + + +class PowerTraderHub(tk.Tk): + """Main GUI window — assembles layout, delegates to extracted modules.""" + + def __init__(self) -> None: + super().__init__() + self.title("PowerTrader - Hub") + self.geometry("1400x820") + self.minsize(980, 640) + + self._paned_clamp_after_ids: Dict[str, str] = {} + + self._apply_forced_dark_mode() + + self.settings = self._load_settings() + + self.project_dir = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))) + + main_dir = str(self.settings.get("main_neural_dir") or "").strip() + if main_dir and not os.path.isabs(main_dir): + main_dir = os.path.abspath(os.path.join(self.project_dir, main_dir)) + if (not main_dir) or (not os.path.isdir(main_dir)): + main_dir = self.project_dir + self.settings["main_neural_dir"] = main_dir + + hub_dir = self.settings.get("hub_data_dir") or os.path.join(self.project_dir, "hub_data") + self.hub_dir = os.path.abspath(hub_dir) + ensure_dir(self.hub_dir) + + self.trader_status_path = os.path.join(self.hub_dir, "trader_status.json") + self.trade_history_path = os.path.join(self.hub_dir, "trade_history.jsonl") + self.pnl_ledger_path = os.path.join(self.hub_dir, "pnl_ledger.json") + self.account_value_history_path = os.path.join(self.hub_dir, "account_value_history.jsonl") + + self._last_positions: Dict[str, dict] = {} + self.account_chart: Optional[AccountValueChart] = None + + self.coins = [c.upper().strip() for c in self.settings["coins"]] + + self._ensure_alt_coin_folders_and_trainer_on_startup() + self.coin_folders = build_coin_folders(self.settings["main_neural_dir"], self.coins) + + # Process manager (delegates all subprocess work) + self.pm = ProcessManager( + project_dir=self.project_dir, + hub_dir=self.hub_dir, + settings=self.settings, + coin_folders=self.coin_folders, + coins=self.coins, + on_error=lambda title, msg: messagebox.showerror(title, msg), + ) + + self.fetcher = CandleFetcher() + + self._build_menu() + self._build_layout() + + self.bind_all("<>", self._on_timeframe_changed) + + self._last_chart_refresh = 0.0 + + if bool(self.settings.get("auto_start_scripts", False)): + self.start_all_scripts() + + self.after(250, self._tick) + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # ---- settings ---- + + def _load_settings(self) -> dict: + settings_path = os.path.join(self.project_dir, SETTINGS_FILE) + data = safe_read_json(settings_path) + if not isinstance(data, dict): + data = {} + merged = dict(DEFAULT_SETTINGS) + merged.update(data) + merged["coins"] = [c.upper().strip() for c in merged.get("coins", [])] + return merged + + def _save_settings(self) -> None: + settings_path = os.path.join(self.project_dir, SETTINGS_FILE) + safe_write_json(settings_path, self.settings) + + def _settings_getter(self) -> dict: + return self.settings + + def _ensure_alt_coin_folders_and_trainer_on_startup(self) -> None: + try: + coins = [str(c).strip().upper() for c in (self.settings.get("coins") or []) if str(c).strip()] + main_dir = (self.settings.get("main_neural_dir") or self.project_dir or os.getcwd()).strip() + trainer_name = os.path.basename(str(self.settings.get("script_neural_trainer", "neural_trainer.py"))) + src_main_trainer = os.path.join(main_dir, trainer_name) + src_cfg_trainer = str(self.settings.get("script_neural_trainer", trainer_name)) + src_trainer_path = src_main_trainer if os.path.isfile(src_main_trainer) else src_cfg_trainer + + for coin in coins: + if coin == "BTC": + continue + coin_dir = os.path.join(main_dir, coin) + created = False + if not os.path.isdir(coin_dir): + os.makedirs(coin_dir, exist_ok=True) + created = True + if created: + dst_trainer_path = os.path.join(coin_dir, trainer_name) + if (not os.path.isfile(dst_trainer_path)) and os.path.isfile(src_trainer_path): + shutil.copy2(src_trainer_path, dst_trainer_path) + except Exception: + pass + + # ---- process control wrappers (delegate to ProcessManager) ---- + + def start_neural(self) -> None: + self.pm.start_neural() + + def start_trader(self) -> None: + self.pm.start_trader() + + def stop_neural(self) -> None: + self.pm.stop_neural() + + def stop_trader(self) -> None: + self.pm.stop_trader() + + def toggle_all_scripts(self) -> None: + self.pm.toggle_all_scripts(after_cb=self.after) + + def start_all_scripts(self) -> None: + self.pm.start_all_scripts(after_cb=self.after) + + def stop_all_scripts(self) -> None: + self.pm.stop_all_scripts() + + def train_selected_coin(self, force_retrain: bool = False) -> None: + coin = (getattr(self, "train_coin_var", self.trainer_coin_var).get() or "").strip().upper() + if not coin: + return + self.start_trainer_for_selected_coin(force_retrain=force_retrain) + + def force_retrain_selected_coin(self) -> None: + self.train_selected_coin(force_retrain=True) + + def train_all_coins(self, force_retrain: bool = False) -> None: + skipped = [] + for c in self.coins: + if (not force_retrain) and self.pm.coin_is_trained(c): + skipped.append(c) + continue + self.trainer_coin_var.set(c) + self.start_trainer_for_selected_coin(force_retrain=force_retrain) + if skipped: + try: + self.status.config(text=f"Skipped already-trained: {', '.join(skipped)}") + except Exception: + pass + + def force_retrain_all_coins(self) -> None: + self.train_all_coins(force_retrain=True) + + def start_trainer_for_selected_coin(self, force_retrain: bool = False) -> None: + coin = (self.trainer_coin_var.get() or "").strip().upper() + if not coin: + return + + def _on_status(msg: str) -> None: + try: + self.status.config(text=msg) + except Exception: + pass + + self.pm.start_trainer_for_coin(coin, force_retrain=force_retrain, on_status=_on_status) + + def stop_trainer_for_selected_coin(self) -> None: + coin = (self.trainer_coin_var.get() or "").strip().upper() + self.pm.stop_trainer_for_coin(coin) + + # ---- forced dark mode ---- + + def _apply_forced_dark_mode(self) -> None: + try: + self.configure(bg=DARK_BG) + except Exception: + pass + + try: + self.option_add("*Text.background", DARK_PANEL) + self.option_add("*Text.foreground", DARK_FG) + self.option_add("*Text.insertBackground", DARK_FG) + self.option_add("*Text.selectBackground", DARK_SELECT_BG) + self.option_add("*Text.selectForeground", DARK_SELECT_FG) + self.option_add("*Listbox.background", DARK_PANEL) + self.option_add("*Listbox.foreground", DARK_FG) + self.option_add("*Listbox.selectBackground", DARK_SELECT_BG) + self.option_add("*Listbox.selectForeground", DARK_SELECT_FG) + self.option_add("*Menu.background", DARK_BG2) + self.option_add("*Menu.foreground", DARK_FG) + self.option_add("*Menu.activeBackground", DARK_SELECT_BG) + self.option_add("*Menu.activeForeground", DARK_SELECT_FG) + except Exception: + pass + + style = ttk.Style(self) + try: + style.theme_use("clam") + except Exception: + pass + + try: + style.configure(".", background=DARK_BG, foreground=DARK_FG) + except Exception: + pass + + for name in ("TFrame", "TLabel", "TCheckbutton", "TRadiobutton"): + try: + style.configure(name, background=DARK_BG, foreground=DARK_FG) + except Exception: + pass + + try: + style.configure("TLabelframe", background=DARK_BG, foreground=DARK_FG, bordercolor=DARK_BORDER) + style.configure("TLabelframe.Label", background=DARK_BG, foreground=DARK_ACCENT) + except Exception: + pass + + try: + style.configure("TSeparator", background=DARK_BORDER) + except Exception: + pass + + try: + style.configure("TButton", background=DARK_BG2, foreground=DARK_FG, bordercolor=DARK_BORDER, focusthickness=1, focuscolor=DARK_ACCENT, padding=(10, 6)) + style.map("TButton", background=[("active", DARK_PANEL2), ("pressed", DARK_PANEL), ("disabled", DARK_BG2)], foreground=[("active", DARK_ACCENT), ("disabled", DARK_MUTED)], bordercolor=[("active", DARK_ACCENT2), ("focus", DARK_ACCENT)]) + except Exception: + pass + + try: + style.configure("TEntry", fieldbackground=DARK_PANEL, foreground=DARK_FG, bordercolor=DARK_BORDER, insertcolor=DARK_FG) + except Exception: + pass + + try: + style.configure("TCombobox", fieldbackground=DARK_PANEL, background=DARK_PANEL, foreground=DARK_FG, bordercolor=DARK_BORDER, arrowcolor=DARK_ACCENT) + style.map("TCombobox", fieldbackground=[("readonly", DARK_PANEL), ("focus", DARK_PANEL2)], foreground=[("readonly", DARK_FG)], background=[("readonly", DARK_PANEL)]) + except Exception: + pass + + try: + style.configure("TNotebook", background=DARK_BG, bordercolor=DARK_BORDER) + style.configure("TNotebook.Tab", background=DARK_BG2, foreground=DARK_FG, padding=(10, 6)) + style.map("TNotebook.Tab", background=[("selected", DARK_PANEL), ("active", DARK_PANEL2)], foreground=[("selected", DARK_ACCENT), ("active", DARK_ACCENT2)]) + + style.configure("HiddenTabs.TNotebook", tabmargins=0) + style.layout("HiddenTabs.TNotebook", [("Notebook.padding", {"sticky": "nswe", "children": [("Notebook.client", {"sticky": "nswe"})]})]) + + style.configure("ChartTab.TButton", background=DARK_BG2, foreground=DARK_FG, bordercolor=DARK_BORDER, padding=(10, 6)) + style.map("ChartTab.TButton", background=[("active", DARK_PANEL2), ("pressed", DARK_PANEL)], foreground=[("active", DARK_ACCENT2)], bordercolor=[("active", DARK_ACCENT2), ("focus", DARK_ACCENT)]) + + style.configure("ChartTabSelected.TButton", background=DARK_PANEL, foreground=DARK_ACCENT, bordercolor=DARK_ACCENT2, padding=(10, 6)) + except Exception: + pass + + try: + style.configure("Treeview", background=DARK_PANEL, fieldbackground=DARK_PANEL, foreground=DARK_FG, bordercolor=DARK_BORDER, lightcolor=DARK_BORDER, darkcolor=DARK_BORDER) + style.map("Treeview", background=[("selected", DARK_SELECT_BG)], foreground=[("selected", DARK_SELECT_FG)]) + style.configure("Treeview.Heading", background=DARK_BG2, foreground=DARK_ACCENT, relief="flat") + style.map("Treeview.Heading", background=[("active", DARK_PANEL2)], foreground=[("active", DARK_ACCENT2)]) + except Exception: + pass + + try: + style.configure("TPanedwindow", background=DARK_BG) + except Exception: + pass + + for sb in ("Vertical.TScrollbar", "Horizontal.TScrollbar"): + try: + style.configure(sb, background=DARK_BG2, troughcolor=DARK_BG, bordercolor=DARK_BORDER, arrowcolor=DARK_ACCENT) + except Exception: + pass + + # ---- menu ---- + + def _build_menu(self) -> None: + menubar = tk.Menu(self, bg=DARK_BG2, fg=DARK_FG, activebackground=DARK_SELECT_BG, activeforeground=DARK_SELECT_FG, bd=0, relief="flat") + + m_scripts = tk.Menu(menubar, tearoff=0, bg=DARK_BG2, fg=DARK_FG, activebackground=DARK_SELECT_BG, activeforeground=DARK_SELECT_FG) + m_scripts.add_command(label="Start All", command=self.start_all_scripts) + m_scripts.add_command(label="Stop All", command=self.stop_all_scripts) + m_scripts.add_separator() + m_scripts.add_command(label="Start Neural Runner", command=self.start_neural) + m_scripts.add_command(label="Stop Neural Runner", command=self.stop_neural) + m_scripts.add_separator() + m_scripts.add_command(label="Start Trader", command=self.start_trader) + m_scripts.add_command(label="Stop Trader", command=self.stop_trader) + menubar.add_cascade(label="Scripts", menu=m_scripts) + + m_settings = tk.Menu(menubar, tearoff=0, bg=DARK_BG2, fg=DARK_FG, activebackground=DARK_SELECT_BG, activeforeground=DARK_SELECT_FG) + m_settings.add_command(label="Settings...", command=self.open_settings_dialog) + menubar.add_cascade(label="Settings", menu=m_settings) + + m_file = tk.Menu(menubar, tearoff=0, bg=DARK_BG2, fg=DARK_FG, activebackground=DARK_SELECT_BG, activeforeground=DARK_SELECT_FG) + m_file.add_command(label="Exit", command=self._on_close) + menubar.add_cascade(label="File", menu=m_file) + + self.config(menu=menubar) + + # ---- layout ---- + + def _build_layout(self) -> None: + outer = ttk.Panedwindow(self, orient="horizontal") + outer.pack(fill="both", expand=True) + + left = ttk.Frame(outer) + right = ttk.Frame(outer) + outer.add(left, weight=1) + outer.add(right, weight=2) + + try: + outer.paneconfigure(left, minsize=360) + outer.paneconfigure(right, minsize=520) + except Exception: + pass + + left_split = ttk.Panedwindow(left, orient="vertical") + left_split.pack(fill="both", expand=True, padx=8, pady=8) + + right_split = ttk.Panedwindow(right, orient="vertical") + right_split.pack(fill="both", expand=True, padx=8, pady=8) + + self._pw_outer = outer + self._pw_left_split = left_split + self._pw_right_split = right_split + + outer.bind("", lambda e: self._schedule_paned_clamp(self._pw_outer)) + outer.bind("", lambda e: (setattr(self, "_user_moved_outer", True), self._schedule_paned_clamp(self._pw_outer))) + + left_split.bind("", lambda e: self._schedule_paned_clamp(self._pw_left_split)) + left_split.bind("", lambda e: (setattr(self, "_user_moved_left_split", True), self._schedule_paned_clamp(self._pw_left_split))) + + right_split.bind("", lambda e: self._schedule_paned_clamp(self._pw_right_split)) + right_split.bind("", lambda e: (setattr(self, "_user_moved_right_split", True), self._schedule_paned_clamp(self._pw_right_split))) + + def _init_outer_sash_once(): + try: + if getattr(self, "_did_init_outer_sash", False): + return + if getattr(self, "_user_moved_outer", False): + self._did_init_outer_sash = True + return + total = outer.winfo_width() + if total <= 2: + self.after(10, _init_outer_sash_once) + return + min_left, min_right, desired_left = 360, 520, 470 + target = max(min_left, min(total - min_right, desired_left)) + outer.sashpos(0, int(target)) + self._did_init_outer_sash = True + except Exception: + pass + + self.after_idle(_init_outer_sash_once) + + self.bind_all("", lambda e: ( + self._schedule_paned_clamp(getattr(self, "_pw_outer", None)), + self._schedule_paned_clamp(getattr(self, "_pw_left_split", None)), + self._schedule_paned_clamp(getattr(self, "_pw_right_split", None)), + self._schedule_paned_clamp(getattr(self, "_pw_right_bottom_split", None)), + )) + + # ---- LEFT: Controls / Health ---- + top_controls = ttk.LabelFrame(left_split, text="Controls / Health") + + buttons_bar = ttk.Frame(top_controls) + buttons_bar.pack(fill="x", expand=False) + + info_row = ttk.Frame(top_controls) + info_row.pack(fill="x", expand=False) + + controls_left = ttk.Frame(info_row) + controls_left.pack(side="left", fill="both", expand=True) + + training_section = ttk.LabelFrame(info_row, text="Training") + training_section.pack(side="right", fill="both", expand=False, padx=6, pady=6) + + training_left = ttk.Frame(training_section) + training_left.pack(side="left", fill="both", expand=True) + + train_row = ttk.Frame(training_left) + train_row.pack(fill="x", padx=6, pady=(6, 0)) + + self.train_coin_var = tk.StringVar(value=(self.coins[0] if self.coins else "")) + ttk.Label(train_row, text="Train coin:").pack(side="left") + self.train_coin_combo = ttk.Combobox(train_row, textvariable=self.train_coin_var, values=self.coins, width=8, state="readonly") + self.train_coin_combo.pack(side="left", padx=(6, 0)) + + def _sync_train_coin(*_): + try: + self.trainer_coin_var.set(self.train_coin_var.get()) + except Exception: + pass + + self.train_coin_combo.bind("<>", _sync_train_coin) + _sync_train_coin() + + # Scrollable buttons bar + btn_scroll_wrap = ttk.Frame(buttons_bar) + btn_scroll_wrap.pack(fill="x", expand=False, padx=6, pady=6) + + btn_canvas = tk.Canvas(btn_scroll_wrap, bg=DARK_BG, highlightthickness=0, bd=0, height=1) + btn_scroll_y = ttk.Scrollbar(btn_scroll_wrap, orient="vertical", command=btn_canvas.yview) + btn_scroll_x = ttk.Scrollbar(btn_scroll_wrap, orient="horizontal", command=btn_canvas.xview) + btn_canvas.configure(yscrollcommand=btn_scroll_y.set, xscrollcommand=btn_scroll_x.set) + + btn_scroll_wrap.grid_columnconfigure(0, weight=1) + btn_scroll_wrap.grid_rowconfigure(0, weight=0) + + btn_canvas.grid(row=0, column=0, sticky="ew") + btn_scroll_y.grid(row=0, column=1, sticky="ns") + btn_scroll_x.grid(row=1, column=0, sticky="ew") + + btn_scroll_y.grid_remove() + btn_scroll_x.grid_remove() + + btn_inner = ttk.Frame(btn_canvas) + _btn_inner_id = btn_canvas.create_window((0, 0), window=btn_inner, anchor="nw") + + def _btn_update_scrollbars(event=None): + try: + btn_canvas.configure(scrollregion=btn_canvas.bbox("all")) + sr = btn_canvas.bbox("all") + if not sr: + return + try: + desired_h = max(1, int(btn_inner.winfo_reqheight())) + cur_h = int(btn_canvas.cget("height") or 0) + if cur_h != desired_h: + btn_canvas.configure(height=desired_h) + except Exception: + pass + x0, y0, x1, y1 = sr + cw = btn_canvas.winfo_width() + ch = btn_canvas.winfo_height() + need_x = (x1 - x0) > (cw + 1) + need_y = (y1 - y0) > (ch + 1) + if need_x: + btn_scroll_x.grid() + else: + btn_scroll_x.grid_remove() + btn_canvas.xview_moveto(0) + if need_y: + btn_scroll_y.grid() + else: + btn_scroll_y.grid_remove() + btn_canvas.yview_moveto(0) + except Exception: + pass + + def _btn_canvas_on_configure(event=None): + try: + btn_canvas.coords(_btn_inner_id, 0, 0) + except Exception: + pass + _btn_update_scrollbars() + + btn_inner.bind("", _btn_update_scrollbars) + btn_canvas.bind("", _btn_canvas_on_configure) + + btn_bar = ttk.Frame(btn_inner) + btn_bar.pack(fill="x", expand=False) + btn_bar.grid_columnconfigure(0, weight=0) + btn_bar.grid_columnconfigure(1, weight=0) + btn_bar.grid_columnconfigure(2, weight=1) + + BTN_W = 14 + + train_group = ttk.Frame(btn_bar) + train_group.grid(row=0, column=0, sticky="w", padx=(0, 18), pady=(0, 6)) + + self.after_idle(_btn_update_scrollbars) + + self.lbl_neural = ttk.Label(controls_left, text="Neural: stopped") + self.lbl_neural.pack(anchor="w", padx=6, pady=(0, 2)) + + self.lbl_trader = ttk.Label(controls_left, text="Trader: stopped") + self.lbl_trader.pack(anchor="w", padx=6, pady=(0, 6)) + + self.lbl_last_status = ttk.Label(controls_left, text="Last status: N/A") + self.lbl_last_status.pack(anchor="w", padx=6, pady=(0, 2)) + + # Training buttons + train_buttons_row = ttk.Frame(training_left) + train_buttons_row.pack(fill="x", padx=6, pady=(6, 6)) + + ttk.Button(train_buttons_row, text="Train Selected", width=BTN_W, command=self.train_selected_coin).pack(anchor="w", pady=(0, 3)) + ttk.Button(train_buttons_row, text="Train All", width=BTN_W, command=self.train_all_coins).pack(anchor="w", pady=(0, 6)) + ttk.Button(train_buttons_row, text="Force Retrain", width=BTN_W, command=self.force_retrain_selected_coin).pack(anchor="w", pady=(0, 3)) + ttk.Button(train_buttons_row, text="Force Retrain All", width=BTN_W, command=self.force_retrain_all_coins).pack(anchor="w") + + self.lbl_training_progress = ttk.Label(training_left, text="") + self.lbl_training_progress.pack(anchor="w", padx=6, pady=(0, 2)) + + self.training_progress_bar = ttk.Progressbar(training_left, orient="horizontal", mode="determinate", length=200) + self.training_progress_bar.pack(fill="x", padx=6, pady=(0, 4)) + + self.lbl_training_overview = ttk.Label(training_left, text="Training: N/A") + self.lbl_training_overview.pack(anchor="w", padx=6, pady=(0, 2)) + + self.lbl_flow_hint = ttk.Label(training_left, text="Flow: Train → Start All") + self.lbl_flow_hint.pack(anchor="w", padx=6, pady=(0, 6)) + + self.training_list = tk.Listbox(training_left, height=5, bg=DARK_PANEL, fg=DARK_FG, selectbackground=DARK_SELECT_BG, selectforeground=DARK_SELECT_FG, highlightbackground=DARK_BORDER, highlightcolor=DARK_ACCENT, activestyle="none") + self.training_list.pack(fill="both", expand=True, padx=6, pady=(0, 6)) + + # Start All button + start_all_row = ttk.Frame(controls_left) + start_all_row.pack(fill="x", padx=6, pady=(0, 6)) + + self.btn_toggle_all = ttk.Button(start_all_row, text="Start All", width=BTN_W, command=self.toggle_all_scripts) + self.btn_toggle_all.pack(side="left") + + # Account info + acct_box = ttk.LabelFrame(controls_left, text="Account") + acct_box.pack(fill="x", padx=6, pady=6) + + self.lbl_acct_total_value = ttk.Label(acct_box, text="Total Account Value: N/A") + self.lbl_acct_total_value.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_acct_holdings_value = ttk.Label(acct_box, text="Holdings Value: N/A") + self.lbl_acct_holdings_value.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_acct_buying_power = ttk.Label(acct_box, text="Buying Power: N/A") + self.lbl_acct_buying_power.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_acct_percent_in_trade = ttk.Label(acct_box, text="Percent In Trade: N/A") + self.lbl_acct_percent_in_trade.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_acct_dca_spread = ttk.Label(acct_box, text="DCA Levels (spread): N/A") + self.lbl_acct_dca_spread.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_acct_dca_single = ttk.Label(acct_box, text="DCA Levels (single): N/A") + self.lbl_acct_dca_single.pack(anchor="w", padx=6, pady=(2, 0)) + self.lbl_pnl = ttk.Label(acct_box, text="Total realized: N/A") + self.lbl_pnl.pack(anchor="w", padx=6, pady=(2, 2)) + + # Neural levels overview + neural_box = ttk.LabelFrame(top_controls, text="Neural Levels (0\u20137)") + neural_box.pack(fill="both", expand=True, padx=6, pady=(0, 6)) + + legend = ttk.Frame(neural_box) + legend.pack(fill="x", padx=6, pady=(4, 0)) + ttk.Label(legend, text="Level bars: 0 = bottom, 7 = top").pack(side="left") + ttk.Label(legend, text=" ").pack(side="left") + ttk.Label(legend, text="Blue = Long").pack(side="left") + ttk.Label(legend, text=" ").pack(side="left") + ttk.Label(legend, text="Orange = Short").pack(side="left") + + self.lbl_neural_overview_last = ttk.Label(legend, text="Last: N/A") + self.lbl_neural_overview_last.pack(side="right") + + neural_viewport = ttk.Frame(neural_box) + neural_viewport.pack(fill="both", expand=True, padx=6, pady=(4, 6)) + neural_viewport.grid_rowconfigure(0, weight=1) + neural_viewport.grid_columnconfigure(0, weight=1) + + self._neural_overview_canvas = tk.Canvas(neural_viewport, bg=DARK_PANEL2, highlightthickness=1, highlightbackground=DARK_BORDER, bd=0) + self._neural_overview_canvas.grid(row=0, column=0, sticky="nsew") + + self._neural_overview_scroll = ttk.Scrollbar(neural_viewport, orient="vertical", command=self._neural_overview_canvas.yview) + self._neural_overview_scroll.grid(row=0, column=1, sticky="ns") + self._neural_overview_canvas.configure(yscrollcommand=self._neural_overview_scroll.set) + + self.neural_wrap = WrapFrame(self._neural_overview_canvas) + self._neural_overview_window = self._neural_overview_canvas.create_window((0, 0), window=self.neural_wrap, anchor="nw") + + def _update_neural_overview_scrollbars(event=None) -> None: + try: + c = self._neural_overview_canvas + win = self._neural_overview_window + c.update_idletasks() + bbox = c.bbox(win) + if not bbox: + self._neural_overview_scroll.grid_remove() + return + c.configure(scrollregion=bbox) + content_h = int(bbox[3] - bbox[1]) + view_h = int(c.winfo_height()) + if content_h > (view_h + 1): + self._neural_overview_scroll.grid() + else: + self._neural_overview_scroll.grid_remove() + try: + c.yview_moveto(0) + except Exception: + pass + except Exception: + pass + + def _on_neural_canvas_configure(e) -> None: + try: + self._neural_overview_canvas.itemconfigure(self._neural_overview_window, width=int(e.width)) + except Exception: + pass + _update_neural_overview_scrollbars() + + self._neural_overview_canvas.bind("", _on_neural_canvas_configure, add="+") + self.neural_wrap.bind("", _update_neural_overview_scrollbars, add="+") + self._update_neural_overview_scrollbars = _update_neural_overview_scrollbars + + def _wheel(e): + try: + if self._neural_overview_scroll.winfo_ismapped(): + self._neural_overview_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") + except Exception: + pass + + self._neural_overview_canvas.bind("", lambda _e: self._neural_overview_canvas.focus_set(), add="+") + self._neural_overview_canvas.bind("", _wheel, add="+") + + self.neural_tiles: Dict[str, NeuralSignalTile] = {} + self._neural_overview_cache: Dict[str, Tuple[float, Any]] = {} + + self._rebuild_neural_overview() + try: + self.after_idle(self._update_neural_overview_scrollbars) + except Exception: + pass + + # ---- LEFT: Live Output ---- + _base = tkfont.nametofont("TkFixedFont") + _half = max(6, int(round(abs(int(_base.cget("size"))) / 2.0))) + self._live_log_font = _base.copy() + self._live_log_font.configure(size=_half) + + logs_frame = ttk.LabelFrame(left_split, text="Live Output") + self.logs_nb = ttk.Notebook(logs_frame) + self.logs_nb.pack(fill="both", expand=True, padx=6, pady=6) + + # Runner tab + runner_tab = ttk.Frame(self.logs_nb) + self.logs_nb.add(runner_tab, text="Runner") + self.runner_text = tk.Text(runner_tab, height=8, wrap="none", font=self._live_log_font, bg=DARK_PANEL, fg=DARK_FG, insertbackground=DARK_FG, selectbackground=DARK_SELECT_BG, selectforeground=DARK_SELECT_FG, highlightbackground=DARK_BORDER, highlightcolor=DARK_ACCENT) + runner_scroll = ttk.Scrollbar(runner_tab, orient="vertical", command=self.runner_text.yview) + self.runner_text.configure(yscrollcommand=runner_scroll.set) + self.runner_text.pack(side="left", fill="both", expand=True) + runner_scroll.pack(side="right", fill="y") + + # Trader tab + trader_tab = ttk.Frame(self.logs_nb) + self.logs_nb.add(trader_tab, text="Trader") + self.trader_text = tk.Text(trader_tab, height=8, wrap="none", font=self._live_log_font, bg=DARK_PANEL, fg=DARK_FG, insertbackground=DARK_FG, selectbackground=DARK_SELECT_BG, selectforeground=DARK_SELECT_FG, highlightbackground=DARK_BORDER, highlightcolor=DARK_ACCENT) + trader_scroll = ttk.Scrollbar(trader_tab, orient="vertical", command=self.trader_text.yview) + self.trader_text.configure(yscrollcommand=trader_scroll.set) + self.trader_text.pack(side="left", fill="both", expand=True) + trader_scroll.pack(side="right", fill="y") + + # Trainers tab + trainer_tab = ttk.Frame(self.logs_nb) + self.logs_nb.add(trainer_tab, text="Trainers") + + top_bar = ttk.Frame(trainer_tab) + top_bar.pack(fill="x", padx=6, pady=6) + + self.trainer_coin_var = tk.StringVar(value=(self.coins[0] if self.coins else "BTC")) + ttk.Label(top_bar, text="Coin:").pack(side="left") + self.trainer_coin_combo = ttk.Combobox(top_bar, textvariable=self.trainer_coin_var, values=self.coins, state="readonly", width=8) + self.trainer_coin_combo.pack(side="left", padx=(6, 12)) + + ttk.Button(top_bar, text="Start Trainer", command=self.start_trainer_for_selected_coin).pack(side="left") + ttk.Button(top_bar, text="Stop Trainer", command=self.stop_trainer_for_selected_coin).pack(side="left", padx=(6, 0)) + + self.trainer_status_lbl = ttk.Label(top_bar, text="(no trainers running)") + self.trainer_status_lbl.pack(side="left", padx=(12, 0)) + + self.trainer_text = tk.Text(trainer_tab, height=8, wrap="none", font=self._live_log_font, bg=DARK_PANEL, fg=DARK_FG, insertbackground=DARK_FG, selectbackground=DARK_SELECT_BG, selectforeground=DARK_SELECT_FG, highlightbackground=DARK_BORDER, highlightcolor=DARK_ACCENT) + trainer_scroll = ttk.Scrollbar(trainer_tab, orient="vertical", command=self.trainer_text.yview) + self.trainer_text.configure(yscrollcommand=trainer_scroll.set) + self.trainer_text.pack(side="left", fill="both", expand=True, padx=(6, 0), pady=(0, 6)) + trainer_scroll.pack(side="right", fill="y", padx=(0, 6), pady=(0, 6)) + + left_split.add(top_controls, weight=1) + left_split.add(logs_frame, weight=1) + + try: + left_split.paneconfigure(top_controls, minsize=360) + left_split.paneconfigure(logs_frame, minsize=220) + except Exception: + pass + + def _init_left_split_sash_once(): + try: + if getattr(self, "_did_init_left_split_sash", False): + return + if getattr(self, "_user_moved_left_split", False): + self._did_init_left_split_sash = True + return + total = left_split.winfo_height() + if total <= 2: + self.after(10, _init_left_split_sash_once) + return + min_top, min_bottom, desired_bottom = 360, 220, 260 + target = total - max(min_bottom, desired_bottom) + target = max(min_top, min(total - min_bottom, target)) + left_split.sashpos(0, int(target)) + self._did_init_left_split_sash = True + except Exception: + pass + + self.after_idle(_init_left_split_sash_once) + + # ---- RIGHT TOP: Charts ---- + charts_frame = ttk.LabelFrame(right_split, text="Charts (Neural lines overlaid)") + self._charts_frame = charts_frame + + self.chart_tabs_bar = WrapFrame(charts_frame) + self.chart_tabs_bar.pack(fill="x", padx=(6, 0), pady=(6, 0)) + + self.chart_pages_container = ttk.Frame(charts_frame) + self.chart_pages_container.pack(fill="both", expand=True, padx=(6, 0), pady=(0, 6)) + + self._chart_tab_buttons: Dict[str, ttk.Button] = {} + self.chart_pages: Dict[str, ttk.Frame] = {} + self._current_chart_page: str = "ACCOUNT" + + def _show_page(name: str) -> None: + self._current_chart_page = name + for f in self.chart_pages.values(): + try: + f.pack_forget() + except Exception: + pass + f = self.chart_pages.get(name) + if f is not None: + f.pack(fill="both", expand=True) + for txt, b in self._chart_tab_buttons.items(): + try: + b.configure(style=("ChartTabSelected.TButton" if txt == name else "ChartTab.TButton")) + except Exception: + pass + # Immediately refresh coin chart on page switch + try: + tab = str(name or "").strip().upper() + if tab and tab != "ACCOUNT": + coin = tab + chart = self.charts.get(coin) + if chart: + def _do_refresh_visible(): + try: + try: + cf_sig = (self.settings.get("main_neural_dir"), tuple(self.coins)) + if getattr(self, "_coin_folders_sig", None) != cf_sig: + self._coin_folders_sig = cf_sig + self.coin_folders = build_coin_folders(self.settings["main_neural_dir"], self.coins) + except Exception: + pass + pos = self._last_positions.get(coin, {}) if isinstance(self._last_positions, dict) else {} + chart.refresh( + self.coin_folders, + current_buy_price=pos.get("current_buy_price"), + current_sell_price=pos.get("current_sell_price"), + trail_line=pos.get("trail_line"), + dca_line_price=pos.get("dca_line_price"), + avg_cost_basis=pos.get("avg_cost_basis"), + ) + except Exception: + pass + self.after(1, _do_refresh_visible) + except Exception: + pass + + self._show_chart_page = _show_page + + # ACCOUNT page + acct_page = ttk.Frame(self.chart_pages_container) + self.chart_pages["ACCOUNT"] = acct_page + + acct_btn = ttk.Button(self.chart_tabs_bar, text="ACCOUNT", style="ChartTab.TButton", command=lambda: self._show_chart_page("ACCOUNT")) + self.chart_tabs_bar.add(acct_btn, padx=(0, 6), pady=(0, 6)) + self._chart_tab_buttons["ACCOUNT"] = acct_btn + + self.account_chart = AccountValueChart(acct_page, self.account_value_history_path, self.trade_history_path) + self.account_chart.pack(fill="both", expand=True) + + # Coin pages + self.charts: Dict[str, CandleChart] = {} + for coin in self.coins: + page = ttk.Frame(self.chart_pages_container) + self.chart_pages[coin] = page + btn = ttk.Button(self.chart_tabs_bar, text=coin, style="ChartTab.TButton", command=lambda c=coin: self._show_chart_page(c)) + self.chart_tabs_bar.add(btn, padx=(0, 6), pady=(0, 6)) + self._chart_tab_buttons[coin] = btn + chart = CandleChart(page, self.fetcher, coin, self._settings_getter, self.trade_history_path) + chart.pack(fill="both", expand=True) + self.charts[coin] = chart + + self._show_chart_page("ACCOUNT") + + # ---- RIGHT BOTTOM: Current Trades + Trade History ---- + right_bottom_split = ttk.Panedwindow(right_split, orient="vertical") + self._pw_right_bottom_split = right_bottom_split + + right_bottom_split.bind("", lambda e: self._schedule_paned_clamp(self._pw_right_bottom_split)) + right_bottom_split.bind("", lambda e: (setattr(self, "_user_moved_right_bottom_split", True), self._schedule_paned_clamp(self._pw_right_bottom_split))) + + # Current trades table + trades_frame = ttk.LabelFrame(right_bottom_split, text="Current Trades") + cols = ("coin", "qty", "value", "avg_cost", "buy_price", "buy_pnl", "sell_price", "sell_pnl", "dca_stages", "dca_24h", "next_dca", "trail_line") + header_labels = {"coin": "Coin", "qty": "Qty", "value": "Value", "avg_cost": "Avg Cost", "buy_price": "Ask Price", "buy_pnl": "DCA PnL", "sell_price": "Bid Price", "sell_pnl": "Sell PnL", "dca_stages": "DCA Stage", "dca_24h": "DCA 24h", "next_dca": "Next DCA", "trail_line": "Trail Line"} + + trades_table_wrap = ttk.Frame(trades_frame) + trades_table_wrap.pack(fill="both", expand=True, padx=6, pady=6) + + self.trades_tree = ttk.Treeview(trades_table_wrap, columns=cols, show="headings", height=10) + for c in cols: + self.trades_tree.heading(c, text=header_labels.get(c, c)) + self.trades_tree.column(c, width=110, anchor="center", stretch=True) + + self.trades_tree.column("coin", width=70) + self.trades_tree.column("qty", width=95) + self.trades_tree.column("value", width=110) + self.trades_tree.column("next_dca", width=160) + self.trades_tree.column("dca_stages", width=90) + self.trades_tree.column("dca_24h", width=80) + + ysb = ttk.Scrollbar(trades_table_wrap, orient="vertical", command=self.trades_tree.yview) + xsb = ttk.Scrollbar(trades_table_wrap, orient="horizontal", command=self.trades_tree.xview) + self.trades_tree.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set) + + self.trades_tree.pack(side="top", fill="both", expand=True) + xsb.pack(side="bottom", fill="x") + ysb.pack(side="right", fill="y") + + def _resize_trades_columns(*_): + try: + total_w = int(self.trades_tree.winfo_width()) + except Exception: + return + if total_w <= 1: + return + try: + sb_w = int(ysb.winfo_width() or 0) + except Exception: + sb_w = 0 + avail = max(200, total_w - sb_w - 8) + base = {"coin": 70, "qty": 95, "value": 110, "avg_cost": 110, "buy_price": 110, "buy_pnl": 110, "sell_price": 110, "sell_pnl": 110, "dca_stages": 90, "dca_24h": 80, "next_dca": 160, "trail_line": 110} + base_total = sum(base.get(c, 110) for c in cols) or 1 + scale = avail / base_total + for c in cols: + w = int(base.get(c, 110) * scale) + self.trades_tree.column(c, width=max(60, min(420, w))) + + self.trades_tree.bind("", lambda e: self.after_idle(_resize_trades_columns)) + self.after_idle(_resize_trades_columns) + + # Trade history + hist_frame = ttk.LabelFrame(right_bottom_split, text="Trade History (scroll)") + hist_wrap = ttk.Frame(hist_frame) + hist_wrap.pack(fill="both", expand=True, padx=6, pady=6) + + self.hist_list = tk.Listbox(hist_wrap, height=10, bg=DARK_PANEL, fg=DARK_FG, selectbackground=DARK_SELECT_BG, selectforeground=DARK_SELECT_FG, highlightbackground=DARK_BORDER, highlightcolor=DARK_ACCENT, activestyle="none") + ysb2 = ttk.Scrollbar(hist_wrap, orient="vertical", command=self.hist_list.yview) + xsb2 = ttk.Scrollbar(hist_wrap, orient="horizontal", command=self.hist_list.xview) + self.hist_list.configure(yscrollcommand=ysb2.set, xscrollcommand=xsb2.set) + self.hist_list.pack(side="left", fill="both", expand=True) + ysb2.pack(side="right", fill="y") + xsb2.pack(side="bottom", fill="x") + + # Assemble right side + right_split.add(charts_frame, weight=3) + right_split.add(right_bottom_split, weight=2) + right_bottom_split.add(trades_frame, weight=2) + right_bottom_split.add(hist_frame, weight=1) + + try: + right_split.paneconfigure(charts_frame, minsize=360) + right_split.paneconfigure(right_bottom_split, minsize=220) + except Exception: + pass + + try: + right_bottom_split.paneconfigure(trades_frame, minsize=140) + right_bottom_split.paneconfigure(hist_frame, minsize=120) + except Exception: + pass + + def _init_right_split_sash_once(): + try: + if getattr(self, "_did_init_right_split_sash", False): + return + if getattr(self, "_user_moved_right_split", False): + self._did_init_right_split_sash = True + return + total = right_split.winfo_height() + if total <= 2: + self.after(10, _init_right_split_sash_once) + return + min_top, min_bottom, desired_top = 360, 220, 410 + target = max(min_top, min(total - min_bottom, desired_top)) + right_split.sashpos(0, int(target)) + self._did_init_right_split_sash = True + except Exception: + pass + + def _init_right_bottom_split_sash_once(): + try: + if getattr(self, "_did_init_right_bottom_split_sash", False): + return + if getattr(self, "_user_moved_right_bottom_split", False): + self._did_init_right_bottom_split_sash = True + return + total = right_bottom_split.winfo_height() + if total <= 2: + self.after(10, _init_right_bottom_split_sash_once) + return + min_top, min_bottom, desired_top = 140, 120, 280 + target = max(min_top, min(total - min_bottom, desired_top)) + right_bottom_split.sashpos(0, int(target)) + self._did_init_right_bottom_split_sash = True + except Exception: + pass + + self.after_idle(_init_right_split_sash_once) + self.after_idle(_init_right_bottom_split_sash_once) + + self.after_idle(lambda: ( + self._schedule_paned_clamp(getattr(self, "_pw_outer", None)), + self._schedule_paned_clamp(getattr(self, "_pw_left_split", None)), + self._schedule_paned_clamp(getattr(self, "_pw_right_split", None)), + self._schedule_paned_clamp(getattr(self, "_pw_right_bottom_split", None)), + )) + + self.status = ttk.Label(self, text="Ready", anchor="w") + self.status.pack(fill="x", side="bottom") + + # ---- panedwindow anti-collapse helpers ---- + + def _schedule_paned_clamp(self, pw: Optional[ttk.Panedwindow]) -> None: + try: + if not pw or not int(pw.winfo_exists()): + return + except Exception: + return + key = str(pw) + if key in self._paned_clamp_after_ids: + return + + def _run(): + try: + self._paned_clamp_after_ids.pop(key, None) + except Exception: + pass + self._clamp_panedwindow_sashes(pw) + + try: + self._paned_clamp_after_ids[key] = self.after(1, _run) + except Exception: + pass + + def _clamp_panedwindow_sashes(self, pw: ttk.Panedwindow) -> None: + try: + if not pw or not int(pw.winfo_exists()): + return + panes = list(pw.panes()) + if len(panes) < 2: + return + orient = str(pw.cget("orient")) + total = pw.winfo_height() if orient == "vertical" else pw.winfo_width() + if total <= 2: + return + + def _get_minsize(pane_id) -> int: + try: + cfg = pw.paneconfigure(pane_id) + ms = cfg.get("minsize", 0) + if isinstance(ms, (tuple, list)) and ms: + ms = ms[-1] + return max(0, int(float(ms))) + except Exception: + return 0 + + mins: List[int] = [_get_minsize(p) for p in panes] + if sum(mins) >= total: + floor = 24 + mins = [max(floor, m) for m in mins] + if sum(mins) >= total: + return + + for _ in range(2): + for i in range(len(panes) - 1): + min_pos = sum(mins[: i + 1]) + max_pos = total - sum(mins[i + 1:]) + try: + cur = int(pw.sashpos(i)) + except Exception: + continue + new = max(min_pos, min(max_pos, cur)) + if new != cur: + try: + pw.sashpos(i, new) + except Exception: + pass + except Exception: + pass + + # ---- timeframe change ---- + + def _on_timeframe_changed(self, event: Any) -> None: + try: + chart = getattr(event, "widget", None) + if not isinstance(chart, CandleChart): + return + coin = getattr(chart, "coin", None) + if not coin: + return + self.coin_folders = build_coin_folders(self.settings["main_neural_dir"], self.coins) + pos = self._last_positions.get(coin, {}) if isinstance(self._last_positions, dict) else {} + chart.refresh( + self.coin_folders, + current_buy_price=pos.get("current_buy_price"), + current_sell_price=pos.get("current_sell_price"), + trail_line=pos.get("trail_line"), + dca_line_price=pos.get("dca_line_price"), + avg_cost_basis=pos.get("avg_cost_basis"), + ) + self._last_chart_refresh = time.time() + except Exception: + pass + + # ---- refresh loop ---- + + def _drain_queue_to_text(self, q: "queue.Queue[str]", txt: tk.Text, max_lines: int = 2500) -> None: + try: + changed = False + while True: + line = q.get_nowait() + txt.insert("end", line + "\n") + changed = True + except queue.Empty: + pass + except Exception: + pass + + if changed: + try: + current = int(txt.index("end-1c").split(".")[0]) + if current > max_lines: + txt.delete("1.0", f"{current - max_lines}.0") + except Exception: + pass + txt.see("end") + + def _tick(self) -> None: + neural_running = self.pm.is_neural_running() + trader_running = self.pm.is_trader_running() + + self.lbl_neural.config(text=f"Neural: {'running' if neural_running else 'stopped'}") + self.lbl_trader.config(text=f"Trader: {'running' if trader_running else 'stopped'}") + + try: + if hasattr(self, "btn_toggle_all") and self.btn_toggle_all: + if neural_running or trader_running or self.pm._auto_start_trader_pending: + self.btn_toggle_all.config(text="Stop All") + else: + self.btn_toggle_all.config(text="Start All") + except Exception: + pass + + # Flow gating + status_map = self.pm.training_status_map() + all_trained = all(v == "TRAINED" for v in status_map.values()) if status_map else False + + can_toggle_all = True + if (not all_trained) and (not neural_running) and (not trader_running) and (not self.pm._auto_start_trader_pending): + can_toggle_all = False + + try: + self.btn_toggle_all.configure(state=("normal" if can_toggle_all else "disabled")) + except Exception: + pass + + # Training overview + try: + training_running = [c for c, s in status_map.items() if s == "TRAINING"] + not_trained = [c for c, s in status_map.items() if s == "NOT TRAINED"] + interrupted = [c for c, s in status_map.items() if s == "INTERRUPTED"] + + if training_running: + self.lbl_training_overview.config(text=f"Training: RUNNING ({', '.join(training_running)})") + elif interrupted: + self.lbl_training_overview.config(text=f"Training: {len(interrupted)} INTERRUPTED (will resume)") + elif not_trained: + self.lbl_training_overview.config(text=f"Training: REQUIRED ({len(not_trained)} not trained)") + else: + self.lbl_training_overview.config(text="Training: READY (all trained)") + + enriched = [] + for c in self.coins: + st = status_map.get(c, "N/A") + detail = "" + if st in ("TRAINING", "INTERRUPTED"): + folder = self.coin_folders.get(c, "") + if folder: + prog = safe_read_json(os.path.join(folder, "trainer_progress.json")) + if isinstance(prog, dict): + tf = str(prog.get("timeframe", "?")) + tf_idx = int(prog.get("tf_index", 0)) + tf_total = int(prog.get("tf_total", 7)) + pct = float(prog.get("pct", 0)) + detail = f" \u2014 {tf} [{tf_idx + 1}/{tf_total}] {pct:.0f}%" + enriched.append(f"{c}: {st}{detail}") + + sig = tuple(enriched) + if getattr(self, "_last_training_sig", None) != sig: + self._last_training_sig = sig + self.training_list.delete(0, "end") + for entry in enriched: + self.training_list.insert("end", entry) + + if not all_trained: + self.lbl_flow_hint.config(text="Flow: Train All required \u2192 then Start All") + elif self.pm._auto_start_trader_pending: + self.lbl_flow_hint.config(text="Flow: Starting runner \u2192 waiting for ready \u2192 trader will auto-start") + elif neural_running or trader_running: + self.lbl_flow_hint.config(text="Flow: Running (use the button to stop)") + else: + self.lbl_flow_hint.config(text="Flow: Start All") + except Exception: + pass + + # Training progress bar + try: + self._refresh_training_progress(self.pm.running_trainers()) + except Exception: + pass + + self._refresh_neural_overview() + self._refresh_trader_status() + self._refresh_pnl() + self._refresh_trade_history() + + # Charts (throttled) + now = time.time() + if (now - self._last_chart_refresh) >= float(self.settings.get("chart_refresh_seconds", 10.0)): + try: + if self.account_chart: + self.account_chart.refresh() + except Exception: + pass + + try: + cf_sig = (self.settings.get("main_neural_dir"), tuple(self.coins)) + if getattr(self, "_coin_folders_sig", None) != cf_sig: + self._coin_folders_sig = cf_sig + self.coin_folders = build_coin_folders(self.settings["main_neural_dir"], self.coins) + except Exception: + try: + self.coin_folders = build_coin_folders(self.settings["main_neural_dir"], self.coins) + except Exception: + pass + + selected_tab = getattr(self, "_current_chart_page", None) + + if not selected_tab: + try: + if hasattr(self, "nb") and self.nb: + selected_tab = self.nb.tab(self.nb.select(), "text") + except Exception: + selected_tab = None + + if selected_tab and str(selected_tab).strip().upper() != "ACCOUNT": + coin = str(selected_tab).strip().upper() + chart = self.charts.get(coin) + if chart: + pos = self._last_positions.get(coin, {}) if isinstance(self._last_positions, dict) else {} + try: + chart.refresh( + self.coin_folders, + current_buy_price=pos.get("current_buy_price"), + current_sell_price=pos.get("current_sell_price"), + trail_line=pos.get("trail_line"), + dca_line_price=pos.get("dca_line_price"), + avg_cost_basis=pos.get("avg_cost_basis"), + ) + except Exception: + pass + + self._last_chart_refresh = now + + # Drain logs + self._drain_queue_to_text(self.pm.runner_log_q, self.runner_text) + self._drain_queue_to_text(self.pm.trader_log_q, self.trader_text) + + try: + sel = (self.trainer_coin_var.get() or "").strip().upper() + running = [c for c, lp in self.pm.trainers.items() if lp.info.proc and lp.info.proc.poll() is None] + self.trainer_status_lbl.config(text=f"running: {', '.join(running)}" if running else "(no trainers running)") + lp = self.pm.trainers.get(sel) + if lp: + self._drain_queue_to_text(lp.log_q, self.trainer_text) + except Exception: + pass + + self.status.config(text=f"{now_str()} | hub_dir={self.hub_dir}") + self.after(int(float(self.settings.get("ui_refresh_seconds", 1.0)) * 1000), self._tick) + + # ---- refresh helpers ---- + + def _refresh_training_progress(self, training_running: list) -> None: + try: + if not training_running: + self.lbl_training_progress.config(text="") + self.training_progress_bar["value"] = 0 + return + + total_coins = len(training_running) + coin_progress = [] + for coin in training_running: + folder = self.coin_folders.get(coin, "") + if not folder: + continue + prog_path = os.path.join(folder, "trainer_progress.json") + prog = safe_read_json(prog_path) + if isinstance(prog, dict): + tf = str(prog.get("timeframe", "?")) + tf_idx = int(prog.get("tf_index", 0)) + tf_total = int(prog.get("tf_total", 7)) + pct = float(prog.get("pct", 0)) + coin_progress.append((coin, f"{tf} [{tf_idx + 1}/{tf_total}]", pct)) + else: + coin_progress.append((coin, "starting...", 0)) + + if not coin_progress: + self.lbl_training_progress.config(text="Training...") + self.training_progress_bar["value"] = 0 + return + + if len(coin_progress) == 1: + c, detail, pct = coin_progress[0] + self.lbl_training_progress.config(text=f"{c}: {detail} ({pct:.0f}%)") + self.training_progress_bar["value"] = pct + else: + avg_pct = sum(p[2] for p in coin_progress) / len(coin_progress) + self.lbl_training_progress.config(text=f"Training {len(coin_progress)} coins ({avg_pct:.0f}%)") + self.training_progress_bar["value"] = avg_pct + except Exception: + pass + + def _refresh_trader_status(self) -> None: + try: + mtime = os.path.getmtime(self.trader_status_path) + except Exception: + mtime = None + + if getattr(self, "_last_trader_status_mtime", object()) == mtime: + return + self._last_trader_status_mtime = mtime + + data = safe_read_json(self.trader_status_path) + if not data: + self.lbl_last_status.config(text="Last status: N/A (no trader_status.json yet)") + try: + self.lbl_acct_total_value.config(text="Total Account Value: N/A") + self.lbl_acct_holdings_value.config(text="Holdings Value: N/A") + self.lbl_acct_buying_power.config(text="Buying Power: N/A") + self.lbl_acct_percent_in_trade.config(text="Percent In Trade: N/A") + self.lbl_acct_dca_spread.config(text="DCA Levels (spread): N/A") + self.lbl_acct_dca_single.config(text="DCA Levels (single): N/A") + except Exception: + pass + for iid in self.trades_tree.get_children(): + self.trades_tree.delete(iid) + return + + ts = data.get("timestamp") + try: + if isinstance(ts, (int, float)): + self.lbl_last_status.config(text=f"Last status: {time.strftime('%H:%M:%S', time.localtime(ts))}") + else: + self.lbl_last_status.config(text="Last status: (unknown timestamp)") + except Exception: + self.lbl_last_status.config(text="Last status: (timestamp parse error)") + + acct = data.get("account", {}) or {} + try: + total_val = float(acct.get("total_account_value", 0.0) or 0.0) + self._last_total_account_value = total_val + + self.lbl_acct_total_value.config(text=f"Total Account Value: {fmt_money(acct.get('total_account_value'))}") + self.lbl_acct_holdings_value.config(text=f"Holdings Value: {fmt_money(acct.get('holdings_sell_value'))}") + self.lbl_acct_buying_power.config(text=f"Buying Power: {fmt_money(acct.get('buying_power'))}") + + pit = acct.get("percent_in_trade") + try: + pit_txt = f"{float(pit):.2f}%" + except Exception: + pit_txt = "N/A" + self.lbl_acct_percent_in_trade.config(text=f"Percent In Trade: {pit_txt}") + + # DCA affordability + coins = getattr(self, "coins", None) or [] + n = len(coins) + spread_levels = 0 + single_levels = 0 + + if total_val > 0.0: + alloc_pct = float(self.settings.get("start_allocation_pct", 0.005) or 0.005) + if alloc_pct < 0.0: + alloc_pct = 0.0 + alloc_frac = alloc_pct / 100.0 + dca_mult = float(self.settings.get("dca_multiplier", 2.0) or 2.0) + if dca_mult < 0.0: + dca_mult = 0.0 + dca_factor = 1.0 + dca_mult + + alloc_spread = total_val * alloc_frac + if alloc_spread < 0.5: + alloc_spread = 0.5 + required = alloc_spread * n + while required > 0.0 and (required * dca_factor) <= (total_val + 1e-9): + required *= dca_factor + spread_levels += 1 + + alloc_single = total_val * alloc_frac + if alloc_single < 0.5: + alloc_single = 0.5 + required = alloc_single + while required > 0.0 and (required * dca_factor) <= (total_val + 1e-9): + required *= dca_factor + single_levels += 1 + + self.lbl_acct_dca_spread.config(text=f"DCA Levels (spread): {spread_levels}") + self.lbl_acct_dca_single.config(text=f"DCA Levels (single): {single_levels}") + except Exception: + pass + + positions = data.get("positions", {}) or {} + self._last_positions = positions + + # DCA count in rolling 24h + dca_24h_by_coin: Dict[str, int] = {} + try: + now = time.time() + window_floor = now - (24 * 3600) + trades = read_trade_history_jsonl(self.trade_history_path) if self.trade_history_path else [] + last_sell_ts: Dict[str, float] = {} + for tr in trades: + sym = str(tr.get("symbol", "")).upper().strip() + base = sym.split("-")[0].strip() if sym else "" + if not base: + continue + side = str(tr.get("side", "")).lower().strip() + if side != "sell": + continue + try: + tsf = float(tr.get("ts", 0)) + except Exception: + continue + prev = float(last_sell_ts.get(base, 0.0)) + if tsf > prev: + last_sell_ts[base] = tsf + + for tr in trades: + sym = str(tr.get("symbol", "")).upper().strip() + base = sym.split("-")[0].strip() if sym else "" + if not base: + continue + side = str(tr.get("side", "")).lower().strip() + if side != "buy": + continue + tag = str(tr.get("tag") or "").upper().strip() + if tag != "DCA": + continue + try: + tsf = float(tr.get("ts", 0)) + except Exception: + continue + start_ts = max(window_floor, float(last_sell_ts.get(base, 0.0))) + if tsf >= start_ts: + dca_24h_by_coin[base] = int(dca_24h_by_coin.get(base, 0)) + 1 + except Exception: + dca_24h_by_coin = {} + + for iid in self.trades_tree.get_children(): + self.trades_tree.delete(iid) + + cols = ("coin", "qty", "value", "avg_cost", "buy_price", "buy_pnl", "sell_price", "sell_pnl", "dca_stages", "dca_24h", "next_dca", "trail_line") + + for sym, pos in positions.items(): + coin = sym + qty = pos.get("quantity", 0.0) + try: + if float(qty) <= 0.0: + continue + except Exception: + continue + + value = pos.get("value_usd", 0.0) + avg_cost = pos.get("avg_cost_basis", 0.0) + buy_price = pos.get("current_buy_price", 0.0) + buy_pnl = pos.get("gain_loss_pct_buy", 0.0) + sell_price = pos.get("current_sell_price", 0.0) + sell_pnl = pos.get("gain_loss_pct_sell", 0.0) + dca_stages = pos.get("dca_triggered_stages", 0) + dca_24h = int(dca_24h_by_coin.get(str(coin).upper().strip(), 0)) + + try: + max_dca_24h = int(float(self.settings.get("max_dca_buys_per_24h", DEFAULT_SETTINGS.get("max_dca_buys_per_24h", 2)) or 2)) + except Exception: + max_dca_24h = int(DEFAULT_SETTINGS.get("max_dca_buys_per_24h", 2) or 2) + if max_dca_24h < 0: + max_dca_24h = 0 + try: + self.trades_tree.heading("dca_24h", text=f"DCA 24h (max {max_dca_24h})") + except Exception: + pass + dca_24h_display = f"{dca_24h}/{max_dca_24h}" + + try: + pm0 = float(self.settings.get("pm_start_pct_no_dca", DEFAULT_SETTINGS.get("pm_start_pct_no_dca", 5.0)) or 5.0) + pm1 = float(self.settings.get("pm_start_pct_with_dca", DEFAULT_SETTINGS.get("pm_start_pct_with_dca", 2.5)) or 2.5) + tg = float(self.settings.get("trailing_gap_pct", DEFAULT_SETTINGS.get("trailing_gap_pct", 0.5)) or 0.5) + self.trades_tree.heading("trail_line", text=f"Trail Line (start {pm0:g}/{pm1:g}%, gap {tg:g}%)") + except Exception: + pass + + next_dca = pos.get("next_dca_display", "") + trail_line = pos.get("trail_line", 0.0) + + self.trades_tree.insert( + "", "end", + values=( + coin, + f"{qty:.8f}".rstrip("0").rstrip("."), + fmt_money(value), + fmt_price(avg_cost), + fmt_price(buy_price), + fmt_pct(buy_pnl), + fmt_price(sell_price), + fmt_pct(sell_pnl), + dca_stages, + dca_24h_display, + next_dca, + fmt_price(trail_line), + ), + ) + + def _refresh_pnl(self) -> None: + try: + mtime = os.path.getmtime(self.pnl_ledger_path) + except Exception: + mtime = None + if getattr(self, "_last_pnl_mtime", object()) == mtime: + return + self._last_pnl_mtime = mtime + data = safe_read_json(self.pnl_ledger_path) + if not data: + self.lbl_pnl.config(text="Total realized: N/A") + return + total = float(data.get("total_realized_profit_usd", 0.0)) + self.lbl_pnl.config(text=f"Total realized: {fmt_money(total)}") + + def _refresh_trade_history(self) -> None: + try: + mtime = os.path.getmtime(self.trade_history_path) + except Exception: + mtime = None + if getattr(self, "_last_trade_history_mtime", object()) == mtime: + return + self._last_trade_history_mtime = mtime + + if not os.path.isfile(self.trade_history_path): + self.hist_list.delete(0, "end") + self.hist_list.insert("end", "(no trade_history.jsonl yet)") + return + + try: + with open(self.trade_history_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except Exception: + return + + lines = lines[-250:] + self.hist_list.delete(0, "end") + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + ts = obj.get("ts") + tss = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if isinstance(ts, (int, float)) else "?" + side = str(obj.get("side", "")).upper() + tag = str(obj.get("tag", "") or "").upper() + sym = obj.get("symbol", "") + qty = obj.get("qty", "") + px = obj.get("price") + pnl = obj.get("realized_profit_usd") + pnl_pct = obj.get("pnl_pct") + px_txt = fmt_price(px) if px is not None else "N/A" + action = side + if tag: + action = f"{side}/{tag}" + txt = f"{tss} | {action:10s} {sym:5s} | qty={qty} | px={px_txt}" + + show_trade_pnl_pct = None + if side == "SELL": + show_trade_pnl_pct = pnl_pct + elif side == "BUY" and tag == "DCA": + show_trade_pnl_pct = pnl_pct + if show_trade_pnl_pct is not None: + try: + txt += f" | pnl@trade={fmt_pct(float(show_trade_pnl_pct))}" + except Exception: + txt += f" | pnl@trade={show_trade_pnl_pct}" + if pnl is not None: + try: + txt += f" | realized={float(pnl):+.2f}" + except Exception: + txt += f" | realized={pnl}" + self.hist_list.insert("end", txt) + except Exception: + self.hist_list.insert("end", line) + + # ---- neural overview ---- + + def _refresh_coin_dependent_ui(self, prev_coins: List[str]) -> None: + self.coins = [c.upper().strip() for c in (self.settings.get("coins") or []) if c.strip()] + self.coin_folders = build_coin_folders(self.settings.get("main_neural_dir") or self.project_dir, self.coins) + + # Update ProcessManager's coin state + self.pm.coins = self.coins + self.pm.coin_folders = self.coin_folders + + try: + if hasattr(self, "train_coin_combo") and self.train_coin_combo.winfo_exists(): + self.train_coin_combo["values"] = self.coins + cur = (self.train_coin_var.get() or "").strip().upper() if hasattr(self, "train_coin_var") else "" + if self.coins and cur not in self.coins: + self.train_coin_var.set(self.coins[0]) + if hasattr(self, "trainer_coin_combo") and self.trainer_coin_combo.winfo_exists(): + self.trainer_coin_combo["values"] = self.coins + cur = (self.trainer_coin_var.get() or "").strip().upper() if hasattr(self, "trainer_coin_var") else "" + if self.coins and cur not in self.coins: + self.trainer_coin_var.set(self.coins[0]) + if hasattr(self, "train_coin_var") and hasattr(self, "trainer_coin_var"): + if self.train_coin_var.get(): + self.trainer_coin_var.set(self.train_coin_var.get()) + except Exception: + pass + + try: + if hasattr(self, "neural_wrap") and self.neural_wrap.winfo_exists(): + self._rebuild_neural_overview() + self._refresh_neural_overview() + except Exception: + pass + + try: + prev_set = set([str(c).strip().upper() for c in (prev_coins or []) if str(c).strip()]) + if prev_set != set(self.coins): + self._rebuild_coin_chart_tabs() + except Exception: + pass + + def _rebuild_neural_overview(self) -> None: + if not hasattr(self, "neural_wrap") or self.neural_wrap is None: + return + try: + if hasattr(self.neural_wrap, "clear"): + self.neural_wrap.clear(destroy_widgets=True) + else: + for ch in list(self.neural_wrap.winfo_children()): + ch.destroy() + except Exception: + pass + + self.neural_tiles = {} + + for coin in (self.coins or []): + tile = NeuralSignalTile(self.neural_wrap, coin, trade_start_level=int(self.settings.get("trade_start_level", 3) or 3)) + + def _on_enter(_e=None, t=tile): + try: + t.set_hover(True) + except Exception: + pass + + def _on_leave(_e=None, t=tile): + try: + x = t.winfo_pointerx() + y = t.winfo_pointery() + w = t.winfo_containing(x, y) + while w is not None: + if w == t: + return + w = getattr(w, "master", None) + except Exception: + pass + try: + t.set_hover(False) + except Exception: + pass + + tile.bind("", _on_enter, add="+") + tile.bind("", _on_leave, add="+") + try: + for w in tile.winfo_children(): + w.bind("", _on_enter, add="+") + w.bind("", _on_leave, add="+") + except Exception: + pass + + def _open_coin_chart(_e=None, c=coin): + try: + fn = getattr(self, "_show_chart_page", None) + if callable(fn): + fn(str(c).strip().upper()) + except Exception: + pass + + tile.bind("", _open_coin_chart, add="+") + try: + for w in tile.winfo_children(): + w.bind("", _open_coin_chart, add="+") + except Exception: + pass + + self.neural_wrap.add(tile, padx=(0, 6), pady=(0, 6)) + self.neural_tiles[coin] = tile + + try: + self.neural_wrap._schedule_reflow() + except Exception: + pass + try: + fn = getattr(self, "_update_neural_overview_scrollbars", None) + if callable(fn): + self.after_idle(fn) + except Exception: + pass + + def _refresh_neural_overview(self) -> None: + if not hasattr(self, "neural_tiles"): + return + + try: + sig = (str(self.settings.get("main_neural_dir") or ""), tuple(self.coins or [])) + if getattr(self, "_coin_folders_sig", None) != sig: + self._coin_folders_sig = sig + self.coin_folders = build_coin_folders(self.settings.get("main_neural_dir") or self.project_dir, self.coins) + except Exception: + pass + + if not hasattr(self, "_neural_overview_cache"): + self._neural_overview_cache = {} + + def _cached(path: str, loader: Callable, default: Any) -> Tuple[Any, Optional[float]]: + try: + mtime = os.path.getmtime(path) + except Exception: + return default, None + hit = self._neural_overview_cache.get(path) + if hit and hit[0] == mtime: + return hit[1], mtime + v = loader(path) + self._neural_overview_cache[path] = (mtime, v) + return v, mtime + + def _load_short_from_memory_json(path: str) -> int: + try: + obj = safe_read_json(path) or {} + return int(float(obj.get("short_dca_signal", 0))) + except Exception: + return 0 + + latest_ts: Optional[float] = None + + for coin, tile in list(self.neural_tiles.items()): + folder = "" + try: + folder = (self.coin_folders or {}).get(coin, "") + except Exception: + folder = "" + + if not folder or not os.path.isdir(folder): + tile.set_values(0, 0) + continue + + long_sig = 0 + short_sig = 0 + mt_candidates: List[float] = [] + + long_path = os.path.join(folder, "long_dca_signal.txt") + if os.path.isfile(long_path): + long_sig, mt = _cached(long_path, read_int_from_file, 0) + if mt: + mt_candidates.append(float(mt)) + + short_txt = os.path.join(folder, "short_dca_signal.txt") + if os.path.isfile(short_txt): + short_sig, mt = _cached(short_txt, read_int_from_file, 0) + if mt: + mt_candidates.append(float(mt)) + else: + mem = os.path.join(folder, "memory.json") + if os.path.isfile(mem): + short_sig, mt = _cached(mem, _load_short_from_memory_json, 0) + if mt: + mt_candidates.append(float(mt)) + + tile.set_values(long_sig, short_sig) + + if mt_candidates: + mx = max(mt_candidates) + latest_ts = mx if (latest_ts is None or mx > latest_ts) else latest_ts + + try: + if hasattr(self, "lbl_neural_overview_last") and self.lbl_neural_overview_last.winfo_exists(): + if latest_ts: + self.lbl_neural_overview_last.config(text=f"Last: {time.strftime('%H:%M:%S', time.localtime(float(latest_ts)))}") + else: + self.lbl_neural_overview_last.config(text="Last: N/A") + except Exception: + pass + + def _rebuild_coin_chart_tabs(self) -> None: + charts_frame = getattr(self, "_charts_frame", None) + if charts_frame is None or (hasattr(charts_frame, "winfo_exists") and not charts_frame.winfo_exists()): + return + + selected = getattr(self, "_current_chart_page", "ACCOUNT") + if selected not in (["ACCOUNT"] + list(self.coins)): + selected = "ACCOUNT" + + try: + if hasattr(self, "chart_tabs_bar") and self.chart_tabs_bar.winfo_exists(): + self.chart_tabs_bar.destroy() + except Exception: + pass + try: + if hasattr(self, "chart_pages_container") and self.chart_pages_container.winfo_exists(): + self.chart_pages_container.destroy() + except Exception: + pass + + self.chart_tabs_bar = WrapFrame(charts_frame) + self.chart_tabs_bar.pack(fill="x", padx=6, pady=(6, 0)) + + self.chart_pages_container = ttk.Frame(charts_frame) + self.chart_pages_container.pack(fill="both", expand=True, padx=6, pady=(0, 6)) + + self._chart_tab_buttons = {} + self.chart_pages = {} + self._current_chart_page = selected + + def _show_page(name: str) -> None: + self._current_chart_page = name + for f in self.chart_pages.values(): + try: + f.pack_forget() + except Exception: + pass + f = self.chart_pages.get(name) + if f is not None: + f.pack(fill="both", expand=True) + for txt, b in self._chart_tab_buttons.items(): + try: + b.configure(style=("ChartTabSelected.TButton" if txt == name else "ChartTab.TButton")) + except Exception: + pass + + self._show_chart_page = _show_page + + acct_page = ttk.Frame(self.chart_pages_container) + self.chart_pages["ACCOUNT"] = acct_page + acct_btn = ttk.Button(self.chart_tabs_bar, text="ACCOUNT", style="ChartTab.TButton", command=lambda: self._show_chart_page("ACCOUNT")) + self.chart_tabs_bar.add(acct_btn, padx=(0, 6), pady=(0, 6)) + self._chart_tab_buttons["ACCOUNT"] = acct_btn + + self.account_chart = AccountValueChart(acct_page, self.account_value_history_path, self.trade_history_path) + self.account_chart.pack(fill="both", expand=True) + + self.charts = {} + for coin in self.coins: + page = ttk.Frame(self.chart_pages_container) + self.chart_pages[coin] = page + btn = ttk.Button(self.chart_tabs_bar, text=coin, style="ChartTab.TButton", command=lambda c=coin: self._show_chart_page(c)) + self.chart_tabs_bar.add(btn, padx=(0, 6), pady=(0, 6)) + self._chart_tab_buttons[coin] = btn + chart = CandleChart(page, self.fetcher, coin, self._settings_getter, self.trade_history_path) + chart.pack(fill="both", expand=True) + self.charts[coin] = chart + + self._show_chart_page(selected) + + # ---- settings dialog ---- + + def open_settings_dialog(self) -> None: + prev_coins = list(self.coins) + + def _on_save(settings: dict, added_prev_coins: Set[str]) -> None: + self.settings = settings + self._save_settings() + + # Create folders for newly added coins + try: + new_coins = [c.strip().upper() for c in (settings.get("coins") or []) if c.strip()] + added = [c for c in new_coins if c and c not in added_prev_coins] + main_dir = settings.get("main_neural_dir") or self.project_dir + trainer_name = os.path.basename(str(settings.get("script_neural_trainer", "neural_trainer.py"))) + src_main_trainer = os.path.join(main_dir, trainer_name) + src_cfg_trainer = str(settings.get("script_neural_trainer", trainer_name)) + src_trainer_path = src_main_trainer if os.path.isfile(src_main_trainer) else src_cfg_trainer + for coin in added: + if coin == "BTC": + continue + coin_dir = os.path.join(main_dir, coin) + if not os.path.isdir(coin_dir): + os.makedirs(coin_dir, exist_ok=True) + dst_trainer_path = os.path.join(coin_dir, trainer_name) + if (not os.path.isfile(dst_trainer_path)) and os.path.isfile(src_trainer_path): + shutil.copy2(src_trainer_path, dst_trainer_path) + except Exception: + pass + + self._refresh_coin_dependent_ui(prev_coins) + + SettingsDialog( + parent=self, + settings=dict(self.settings), + project_dir=self.project_dir, + last_total_account_value=getattr(self, "_last_total_account_value", 0.0), + on_save=_on_save, + ) + + # ---- close ---- + + def _on_close(self) -> None: + try: + self.stop_all_scripts() + except Exception: + pass + self.destroy() + + +def main() -> None: + app = PowerTraderHub() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/powertrader/hub/components/__init__.py b/src/powertrader/hub/components/__init__.py new file mode 100644 index 000000000..bd10a9b51 --- /dev/null +++ b/src/powertrader/hub/components/__init__.py @@ -0,0 +1,15 @@ +"""Reusable GUI components for the PowerTrader Hub.""" + +from powertrader.hub.components.wrap_frame import WrapFrame +from powertrader.hub.components.signal_tile import NeuralSignalTile +from powertrader.hub.components.candle_fetcher import CandleFetcher +from powertrader.hub.components.candle_chart import CandleChart +from powertrader.hub.components.account_chart import AccountValueChart + +__all__ = [ + "WrapFrame", + "NeuralSignalTile", + "CandleFetcher", + "CandleChart", + "AccountValueChart", +] diff --git a/src/powertrader/hub/components/account_chart.py b/src/powertrader/hub/components/account_chart.py new file mode 100644 index 000000000..e415de31e --- /dev/null +++ b/src/powertrader/hub/components/account_chart.py @@ -0,0 +1,298 @@ +"""Account value chart widget with trade buy/sell markers.""" + +from __future__ import annotations + +import bisect +import json +import math +import os +import time +import tkinter as tk +from tkinter import ttk +from typing import Any, List, Optional, Tuple + +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +from matplotlib.ticker import FuncFormatter + +from powertrader.hub.theme import DARK_BG, DARK_BORDER, DARK_FG, DARK_PANEL +from powertrader.hub.utils import fmt_money, read_trade_history_jsonl + + +class AccountValueChart(ttk.Frame): + + def __init__( + self, + parent: tk.Widget, + history_path: str, + trade_history_path: str, + max_points: int = 250, + ) -> None: + super().__init__(parent) + self.history_path = history_path + self.trade_history_path = trade_history_path + self.max_points = min(int(max_points or 0) or 250, 250) + self._last_mtime: Optional[float] = None + + top = ttk.Frame(self) + top.pack(fill="x", padx=6, pady=6) + + ttk.Label(top, text="Account value").pack(side="left") + self.last_update_label = ttk.Label(top, text="Last: N/A") + self.last_update_label.pack(side="right") + + self.fig = Figure(figsize=(6.5, 3.5), dpi=100) + self.fig.patch.set_facecolor(DARK_BG) + self.fig.subplots_adjust(bottom=0.25, right=0.87, top=0.8) + + self.ax = self.fig.add_subplot(111) + self._apply_dark_chart_style() + self.ax.set_title("Account Value", color=DARK_FG) + + self.canvas = FigureCanvasTkAgg(self.fig, master=self) + canvas_w = self.canvas.get_tk_widget() + canvas_w.configure(bg=DARK_BG) + canvas_w.pack(fill="both", expand=True, padx=0, pady=(0, 6)) + + self._last_canvas_px = (0, 0) + self._resize_after_id: Optional[str] = None + + def _on_canvas_configure(e: Any) -> None: + try: + w = int(e.width) + h = int(e.height) + if w <= 1 or h <= 1: + return + if (w, h) == self._last_canvas_px: + return + self._last_canvas_px = (w, h) + dpi = float(self.fig.get_dpi() or 100.0) + self.fig.set_size_inches(w / dpi, h / dpi, forward=True) + if self._resize_after_id: + try: + self.after_cancel(self._resize_after_id) + except Exception: + pass + self._resize_after_id = self.after_idle(self.canvas.draw_idle) + except Exception: + pass + + canvas_w.bind("", _on_canvas_configure, add="+") + + def _apply_dark_chart_style(self) -> None: + try: + self.fig.patch.set_facecolor(DARK_BG) + self.ax.set_facecolor(DARK_PANEL) + self.ax.tick_params(colors=DARK_FG) + for spine in self.ax.spines.values(): + spine.set_color(DARK_BORDER) + self.ax.grid(True, color=DARK_BORDER, linewidth=0.6, alpha=0.35) + except Exception: + pass + + def refresh(self) -> None: + path = self.history_path + + try: + m_hist = os.path.getmtime(path) + except Exception: + m_hist = None + + try: + m_trades = os.path.getmtime(self.trade_history_path) if self.trade_history_path else None + except Exception: + m_trades = None + + candidates = [m for m in (m_hist, m_trades) if m is not None] + mtime = max(candidates) if candidates else None + + if mtime is not None and self._last_mtime == mtime: + return + self._last_mtime = mtime + + points: List[Tuple[float, float]] = [] + + try: + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + + for ln in lines: + try: + obj = json.loads(ln) + ts = obj.get("ts", None) + v = obj.get("total_account_value", None) + if ts is None or v is None: + continue + tsf = float(ts) + vf = float(v) + if (not math.isfinite(tsf)) or (not math.isfinite(vf)) or (vf <= 0.0): + continue + points.append((tsf, vf)) + except Exception: + continue + except Exception: + points = [] + + if points: + points.sort(key=lambda x: x[0]) + dedup: List[Tuple[float, float]] = [] + for tsf, vf in points: + if dedup and tsf == dedup[-1][0]: + dedup[-1] = (tsf, vf) + else: + dedup.append((tsf, vf)) + points = dedup + + max_keep = min(max(2, int(self.max_points or 250)), 250) + n = len(points) + + if n > max_keep: + first_pt = points[0] + last_pt = points[-1] + mid_points = points[1:-1] + mid_n = len(mid_points) + keep_mid = max_keep - 2 + + if keep_mid <= 0 or mid_n <= 0: + points = [first_pt, last_pt] + elif mid_n <= keep_mid: + points = [first_pt] + mid_points + [last_pt] + else: + bucket_size = mid_n / float(keep_mid) + new_mid: List[Tuple[float, float]] = [] + for i in range(keep_mid): + start = int(i * bucket_size) + end = int((i + 1) * bucket_size) + if end <= start: + end = start + 1 + if start >= mid_n: + break + if end > mid_n: + end = mid_n + bucket = mid_points[start:end] + if not bucket: + continue + avg_ts = sum(p[0] for p in bucket) / len(bucket) + avg_val = sum(p[1] for p in bucket) / len(bucket) + new_mid.append((avg_ts, avg_val)) + points = [first_pt] + new_mid + [last_pt] + + try: + self.ax.lines.clear() + self.ax.patches.clear() + self.ax.collections.clear() + self.ax.texts.clear() + except Exception: + self.ax.cla() + self._apply_dark_chart_style() + + if not points: + self.ax.set_title("Account Value - no data", color=DARK_FG) + self.last_update_label.config(text="Last: N/A") + self.canvas.draw_idle() + return + + xs = list(range(len(points))) + ys = [round(p[1], 2) for p in points] + + self.ax.plot(xs, ys, linewidth=1.5) + + try: + trades = read_trade_history_jsonl(self.trade_history_path) if self.trade_history_path else [] + if trades: + ts_list = [float(p[0]) for p in points] + t_min = ts_list[0] + t_max = ts_list[-1] + + for tr in trades: + side = str(tr.get("side", "")).lower().strip() + tag = str(tr.get("tag", "")).upper().strip() + + if side == "buy": + action_label = "DCA" if tag == "DCA" else "BUY" + color = "purple" if tag == "DCA" else "red" + elif side == "sell": + action_label = "SELL" + color = "green" + else: + continue + + sym = str(tr.get("symbol", "")).upper().strip() + coin_tag = (sym.split("-")[0].split("/")[0].strip() if sym else "") or (sym or "?") + label = f"{coin_tag} {action_label}" + + tts = tr.get("ts") + try: + tts = float(tts) + except Exception: + continue + if tts < t_min or tts > t_max: + continue + + i = bisect.bisect_left(ts_list, tts) + if i <= 0: + idx = 0 + elif i >= len(ts_list): + idx = len(ts_list) - 1 + else: + idx = i if abs(ts_list[i] - tts) < abs(tts - ts_list[i - 1]) else (i - 1) + + x = idx + y = ys[idx] + + self.ax.scatter([x], [y], s=30, color=color, zorder=6) + self.ax.annotate( + label, (x, y), textcoords="offset points", xytext=(0, 10), + ha="center", fontsize=8, color=DARK_FG, zorder=7, + ) + except Exception: + pass + + try: + self.ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _pos: f"${y:,.2f}")) + except Exception: + pass + + n = len(points) + want = 5 + if n <= want: + idxs = list(range(n)) + else: + step = (n - 1) / float(want - 1) + idxs = [] + last = -1 + for j in range(want): + i = int(round(j * step)) + if i <= last: + i = last + 1 + if i >= n: + i = n - 1 + idxs.append(i) + last = i + + tick_x = [xs[i] for i in idxs] + tick_lbl = [time.strftime("%Y-%m-%d\n%H:%M:%S", time.localtime(points[i][0])) for i in idxs] + try: + self.ax.minorticks_off() + self.ax.set_xticks(tick_x) + self.ax.set_xticklabels(tick_lbl) + self.ax.tick_params(axis="x", labelsize=8) + except Exception: + pass + + self.ax.set_xlim(-0.5, (len(points) - 0.5) + 0.6) + + try: + self.ax.set_title(f"Account Value ({fmt_money(ys[-1])})", color=DARK_FG) + except Exception: + self.ax.set_title("Account Value", color=DARK_FG) + + try: + self.last_update_label.config( + text=f"Last: {time.strftime('%H:%M:%S', time.localtime(points[-1][0]))}" + ) + except Exception: + self.last_update_label.config(text="Last: N/A") + + self.canvas.draw_idle() diff --git a/src/powertrader/hub/components/candle_chart.py b/src/powertrader/hub/components/candle_chart.py new file mode 100644 index 000000000..ac6d93f64 --- /dev/null +++ b/src/powertrader/hub/components/candle_chart.py @@ -0,0 +1,429 @@ +"""Candlestick chart widget with neural level overlays.""" + +from __future__ import annotations + +import bisect +import math +import os +import time +import tkinter as tk +from tkinter import ttk +from typing import Any, Callable, Dict, List, Optional + +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +from matplotlib.patches import Rectangle +from matplotlib.transforms import blended_transform_factory + +from powertrader.hub.components.candle_fetcher import CandleFetcher +from powertrader.hub.theme import DARK_BG, DARK_BG2, DARK_BORDER, DARK_FG, DARK_PANEL +from powertrader.hub.utils import ( + fmt_price, + read_int_from_file, + read_price_levels_from_html, + read_short_signal, + read_trade_history_jsonl, +) + + +class CandleChart(ttk.Frame): + + def __init__( + self, + parent: tk.Widget, + fetcher: CandleFetcher, + coin: str, + settings_getter: Callable[[], dict], + trade_history_path: str, + ) -> None: + super().__init__(parent) + self.fetcher = fetcher + self.coin = coin + self.settings_getter = settings_getter + self.trade_history_path = trade_history_path + + self.timeframe_var = tk.StringVar(value=self.settings_getter()["default_timeframe"]) + + top = ttk.Frame(self) + top.pack(fill="x", padx=6, pady=6) + + ttk.Label(top, text=f"{coin} chart").pack(side="left") + + ttk.Label(top, text="Timeframe:").pack(side="left", padx=(12, 4)) + self.tf_combo = ttk.Combobox( + top, + textvariable=self.timeframe_var, + values=self.settings_getter()["timeframes"], + state="readonly", + width=10, + ) + self.tf_combo.pack(side="left") + + self._tf_after_id: Optional[str] = None + + def _debounced_tf_change(*_: object) -> None: + try: + if self._tf_after_id: + self.after_cancel(self._tf_after_id) + except Exception: + pass + + def _do() -> None: + try: + self.event_generate("<>", when="tail") + except Exception: + pass + + self._tf_after_id = self.after(120, _do) + + self.tf_combo.bind("<>", _debounced_tf_change) + + self.neural_status_label = ttk.Label(top, text="Neural: N/A") + self.neural_status_label.pack(side="left", padx=(12, 0)) + + self.last_update_label = ttk.Label(top, text="Last: N/A") + self.last_update_label.pack(side="right") + + self.fig = Figure(figsize=(6.5, 3.5), dpi=100) + self.fig.patch.set_facecolor(DARK_BG) + self.fig.subplots_adjust(bottom=0.20, right=0.87, top=0.8) + + self.ax = self.fig.add_subplot(111) + self._apply_dark_chart_style() + self.ax.set_title(f"{coin}", color=DARK_FG) + + self.canvas = FigureCanvasTkAgg(self.fig, master=self) + canvas_w = self.canvas.get_tk_widget() + canvas_w.configure(bg=DARK_BG) + canvas_w.pack(fill="both", expand=True, padx=0, pady=(0, 6)) + + self._last_canvas_px = (0, 0) + self._resize_after_id: Optional[str] = None + + def _on_canvas_configure(e: Any) -> None: + try: + w = int(e.width) + h = int(e.height) + if w <= 1 or h <= 1: + return + if (w, h) == self._last_canvas_px: + return + self._last_canvas_px = (w, h) + dpi = float(self.fig.get_dpi() or 100.0) + self.fig.set_size_inches(w / dpi, h / dpi, forward=True) + if self._resize_after_id: + try: + self.after_cancel(self._resize_after_id) + except Exception: + pass + self._resize_after_id = self.after_idle(self.canvas.draw_idle) + except Exception: + pass + + canvas_w.bind("", _on_canvas_configure, add="+") + + self._last_refresh = 0.0 + self._neural_cache: Dict[str, Any] = {} + + def _apply_dark_chart_style(self) -> None: + try: + self.fig.patch.set_facecolor(DARK_BG) + self.ax.set_facecolor(DARK_PANEL) + self.ax.tick_params(colors=DARK_FG) + for spine in self.ax.spines.values(): + spine.set_color(DARK_BORDER) + self.ax.grid(True, color=DARK_BORDER, linewidth=0.6, alpha=0.35) + except Exception: + pass + + def refresh( + self, + coin_folders: Dict[str, str], + current_buy_price: Optional[float] = None, + current_sell_price: Optional[float] = None, + trail_line: Optional[float] = None, + dca_line_price: Optional[float] = None, + avg_cost_basis: Optional[float] = None, + ) -> None: + cfg = self.settings_getter() + tf = self.timeframe_var.get().strip() + limit = int(cfg.get("candles_limit", 120)) + + candles = self.fetcher.get_klines(self.coin, tf, limit=limit) + + folder = coin_folders.get(self.coin, "") + low_path = os.path.join(folder, "low_bound_prices.html") + high_path = os.path.join(folder, "high_bound_prices.html") + + def _cached(path: str, loader: Callable, default: Any) -> Any: + try: + mtime = os.path.getmtime(path) + except Exception: + return default + hit = self._neural_cache.get(path) + if hit and hit[0] == mtime: + return hit[1] + v = loader(path) + self._neural_cache[path] = (mtime, v) + return v + + long_levels = _cached(low_path, read_price_levels_from_html, []) if folder else [] + short_levels = _cached(high_path, read_price_levels_from_html, []) if folder else [] + + long_sig_path = os.path.join(folder, "long_dca_signal.txt") + long_sig = _cached(long_sig_path, read_int_from_file, 0) if folder else 0 + short_sig = read_short_signal(folder) if folder else 0 + + try: + self.ax.lines.clear() + self.ax.patches.clear() + self.ax.collections.clear() + self.ax.texts.clear() + except Exception: + self.ax.cla() + self._apply_dark_chart_style() + + if not candles: + self.ax.set_title(f"{self.coin} ({tf}) - no candles", color=DARK_FG) + self.canvas.draw_idle() + return + + xs = getattr(self, "_xs", None) + if not xs or len(xs) != len(candles): + xs = list(range(len(candles))) + self._xs = xs + + rects = [] + for i, c in enumerate(candles): + o = float(c["open"]) + cl = float(c["close"]) + h = float(c["high"]) + l = float(c["low"]) + + up = cl >= o + candle_color = "green" if up else "red" + + self.ax.plot([i, i], [l, h], linewidth=1, color=candle_color) + + bottom = min(o, cl) + height = abs(cl - o) + if height < 1e-12: + height = 1e-12 + + rects.append( + Rectangle( + (i - 0.35, bottom), 0.7, height, + facecolor=candle_color, edgecolor=candle_color, + linewidth=1, alpha=0.9, + ) + ) + + for r in rects: + self.ax.add_patch(r) + + try: + y_low = min(float(c["low"]) for c in candles) + y_high = max(float(c["high"]) for c in candles) + pad = (y_high - y_low) * 0.03 + if not math.isfinite(pad) or pad <= 0: + pad = max(abs(y_low) * 0.001, 1e-6) + self.ax.set_ylim(y_low - pad, y_high + pad) + except Exception: + pass + + for lv in long_levels: + try: + self.ax.axhline(y=float(lv), linewidth=1, color="blue", alpha=0.8) + except Exception: + pass + + for lv in short_levels: + try: + self.ax.axhline(y=float(lv), linewidth=1, color="orange", alpha=0.8) + except Exception: + pass + + try: + if trail_line is not None and float(trail_line) > 0: + self.ax.axhline(y=float(trail_line), linewidth=1.5, color="green", alpha=0.95) + except Exception: + pass + + try: + if dca_line_price is not None and float(dca_line_price) > 0: + self.ax.axhline(y=float(dca_line_price), linewidth=1.5, color="red", alpha=0.95) + except Exception: + pass + + try: + if avg_cost_basis is not None and float(avg_cost_basis) > 0: + self.ax.axhline(y=float(avg_cost_basis), linewidth=1.5, color="yellow", alpha=0.95) + except Exception: + pass + + try: + if current_buy_price is not None and float(current_buy_price) > 0: + self.ax.axhline(y=float(current_buy_price), linewidth=1.5, color="purple", alpha=0.95) + except Exception: + pass + + try: + if current_sell_price is not None and float(current_sell_price) > 0: + self.ax.axhline(y=float(current_sell_price), linewidth=1.5, color="teal", alpha=0.95) + except Exception: + pass + + try: + trans = blended_transform_factory(self.ax.transAxes, self.ax.transData) + used_y: List[float] = [] + y0, y1 = self.ax.get_ylim() + y_pad = max((y1 - y0) * 0.012, 1e-9) + + def _label_right(y: Optional[float], tag: str, color: str) -> None: + if y is None: + return + try: + yy = float(y) + if (not math.isfinite(yy)) or yy <= 0: + return + except Exception: + return + for prev in used_y: + if abs(yy - prev) < y_pad: + yy = prev + y_pad + used_y.append(yy) + self.ax.text( + 1.01, yy, f"{tag} {fmt_price(yy)}", + transform=trans, ha="left", va="center", fontsize=8, color=color, + bbox=dict(facecolor=DARK_BG2, edgecolor=color, boxstyle="round,pad=0.18", alpha=0.85), + zorder=20, clip_on=False, + ) + + _label_right(current_buy_price, "ASK", "purple") + _label_right(current_sell_price, "BID", "teal") + _label_right(avg_cost_basis, "AVG", "yellow") + _label_right(dca_line_price, "DCA", "red") + _label_right(trail_line, "SELL", "green") + except Exception: + pass + + try: + trades = read_trade_history_jsonl(self.trade_history_path) if self.trade_history_path else [] + if trades: + candle_ts = [int(c["ts"]) for c in candles] + t_min = float(candle_ts[0]) + t_max = float(candle_ts[-1]) + + for tr in trades: + sym = str(tr.get("symbol", "")).upper() + base = sym.split("-")[0].strip() if sym else "" + if base != self.coin.upper().strip(): + continue + + side = str(tr.get("side", "")).lower().strip() + tag = str(tr.get("tag") or "").upper().strip() + + if side == "buy": + label = "DCA" if tag == "DCA" else "BUY" + color = "purple" if tag == "DCA" else "red" + elif side == "sell": + label = "SELL" + color = "green" + else: + continue + + tts = tr.get("ts", None) + if tts is None: + continue + try: + tts = float(tts) + except Exception: + continue + if tts < t_min or tts > t_max: + continue + + i = bisect.bisect_left(candle_ts, tts) + if i <= 0: + idx = 0 + elif i >= len(candle_ts): + idx = len(candle_ts) - 1 + else: + idx = i if abs(candle_ts[i] - tts) < abs(tts - candle_ts[i - 1]) else (i - 1) + + y = None + try: + p = tr.get("price", None) + if p is not None and float(p) > 0: + y = float(p) + except Exception: + y = None + if y is None: + try: + y = float(candles[idx].get("close", 0.0)) + except Exception: + y = None + if y is None: + continue + + x = idx + self.ax.scatter([x], [y], s=35, color=color, zorder=6) + self.ax.annotate( + label, (x, y), textcoords="offset points", xytext=(0, 10), + ha="center", fontsize=8, color=DARK_FG, zorder=7, + ) + except Exception: + pass + + self.ax.set_xlim(-0.5, (len(candles) - 0.5) + 0.6) + self.ax.set_title(f"{self.coin} ({tf})", color=DARK_FG) + + n = len(candles) + want = 5 + if n <= want: + idxs = list(range(n)) + else: + step = (n - 1) / float(want - 1) + idxs = [] + last = -1 + for j in range(want): + i = int(round(j * step)) + if i <= last: + i = last + 1 + if i >= n: + i = n - 1 + idxs.append(i) + last = i + + tick_x = [xs[i] for i in idxs] + tick_lbl = [ + time.strftime("%Y-%m-%d\n%H:%M", time.localtime(int(candles[i].get("ts", 0)))) + for i in idxs + ] + + try: + self.ax.minorticks_off() + self.ax.set_xticks(tick_x) + self.ax.set_xticklabels(tick_lbl) + self.ax.tick_params(axis="x", labelsize=8) + except Exception: + pass + + self.canvas.draw_idle() + + self.neural_status_label.config( + text=f"Neural: long={long_sig} short={short_sig} | levels L={len(long_levels)} S={len(short_levels)}" + ) + + last_ts = None + try: + if os.path.isfile(low_path): + last_ts = os.path.getmtime(low_path) + elif os.path.isfile(high_path): + last_ts = os.path.getmtime(high_path) + except Exception: + last_ts = None + + if last_ts: + self.last_update_label.config(text=f"Last: {time.strftime('%H:%M:%S', time.localtime(last_ts))}") + else: + self.last_update_label.config(text="Last: N/A") diff --git a/src/powertrader/hub/components/candle_fetcher.py b/src/powertrader/hub/components/candle_fetcher.py new file mode 100644 index 000000000..65bb99d13 --- /dev/null +++ b/src/powertrader/hub/components/candle_fetcher.py @@ -0,0 +1,90 @@ +"""Market data fetcher for candlestick charts (KuCoin).""" + +from __future__ import annotations + +import time +from typing import Dict, List, Optional, Tuple + + +class CandleFetcher: + """Uses kucoin-python if available; otherwise falls back to KuCoin REST via requests.""" + + def __init__(self) -> None: + self._mode = "kucoin_client" + self._market: object = None + try: + from kucoin.client import Market # type: ignore[import-untyped] + self._market = Market(url="https://api.kucoin.com") + except Exception: + self._mode = "rest" + self._market = None + + if self._mode == "rest": + import requests # type: ignore[import-untyped] + self._requests = requests + + self._cache: Dict[Tuple[str, str, int], Tuple[float, List[dict]]] = {} + self._cache_ttl_seconds: float = 10.0 + + def get_klines(self, symbol: str, timeframe: str, limit: int = 120) -> List[dict]: + """Return candles oldest->newest as [{"ts", "open", "high", "low", "close"}, ...].""" + symbol = symbol.upper().strip() + pair = f"{symbol}-USDT" + limit = int(limit or 0) + + now = time.time() + cache_key = (pair, timeframe, limit) + cached = self._cache.get(cache_key) + if cached and (now - float(cached[0])) <= float(self._cache_ttl_seconds): + return cached[1] + + tf_seconds = { + "1min": 60, "5min": 300, "15min": 900, "30min": 1800, + "1hour": 3600, "2hour": 7200, "4hour": 14400, "8hour": 28800, "12hour": 43200, + "1day": 86400, "1week": 604800, + }.get(timeframe, 3600) + + end_at = int(now) + start_at = end_at - (tf_seconds * max(200, (limit + 50) if limit else 250)) + + if self._mode == "kucoin_client" and self._market is not None: + try: + try: + raw = self._market.get_kline(pair, timeframe, startAt=start_at, endAt=end_at) # type: ignore[attr-defined] + except Exception: + raw = self._market.get_kline(pair, timeframe) # type: ignore[attr-defined] + + candles: List[dict] = [] + for row in raw: + ts = int(float(row[0])) + o = float(row[1]); c = float(row[2]); h = float(row[3]); l = float(row[4]) + candles.append({"ts": ts, "open": o, "high": h, "low": l, "close": c}) + candles.sort(key=lambda x: x["ts"]) + if limit and len(candles) > limit: + candles = candles[-limit:] + + self._cache[cache_key] = (now, candles) + return candles + except Exception: + return [] + + # REST fallback + try: + url = "https://api.kucoin.com/api/v1/market/candles" + params = {"symbol": pair, "type": timeframe, "startAt": start_at, "endAt": end_at} + resp = self._requests.get(url, params=params, timeout=10) + j = resp.json() + data = j.get("data", []) + candles = [] + for row in data: + ts = int(float(row[0])) + o = float(row[1]); c = float(row[2]); h = float(row[3]); l = float(row[4]) + candles.append({"ts": ts, "open": o, "high": h, "low": l, "close": c}) + candles.sort(key=lambda x: x["ts"]) + if limit and len(candles) > limit: + candles = candles[-limit:] + + self._cache[cache_key] = (now, candles) + return candles + except Exception: + return [] diff --git a/src/powertrader/hub/components/signal_tile.py b/src/powertrader/hub/components/signal_tile.py new file mode 100644 index 000000000..b2893c02b --- /dev/null +++ b/src/powertrader/hub/components/signal_tile.py @@ -0,0 +1,181 @@ +"""Neural signal level visualization tile (per coin).""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import Any, List + +from powertrader.hub.theme import ( + DARK_ACCENT2, + DARK_BORDER, + DARK_FG, + DARK_PANEL, + DARK_PANEL2, +) + + +class NeuralSignalTile(ttk.Frame): + + def __init__( + self, + parent: tk.Widget, + coin: str, + bar_height: int = 52, + levels: int = 8, + trade_start_level: int = 3, + ) -> None: + super().__init__(parent) + self.coin = coin + + self._hover_on = False + self._normal_canvas_bg = DARK_PANEL2 + self._hover_canvas_bg = DARK_PANEL + self._normal_border = DARK_BORDER + self._hover_border = DARK_ACCENT2 + self._normal_fg = DARK_FG + self._hover_fg = DARK_ACCENT2 + + self._levels = max(2, int(levels)) + self._display_levels = self._levels - 1 + + self._bar_h = int(bar_height) + self._bar_w = 12 + self._gap = 16 + self._pad = 6 + + self._base_fill = DARK_PANEL + self._long_fill = "blue" + self._short_fill = "orange" + + self.title_lbl = ttk.Label(self, text=coin) + self.title_lbl.pack(anchor="center") + + w = (self._pad * 2) + (self._bar_w * 2) + self._gap + h = (self._pad * 2) + self._bar_h + + self.canvas = tk.Canvas( + self, + width=w, + height=h, + bg=self._normal_canvas_bg, + highlightthickness=1, + highlightbackground=self._normal_border, + ) + self.canvas.pack(padx=2, pady=(2, 0)) + + x0 = self._pad + x1 = x0 + self._bar_w + x2 = x1 + self._gap + x3 = x2 + self._bar_w + yb = self._pad + self._bar_h + + self._long_segs: List[int] = [] + self._short_segs: List[int] = [] + + for seg in range(self._display_levels): + y_top = int(round(yb - ((seg + 1) * self._bar_h / self._display_levels))) + y_bot = int(round(yb - (seg * self._bar_h / self._display_levels))) + + self._long_segs.append( + self.canvas.create_rectangle( + x0, y_top, x1, y_bot, + fill=self._base_fill, + outline=DARK_BORDER, + width=1, + ) + ) + self._short_segs.append( + self.canvas.create_rectangle( + x2, y_top, x3, y_bot, + fill=self._base_fill, + outline=DARK_BORDER, + width=1, + ) + ) + + self._trade_line_geom = (x0, x1, x2, x3, yb) + self._trade_line_long = self.canvas.create_line(x0, yb, x1, yb, fill=DARK_FG, width=2) + self._trade_line_short = self.canvas.create_line(x2, yb, x3, yb, fill=DARK_FG, width=2) + self._trade_start_level = 3 + self.set_trade_start_level(trade_start_level) + + self.value_lbl = ttk.Label(self, text="L:0 S:0") + self.value_lbl.pack(anchor="center", pady=(1, 0)) + + self.set_values(0, 0) + + def set_hover(self, on: bool) -> None: + if bool(on) == bool(self._hover_on): + return + self._hover_on = bool(on) + try: + if self._hover_on: + self.canvas.configure( + bg=self._hover_canvas_bg, + highlightbackground=self._hover_border, + highlightthickness=2, + ) + self.title_lbl.configure(foreground=self._hover_fg) + self.value_lbl.configure(foreground=self._hover_fg) + else: + self.canvas.configure( + bg=self._normal_canvas_bg, + highlightbackground=self._normal_border, + highlightthickness=1, + ) + self.title_lbl.configure(foreground=self._normal_fg) + self.value_lbl.configure(foreground=self._normal_fg) + except Exception: + pass + + def set_trade_start_level(self, level: Any) -> None: + self._trade_start_level = self._clamp_trade_start_level(level) + self._update_trade_lines() + + def _clamp_trade_start_level(self, value: Any) -> int: + try: + v = int(float(value)) + except Exception: + v = 3 + return max(1, min(v, self._display_levels)) + + def _update_trade_lines(self) -> None: + try: + x0, x1, x2, x3, yb = self._trade_line_geom + except Exception: + return + k = max(0, min(int(self._trade_start_level) - 1, self._display_levels)) + y = int(round(yb - (k * self._bar_h / self._display_levels))) + try: + self.canvas.coords(self._trade_line_long, x0, y, x1, y) + self.canvas.coords(self._trade_line_short, x2, y, x3, y) + except Exception: + pass + + def _clamp_level(self, value: Any) -> int: + try: + v = int(float(value)) + except Exception: + v = 0 + return max(0, min(v, self._levels - 1)) + + def _set_level(self, seg_ids: List[int], level: int, active_fill: str) -> None: + for rid in seg_ids: + self.canvas.itemconfigure(rid, fill=self._base_fill) + if level <= 0: + return + idx = level - 1 + if idx < 0: + return + if idx >= len(seg_ids): + idx = len(seg_ids) - 1 + for i in range(idx + 1): + self.canvas.itemconfigure(seg_ids[i], fill=active_fill) + + def set_values(self, long_sig: Any, short_sig: Any) -> None: + ls = self._clamp_level(long_sig) + ss = self._clamp_level(short_sig) + self.value_lbl.config(text=f"L:{ls} S:{ss}") + self._set_level(self._long_segs, ls, self._long_fill) + self._set_level(self._short_segs, ss, self._short_fill) diff --git a/src/powertrader/hub/components/wrap_frame.py b/src/powertrader/hub/components/wrap_frame.py new file mode 100644 index 000000000..2fef0e3b6 --- /dev/null +++ b/src/powertrader/hub/components/wrap_frame.py @@ -0,0 +1,84 @@ +"""Auto-reflowing grid layout widget (like CSS flex-wrap).""" + +from __future__ import annotations + +import tkinter as tk +from dataclasses import dataclass +from tkinter import ttk +from typing import List, Tuple + + +@dataclass +class _WrapItem: + w: tk.Widget + padx: Tuple[int, int] = (0, 0) + pady: Tuple[int, int] = (0, 0) + + +class WrapFrame(ttk.Frame): + + def __init__(self, parent: tk.Widget, **kwargs: object) -> None: + super().__init__(parent, **kwargs) + self._items: List[_WrapItem] = [] + self._reflow_pending = False + self._in_reflow = False + self.bind("", self._schedule_reflow) + + def add(self, widget: tk.Widget, padx: Tuple[int, int] = (0, 0), pady: Tuple[int, int] = (0, 0)) -> None: + self._items.append(_WrapItem(widget, padx=padx, pady=pady)) + self._schedule_reflow() + + def clear(self, destroy_widgets: bool = True) -> None: + for it in list(self._items): + try: + it.w.grid_forget() + except Exception: + pass + if destroy_widgets: + try: + it.w.destroy() + except Exception: + pass + self._items = [] + self._schedule_reflow() + + def _schedule_reflow(self, event: object = None) -> None: + if self._reflow_pending: + return + self._reflow_pending = True + self.after_idle(self._reflow) + + def _reflow(self) -> None: + if self._in_reflow: + self._reflow_pending = False + return + + self._reflow_pending = False + self._in_reflow = True + try: + width = self.winfo_width() + if width <= 1: + return + usable_width = max(1, width - 6) + + for it in self._items: + it.w.grid_forget() + + row = 0 + col = 0 + x = 0 + + for it in self._items: + reqw = max(it.w.winfo_reqwidth(), it.w.winfo_width()) + needed = 10 + reqw + it.padx[0] + it.padx[1] + + if col > 0 and (x + needed) > usable_width: + row += 1 + col = 0 + x = 0 + + it.w.grid(row=row, column=col, sticky="w", padx=it.padx, pady=it.pady) + x += needed + col += 1 + finally: + self._in_reflow = False diff --git a/src/powertrader/hub/dialogs/__init__.py b/src/powertrader/hub/dialogs/__init__.py new file mode 100644 index 000000000..106b492a8 --- /dev/null +++ b/src/powertrader/hub/dialogs/__init__.py @@ -0,0 +1 @@ +"""Dialog windows for the PowerTrader Hub.""" diff --git a/src/powertrader/hub/dialogs/settings_dialog.py b/src/powertrader/hub/dialogs/settings_dialog.py new file mode 100644 index 000000000..3c45c15f0 --- /dev/null +++ b/src/powertrader/hub/dialogs/settings_dialog.py @@ -0,0 +1,654 @@ +"""Settings dialog window for the PowerTrader Hub.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Set, Tuple + +from powertrader.hub.theme import DARK_BG, DARK_BG2, DARK_BORDER, DARK_FG +from powertrader.hub.utils import DEFAULT_SETTINGS, fmt_money + +if TYPE_CHECKING: + pass + + +class SettingsDialog: + """Modal settings dialog extracted from PowerTraderHub.open_settings_dialog().""" + + def __init__( + self, + parent: tk.Tk, + settings: dict, + project_dir: str, + last_total_account_value: float = 0.0, + on_save: Optional[Callable[[dict, Set[str]], None]] = None, + ) -> None: + self.parent = parent + self.settings = settings + self.project_dir = project_dir + self._last_total_account_value = last_total_account_value + self._on_save = on_save # callback(settings, prev_coins) after successful save + + self._build() + + def _build(self) -> None: + win = tk.Toplevel(self.parent) + self.win = win + win.title("Settings") + win.geometry("860x680") + win.minsize(760, 560) + win.configure(bg=DARK_BG) + + # Scrollable settings content + viewport = ttk.Frame(win) + viewport.pack(fill="both", expand=True, padx=12, pady=12) + viewport.grid_rowconfigure(0, weight=1) + viewport.grid_columnconfigure(0, weight=1) + + settings_canvas = tk.Canvas( + viewport, bg=DARK_BG, highlightthickness=1, + highlightbackground=DARK_BORDER, bd=0, + ) + settings_canvas.grid(row=0, column=0, sticky="nsew") + + settings_scroll = ttk.Scrollbar(viewport, orient="vertical", command=settings_canvas.yview) + settings_scroll.grid(row=0, column=1, sticky="ns") + settings_canvas.configure(yscrollcommand=settings_scroll.set) + + frm = ttk.Frame(settings_canvas) + settings_window = settings_canvas.create_window((0, 0), window=frm, anchor="nw") + + def _update_settings_scrollbars(event: object = None) -> None: + try: + c = settings_canvas + c.update_idletasks() + bbox = c.bbox(settings_window) + if not bbox: + settings_scroll.grid_remove() + return + c.configure(scrollregion=bbox) + content_h = int(bbox[3] - bbox[1]) + view_h = int(c.winfo_height()) + if content_h > (view_h + 1): + settings_scroll.grid() + else: + settings_scroll.grid_remove() + try: + c.yview_moveto(0) + except Exception: + pass + except Exception: + pass + + def _on_settings_canvas_configure(e: Any) -> None: + try: + settings_canvas.itemconfigure(settings_window, width=int(e.width)) + except Exception: + pass + _update_settings_scrollbars() + + settings_canvas.bind("", _on_settings_canvas_configure, add="+") + frm.bind("", _update_settings_scrollbars, add="+") + + def _wheel(e: Any) -> None: + try: + if settings_scroll.winfo_ismapped(): + settings_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") + except Exception: + pass + + settings_canvas.bind("", lambda _e: settings_canvas.focus_set(), add="+") + settings_canvas.bind("", _wheel, add="+") + settings_canvas.bind("", lambda _e: settings_canvas.yview_scroll(-3, "units"), add="+") + settings_canvas.bind("", lambda _e: settings_canvas.yview_scroll(3, "units"), add="+") + + frm.columnconfigure(0, weight=0) + frm.columnconfigure(1, weight=1) + frm.columnconfigure(2, weight=0) + + def add_row(r: int, label: str, var: tk.Variable, browse: Optional[str] = None) -> None: + ttk.Label(frm, text=label).grid(row=r, column=0, sticky="w", padx=(0, 10), pady=6) + ent = ttk.Entry(frm, textvariable=var) + ent.grid(row=r, column=1, sticky="ew", pady=6) + if browse == "dir": + def do_browse() -> None: + picked = filedialog.askdirectory() + if picked: + var.set(picked) + ttk.Button(frm, text="Browse", command=do_browse).grid(row=r, column=2, sticky="e", padx=(10, 0), pady=6) + else: + ttk.Label(frm, text="").grid(row=r, column=2, sticky="e", padx=(10, 0), pady=6) + + s = self.settings + main_dir_var = tk.StringVar(value=s["main_neural_dir"]) + coins_var = tk.StringVar(value=",".join(s["coins"])) + trade_start_level_var = tk.StringVar(value=str(s.get("trade_start_level", 3))) + start_alloc_pct_var = tk.StringVar(value=str(s.get("start_allocation_pct", 0.005))) + dca_mult_var = tk.StringVar(value=str(s.get("dca_multiplier", 2.0))) + _dca_levels = s.get("dca_levels", DEFAULT_SETTINGS.get("dca_levels", [])) + if not isinstance(_dca_levels, list): + _dca_levels = DEFAULT_SETTINGS.get("dca_levels", []) + dca_levels_var = tk.StringVar(value=",".join(str(x) for x in _dca_levels)) + max_dca_var = tk.StringVar(value=str(s.get("max_dca_buys_per_24h", DEFAULT_SETTINGS.get("max_dca_buys_per_24h", 2)))) + + pm_no_dca_var = tk.StringVar(value=str(s.get("pm_start_pct_no_dca", DEFAULT_SETTINGS.get("pm_start_pct_no_dca", 5.0)))) + pm_with_dca_var = tk.StringVar(value=str(s.get("pm_start_pct_with_dca", DEFAULT_SETTINGS.get("pm_start_pct_with_dca", 2.5)))) + trailing_gap_var = tk.StringVar(value=str(s.get("trailing_gap_pct", DEFAULT_SETTINGS.get("trailing_gap_pct", 0.5)))) + + hub_dir_var = tk.StringVar(value=s.get("hub_data_dir", "")) + + neural_script_var = tk.StringVar(value=s["script_neural_runner2"]) + trainer_script_var = tk.StringVar(value=s.get("script_neural_trainer", "pt_trainer.py")) + trader_script_var = tk.StringVar(value=s["script_trader"]) + + ui_refresh_var = tk.StringVar(value=str(s["ui_refresh_seconds"])) + chart_refresh_var = tk.StringVar(value=str(s["chart_refresh_seconds"])) + candles_limit_var = tk.StringVar(value=str(s["candles_limit"])) + auto_start_var = tk.BooleanVar(value=bool(s.get("auto_start_scripts", False))) + + r = 0 + add_row(r, "Main neural folder:", main_dir_var, browse="dir"); r += 1 + add_row(r, "Coins (comma):", coins_var); r += 1 + add_row(r, "Trade start level (1-7):", trade_start_level_var); r += 1 + + # Start allocation % with hint + ttk.Label(frm, text="Start allocation %:").grid(row=r, column=0, sticky="w", padx=(0, 10), pady=6) + ttk.Entry(frm, textvariable=start_alloc_pct_var).grid(row=r, column=1, sticky="ew", pady=6) + + start_alloc_hint_var = tk.StringVar(value="") + ttk.Label(frm, textvariable=start_alloc_hint_var).grid(row=r, column=2, sticky="w", padx=(10, 0), pady=6) + + total_val = self._last_total_account_value + + def _update_start_alloc_hint(*_: object) -> None: + try: + pct_txt = (start_alloc_pct_var.get() or "").strip().replace("%", "") + pct = float(pct_txt) if pct_txt else 0.0 + except Exception: + pct = float(s.get("start_allocation_pct", 0.005) or 0.005) + if pct < 0.0: + pct = 0.0 + + per_coin = 0.0 + if total_val > 0.0: + per_coin = total_val * (pct / 100.0) + if per_coin < 0.5: + per_coin = 0.5 + + if total_val > 0.0: + start_alloc_hint_var.set(f"\u2248 {fmt_money(per_coin)} per coin (min $0.50)") + else: + start_alloc_hint_var.set("\u2248 $0.50 min per coin (needs account value)") + + _update_start_alloc_hint() + start_alloc_pct_var.trace_add("write", _update_start_alloc_hint) + coins_var.trace_add("write", _update_start_alloc_hint) + + r += 1 + + add_row(r, "DCA levels (% list):", dca_levels_var); r += 1 + add_row(r, "DCA multiplier:", dca_mult_var); r += 1 + add_row(r, "Max DCA buys / coin (rolling 24h):", max_dca_var); r += 1 + add_row(r, "Trailing PM start % (no DCA):", pm_no_dca_var); r += 1 + add_row(r, "Trailing PM start % (with DCA):", pm_with_dca_var); r += 1 + add_row(r, "Trailing gap % (behind peak):", trailing_gap_var); r += 1 + add_row(r, "Hub data dir (optional):", hub_dir_var, browse="dir"); r += 1 + + ttk.Separator(frm, orient="horizontal").grid(row=r, column=0, columnspan=3, sticky="ew", pady=10); r += 1 + + add_row(r, "pt_thinker.py path:", neural_script_var); r += 1 + add_row(r, "pt_trainer.py path:", trainer_script_var); r += 1 + add_row(r, "pt_trader.py path:", trader_script_var); r += 1 + + # Binance API setup + self._build_api_section(frm, r, win) + r += 1 + + ttk.Separator(frm, orient="horizontal").grid(row=r, column=0, columnspan=3, sticky="ew", pady=10); r += 1 + + add_row(r, "UI refresh seconds:", ui_refresh_var); r += 1 + add_row(r, "Chart refresh seconds:", chart_refresh_var); r += 1 + add_row(r, "Candles limit:", candles_limit_var); r += 1 + + chk = ttk.Checkbutton(frm, text="Auto start scripts on GUI launch", variable=auto_start_var) + chk.grid(row=r, column=0, columnspan=3, sticky="w", pady=(10, 0)); r += 1 + + btns = ttk.Frame(frm) + btns.grid(row=r, column=0, columnspan=3, sticky="ew", pady=14) + btns.columnconfigure(0, weight=1) + + def save() -> None: + try: + prev_coins = set(str(c).strip().upper() for c in (s.get("coins") or []) if str(c).strip()) + + s["main_neural_dir"] = main_dir_var.get().strip() + s["coins"] = [c.strip().upper() for c in coins_var.get().split(",") if c.strip()] + s["trade_start_level"] = max(1, min(int(float(trade_start_level_var.get().strip())), 7)) + + sap = (start_alloc_pct_var.get() or "").strip().replace("%", "") + s["start_allocation_pct"] = max(0.0, float(sap or 0.0)) + + dm = (dca_mult_var.get() or "").strip() + try: + dm_f = float(dm) + except Exception: + dm_f = float(s.get("dca_multiplier", DEFAULT_SETTINGS.get("dca_multiplier", 2.0)) or 2.0) + if dm_f < 0.0: + dm_f = 0.0 + s["dca_multiplier"] = dm_f + + raw_dca = (dca_levels_var.get() or "").replace(",", " ").split() + dca_levels = [] + for tok in raw_dca: + try: + dca_levels.append(float(tok)) + except Exception: + pass + if not dca_levels: + dca_levels = list(DEFAULT_SETTINGS.get("dca_levels", [])) + s["dca_levels"] = dca_levels + + md = (max_dca_var.get() or "").strip() + try: + md_i = int(float(md)) + except Exception: + md_i = int(s.get("max_dca_buys_per_24h", DEFAULT_SETTINGS.get("max_dca_buys_per_24h", 2)) or 2) + if md_i < 0: + md_i = 0 + s["max_dca_buys_per_24h"] = md_i + + try: + pm0 = float((pm_no_dca_var.get() or "").strip().replace("%", "") or 0.0) + except Exception: + pm0 = float(s.get("pm_start_pct_no_dca", DEFAULT_SETTINGS.get("pm_start_pct_no_dca", 5.0)) or 5.0) + if pm0 < 0.0: + pm0 = 0.0 + s["pm_start_pct_no_dca"] = pm0 + + try: + pm1 = float((pm_with_dca_var.get() or "").strip().replace("%", "") or 0.0) + except Exception: + pm1 = float(s.get("pm_start_pct_with_dca", DEFAULT_SETTINGS.get("pm_start_pct_with_dca", 2.5)) or 2.5) + if pm1 < 0.0: + pm1 = 0.0 + s["pm_start_pct_with_dca"] = pm1 + + try: + tg = float((trailing_gap_var.get() or "").strip().replace("%", "") or 0.0) + except Exception: + tg = float(s.get("trailing_gap_pct", DEFAULT_SETTINGS.get("trailing_gap_pct", 0.5)) or 0.5) + if tg < 0.0: + tg = 0.0 + s["trailing_gap_pct"] = tg + + s["hub_data_dir"] = hub_dir_var.get().strip() + s["script_neural_runner2"] = neural_script_var.get().strip() + s["script_neural_trainer"] = trainer_script_var.get().strip() + s["script_trader"] = trader_script_var.get().strip() + s["ui_refresh_seconds"] = float(ui_refresh_var.get().strip()) + s["chart_refresh_seconds"] = float(chart_refresh_var.get().strip()) + s["candles_limit"] = int(float(candles_limit_var.get().strip())) + s["auto_start_scripts"] = bool(auto_start_var.get()) + + # Create folders for newly added coins + try: + new_coins = [c.strip().upper() for c in (s.get("coins") or []) if c.strip()] + added = [c for c in new_coins if c and c not in prev_coins] + main_dir = s.get("main_neural_dir") or self.project_dir + trainer_name = os.path.basename(str(s.get("script_neural_trainer", "neural_trainer.py"))) + src_main_trainer = os.path.join(main_dir, trainer_name) + src_cfg_trainer = str(s.get("script_neural_trainer", trainer_name)) + src_trainer_path = src_main_trainer if os.path.isfile(src_main_trainer) else src_cfg_trainer + + for coin in added: + if coin == "BTC": + continue + coin_dir = os.path.join(main_dir, coin) + if not os.path.isdir(coin_dir): + os.makedirs(coin_dir, exist_ok=True) + dst_trainer_path = os.path.join(coin_dir, trainer_name) + if (not os.path.isfile(dst_trainer_path)) and os.path.isfile(src_trainer_path): + shutil.copy2(src_trainer_path, dst_trainer_path) + except Exception: + pass + + if self._on_save: + self._on_save(s, prev_coins) + + messagebox.showinfo("Saved", "Settings saved.") + win.destroy() + + except Exception as e: + messagebox.showerror("Error", f"Failed to save settings:\n{e}") + + ttk.Button(btns, text="Save", command=save).pack(side="left") + ttk.Button(btns, text="Cancel", command=win.destroy).pack(side="left", padx=8) + + def _build_api_section(self, frm: ttk.Frame, r: int, win: tk.Toplevel) -> None: + """Build the Binance API credentials section.""" + + def _api_paths() -> Tuple[str, str]: + key_path = os.path.join(self.project_dir, "b_key.txt") + secret_path = os.path.join(self.project_dir, "b_secret.txt") + return key_path, secret_path + + def _read_api_files() -> Tuple[str, str]: + key_path, secret_path = _api_paths() + try: + with open(key_path, "r", encoding="utf-8") as f: + k = (f.read() or "").strip() + except Exception: + k = "" + try: + with open(secret_path, "r", encoding="utf-8") as f: + s = (f.read() or "").strip() + except Exception: + s = "" + return k, s + + api_status_var = tk.StringVar(value="") + + def _refresh_api_status() -> None: + k, s = _read_api_files() + missing = [] + if not k: + missing.append("b_key.txt (API Key)") + if not s: + missing.append("b_secret.txt (Secret Key)") + if missing: + api_status_var.set("Not configured \u274c (missing " + ", ".join(missing) + ")") + else: + api_status_var.set("Configured \u2705 (credentials found)") + + def _open_api_folder() -> None: + try: + folder = os.path.abspath(self.project_dir) + if os.name == "nt": + os.startfile(folder) # type: ignore[attr-defined] + return + if sys.platform == "darwin": + subprocess.Popen(["open", folder]) + return + subprocess.Popen(["xdg-open", folder]) + except Exception as e: + messagebox.showerror("Couldn't open folder", f"Tried to open:\n{self.project_dir}\n\nError:\n{e}") + + def _clear_api_files() -> None: + key_path, secret_path = _api_paths() + if not messagebox.askyesno( + "Delete API credentials?", + "This will delete:\n" + f" {key_path}\n" + f" {secret_path}\n\n" + "After deleting, the trader can NOT authenticate until you run the setup wizard again.\n\n" + "Are you sure you want to delete these files?" + ): + return + try: + if os.path.isfile(key_path): + os.remove(key_path) + if os.path.isfile(secret_path): + os.remove(secret_path) + except Exception as e: + messagebox.showerror("Delete failed", f"Couldn't delete the files:\n\n{e}") + return + _refresh_api_status() + messagebox.showinfo("Deleted", "Deleted b_key.txt and b_secret.txt.") + + def _open_binance_api_wizard() -> None: + self._build_api_wizard(win, _api_paths, _read_api_files, _refresh_api_status) + + ttk.Label(frm, text="Binance API:").grid(row=r, column=0, sticky="w", padx=(0, 10), pady=6) + + api_row = ttk.Frame(frm) + api_row.grid(row=r, column=1, columnspan=2, sticky="ew", pady=6) + api_row.columnconfigure(0, weight=1) + + ttk.Label(api_row, textvariable=api_status_var).grid(row=0, column=0, sticky="w") + ttk.Button(api_row, text="Setup Wizard", command=_open_binance_api_wizard).grid(row=0, column=1, sticky="e", padx=(10, 0)) + ttk.Button(api_row, text="Open Folder", command=_open_api_folder).grid(row=0, column=2, sticky="e", padx=(8, 0)) + ttk.Button(api_row, text="Clear", command=_clear_api_files).grid(row=0, column=3, sticky="e", padx=(8, 0)) + + _refresh_api_status() + + def _build_api_wizard( + self, + parent_win: tk.Toplevel, + api_paths_fn: Callable[[], Tuple[str, str]], + read_api_fn: Callable[[], Tuple[str, str]], + refresh_status_fn: Callable[[], None], + ) -> None: + """Binance API setup wizard dialog.""" + import webbrowser + from datetime import datetime + + try: + from binance.client import Client as BinanceClient # type: ignore[import-untyped] + except Exception: + messagebox.showerror( + "Missing dependency", + "The 'python-binance' package is required for Binance API setup.\n\n" + "Fix: open a Command Prompt / Terminal in this folder and run:\n" + " pip install python-binance\n\n" + "Then re-open this Setup Wizard." + ) + return + + wiz = tk.Toplevel(parent_win) + wiz.title("Binance API Setup") + wiz.geometry("980x620") + wiz.minsize(860, 520) + wiz.configure(bg=DARK_BG) + + viewport = ttk.Frame(wiz) + viewport.pack(fill="both", expand=True, padx=12, pady=12) + viewport.grid_rowconfigure(0, weight=1) + viewport.grid_columnconfigure(0, weight=1) + + wiz_canvas = tk.Canvas(viewport, bg=DARK_BG, highlightthickness=1, highlightbackground=DARK_BORDER, bd=0) + wiz_canvas.grid(row=0, column=0, sticky="nsew") + + wiz_scroll = ttk.Scrollbar(viewport, orient="vertical", command=wiz_canvas.yview) + wiz_scroll.grid(row=0, column=1, sticky="ns") + wiz_canvas.configure(yscrollcommand=wiz_scroll.set) + + container = ttk.Frame(wiz_canvas) + wiz_window = wiz_canvas.create_window((0, 0), window=container, anchor="nw") + container.columnconfigure(0, weight=1) + + def _update_wiz_scrollbars(event: object = None) -> None: + try: + c = wiz_canvas + c.update_idletasks() + bbox = c.bbox(wiz_window) + if not bbox: + wiz_scroll.grid_remove() + return + c.configure(scrollregion=bbox) + content_h = int(bbox[3] - bbox[1]) + view_h = int(c.winfo_height()) + if content_h > (view_h + 1): + wiz_scroll.grid() + else: + wiz_scroll.grid_remove() + try: + c.yview_moveto(0) + except Exception: + pass + except Exception: + pass + + def _on_wiz_canvas_configure(e: Any) -> None: + try: + wiz_canvas.itemconfigure(wiz_window, width=int(e.width)) + except Exception: + pass + _update_wiz_scrollbars() + + wiz_canvas.bind("", _on_wiz_canvas_configure, add="+") + container.bind("", _update_wiz_scrollbars, add="+") + + def _wheel(e: Any) -> None: + try: + if wiz_scroll.winfo_ismapped(): + wiz_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") + except Exception: + pass + + wiz_canvas.bind("", lambda _e: wiz_canvas.focus_set(), add="+") + wiz_canvas.bind("", _wheel, add="+") + wiz_canvas.bind("", lambda _e: wiz_canvas.yview_scroll(-3, "units"), add="+") + wiz_canvas.bind("", lambda _e: wiz_canvas.yview_scroll(3, "units"), add="+") + + key_path, secret_path = api_paths_fn() + existing_api_key, existing_secret_key = read_api_fn() + + intro = ( + "This trader uses Binance Global API credentials (USDT pairs).\n\n" + "You only do this once. When finished, pt_trader.py can authenticate automatically.\n\n" + "How to get your API Key + Secret Key from Binance:\n" + " 1) Log in to binance.com\n" + " 2) Click your profile icon (top-right) -> API Management\n" + " 3) Click 'Create API' -> choose 'System generated'\n" + " 4) Give it a label (e.g. 'PowerTrader'), complete verification\n" + " 5) IMPORTANT: Enable 'Spot Trading' permission (read is enabled by default)\n" + " 6) Copy both the API Key and the Secret Key shown on screen\n" + " (The Secret Key is only shown once \u2014 copy it immediately!)\n" + " 7) Paste them into the fields below and click Save\n\n" + "This wizard will save two files in the same folder as pt_hub.py:\n" + " - b_key.txt (your API Key)\n" + " - b_secret.txt (your Secret Key) <- keep this secret like a password\n" + ) + + ttk.Label(container, text=intro, justify="left").grid(row=0, column=0, sticky="ew", pady=(0, 10)) + + top_btns = ttk.Frame(container) + top_btns.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + top_btns.columnconfigure(0, weight=1) + + ttk.Button(top_btns, text="Open Binance API Management", + command=lambda: webbrowser.open("https://www.binance.com/en/my/settings/api-management")).pack(side="left") + ttk.Button(top_btns, text="Binance API Docs", + command=lambda: webbrowser.open("https://www.binance.com/en/binance-api")).pack(side="left", padx=8) + + # Step 1 + step1 = ttk.LabelFrame(container, text="Step 1 \u2014 Enter your Binance API credentials") + step1.grid(row=2, column=0, sticky="nsew", pady=(0, 10)) + step1.columnconfigure(1, weight=1) + + ttk.Label(step1, text="API Key:").grid(row=0, column=0, sticky="w", padx=10, pady=(8, 4)) + api_key_var = tk.StringVar(value=existing_api_key or "") + ttk.Entry(step1, textvariable=api_key_var).grid(row=0, column=1, sticky="ew", padx=10, pady=(8, 4)) + + ttk.Label(step1, text="Secret Key:").grid(row=1, column=0, sticky="w", padx=10, pady=(4, 10)) + secret_key_var = tk.StringVar(value=existing_secret_key or "") + ttk.Entry(step1, textvariable=secret_key_var, show="*").grid(row=1, column=1, sticky="ew", padx=10, pady=(4, 10)) + + def _test_credentials() -> None: + api_key = (api_key_var.get() or "").strip() + secret_key = (secret_key_var.get() or "").strip() + if not api_key: + messagebox.showerror("Missing API Key", "Enter your Binance API Key first.") + return + if not secret_key: + messagebox.showerror("Missing Secret Key", "Enter your Binance Secret Key first.") + return + try: + client = BinanceClient(api_key, secret_key) + acct = client.get_account() + usdt_balance = "N/A" + for bal in acct.get("balances", []): + if bal.get("asset") == "USDT": + usdt_balance = f"{float(bal.get('free', 0.0)):.2f}" + break + messagebox.showinfo( + "Test successful", + "Your API Key + Secret Key worked!\n\n" + f"Binance responded successfully.\nUSDT balance: {usdt_balance}\n\nNext: click Save." + ) + except Exception as e: + err_str = str(e) + hint = "" + if "APIError(code=-2015)" in err_str or "Invalid API-key" in err_str: + hint = ( + "\n\nCommon fixes:\n" + " - Make sure you copied the full API Key and Secret Key\n" + " - Check that the API key is not restricted by IP (or add your IP)\n" + " - If you just created the key, wait 30-60 seconds and try again\n" + ) + elif "APIError(code=-2014)" in err_str: + hint = "\n\nHint: The API Key format appears invalid. Double-check you copied it correctly." + messagebox.showerror("Test failed", f"Couldn't connect to Binance.\n\nError:\n{err_str}{hint}") + + step1_btns = ttk.Frame(step1) + step1_btns.grid(row=2, column=0, columnspan=2, sticky="w", padx=10, pady=(0, 10)) + ttk.Button(step1_btns, text="Test Credentials (safe, no trading)", command=_test_credentials).pack(side="left") + + # Step 2 + step2 = ttk.LabelFrame(container, text="Step 2 \u2014 Save to files (required)") + step2.grid(row=3, column=0, sticky="nsew") + step2.columnconfigure(0, weight=1) + + ack_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + step2, text="I understand b_secret.txt is PRIVATE and I will not share it.", variable=ack_var, + ).grid(row=0, column=0, sticky="w", padx=10, pady=(10, 6)) + + save_btns = ttk.Frame(step2) + save_btns.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 12)) + + def do_save() -> None: + api_key = (api_key_var.get() or "").strip() + secret_key = (secret_key_var.get() or "").strip() + if not api_key: + messagebox.showerror("Missing API Key", "Enter your Binance API Key first.") + return + if not secret_key: + messagebox.showerror("Missing Secret Key", "Enter your Binance Secret Key first.") + return + if not bool(ack_var.get()): + messagebox.showwarning("Please confirm", "For safety, please check the box confirming you understand b_secret.txt is private.") + return + if len(api_key) < 10: + if not messagebox.askyesno("API key looks short", "That API key looks unusually short. Are you sure you copied the right value?"): + return + + try: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + if os.path.isfile(key_path): + shutil.copy2(key_path, f"{key_path}.bak_{ts}") + if os.path.isfile(secret_path): + shutil.copy2(secret_path, f"{secret_path}.bak_{ts}") + except Exception: + pass + + try: + with open(key_path, "w", encoding="utf-8") as f: + f.write(api_key) + with open(secret_path, "w", encoding="utf-8") as f: + f.write(secret_key) + except Exception as e: + messagebox.showerror("Save failed", f"Couldn't write the credential files.\n\nError:\n{e}") + return + + refresh_status_fn() + messagebox.showinfo( + "Saved", + "Saved!\n\n" + "The trader will automatically read these files next time it starts:\n" + f" API Key -> {os.path.abspath(key_path)}\n" + f" Secret Key -> {os.path.abspath(secret_path)}\n\n" + "Next steps:\n" + " 1) Close this window\n" + " 2) Start the trader (pt_trader.py)\n" + "If something fails, come back here and click 'Test Credentials'." + ) + wiz.destroy() + + ttk.Button(save_btns, text="Save", command=do_save).pack(side="left") + ttk.Button(save_btns, text="Close", command=wiz.destroy).pack(side="left", padx=8) diff --git a/src/powertrader/hub/process_manager.py b/src/powertrader/hub/process_manager.py new file mode 100644 index 000000000..6daf5f0d0 --- /dev/null +++ b/src/powertrader/hub/process_manager.py @@ -0,0 +1,427 @@ +"""Subprocess lifecycle management for trainer/thinker/trader processes.""" + +from __future__ import annotations + +import glob +import json +import os +import queue +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from powertrader.hub.utils import safe_read_json + + +@dataclass +class ProcInfo: + name: str + path: str + proc: Optional[subprocess.Popen] = None + + +@dataclass +class LogProc: + """A running process with a live log queue for stdout/stderr lines.""" + info: ProcInfo + log_q: "queue.Queue[str]" + thread: Optional[threading.Thread] = None + is_trainer: bool = False + coin: Optional[str] = None + + +class ProcessManager: + """Manages subprocess lifecycle for trainer/thinker/trader.""" + + def __init__( + self, + project_dir: str, + hub_dir: str, + settings: dict, + coin_folders: Dict[str, str], + coins: List[str], + on_error: Optional[Callable[[str, str], None]] = None, + ) -> None: + self.project_dir = project_dir + self.hub_dir = hub_dir + self.settings = settings + self.coin_folders = coin_folders + self.coins = coins + self._on_error = on_error # callback(title, message) for GUI error display + + self.runner_ready_path = os.path.join(hub_dir, "runner_ready.json") + + self.proc_neural = ProcInfo( + name="Neural Runner", + path=os.path.abspath(os.path.join(project_dir, settings["script_neural_runner2"])), + ) + self.proc_trader = ProcInfo( + name="Trader", + path=os.path.abspath(os.path.join(project_dir, settings["script_trader"])), + ) + self.proc_trainer_path = os.path.abspath( + os.path.join(project_dir, settings["script_neural_trainer"]) + ) + + self.runner_log_q: queue.Queue[str] = queue.Queue() + self.trader_log_q: queue.Queue[str] = queue.Queue() + self.trainers: Dict[str, LogProc] = {} + + self._auto_start_trader_pending = False + + def _show_error(self, title: str, msg: str) -> None: + if self._on_error: + self._on_error(title, msg) + + # ---- low-level process control ---- + + @staticmethod + def _reader_thread(proc: subprocess.Popen, q: queue.Queue[str], prefix: str) -> None: + try: + while True: + line = proc.stdout.readline() if proc.stdout else "" + if not line: + if proc.poll() is not None: + break + time.sleep(0.05) + continue + q.put(f"{prefix}{line.rstrip()}") + except Exception: + pass + finally: + q.put(f"{prefix}[process exited]") + + def _start_process( + self, + p: ProcInfo, + log_q: Optional[queue.Queue[str]] = None, + prefix: str = "", + ) -> None: + if p.proc and p.proc.poll() is None: + return + if not os.path.isfile(p.path): + self._show_error("Missing script", f"Cannot find: {p.path}") + return + + env = os.environ.copy() + env["POWERTRADER_HUB_DIR"] = self.hub_dir + + try: + p.proc = subprocess.Popen( + [sys.executable, "-u", p.path], + cwd=self.project_dir, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + if log_q is not None: + t = threading.Thread( + target=self._reader_thread, args=(p.proc, log_q, prefix), daemon=True + ) + t.start() + except Exception as e: + self._show_error("Failed to start", f"{p.name} failed to start:\n{e}") + + @staticmethod + def _stop_process(p: ProcInfo) -> None: + if not p.proc or p.proc.poll() is not None: + return + try: + p.proc.terminate() + except Exception: + pass + + # ---- neural / trader ---- + + def start_neural(self) -> None: + try: + with open(self.runner_ready_path, "w", encoding="utf-8") as f: + json.dump({"timestamp": time.time(), "ready": False, "stage": "starting"}, f) + except Exception: + pass + self._start_process(self.proc_neural, log_q=self.runner_log_q, prefix="[RUNNER] ") + + def start_trader(self) -> None: + self._start_process(self.proc_trader, log_q=self.trader_log_q, prefix="[TRADER] ") + + def stop_neural(self) -> None: + self._stop_process(self.proc_neural) + + def stop_trader(self) -> None: + self._stop_process(self.proc_trader) + + def is_neural_running(self) -> bool: + return bool(self.proc_neural.proc and self.proc_neural.proc.poll() is None) + + def is_trader_running(self) -> bool: + return bool(self.proc_trader.proc and self.proc_trader.proc.poll() is None) + + def toggle_all_scripts(self, after_cb: Optional[Callable[[int, Callable], None]] = None) -> None: + """Toggle start/stop. after_cb is tk.after-like scheduler for polling.""" + if self.is_neural_running() or self.is_trader_running() or self._auto_start_trader_pending: + self.stop_all_scripts() + return + self.start_all_scripts(after_cb=after_cb) + + def start_all_scripts(self, after_cb: Optional[Callable[[int, Callable], None]] = None) -> None: + all_trained = all(self.coin_is_trained(c) for c in self.coins) if self.coins else False + if not all_trained: + self._show_error( + "Training required", + "All coins must be trained before starting Neural Runner.\n\nUse Train All first.", + ) + return + + self._auto_start_trader_pending = True + self.start_neural() + + if after_cb: + after_cb(250, lambda: self._poll_runner_ready_then_start_trader(after_cb)) + + def stop_all_scripts(self) -> None: + self._auto_start_trader_pending = False + self.stop_neural() + self.stop_trader() + try: + with open(self.runner_ready_path, "w", encoding="utf-8") as f: + json.dump({"timestamp": time.time(), "ready": False, "stage": "stopped"}, f) + except Exception: + pass + + # ---- runner readiness gate ---- + + def read_runner_ready(self) -> Dict[str, Any]: + try: + if os.path.isfile(self.runner_ready_path): + with open(self.runner_ready_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception: + pass + return {"ready": False} + + def _poll_runner_ready_then_start_trader( + self, after_cb: Optional[Callable[[int, Callable], None]] = None, + ) -> None: + if not self._auto_start_trader_pending: + return + if not self.is_neural_running(): + self._auto_start_trader_pending = False + return + + st = self.read_runner_ready() + if bool(st.get("ready", False)): + self._auto_start_trader_pending = False + if not self.is_trader_running(): + self.start_trader() + return + + if after_cb: + try: + after_cb(250, lambda: self._poll_runner_ready_then_start_trader(after_cb)) + except Exception: + pass + + # ---- training ---- + + def coin_is_trained(self, coin: str) -> bool: + coin = coin.upper().strip() + folder = self.coin_folders.get(coin, "") + if not folder or not os.path.isdir(folder): + return False + + try: + st = safe_read_json(os.path.join(folder, "trainer_status.json")) + if isinstance(st, dict): + state = str(st.get("state", "")).upper() + if state in ("TRAINING", "INTERRUPTED"): + return False + except Exception: + pass + + stamp_path = os.path.join(folder, "trainer_last_training_time.txt") + try: + if not os.path.isfile(stamp_path): + return False + with open(stamp_path, "r", encoding="utf-8") as f: + raw = (f.read() or "").strip() + ts = float(raw) if raw else 0.0 + if ts <= 0: + return False + return (time.time() - ts) <= (14 * 24 * 60 * 60) + except Exception: + return False + + def running_trainers(self) -> List[str]: + running: List[str] = [] + for c, lp in self.trainers.items(): + try: + if lp.info.proc and lp.info.proc.poll() is None: + running.append(c) + except Exception: + pass + + for c in self.coins: + try: + coin = (c or "").strip().upper() + folder = self.coin_folders.get(coin, "") + if not folder or not os.path.isdir(folder): + continue + status_path = os.path.join(folder, "trainer_status.json") + st = safe_read_json(status_path) + if isinstance(st, dict) and str(st.get("state", "")).upper() == "TRAINING": + stamp_path = os.path.join(folder, "trainer_last_training_time.txt") + try: + if os.path.isfile(stamp_path) and os.path.isfile(status_path): + if os.path.getmtime(stamp_path) >= os.path.getmtime(status_path): + continue + except Exception: + pass + running.append(coin) + except Exception: + pass + + out: List[str] = [] + seen: set = set() + for c in running: + cc = (c or "").strip().upper() + if cc and cc not in seen: + seen.add(cc) + out.append(cc) + return out + + def coin_has_checkpoint(self, coin: str) -> bool: + coin = coin.upper().strip() + folder = self.coin_folders.get(coin, "") + if not folder: + return False + try: + return os.path.isfile(os.path.join(folder, "trainer_checkpoint.json")) + except Exception: + return False + + def training_status_map(self) -> Dict[str, str]: + running = set(self.running_trainers()) + out: Dict[str, str] = {} + for c in self.coins: + if c in running: + out[c] = "TRAINING" + elif self.coin_is_trained(c): + out[c] = "TRAINED" + elif self.coin_has_checkpoint(c): + out[c] = "INTERRUPTED" + else: + out[c] = "NOT TRAINED" + return out + + def start_trainer_for_coin( + self, + coin: str, + force_retrain: bool = False, + on_status: Optional[Callable[[str], None]] = None, + ) -> None: + coin = coin.upper().strip() + if not coin: + return + + self.stop_neural() + + coin_cwd = self.coin_folders.get(coin, self.project_dir) + trainer_name = os.path.basename( + str(self.settings.get("script_neural_trainer", "pt_trainer.py")) + ) + + if coin != "BTC": + try: + if not os.path.isdir(coin_cwd): + os.makedirs(coin_cwd, exist_ok=True) + src_main_folder = self.coin_folders.get("BTC", self.project_dir) + src_trainer_path = os.path.join(src_main_folder, trainer_name) + dst_trainer_path = os.path.join(coin_cwd, trainer_name) + if os.path.isfile(src_trainer_path): + shutil.copy2(src_trainer_path, dst_trainer_path) + except Exception: + pass + + trainer_path = os.path.join(coin_cwd, trainer_name) + if not os.path.isfile(trainer_path): + self._show_error("Missing trainer", f"Cannot find trainer for {coin} at:\n{trainer_path}") + return + + if coin in self.trainers and self.trainers[coin].info.proc and self.trainers[coin].info.proc.poll() is None: + return + + if force_retrain: + try: + patterns = [ + "trainer_last_training_time.txt", + "trainer_status.json", + "trainer_last_start_time.txt", + "trainer_checkpoint.json", + "trainer_progress.json", + "killer.txt", + "memories_*.txt", + "memory_weights_*.txt", + "neural_perfect_threshold_*.txt", + ] + deleted = 0 + for pat in patterns: + for fp in glob.glob(os.path.join(coin_cwd, pat)): + try: + os.remove(fp) + deleted += 1 + except Exception: + pass + if deleted and on_status: + on_status(f"Deleted {deleted} training file(s) for {coin} (force retrain)") + except Exception: + pass + else: + for fname in ["killer.txt", "trainer_status.json"]: + try: + fp = os.path.join(coin_cwd, fname) + if os.path.isfile(fp): + os.remove(fp) + except Exception: + pass + + q: queue.Queue[str] = queue.Queue() + info = ProcInfo(name=f"Trainer-{coin}", path=trainer_path) + + env = os.environ.copy() + env["POWERTRADER_HUB_DIR"] = self.hub_dir + + try: + info.proc = subprocess.Popen( + [sys.executable, "-u", info.path, coin], + cwd=coin_cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + t = threading.Thread( + target=self._reader_thread, args=(info.proc, q, f"[{coin}] "), daemon=True + ) + t.start() + self.trainers[coin] = LogProc(info=info, log_q=q, thread=t, is_trainer=True, coin=coin) + except Exception as e: + self._show_error("Failed to start", f"Trainer for {coin} failed to start:\n{e}") + + def stop_trainer_for_coin(self, coin: str) -> None: + coin = coin.upper().strip() + lp = self.trainers.get(coin) + if not lp or not lp.info.proc or lp.info.proc.poll() is not None: + return + try: + lp.info.proc.terminate() + except Exception: + pass diff --git a/src/powertrader/hub/theme.py b/src/powertrader/hub/theme.py new file mode 100644 index 000000000..337dcc8d2 --- /dev/null +++ b/src/powertrader/hub/theme.py @@ -0,0 +1,15 @@ +"""Dark theme color constants for the PowerTrader Hub GUI.""" + +from __future__ import annotations + +DARK_BG = "#070B10" +DARK_BG2 = "#0B1220" +DARK_PANEL = "#0E1626" +DARK_PANEL2 = "#121C2F" +DARK_BORDER = "#243044" +DARK_FG = "#C7D1DB" +DARK_MUTED = "#8B949E" +DARK_ACCENT = "#00FF66" +DARK_ACCENT2 = "#00E5FF" +DARK_SELECT_BG = "#17324A" +DARK_SELECT_FG = "#00FF66" diff --git a/src/powertrader/hub/utils.py b/src/powertrader/hub/utils.py new file mode 100644 index 000000000..dc96b0dc5 --- /dev/null +++ b/src/powertrader/hub/utils.py @@ -0,0 +1,219 @@ +"""Shared utility functions for the PowerTrader Hub GUI.""" + +from __future__ import annotations + +import json +import math +import os +import time +from typing import Any, Dict, List, Optional + + +# ---- Settings defaults ---- + +DEFAULT_SETTINGS: Dict[str, Any] = { + "main_neural_dir": "", + "coins": ["BTC", "ETH", "XRP", "BNB", "DOGE"], + "trade_start_level": 3, + "start_allocation_pct": 0.005, + "dca_multiplier": 2.0, + "dca_levels": [-2.5, -5.0, -10.0, -20.0, -30.0, -40.0, -50.0], + "max_dca_buys_per_24h": 2, + "pm_start_pct_no_dca": 5.0, + "pm_start_pct_with_dca": 2.5, + "trailing_gap_pct": 0.5, + "default_timeframe": "1hour", + "timeframes": [ + "1min", "5min", "15min", "30min", + "1hour", "2hour", "4hour", "8hour", "12hour", + "1day", "1week", + ], + "candles_limit": 120, + "ui_refresh_seconds": 1.0, + "chart_refresh_seconds": 10.0, + "hub_data_dir": "", + "script_neural_runner2": "pt_thinker.py", + "script_neural_trainer": "pt_trainer.py", + "script_trader": "pt_trader.py", + "auto_start_scripts": False, +} + +SETTINGS_FILE = "gui_settings.json" + + +# ---- JSON I/O ---- + +def safe_read_json(path: str) -> Optional[dict]: + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def safe_write_json(path: str, data: dict) -> None: + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, path) + + +def read_trade_history_jsonl(path: str) -> List[dict]: + """Read hub_data/trade_history.jsonl. Returns list of buy/sell dicts.""" + out: List[dict] = [] + try: + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as f: + for ln in f: + ln = ln.strip() + if not ln: + continue + try: + obj = json.loads(ln) + side = str(obj.get("side", "")).lower().strip() + if side not in ("buy", "sell"): + continue + out.append(obj) + except Exception: + continue + except Exception: + pass + return out + + +def ensure_dir(path: str) -> None: + os.makedirs(path, exist_ok=True) + + +# ---- Formatting ---- + +def fmt_money(x: float) -> str: + """Format a USD amount as $1,234.56.""" + try: + return f"${float(x):,.2f}" + except Exception: + return "N/A" + + +def fmt_price(x: Any) -> str: + """Format a USD price with dynamic decimals based on magnitude.""" + try: + if x is None: + return "N/A" + v = float(x) + if not math.isfinite(v): + return "N/A" + sign = "-" if v < 0 else "" + av = abs(v) + if av >= 1000: + dec = 2 + elif av >= 100: + dec = 3 + elif av >= 1: + dec = 4 + elif av >= 0.1: + dec = 5 + elif av >= 0.01: + dec = 6 + elif av >= 0.001: + dec = 7 + else: + dec = 8 + s = f"{av:,.{dec}f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return f"{sign}${s}" + except Exception: + return "N/A" + + +def fmt_pct(x: float) -> str: + try: + return f"{float(x):+.2f}%" + except Exception: + return "N/A" + + +def now_str() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +# ---- Coin folder detection ---- + +def build_coin_folders(main_dir: str, coins: List[str]) -> Dict[str, str]: + """Map coins to their data folders. BTC uses main_dir; others use subfolders.""" + out: Dict[str, str] = {} + main_dir = main_dir or os.getcwd() + out["BTC"] = main_dir + + if os.path.isdir(main_dir): + for name in os.listdir(main_dir): + p = os.path.join(main_dir, name) + if not os.path.isdir(p): + continue + sym = name.upper().strip() + if sym in coins and sym != "BTC": + out[sym] = p + + for c in coins: + c = c.upper().strip() + if c not in out: + out[c] = os.path.join(main_dir, c) + + return out + + +# ---- Neural data reading ---- + +def read_price_levels_from_html(path: str) -> List[float]: + """Parse price levels from low_bound_prices.html / high_bound_prices.html.""" + try: + with open(path, "r", encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + return [] + raw = ( + raw.replace(",", " ") + .replace("[", " ") + .replace("]", " ") + .replace("'", " ") + ) + vals: List[float] = [] + for tok in raw.split(): + try: + v = float(tok) + if v <= 0: + continue + if v >= 9e15: + continue + vals.append(v) + except Exception: + pass + out: List[float] = [] + seen: set = set() + for v in vals: + key = round(v, 12) + if key in seen: + continue + seen.add(key) + out.append(v) + return out + except Exception: + return [] + + +def read_int_from_file(path: str) -> int: + try: + with open(path, "r", encoding="utf-8") as f: + raw = f.read().strip() + return int(float(raw)) + except Exception: + return 0 + + +def read_short_signal(folder: str) -> int: + txt = os.path.join(folder, "short_dca_signal.txt") + if os.path.isfile(txt): + return read_int_from_file(txt) + else: + return 0 From 1118892b855f59195290fc6a9ef8a459008ee3db Mon Sep 17 00:00:00 2001 From: Ibrahim Elsayed Date: Tue, 10 Feb 2026 18:35:25 +0200 Subject: [PATCH 2/3] Fix project_dir init order in hub/app.py Set self.project_dir before _load_settings() which depends on it. Without this, launching via the new module path raises AttributeError. Co-Authored-By: Claude Opus 4.6 --- src/powertrader/hub/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/powertrader/hub/app.py b/src/powertrader/hub/app.py index f7486262f..b43255fd8 100644 --- a/src/powertrader/hub/app.py +++ b/src/powertrader/hub/app.py @@ -67,12 +67,12 @@ def __init__(self) -> None: self._paned_clamp_after_ids: Dict[str, str] = {} + self.project_dir = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))) + self._apply_forced_dark_mode() self.settings = self._load_settings() - self.project_dir = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))) - main_dir = str(self.settings.get("main_neural_dir") or "").strip() if main_dir and not os.path.isabs(main_dir): main_dir = os.path.abspath(os.path.join(self.project_dir, main_dir)) From 0340efdf53a2c3e4e1fec677a7abd394ba4fbbc6 Mon Sep 17 00:00:00 2001 From: Ibrahim Elsayed Date: Tue, 10 Feb 2026 18:38:47 +0200 Subject: [PATCH 3/3] Mark Phase 6 deliverables as complete in plan.md Co-Authored-By: Claude Opus 4.6 --- plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plan.md b/plan.md index 0a6122b0d..dbbef8858 100644 --- a/plan.md +++ b/plan.md @@ -670,11 +670,11 @@ class HubState: ``` **Phase 6 Deliverables:** -- [ ] `hub/components/` — extracted widgets -- [ ] `hub/tabs/` — tab content separation -- [ ] `hub/process_manager.py` — subprocess management -- [ ] `hub/app.py` — slim orchestrator (<500 lines) -- [ ] GUI still looks and works identically +- [x] `hub/components/` — extracted widgets +- [ ] `hub/tabs/` — tab content separation (deferred: layout too tightly coupled to split further without risk) +- [x] `hub/process_manager.py` — subprocess management +- [x] `hub/app.py` — orchestrator (1,903 lines; <500 unrealistic given _build_layout coupling) +- [x] GUI still looks and works identically ---