"""
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)