Source code for gui.panels.training_panel

"""
Cerebrum Forex - Advanced Training Panel V2
Professional training UI with pipeline on top, tabbed status/logs below.
"""

import logging
from datetime import datetime

from PyQt6.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
    QTableWidget, QTableWidgetItem, QPushButton, QGroupBox,
    QProgressBar, QHeaderView, QTextEdit, QTabWidget, QFrame,
    QGridLayout, QScrollArea, QSplitter, QSizePolicy, QDialog,
    QCheckBox, QComboBox
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject, QThread
from gui.workers.status_worker import StatusWorker
from PyQt6.QtGui import QFont, QColor
from datetime import datetime, timedelta

from gui.widgets.styled_button import Premium3DButton

logger = logging.getLogger(__name__)


[docs] class TrainingSignals(QObject): """Signals for thread-safe UI updates""" update_phase = pyqtSignal(str, str, str) # phase_id, status, detail update_log = pyqtSignal(str, str) update_stats = pyqtSignal(dict) training_done = pyqtSignal()
[docs] class PhaseCard(QFrame): """Compact phase card with vertical layout""" def __init__(self, phase_id: str, title: str, icon: str, parent=None): super().__init__(parent) self.phase_id = phase_id self.title = title self.icon = icon self._status = "pending" self._init_ui() def _init_ui(self): self.setFixedSize(75, 65) self.setStyleSheet(self._get_style("pending")) layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(2) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # Icon centered self.icon_label = QLabel(self.icon) self.icon_label.setFont(QFont("Segoe UI Emoji", 16)) self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.icon_label) # Title centered self.title_label = QLabel(self.title) self.title_label.setFont(QFont("Segoe UI", 8, QFont.Weight.Bold)) self.title_label.setStyleSheet("color: #e5e7eb;") self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.title_label) # Status indicator (small text) self.status_label = QLabel("●") self.status_label.setFont(QFont("Segoe UI", 10)) self.status_label.setStyleSheet("color: #6b7280;") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.status_label) def _get_style(self, status: str) -> str: # Transparent background with colored borders for status border_color = "#4b5563" # gray for pending if status == "active": border_color = "#3b82f6" # blue elif status == "complete": border_color = "#10b981" # green elif status == "error": border_color = "#ef4444" # red return f"PhaseCard {{ background: transparent; border-radius: 8px; border: 2px solid {border_color}; }}"
[docs] def set_status(self, status: str, detail: str = ""): self._status = status self.setStyleSheet(self._get_style(status)) if status == "pending": self.status_label.setText("●") self.status_label.setStyleSheet("color: #6b7280;") elif status == "active": self.status_label.setText("◐") self.status_label.setStyleSheet("color: #3b82f6;") elif status == "complete": self.status_label.setText("✓") self.status_label.setStyleSheet("color: #10b981;") elif status == "error": self.status_label.setText("✗") self.status_label.setStyleSheet("color: #ef4444;")
[docs] def set_progress(self, value: int, detail: str = ""): pass # Simplified - no progress bar
[docs] class TrainingPanel(QWidget): """Advanced training panel - takes 65% of main window width""" def __init__(self, app_controller=None): super().__init__() self.app_controller = app_controller self.signals = TrainingSignals() self._phase_cards = {} self._training_active = False self._current_tf_index = 0 # Set size policy for 65% width self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._init_ui() self._connect_signals() self._timer = QTimer() self._timer.timeout.connect(self._update_status) self._timer.start(3000) # === STAGGERED INITIALIZATION (Non-Blocking) === # 0.5s: UI Update Only QTimer.singleShot(500, self._update_status) # 0.9s: Pipeline visual initialization (Static check) QTimer.singleShot(900, self._init_pipeline_from_models) # 4.0s: Heavy Model Scanning (Asynchronous) QTimer.singleShot(4000, self._trigger_status_update) # Register TrainingManager callback once if self.app_controller and self.app_controller.training_manager: self.app_controller.training_manager.add_callback(self._on_tm_event) def _connect_signals(self): self.signals.update_phase.connect(self._on_phase_update) self.signals.update_log.connect(self._on_log_update) self.signals.update_stats.connect(self._on_stats_update) self.signals.training_done.connect(self._on_training_done) def _init_ui(self): layout = QVBoxLayout(self) layout.setSpacing(8) layout.setContentsMargins(10, 10, 10, 10) # ══════════════════════════════════════════════════════════════ # HEADER ROW # ══════════════════════════════════════════════════════════════ header_layout = QHBoxLayout() header = QLabel("🧠 Model Training") header.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold)) header.setStyleSheet("color: #8b5cf6;") header_layout.addWidget(header) header_layout.addStretch() self.global_status = QLabel("Ready") self.global_status.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) self.global_status.setStyleSheet("color: #10b981;") header_layout.addWidget(self.global_status) layout.addLayout(header_layout) # ══════════════════════════════════════════════════════════════ # CONTROL BAR + STATS # ══════════════════════════════════════════════════════════════ controls = QFrame() controls.setStyleSheet("background: #2d2d2d; border-radius: 6px; border: 1px solid #3c3c3c;") controls_layout = QHBoxLayout(controls) controls_layout.setContentsMargins(10, 8, 10, 8) # Single Smart Train button (auto-detects mode) self.train_btn = Premium3DButton("🧠 Smart Train", color="#8b5cf6", hover_color="#9f7aea", pressed_color="#7c3aed") self.train_btn.setMinimumWidth(140) self.train_btn.setMinimumHeight(36) self.train_btn.clicked.connect(self._smart_train_auto) self.train_btn.setToolTip("Smart Train: Updates models based on incremental setting") controls_layout.addWidget(self.train_btn) # Incremental training setting is now managed in Settings Panel # We read it directly from config.settings when needed self.stop_btn = Premium3DButton("⏹ Stop", color="#374151", hover_color="#4b5563", pressed_color="#1f2937") self.stop_btn.setMinimumHeight(36) self.stop_btn.setEnabled(False) self.stop_btn.setToolTip("Interrupt current training process immediately") self.stop_btn.clicked.connect(self._stop_training) controls_layout.addWidget(self.stop_btn) # Info button self.info_btn = Premium3DButton("ℹ️ Info", color="#1e40af", hover_color="#2563eb", pressed_color="#1e3a8a") self.info_btn.setMinimumHeight(36) self.info_btn.clicked.connect(self._show_info_popup) self.info_btn.setToolTip("Show training pipeline explanation") controls_layout.addWidget(self.info_btn) # ════════════════════════════════════════════════════════════════ # AUTO-TRAIN BUTTON (Scheduler Popup Trigger) # ════════════════════════════════════════════════════════════════ # Button that opens the Auto-Training scheduler configuration popup. # Visual indicator: RED when scheduler is OFF, GREEN when scheduler is ON. # The color is updated dynamically via _update_auto_train_btn_color(). # ════════════════════════════════════════════════════════════════ self.auto_train_btn = Premium3DButton("Auto-Train", color="#991b1b", hover_color="#dc2626", pressed_color="#7f1d1d") self.auto_train_btn.setMinimumWidth(100) self.auto_train_btn.setMinimumHeight(36) self.auto_train_btn.clicked.connect(self._show_scheduler_popup) # Opens scheduler dialog self.auto_train_btn.setToolTip("Configure Auto-Training Schedule") controls_layout.addWidget(self.auto_train_btn) # Defer color update to allow app_controller to fully initialize QTimer.singleShot(500, self._update_auto_train_btn_color) controls_layout.addStretch() # Stats inline for name, label in [("TF", "timeframe"), ("Samples", "samples"), ("Accuracy", "accuracy"), ("Time", "elapsed")]: stat_frame = self._create_stat_widget(name, "--" if name != "Time" else "0:00") controls_layout.addWidget(stat_frame) # Delete models button (Right aligned) controls_layout.addStretch() self.delete_btn = Premium3DButton("🗑️", color="#991b1b", hover_color="#dc2626", pressed_color="#7f1d1d") self.delete_btn.setFixedSize(48, 48) self.delete_btn.clicked.connect(self._delete_all_models) self.delete_btn.setToolTip("Delete ALL models and cache (Clean Slate)") controls_layout.addWidget(self.delete_btn) layout.addWidget(controls) # ══════════════════════════════════════════════════════════════ # TRAINING PIPELINE (TOP - FULL WIDTH) # ══════════════════════════════════════════════════════════════ pipeline_group = QGroupBox("Training Pipeline") pipeline_group.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold)) pipeline_group.setStyleSheet(""" QGroupBox { border: 1px solid #3c3c3c; border-radius: 6px; margin-top: 8px; padding-top: 8px; color: #e5e7eb; background-color: #2d2d2d; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; background-color: #2d2d2d; } """) pipeline_layout = QHBoxLayout(pipeline_group) pipeline_layout.setSpacing(6) pipeline_layout.setContentsMargins(10, 15, 10, 10) phases = [ ("data", "📊", "Data"), ("features", "🔧", "Features"), ("labels", "🏷️", "Labels"), ("normalize", "📐", "Normalize"), ("train_xgb", "🌲", "XGBoost"), ("train_lgb", "🍃", "LightGBM"), ("train_rf", "🌳", "RandomForest"), ("train_cat", "🐱", "CatBoost"), ("train_stk", "🔗", "Stacking"), ] for i, (phase_id, icon, title) in enumerate(phases): card = PhaseCard(phase_id, title, icon) self._phase_cards[phase_id] = card pipeline_layout.addWidget(card) if i < len(phases) - 1: arrow = QLabel("→") arrow.setStyleSheet("color: #6b7280; font-size: 14px;") arrow.setAlignment(Qt.AlignmentFlag.AlignCenter) pipeline_layout.addWidget(arrow) layout.addWidget(pipeline_group) # ══════════════════════════════════════════════════════════════ # TABBED SECTION: MODEL STATUS | LIVE LOGS # ══════════════════════════════════════════════════════════════ self.tabs = QTabWidget() self.tabs.setStyleSheet(""" QTabWidget::pane { border: 1px solid #3c3c3c; background: #2d2d2d; border-radius: 6px; } QTabBar::tab { background: #1e1e1e; color: #9ca3af; padding: 10px 25px; border: 1px solid #374151; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 2px; font-weight: bold; } QTabBar::tab:selected { background: #2d2d2d; color: #e5e7eb; border-top: 2px solid #8b5cf6; } QTabBar::tab:hover:!selected { background: #1f2937; } """) # Tab 1: Model Status self.status_tab = self._create_status_tab() self.tabs.addTab(self.status_tab, "📊 Model Status") # Tab 2: Live Logs self.logs_tab = self._create_logs_tab() self.tabs.addTab(self.logs_tab, "📝 Live Logs") # Tab 3: Training History self.history_tab = self._create_history_tab() self.tabs.addTab(self.history_tab, "📜 History") layout.addWidget(self.tabs, stretch=1) def _create_stat_widget(self, label: str, value: str) -> QFrame: frame = QFrame() frame.setStyleSheet("background: transparent; border: none;") layout = QVBoxLayout(frame) layout.setContentsMargins(10, 0, 10, 0) layout.setSpacing(0) lbl = QLabel(label) lbl.setStyleSheet("color: #6b7280; font-size: 9px; border: none;") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(lbl) val = QLabel(value) val.setObjectName(f"stat_{label.lower()}") val.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) val.setStyleSheet("color: #e5e7eb; border: none;") val.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(val) return frame def _create_status_tab(self): tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) # Header with refresh button header = QHBoxLayout() header.addStretch() refresh_btn = Premium3DButton("🔄 Refresh", color="#059669", hover_color="#047857", pressed_color="#065f46") refresh_btn.setFixedHeight(28) refresh_btn.setToolTip("Reload model status from disk") refresh_btn.clicked.connect(self._trigger_status_update) header.addWidget(refresh_btn) layout.addLayout(header) self.status_table = QTableWidget() self.status_table.setColumnCount(10) self.status_table.setHorizontalHeaderLabels([ "TF", "Model Status", "XGBoost", "LightGBM", "RandomForest", "CatBoost", "Stacking", "Last Train", "Avg Acc", "Models" ]) self.status_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.status_table.setAlternatingRowColors(True) self.status_table.setStyleSheet(""" QTableWidget { background: #111827; alternate-background-color: #1f2937; gridline-color: #374151; border: none; } QTableWidget::item { padding: 6px; color: #e5e7eb; } QTableWidget::item:selected { background: #3b82f6; } QHeaderView::section { background: #374151; color: #e5e7eb; padding: 8px; border: none; font-weight: bold; } """) timeframes = ["1m", "5m", "15m", "30m", "1h", "4h"] self.status_table.setRowCount(len(timeframes)) for i, tf in enumerate(timeframes): tf_item = QTableWidgetItem(tf) tf_item.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) self.status_table.setItem(i, 0, tf_item) for j in range(1, 10): self.status_table.setItem(i, j, QTableWidgetItem("--")) layout.addWidget(self.status_table) return tab def _create_logs_tab(self): tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) # Logs header logs_header = QHBoxLayout() logs_header.addStretch() clear_btn = Premium3DButton("🗑️ Clear", color="#374151", hover_color="#4b5563", pressed_color="#1f2937") clear_btn.setFixedHeight(28) clear_btn.setToolTip("Clear the logs console output") clear_btn.clicked.connect(lambda: self.logs_text.clear()) logs_header.addWidget(clear_btn) layout.addLayout(logs_header) self.logs_text = QTextEdit() self.logs_text.setReadOnly(True) self.logs_text.setStyleSheet(""" QTextEdit { background-color: #0d1117; color: #c9d1d9; font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 11px; border: none; border-radius: 4px; padding: 8px; } """) layout.addWidget(self.logs_text) return tab def _create_history_tab(self): tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) # Header with refresh and clear buttons header = QHBoxLayout() header.addWidget(QLabel("Last 100 Training Events")) header.addStretch() # Clear History Button clear_btn = Premium3DButton("🗑️ Supprimer", color="#dc2626", hover_color="#b91c1c", pressed_color="#991b1b") clear_btn.setFixedHeight(28) clear_btn.setToolTip("Effacer l'historique des entraînements") clear_btn.clicked.connect(self._clear_history) header.addWidget(clear_btn) refresh_btn = Premium3DButton("🔄 Refresh", color="#374151", hover_color="#4b5563", pressed_color="#1f2937") refresh_btn.setFixedHeight(28) refresh_btn.clicked.connect(self._load_history) header.addWidget(refresh_btn) layout.addLayout(header) self.history_table = QTableWidget() self.history_table.setColumnCount(4) self.history_table.setHorizontalHeaderLabels(["Timestamp", "Type", "Status", "Details"]) self.history_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.history_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) self.history_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.history_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.history_table.setStyleSheet(""" QTableWidget { background-color: #1f2937; color: #e5e7eb; gridline-color: #374151; border: none; border-radius: 4px; } QHeaderView::section { background-color: #111827; color: #9ca3af; padding: 6px; border: 1px solid #374151; font-weight: bold; } """) layout.addWidget(self.history_table) # Initial load QTimer.singleShot(1000, self._load_history) return tab def _load_history(self): """Load history from json file""" try: from config.settings import DATA_DIR import json history_file = DATA_DIR / "training_history.json" if not history_file.exists(): return with open(history_file, 'r') as f: history = json.load(f) self.history_table.setRowCount(len(history)) for i, event in enumerate(history): # Timestamp self.history_table.setItem(i, 0, QTableWidgetItem(event.get("timestamp", ""))) # Type (MANUAL/AUTO/SYSTEM) source = event.get("source", "") # Fix: User reported mix-up. Swapping visual labels. display_type = source if source == "MANUAL": display_type = "SYSTEM" elif source == "SYSTEM": display_type = "MANUAL" type_item = QTableWidgetItem(display_type) if display_type == "AUTO": type_item.setForeground(QColor("#60a5fa")) # light blue elif display_type == "MANUAL": type_item.setForeground(QColor("#8b5cf6")) # violet elif display_type == "SYSTEM": type_item.setForeground(QColor("#9ca3af")) # gray self.history_table.setItem(i, 1, type_item) # Status status_item = QTableWidgetItem(event.get("status", "")) status = event.get("status", "").upper() if "SUCCESS" in status or "COMPLETE" in status: status_item.setForeground(QColor("#10b981")) elif "FAIL" in status or "ERROR" in status: status_item.setForeground(QColor("#ef4444")) elif "START" in status: status_item.setForeground(QColor("#fbbf24")) self.history_table.setItem(i, 2, status_item) # Details self.history_table.setItem(i, 3, QTableWidgetItem(event.get("details", ""))) except Exception as e: logger.error(f"Failed to load training history: {e}") def _clear_history(self): """Clear training history file and refresh table""" from PyQt6.QtWidgets import QMessageBox from config.settings import DATA_DIR # Confirmation dialog reply = QMessageBox.question( self, "Confirmer la suppression", "Voulez-vous vraiment supprimer tout l'historique des entraînements ?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: history_file = DATA_DIR / "training_history.json" if history_file.exists(): history_file.unlink() logger.info("Training history cleared by user") # Clear the table self.history_table.setRowCount(0) except Exception as e: logger.error(f"Failed to clear training history: {e}") QMessageBox.critical(self, "Erreur", f"Impossible de supprimer l'historique: {e}") def _update_stat(self, name: str, value: str): lbl = self.findChild(QLabel, f"stat_{name.lower()}") if lbl: lbl.setText(value) def _trigger_status_update(self): """Trigger background scanning of all model files (Thread-Safe)""" # 1. Protection against overlapping threads if hasattr(self, 'status_thread') and self.status_thread and self.status_thread.isRunning(): logger.warning("Status update already running - request ignored.") return from config.settings import MODELS_DIR self.status_thread = QThread() self.status_worker = StatusWorker(MODELS_DIR) self.status_worker.moveToThread(self.status_thread) self.status_thread.started.connect(self.status_worker.run) self.status_worker.finished.connect(self._apply_status_results) self.status_worker.finished.connect(self.status_thread.quit) self.status_worker.finished.connect(self.status_worker.deleteLater) self.status_thread.finished.connect(self.status_thread.deleteLater) self.status_worker.error.connect(lambda e: logger.error(f"Status Thread Error: {e}")) self.status_worker.error.connect(self.status_thread.quit) # Cleanup reference to avoid "RuntimeError: wrapped C/C++ object deleted" def cleanup_ref(): self.status_thread = None self.status_thread.finished.connect(cleanup_ref) self.status_thread.start() def _apply_status_results(self, results): """Update UI with results from StatusWorker (Rigid Alignment Fix)""" try: # 1. Global Status total = results["global"]["total_trained"] total_possible = 6 * 5 if total == 0: self.global_status.setText("⚠️ No Models") self.global_status.setStyleSheet("color: #ef4444;") elif total < total_possible: self.global_status.setText(f"⚠️ {total}/{total_possible} Models") self.global_status.setStyleSheet("color: #fbbf24;") else: self.global_status.setText("✅ All Models Ready") self.global_status.setStyleSheet("color: #10b981;") # 2. Header Stats self._update_stat("tf", f"{results['global']['tf_with_models']}/6") self._update_stat("samples", f"{total}") acc = results["global"]["avg_accuracy"] self._update_stat("accuracy", f"{acc:.1%}" if acc > 0 else "--") # 3. Status Table - RIGID 10-COLUMN STRUCTURE self.status_table.setColumnCount(10) self.status_table.setHorizontalHeaderLabels([ "TF", "Status", "XGBoost", "LightGBM", "RandomForest", "CatBoost", "Stacking", "Last Train", "Avg Acc", "Models" ]) timeframes = ["1m", "5m", "15m", "30m", "1h", "4h"] for i, tf in enumerate(timeframes): tf_data = results["timeframes"].get(tf, {}) tf_accs = [] # COL 0: TF (Already set) # COL 1: Status (READY/MISSING) possible = 5 actual = sum(1 for m in ["xgboost", "lightgbm", "randomforest", "catboost", "stacking"] if tf_data.get(m, {}).get("accuracy") is not None) status_item = QTableWidgetItem("✅ READY" if actual == possible else f"⚠️ {actual}/{possible}") status_item.setForeground(QColor("#10b981") if actual == possible else QColor("#fbbf24")) status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.status_table.setItem(i, 1, status_item) # COL 2-6: Models model_map = {"xgboost": 2, "lightgbm": 3, "randomforest": 4, "catboost": 5, "stacking": 6} for mt, col_idx in model_map.items(): m = tf_data.get(mt, {}) if m.get("accuracy") is not None: tf_accs.append(m["accuracy"]) item = QTableWidgetItem(f"{m['accuracy']:.1%}") item.setForeground(QColor("#10b981") if m["accuracy"] > 0.5 else QColor("#e5e7eb")) if mt == "stacking": item.setForeground(QColor("#3b82f6")) # Blue self.status_table.setItem(i, col_idx, item) else: self.status_table.setItem(i, col_idx, QTableWidgetItem("--")) # COL 7: Last Train (Date) mtime = "--" for mt in model_map: t = tf_data.get(mt, {}).get("last_train") if t: mtime = t it = QTableWidgetItem(mtime) it.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.status_table.setItem(i, 7, it) # COL 8: Avg Acc if tf_accs: avg = sum(tf_accs) / len(tf_accs) item = QTableWidgetItem(f"{avg:.1%}") item.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold)) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.status_table.setItem(i, 8, item) else: self.status_table.setItem(i, 8, QTableWidgetItem("--")) # COL 9: Models Count item = QTableWidgetItem(f"{actual}/5") item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.status_table.setItem(i, 9, item) except Exception as e: logger.error(f"Error applying rigid model status results: {e}") def _init_pipeline_from_models(self): """Set pipeline to complete status if models already exist""" from config.settings import MODELS_DIR # Check if any models exist model_types = ["xgboost", "lightgbm", "randomforest", "catboost", "stacking"] timeframes = ["1m", "5m", "15m", "30m", "1h", "4h"] has_xgb = any((MODELS_DIR / f"xgboost_{tf}.pkl").exists() for tf in timeframes) has_lgb = any((MODELS_DIR / f"lightgbm_{tf}.pkl").exists() for tf in timeframes) has_rf = any((MODELS_DIR / f"randomforest_{tf}.pkl").exists() for tf in timeframes) has_cat = any((MODELS_DIR / f"catboost_{tf}.pkl").exists() for tf in timeframes) has_stk = any((MODELS_DIR / f"stacking_{tf}.pkl").exists() for tf in timeframes) # If we have any models, set pipeline to complete if has_xgb or has_lgb or has_rf or has_cat or has_stk: # Data, Features, Labels, Normalize were completed for phase_id in ["data", "features", "labels", "normalize"]: if phase_id in self._phase_cards: self._phase_cards[phase_id].set_status("complete", "✓") # Model phases if has_xgb and "train_xgb" in self._phase_cards: self._phase_cards["train_xgb"].set_status("complete", "✓") if has_lgb and "train_lgb" in self._phase_cards: self._phase_cards["train_lgb"].set_status("complete", "✓") if has_rf and "train_rf" in self._phase_cards: self._phase_cards["train_rf"].set_status("complete", "✓") if has_cat and "train_cat" in self._phase_cards: self._phase_cards["train_cat"].set_status("complete", "✓") if has_stk and "train_stk" in self._phase_cards: self._phase_cards["train_stk"].set_status("complete", "✓") def _reset_pipeline(self): for card in self._phase_cards.values(): card.set_status("pending", "Waiting...") card.set_progress(0) def _smart_train_auto(self): """ Smart training with auto-detection: - If models exist → quick mode (update missing only) - If no models → full mode (train all) """ try: logger.info("Smart Train button clicked - Auto-detecting mode") from config.settings import MODELS_DIR, default_settings import os from datetime import datetime, timedelta timeframes = ["1m", "5m", "15m", "30m", "1h", "4h"] self._add_log("🔍 Checking OHLC data freshness...", "info") needs_training = [] for tf in timeframes: # Check ALL model types exist (xgboost, lightgbm, randomforest, catboost, stacking) model_types = ["xgboost", "lightgbm", "randomforest", "catboost", "stacking"] missing_models = [mt for mt in model_types if not (MODELS_DIR / f"{mt}_{tf}.pkl").exists()] if missing_models: needs_training.append((tf, "missing", f"Missing: {', '.join(missing_models)}")) self._add_log(f" 📋 {tf}: Missing {len(missing_models)} models ({', '.join(missing_models)}) → TRAIN", "warning") continue # All models exist - check freshness using XGBoost as reference model_path = MODELS_DIR / f"xgboost_{tf}.pkl" # Get last training time from file modification (Local Time) model_mtime = datetime.fromtimestamp(os.path.getmtime(model_path)) last_train_time = model_mtime # --- TIMEZONE AWARE FRESHNESS CHECK --- # 1. Get Offset (Local - Server) offset_seconds = 0 if self.app_controller and self.app_controller.mt5.connected: offset_seconds = self.app_controller.mt5.get_time_offset() # 2. Get OHLC Last Date (Server Time) ohlc_path = MODELS_DIR.parent / "ohlc" / f"ohlc_EURUSD_{tf}.csv" last_candle_local = None if ohlc_path.exists(): try: # Fast last line read with open(ohlc_path, 'rb') as f: f.seek(0, 2) # End f.seek(max(0, f.tell() - 200), 0) last_line = f.read().decode(errors='ignore').strip().split('\n')[-1] if last_line: last_ts_str = last_line.split(',')[0] last_candle_server = datetime.strptime(last_ts_str[:19], "%Y-%m-%d %H:%M:%S") last_candle_local = last_candle_server + timedelta(seconds=offset_seconds) except Exception: pass # 3. Decision Logic: Is Model older than Data? is_stale = False msg = "" if last_candle_local: # Check if model is older than the last candle (with 5 min buffer) if last_train_time < (last_candle_local - timedelta(minutes=5)): is_stale = True diff = (last_candle_local - last_train_time).total_seconds() / 3600 msg = f"New Data avail ({diff:.1f}h)" else: # Also check simple age (in case market is moving but no new file yet?? Unlikely) pass # Use strict freshness from Settings freshness_hours = { "1m": 1, "5m": 2, "15m": 4, "30m": 8, "1h": 12, "4h": 24 } limit = freshness_hours.get(tf, 24) age_hours = (datetime.now() - last_train_time).total_seconds() / 3600 if is_stale: needs_training.append((tf, "stale_data", msg)) self._add_log(f" ⚠️ {tf}: Update Needed ({msg})", "warning") elif age_hours > limit: # Only trigger age-based update if we suspect we are missing data # But if file is not updated, training won't help. # So we only train if we HAVE data. # User complaint: "Last dates must be exact". # If Age is high but Date matches Server, we are good (Weekend/Holiday). # We assume if Age > Limit, we SHOULD have new data. check_msg = f"Age {age_hours:.1f}h > {limit}h" needs_training.append((tf, "stale_time", check_msg)) self._add_log(f" ⚠️ {tf}: Stale ({check_msg}) → UPDATE", "warning") else: self._add_log(f" ✓ {tf}: Fresh ({age_hours:.1f}h ago)", "success") # Determine mode from Settings force_global = not default_settings.incremental_training mode = "smart" if not force_global else "full" if not needs_training: # If user wants full retrain, ignore "up-to-date" check if force_global: self._add_log(f"🚀 Force Full Retrain requested...", "info") self._smart_train(mode="full", selected_timeframes=timeframes) # Train ALL in full mode return self._add_log("✅ All models are up-to-date!", "success") self.signals.training_done.emit() return self._add_log(f"🚀 Training {len(needs_training)} timeframes...", "info") # Train only stale/missing timeframes self._smart_train(mode=mode, selected_timeframes=[tf for tf, _, _ in needs_training]) except Exception as e: import traceback tb = traceback.format_exc() print(f"\n[CRITICAL] CRASH IN _smart_train_auto:\n{tb}") logger.error(f"CRASH IN _smart_train_auto: {e}\n{tb}") self._add_log(f"❌ Smart Train Loop Error: {e}", "error") def _smart_train(self, mode="full", selected_timeframes=None): """ Smart training DELEGATED to TrainingManager. Modes: - full: Retrain ALL models from scratch - quick: Train only missing models - smart: Train only specified timeframes (based on OHLC freshness) """ if not self.app_controller: return # UI Setup self._training_active = True self._training_mode = mode self._start_time = datetime.now() self.train_btn.setEnabled(False) self.stop_btn.setEnabled(True) self._reset_pipeline() self.global_status.setText(f"🔄 {mode.upper()} Training...") self.global_status.setStyleSheet("color: #3b82f6;") # Switch to logs tab self.tabs.setCurrentIndex(1) mode_icon = {"full": "🚀", "quick": "🔄", "smart": "🧠"}.get(mode, "🔄") mode_desc = {"full": "FULL RETRAIN", "quick": "QUICK (missing)", "smart": "SMART (stale only)"}.get(mode, mode.upper()) self._add_log("═══════════════════════════════════════", "header") self._add_log(f" {mode_icon} {mode_desc} (Via TrainingManager)", "header") self._add_log("═══════════════════════════════════════", "header") self._elapsed_timer = QTimer() self._elapsed_timer.timeout.connect(self._update_elapsed) self._elapsed_timer.start(1000) # Determine timeframes to train all_timeframes = ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"] # Filter out MN or others if needed tfs_to_train = [] if mode == "smart" and selected_timeframes: tfs_to_train = [tf for tf in selected_timeframes if tf in all_timeframes] elif mode == "quick": # Quick check via manager logic ideally, but for now reuse simple check from before # Or better, just train all missing. # Simplified: Use passed list from _smart_train_auto if available, else all # Since _smart_train is called by _smart_train_auto which logic is in UI... # We keep _smart_train_auto logic for SELECTION, but delegation for EXECUTION. pass # Logic handled by caller passed list usually if not selected_timeframes: tfs_to_train = all_timeframes # Fallback else: tfs_to_train = [tf for tf in selected_timeframes if tf in all_timeframes] else: tfs_to_train = all_timeframes # Hook up signals tm = self.app_controller.training_manager # EXECUTION PHASE self.stop_btn.setEnabled(True) import threading def run_thread(): try: # ════════════════════════════════════════════════════════════════ # PHASE 0: BULK OHLC UPDATE (Before any training) # This ensures all training threads have fresh local data and # never need to wait for MT5 IPC during model training. # ════════════════════════════════════════════════════════════════ self._add_log("📥 Phase 0: Updating OHLC data...", "info") self.signals.update_phase.emit("data", "active", "Bulk Update...") def ohlc_callback(tf, status, msg): self._add_log(f" [{tf}] {msg}", "info" if status != "error" else "error") mt5 = self.app_controller.mt5 ohlc_results = mt5.update_all_ohlc(timeframes=tfs_to_train, callback=ohlc_callback) # Count results fresh_count = sum(1 for v in ohlc_results.values() if v == "fresh") updated_count = sum(1 for v in ohlc_results.values() if isinstance(v, int)) self._add_log(f"✅ OHLC Ready: {fresh_count} fresh, {updated_count} updated.", "success") self.signals.update_phase.emit("data", "complete", f"{fresh_count}+{updated_count}") # ════════════════════════════════════════════════════════════════ # PHASE 1+: MODEL TRAINING (No MT5 calls needed) # ════════════════════════════════════════════════════════════════ for tf in tfs_to_train: if not self._training_active: break self._add_log(f"\n▶ Training {tf}...", "info") self.signals.update_stats.emit({"tf": tf, "samples": "..."}) # Call Manager (No extraction inside, data is already fresh) tm.train_timeframe(tf, force_global=(mode=="full")) self._add_log(f" ✅ {tf} sequence complete", "success") self._reset_pipeline() self.signals.training_done.emit() except BaseException as e: import traceback tb = traceback.format_exc() logger.error(f"TRAINING THREAD CRASH: {e}\n{tb}") # FORCE WRITE TO DISK so user can find it even if app disappears try: with open("TRAINING_CRASH_REPORT.txt", "w") as f: f.write(f"CRASH AT {datetime.now()}\n") f.write(f"ERROR: {str(e)}\n") f.write(tb) except: pass self._add_log(f"❌ Critical Error: {e}", "error") self.signals.training_done.emit() threading.Thread(target=run_thread, daemon=True).start() def _update_phase(self, phase_id, status, detail): QTimer.singleShot(0, lambda: self._do_update_phase(phase_id, status, detail)) def _do_update_phase(self, phase_id, status, detail): if phase_id in self._phase_cards: card = self._phase_cards[phase_id] card.set_status(status, detail) card.set_progress(50 if status == "active" else 100 if status == "complete" else 0) def _on_tm_event(self, event, data): """Bridge between TrainingManager callbacks and UI signals""" if not self._training_active: return if event == "phase_update": # Filter phase updates during Turbo Parallel training to avoid flickering # Only update if it's the "Main" timeframe or if it's a global phase like "normalize" phase = data.get('phase', '') status = data.get('status', 'active') detail = data.get('detail', '') # If we are in Turbo Parallel mode, only show global phases or 'active' for any # For simplicity, we just allow all but prefix the detail with TF # self.signals.update_phase.emit(phase, status, detail) # NEW: Group updates by phase. If any TF is active, set phase to active. self.signals.update_phase.emit(phase, status, detail) elif event == "log": # For logs, always show everything but cleaner msg = data.get('message', '') level = data.get('level', 'info') self.signals.update_log.emit(msg, level) elif event == "model_training_complete": acc = data.get('accuracy', 0) self.signals.update_stats.emit({"accuracy": f"{acc:.1%}"}) elif event == "training_complete": # This is global complete pass def _add_log(self, message, level="info"): QTimer.singleShot(0, lambda: self._do_add_log(message, level)) def _do_add_log(self, message, level): timestamp = datetime.now().strftime("%H:%M:%S") # Detect source from app_controller source_tag = "" if self.app_controller: src = self.app_controller.current_training_source color_src = "#60a5fa" if src == "AUTO" else "#8b5cf6" if src == "MANUAL" else "#9ca3af" source_tag = f'<span style="color:{color_src}; font-weight:bold;">[{src}]</span> ' # Clean emojis for console but keep for UI clean_msg = message for emoji in ["📥 ", "🔍 ", "🌲 ", "✅ ", "⚠️ ", "🚀 ", "🧠 ", "🏷️ ", "📉 ", "🔧 ", "🧵 ", "📡 "]: clean_msg = clean_msg.replace(emoji, "") # Print to terminal for external debugging try: # logger.info(f"[{timestamp}] [{self.app_controller.current_training_source if self.app_controller else 'UI'}] {clean_msg}") pass except: pass colors = {"info": "#9ca3af", "success": "#10b981", "error": "#ef4444", "warning": "#fbbf24", "header": "#8b5cf6"} color = colors.get(level, "#9ca3af") # Auto-scroll to bottom self.logs_text.append(f'<span style="color:#6b7280;">[{timestamp}]</span> {source_tag}<span style="color:{color};">{message}</span>') self.logs_text.verticalScrollBar().setValue(self.logs_text.verticalScrollBar().maximum()) def _update_elapsed(self): if hasattr(self, '_start_time'): elapsed = datetime.now() - self._start_time self._update_stat("time", f"{int(elapsed.total_seconds()//60)}:{int(elapsed.total_seconds()%60):02d}") def _show_info_popup(self): """Show info dialog explaining each training section""" from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextBrowser dialog = QDialog(self) dialog.setWindowTitle("🧠 Training Pipeline Guide") dialog.setMinimumSize(600, 500) dialog.setStyleSheet("background: #1f2937; color: #e5e7eb;") layout = QVBoxLayout(dialog) text = QTextBrowser() text.setOpenExternalLinks(True) text.setStyleSheet(""" QTextBrowser { background: #111827; color: #e5e7eb; border: none; border-radius: 8px; padding: 15px; font-size: 12px; } """) html_content = """ <h2 style="color: #8b5cf6;">📊 Training Pipeline</h2> <h3 style="color: #10b981;">📊 Data</h3> <p>Loading OHLC data (Open, High, Low, Close) from MetaTrader 5 or local CSV files. This historical data forms the basis of training.</p> <h3 style="color: #10b981;">🔧 Features</h3> <p>Calculation of <b>75+ technical indicators</b>: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Ichimoku, etc. These features allow models to understand market patterns.</p> <h3 style="color: #10b981;">🏷️ Labels</h3> <p>Creating <b>BUY / SELL / NEUTRAL</b> labels based on the <b>Noble KPI V3</b>. This uses a <b>Dynamic K (0.8 - 2.0)</b> adapted to the Timeframe and <b>Market Regime</b> (Low Vol, Normal, Crisis) to ensure optimal signal precision.</p> <h3 style="color: #10b981;">📐 Normalize</h3> <p>Normalization of features to the <b>[-1, +1]</b> range using <b>Quantile Transformation</b> to ensure robust handling of outliers.</p> <hr style="border-color: #374151;"> <h2 style="color: #fbbf24;">🤖 ML Models</h2> <h3 style="color: #f59e0b;">🟡 XGBoost</h3> <p><b>Extreme Gradient Boosting</b> - Ensemble algorithm based on decision trees. Highly performant for tabular data with excellent handling of missing features.</p> <h3 style="color: #22c55e;">🟢 LightGBM</h3> <p><b>Light Gradient Boosting Machine</b> - Optimized version of gradient boosting. Faster than XGBoost with reduced memory usage, ideal for large datasets.</p> <h3 style="color: #10b981;">🌳 RandomForest</h3> <p><b>Random Forest Classifier</b> - Ensemble of decision trees using bagging. Robust to overfitting with excellent feature importance analysis.</p> <h3 style="color: #eab308;">🐱 CatBoost (Sentinel)</h3> <p><b>Categorical Boosting</b> - Yandex's high-performance algorithm. Uses symmetric trees and ordered boosting to detect subtle patterns others miss. Acts as the <b>Sentinel</b> of the system.</p> <h3 style="color: #6366f1;">🔗 Stacking</h3> <p><b>Stacking Ensemble</b> - Meta-learner that combines XGBoost, LightGBM, RandomForest, and CatBoost predictions using Logistic Regression for improved consensus.</p> <hr style="border-color: #374151;"> <h2 style="color: #3b82f6;">📈 Metrics</h2> <ul> <li><b>TF</b>: Number of timeframes with models (e.g., 7/9)</li> <li><b>Samples</b>: Total number of models trained</li> <li><b>Accuracy</b>: Average precision across all models</li> <li><b>Time</b>: Duration of the current training</li> </ul> <hr style="border-color: #374151;"> <h2 style="color: #ec4899;">🚀 Training Modes</h2> <p><b>Full Train</b>: Retrains ALL models from scratch.</p> <p><b>Quick Train</b>: Trains only missing models (faster).</p> """ text.setHtml(html_content) layout.addWidget(text) from gui.widgets.styled_button import Premium3DButton close_btn = Premium3DButton("Close", color="#8b5cf6", hover_color="#9f7aea", pressed_color="#7c3aed") close_btn.clicked.connect(dialog.close) layout.addWidget(close_btn) dialog.exec() def _stop_training(self): self._training_active = False self._add_log("⏹ Stopped by user", "warning") self.global_status.setText("⏹ Stopped") self.global_status.setStyleSheet("color: #fbbf24;") if hasattr(self, '_elapsed_timer'): self._elapsed_timer.stop() self.train_btn.setEnabled(True) self.stop_btn.setEnabled(False) def _on_phase_update(self, phase_id, status, detail): """Handle phase update signal""" self._do_update_phase(phase_id, status, detail) def _on_log_update(self, message, level): self._do_add_log(message, level) def _on_stats_update(self, stats): if "tf" in stats: self._update_stat("tf", stats["tf"]) if "samples" in stats: self._update_stat("samples", stats["samples"]) if "accuracy" in stats: self._update_stat("accuracy", stats["accuracy"]) def _on_training_done(self): self._training_active = False if hasattr(self, '_elapsed_timer'): self._elapsed_timer.stop() self.train_btn.setEnabled(True) self.stop_btn.setEnabled(False) self.global_status.setText("✅ Complete") self.global_status.setStyleSheet("color: #10b981;") self._add_log("\n═══════════════════════════════════════", "header") self._add_log(" ✅ TRAINING COMPLETE", "header") self._add_log("═══════════════════════════════════════", "header") # Force cache clear to ensure fresh data if hasattr(self, '_model_cache'): self._model_cache = {} # Small delay to ensure file system release QTimer.singleShot(200, self._update_status) QTimer.singleShot(500, self._load_history) # Refresh history tab when done # Auto-Enable Incremental Training after Full Retrain (User Request) if getattr(self, '_training_mode', '') == "full": if self.app_controller: self.app_controller.save_settings({"incremental_training": True}) # Update runtime config from config.settings import default_settings default_settings.incremental_training = True self._add_log(" ✅ Incremental Training Auto-Enabled", "success") self.tabs.setCurrentIndex(0) # Switch to status tab def _update_status(self): """Update model status table from actual files on disk (OPTIMIZED)""" # Skip if not visible (major perf win) if not self.isVisible(): return try: from config.settings import MODELS_DIR import os timeframes = ["1m", "5m", "15m", "30m", "1h", "4h"] model_types = ["xgboost", "lightgbm", "randomforest", "catboost", "stacking"] # Init cache if not exists if not hasattr(self, '_model_cache'): self._model_cache = {} # {path: (mtime, accuracy)} for i, tf in enumerate(timeframes): last_train = None accuracies = [] for j, mt in enumerate(model_types): # All models use .pkl now model_path = MODELS_DIR / f"{mt}_{tf}.pkl" path_str = str(model_path) if model_path.exists(): mtime = os.path.getmtime(model_path) # Check cache: Only reload if mtime changed cached = self._model_cache.get(path_str) if cached and cached[0] == mtime: acc = cached[1] else: # Read accuracy from file (expensive, but cached) acc = 0 try: import pickle with open(model_path, 'rb') as f: data = pickle.load(f) acc = float(data.get('accuracy', 0)) except Exception as e: logger.debug(f"Failed to read {model_path}: {e}") acc = 0 self._model_cache[path_str] = (mtime, acc) accuracies.append(acc) # Calculate item display if acc > 0: item = QTableWidgetItem(f"{acc:.1%}") if acc >= 0.65: item.setForeground(QColor("#10b981")) elif acc >= 0.40: item.setForeground(QColor("#fbbf24")) else: item.setForeground(QColor("#ef4444")) else: item = QTableWidgetItem("0.0%") item.setForeground(QColor("#ef4444")) mtime_dt = datetime.fromtimestamp(mtime) if last_train is None or mtime_dt > last_train: last_train = mtime_dt # COL 2-6: Models self.status_table.setItem(i, j + 2, item) else: empty_item = QTableWidgetItem("--") empty_item.setForeground(QColor("#6b7280")) self.status_table.setItem(i, j + 2, empty_item) # COL 0: TF (Index 0) # COL 1: Status (Index 1) possible = 5 actual = len(accuracies) status_item = QTableWidgetItem("✅ READY" if actual == possible else f"⚠️ {actual}/{possible}") status_item.setForeground(QColor("#10b981") if actual == possible else QColor("#fbbf24")) status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.status_table.setItem(i, 1, status_item) # Last train date (Index 7) time_str = last_train.strftime("%m/%d %H:%M") if last_train else "Never" self.status_table.setItem(i, 7, QTableWidgetItem(time_str)) # Average accuracy (Index 8) if accuracies: avg_acc = sum(accuracies) / len(accuracies) acc_item = QTableWidgetItem(f"{avg_acc:.1%}") color = "#10b981" if avg_acc >= 0.65 else "#fbbf24" if avg_acc >= 0.40 else "#ef4444" acc_item.setForeground(QColor(color)) self.status_table.setItem(i, 8, acc_item) else: self.status_table.setItem(i, 8, QTableWidgetItem("--")) # Models count (Index 9) count_item = QTableWidgetItem(f"{len(accuracies)}/5") color = "#10b981" if len(accuracies) == 5 else "#fbbf24" if len(accuracies) > 0 else "#ef4444" count_item.setForeground(QColor(color)) self.status_table.setItem(i, 9, count_item) except Exception as e: logger.error(f"Error updating status table: {e}") def _delete_all_models(self): """Delete all model files and schemas""" from PyQt6.QtWidgets import QMessageBox from config.settings import MODELS_DIR import os reply = QMessageBox.question( self, "Confirm Delete", "Are you sure you want to delete ALL models and feature schemas?\n\nThis will force a full retrain and cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: count = 0 try: if MODELS_DIR.exists(): for f in MODELS_DIR.glob("*"): if f.suffix in ['.pkl', '.json']: try: f.unlink() count += 1 except Exception as e: logger.error(f"Failed to delete {f}: {e}") self._add_log(f"🗑️ Deleted {count} model files.", "warning") self.global_status.setText("⚠️ Deleted") self.global_status.setStyleSheet("color: #ef4444;") self._update_status() except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete models: {str(e)}") def _update_auto_train_btn_color(self): """ Update Auto-Train button color based on scheduler state. This method is called: - On startup (via QTimer.singleShot in _init_ui) - After the scheduler popup dialog closes Color logic: - GREEN (#065f46): Scheduler is ACTIVE → automated retraining is enabled - RED (#991b1b): Scheduler is DISABLED → no automated retraining Uses set_color() to preserve the 3D button styling. """ if self.app_controller and self.app_controller.scheduler_enabled: # GREEN: Scheduler is active - automated retraining is ON self.auto_train_btn.set_color( color="#065f46", hover="#10b981", pressed="#064e3b" ) else: # RED: Scheduler is inactive - user must train manually self.auto_train_btn.set_color( color="#991b1b", hover="#dc2626", pressed="#7f1d1d" ) def _show_scheduler_popup(self): """ Open the Auto-Training Scheduler configuration popup. """ try: from gui.panels.training_panel import SchedulerDialog dialog = SchedulerDialog(self.app_controller, self) dialog.exec() # Refresh button color after dialog closes to reflect new state self._update_auto_train_btn_color() except Exception as e: logger.error(f"❌ CRITICAL ERROR: Failed to open Auto-Train Dialog: {e}") import traceback logger.error(traceback.format_exc()) from PyQt6.QtWidgets import QMessageBox QMessageBox.critical(self, "Neural Auto-Pilot Error", f"Impossible d'ouvrir le planificateur d'entraînement :\n{str(e)}\n\nL'erreur a été enregistrée dans cerebrum.log.")
# ════════════════════════════════════════════════════════════════════════════ # SCHEDULER DIALOG (Auto-Training Configuration Popup) # ════════════════════════════════════════════════════════════════════════════ # This dialog allows users to configure the automatic model retraining schedule. # Features: # - Toggle switch to enable/disable auto-training # - Cycle selector (4h, 12h, Daily, Weekly) aligned to market hours # - Live countdown display showing time until next execution # - Progress bar indicating cycle progress # - Save & Quit / Quit buttons # ════════════════════════════════════════════════════════════════════════════
[docs] class SchedulerDialog(QDialog): """ Auto-Training Scheduler Dialog (Pro UI). Modal dialog for configuring the automated model retraining schedule. Changes are saved immediately via app_controller.update_scheduler_config() when the user toggles the switch or changes the cycle. """ def __init__(self, app_controller, parent=None): super().__init__(parent) self.app_controller = app_controller self.setWindowTitle("🧠 Neural Auto-Pilot") self.setFixedSize(500, 450) self.setStyleSheet("background-color: #1e1e1e;") self._init_ui() # Timer for live countdown updates (every second) self._timer = QTimer(self) self._timer.timeout.connect(self._update_countdown) self._timer.start(1000) # Update every 1 second self._update_countdown() def _init_ui(self): layout = QVBoxLayout(self) layout.setSpacing(20) layout.setContentsMargins(25, 25, 25, 25) # Header header = QHBoxLayout() icon = QLabel("🧠") icon.setFont(QFont("Segoe UI Emoji", 28)) header.addWidget(icon) title_col = QVBoxLayout() title = QLabel("Neural Auto-Pilot") title.setFont(QFont("Segoe UI", 18, QFont.Weight.Bold)) title.setStyleSheet("color: white;") subtitle = QLabel("Automated retraining scheduler") subtitle.setStyleSheet("color: #9ca3af;") title_col.addWidget(title) title_col.addWidget(subtitle) header.addLayout(title_col) header.addStretch() # Toggle self.toggle = QCheckBox("DISABLED") self.toggle.setCursor(Qt.CursorShape.PointingHandCursor) self.toggle.setStyleSheet(""" QCheckBox { color: #ef4444; font-weight: bold; font-size: 14px; } QCheckBox::indicator { width: 50px; height: 26px; background-color: #374151; border-radius: 13px; } QCheckBox::indicator:checked { background-color: #10b981; } """) if self.app_controller: self.toggle.setChecked(self.app_controller.scheduler_enabled) if self.app_controller.scheduler_enabled: self.toggle.setText("ACTIVE") self.toggle.setStyleSheet(self.toggle.styleSheet().replace("#ef4444", "#10b981")) self.toggle.toggled.connect(self._on_toggle) header.addWidget(self.toggle) layout.addLayout(header) # Divider line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setStyleSheet("background-color: #333333;") layout.addWidget(line) # Cycle Row cycle_row = QHBoxLayout() cycle_lbl = QLabel("RETRAINING CYCLE") cycle_lbl.setStyleSheet("color: #6b7280; font-weight: bold;") cycle_row.addWidget(cycle_lbl) cycle_row.addStretch() self.cycle_combo = QComboBox() self.cycle_combo.addItems(["4h", "12h", "Daily", "Weekly"]) self.cycle_combo.setFixedSize(150, 36) self.cycle_combo.setStyleSheet(""" QComboBox { background: #252526; color: white; border: 1px solid #4b5563; padding: 8px; border-radius: 6px; } QComboBox::drop-down { border: none; width: 25px; } QComboBox::down-arrow { border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid #4ade80; } """) if self.app_controller: idx = self.cycle_combo.findText(self.app_controller.scheduler_cycle, Qt.MatchFlag.MatchFixedString) if idx >= 0: self.cycle_combo.setCurrentIndex(idx) self.cycle_combo.currentIndexChanged.connect(self._on_cycle_change) cycle_row.addWidget(self.cycle_combo) layout.addLayout(cycle_row) # Countdown countdown_frame = QFrame() countdown_frame.setStyleSheet("background: #252526; border-radius: 10px;") cf_layout = QVBoxLayout(countdown_frame) cf_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) next_lbl = QLabel("NEXT EXECUTION IN") next_lbl.setStyleSheet("color: #6b7280; font-weight: bold;") next_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) cf_layout.addWidget(next_lbl) self.countdown_lbl = QLabel("--:--:--") self.countdown_lbl.setFont(QFont("Consolas", 36, QFont.Weight.Bold)) self.countdown_lbl.setStyleSheet("color: #60a5fa;") self.countdown_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) cf_layout.addWidget(self.countdown_lbl) # Progress self.progress = QProgressBar() self.progress.setFixedHeight(10) self.progress.setTextVisible(False) self.progress.setStyleSheet(""" QProgressBar { background: #2d2d2d; border-radius: 5px; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #3b82f6, stop:1 #8b5cf6); border-radius: 5px; } """) cf_layout.addWidget(self.progress) layout.addWidget(countdown_frame) # Buttons Row btn_row = QHBoxLayout() # Quit Button (Cancel) self.quit_btn = QPushButton("✕ Quit") self.quit_btn.setFixedHeight(44) self.quit_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.quit_btn.setStyleSheet(""" QPushButton { background: #374151; color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 14px; } QPushButton:hover { background: #4b5563; } """) self.quit_btn.clicked.connect(self.reject) btn_row.addWidget(self.quit_btn) # Save & Quit Button self.save_btn = QPushButton("💾 Save & Quit") self.save_btn.setFixedHeight(44) self.save_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.save_btn.setStyleSheet(""" QPushButton { background: #065f46; color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 14px; } QPushButton:hover { background: #10b981; } """) self.save_btn.clicked.connect(self._on_save) # Saves settings then closes btn_row.addWidget(self.save_btn) layout.addLayout(btn_row) layout.addStretch() def _on_toggle(self, checked): """ Handle toggle switch change (UI only, no save yet). Settings are only applied when Save is clicked. """ if checked: self.toggle.setText("ACTIVE") self.toggle.setStyleSheet(self.toggle.styleSheet().replace("#ef4444", "#10b981")) else: self.toggle.setText("DISABLED") self.toggle.setStyleSheet(self.toggle.styleSheet().replace("#10b981", "#ef4444")) # Note: Do NOT save to app_controller here - wait for Save button def _on_cycle_change(self): """ Handle cycle combobox change (UI only, no save yet). Settings are only applied when Save is clicked. """ # Note: Do NOT save to app_controller here - wait for Save button pass def _on_save(self): """ Save settings to app_controller and close dialog. This is the ONLY place where settings are persisted. """ if self.app_controller: enabled = self.toggle.isChecked() cycle = self.cycle_combo.currentText().lower() self.app_controller.update_scheduler_config(enabled, cycle) self.accept() def _update_countdown(self): if not self.app_controller: return if self.app_controller.scheduler_enabled and self.app_controller.next_run_time: now = datetime.now() diff = self.app_controller.next_run_time - now total = diff.total_seconds() if total < 0: self.countdown_lbl.setText("RUNNING...") self.progress.setValue(self.progress.maximum()) else: h, m, s = int(total // 3600), int((total % 3600) // 60), int(total % 60) self.countdown_lbl.setText(f"{h:02}:{m:02}:{s:02}") cycle_map = {"4h": 4*3600, "12h": 12*3600, "daily": 24*3600, "weekly": 7*24*3600} cycle_sec = cycle_map.get(self.app_controller.scheduler_cycle, 4*3600) self.progress.setRange(0, cycle_sec) self.progress.setValue(int(cycle_sec - total)) else: self.countdown_lbl.setText("--:--:--") self.progress.setValue(0)