Analisi Critica di main_window.py

🔴 Problemi Architetturali Gravi

1. God Object Confermato

Questa classe viola massivamente il Single Responsibility Principle. Ha almeno 8 responsabilità diverse:

  1. ✅ Gestione UI/Layout (legittimo per MainWindow)
  2. Orchestrazione del Backtest (backtest_orchestrator)
  3. Gestione Worker (worker_manager)
  4. Dispatching Forecast (on_forecast_requested)
  5. Dispatching Export (on_export_data_requested)
  6. Dispatching Explorer (on_run_explorer_analysis)
  7. Gestione Persistenza (_load_last_* methods)
  8. Signal Routing (30+ connessioni in _connect_signals_slots)

2. Signal Spaghetti

Il metodo _connect_signals_slots() ha 25+ connessioni. Questo è il sintomo classico di un’architettura dove ogni componente parla con tutti:

# Esempio di accoppiamento eccessivo
self.watchlist_panel.ticker_selected.connect(self.controls_panel.set_ticker)
self.watchlist_panel.ticker_selected.connect(self.controls_panel.on_load_chart_clicked)

Perché MainWindow deve sapere che quando watchlist_panel seleziona un ticker, deve chiamare due metodi diversi su controls_panel? Questo è hardcoding della business logic nell’UI.

3. Business Logic nell’UI

Guarda on_forecast_requested():

@Slot()
def on_forecast_requested(self):
    if self.controls_panel.last_price_data is None or self.controls_panel.last_price_data.empty:
        self.app_state.log("Nessun dato sul grafico per eseguire la previsione.", "WARNING")
        return
    
    if self.app_state.current_chart_plotter_name in self.app_state.PLOTLY_CHART_TYPES:
        self.app_state.log("La previsione è disponibile solo sui grafici temporali (es. Candlestick).", "WARNING")
        return
        
    price_series = self.controls_panel.last_price_data['Close'].to_numpy()
    params = {"context_len": 512, "horizon_len": 60}
    worker = TimesFMForecastWorker(price_series=price_series, params=params)
    # ... setup worker ...

Problemi:

  • MainWindow conosce i dettagli del forecasting (context_len, horizon_len)
  • Accede direttamente ai dati interni di controls_panel (last_price_data)
  • Contiene validazione di business logic (tipo di grafico, dati vuoti)

Questa logica dovrebbe essere in ForecastOrchestrator, non in MainWindow.


📊 Metriche di “Fatness”

MetricaValoreSoglia SalutareStato
Linee di codice~200< 300⚠️ Warning
Numero di metodi20+< 15🔴 Critico
Dipendenze dirette15+< 8🔴 Critico
Connessioni segnali25+< 10🔴 Critico
Responsabilità81-2🔴 Critico

🎯 Piano di Refactoring: Approccio Signal Bus

Fase 1: Identificare i Domini di Comunicazione

Ho analizzato _connect_signals_slots() e identificato 5 domini:

1. Forecasting Domain

# Attualmente
self.chrome_manager.forecast_button.clicked.connect(self.on_forecast_requested)
# poi dentro on_forecast_requested():
# - valida dati
# - crea worker
# - connette risultati
 
# Con Signal Bus
class ForecastSignals(QObject):
    requested = Signal(object)  # ForecastRequest
    completed = Signal(object)  # ForecastResult
    failed = Signal(str)

2. Data Export Domain

# Attualmente
self.watchlist_panel.export_data_requested.connect(self.on_export_data_requested)
 
# Con Signal Bus
class ExportSignals(QObject):
    requested = Signal(object)  # ExportRequest
    completed = Signal(str)  # message
    failed = Signal(str)

3. Charting Domain

# Attualmente
self.controls_panel.plot_data_requested.connect(self.dispatch_plot_request)
self.controls_panel.clear_plot_requested.connect(self.charting_panel.clear_charts)
self.controls_panel.advanced_chart_data_updated.connect(...)
 
# Con Signal Bus
class ChartingSignals(QObject):
    plot_requested = Signal(object)  # PlotRequest
    clear_requested = Signal()
    updated = Signal(pd.DataFrame)

4. Explorer/Analysis Domain

# Attualmente
self.explorer_panel.analysis_requested.connect(self.on_run_explorer_analysis)
 
# Con Signal Bus
class AnalysisSignals(QObject):
    requested = Signal(object)  # AnalysisRequest
    completed = Signal(dict)
    failed = Signal(str)

5. Ticker Selection Domain

# Attualmente
self.watchlist_panel.ticker_selected.connect(self.controls_panel.set_ticker)
self.watchlist_panel.ticker_selected.connect(self.controls_panel.on_load_chart_clicked)
 
# Con Signal Bus
class TickerSignals(QObject):
    selected = Signal(str)  # ticker
    chart_requested = Signal(str)  # ticker

🏗️ Struttura Proposta del Signal Bus

File: fire/core/signals/__init__.py

from PySide6.QtCore import QObject, Signal
from dataclasses import dataclass
from typing import Optional
import pandas as pd
 
# ============= Data Models =============
 
@dataclass
class ForecastRequest:
    price_data: pd.DataFrame
    context_len: int = 512
    horizon_len: int = 60
    chart_type: Optional[str] = None
 
@dataclass
class ForecastResult:
    point_forecast: pd.Series
    quantile_forecast: Optional[tuple] = None
    metadata: dict = None
 
@dataclass
class ExportRequest:
    ticker: str
    filepath: str
    start_date: str
    end_date: str
    interval: str
 
@dataclass
class PlotRequest:
    df: pd.DataFrame
    plotter: object
    title: str
    overlays: dict
 
@dataclass
class AnalysisRequest:
    ticker: str
    start_date: str
    end_date: str
    interval: str
    analysis_type: str
 
# ============= Signal Buses =============
 
class ForecastSignals(QObject):
    """Segnali per il forecasting"""
    requested = Signal(object)  # ForecastRequest
    completed = Signal(object)  # ForecastResult
    failed = Signal(str, Exception)
 
class ExportSignals(QObject):
    """Segnali per l'export dati"""
    requested = Signal(object)  # ExportRequest
    completed = Signal(str)  # success message
    failed = Signal(str, Exception)
 
class ChartingSignals(QObject):
    """Segnali per i grafici"""
    plot_requested = Signal(object)  # PlotRequest
    clear_requested = Signal()
    data_updated = Signal(pd.DataFrame)
    equity_updated = Signal(object)
 
class AnalysisSignals(QObject):
    """Segnali per analisi/explorer"""
    requested = Signal(object)  # AnalysisRequest
    completed = Signal(dict)
    failed = Signal(str, Exception)
 
class TickerSignals(QObject):
    """Segnali per selezione ticker"""
    selected = Signal(str)
    chart_requested = Signal(str)
 
class BacktestSignals(QObject):
    """Segnali per backtest"""
    requested = Signal(object)  # BacktestRequest
    completed = Signal(object)  # BacktestResult
    failed = Signal(str, Exception)
    trade_log_updated = Signal(object)
 
# ============= Central Signal Bus =============
 
class SignalBus:
    """
    Bus centrale per tutti i segnali dell'applicazione.
    Istanza singleton accessibile tramite AppState.
    """
    def __init__(self):
        self.forecast = ForecastSignals()
        self.export = ExportSignals()
        self.charting = ChartingSignals()
        self.analysis = AnalysisSignals()
        self.ticker = TickerSignals()
        self.backtest = BacktestSignals()
    
    def disconnect_all(self):
        """Utility per testing: disconnette tutti i segnali"""
        for signal_group in [self.forecast, self.export, self.charting, 
                            self.analysis, self.ticker, self.backtest]:
            for attr_name in dir(signal_group):
                attr = getattr(signal_group, attr_name)
                if isinstance(attr, Signal):
                    try:
                        attr.disconnect()
                    except:
                        pass

File: fire/app_state.py (modificato)

class AppState:
    def __init__(self):
        # ... existing code ...
        from fire.core.signals import SignalBus
        self.signals = SignalBus()

🔧 Refactoring Step-by-Step

Step 1: Creare gli Orchestratori

File: fire/logic/forecast_orchestrator.py

from PySide6.QtCore import QObject, Slot
from fire.app_state import AppState
from fire.workers.forecasting.timesfm_worker import TimesFMForecastWorker
from fire.core.signals import ForecastRequest, ForecastResult
 
class ForecastOrchestrator(QObject):
    """
    Gestisce il flusso completo del forecasting.
    Ascolta richieste dal Signal Bus, valida, esegue, pubblica risultati.
    """
    def __init__(self, app_state: AppState, worker_manager, parent=None):
        super().__init__(parent)
        self.app_state = app_state
        self.worker_manager = worker_manager
        
        # Subscribe to signals
        app_state.signals.forecast.requested.connect(self.handle_forecast_request)
    
    @Slot(object)
    def handle_forecast_request(self, request: ForecastRequest):
        """Gestisce una richiesta di forecast"""
        # Validation
        if request.price_data is None or request.price_data.empty:
            error_msg = "Nessun dato disponibile per la previsione"
            self.app_state.log(error_msg, "WARNING")
            self.app_state.signals.forecast.failed.emit(error_msg, None)
            return
        
        if request.chart_type in self.app_state.PLOTLY_CHART_TYPES:
            error_msg = "La previsione è disponibile solo sui grafici temporali"
            self.app_state.log(error_msg, "WARNING")
            self.app_state.signals.forecast.failed.emit(error_msg, None)
            return
        
        # Extract price series
        price_series = request.price_data['Close'].to_numpy()
        
        # Create worker
        params = {
            "context_len": request.context_len,
            "horizon_len": request.horizon_len
        }
        
        worker = TimesFMForecastWorker(price_series=price_series, params=params)
        worker.signals.finished.connect(self._on_forecast_finished)
        worker.signals.error.connect(self._on_forecast_error)
        
        self.worker_manager.submit_task(worker)
        self.app_state.log("Avvio forecasting...", "INFO")
    
    @Slot(dict)
    def _on_forecast_finished(self, raw_result: dict):
        """Trasforma il risultato del worker in ForecastResult e lo pubblica"""
        result = ForecastResult(
            point_forecast=raw_result.get("point_forecast"),
            quantile_forecast=raw_result.get("quantile_forecast"),
            metadata=raw_result
        )
        
        self.app_state.log("Forecasting completato", "SUCCESS")
        self.app_state.signals.forecast.completed.emit(result)
    
    @Slot(str)
    def _on_forecast_error(self, error_msg: str):
        """Gestisce errori del worker"""
        self.app_state.log(f"Errore forecasting: {error_msg}", "ERROR")
        self.app_state.signals.forecast.failed.emit(error_msg, None)

File: fire/logic/export_orchestrator.py

from PySide6.QtCore import QObject, Slot
from fire.app_state import AppState
from fire.core.data.data_manager import DataManager
from fire.workers.data_export_worker import DataExportWorker
from fire.core.signals import ExportRequest
 
class ExportOrchestrator(QObject):
    """Gestisce l'export dei dati"""
    def __init__(self, app_state: AppState, data_manager: DataManager, 
                 worker_manager, parent=None):
        super().__init__(parent)
        self.app_state = app_state
        self.data_manager = data_manager
        self.worker_manager = worker_manager
        
        app_state.signals.export.requested.connect(self.handle_export_request)
    
    @Slot(object)
    def handle_export_request(self, request: ExportRequest):
        """Gestisce una richiesta di export"""
        try:
            params = {
                'ticker': request.ticker,
                'start_date': request.start_date,
                'end_date': request.end_date,
                'interval': request.interval,
                'filepath': request.filepath
            }
            
            worker = DataExportWorker(data_manager=self.data_manager, params=params)
            
            worker.signals.finished.connect(
                lambda result: self._on_export_finished(result, request.ticker)
            )
            worker.signals.error.connect(
                lambda msg: self._on_export_error(msg, request.ticker)
            )
            
            self.worker_manager.submit_task(worker)
            
        except Exception as e:
            error_msg = f"Errore avvio export per {request.ticker}: {e}"
            self.app_state.log(error_msg, "ERROR")
            self.app_state.signals.export.failed.emit(error_msg, e)
    
    def _on_export_finished(self, result: dict, ticker: str):
        msg = result.get('message', f'Export completato per {ticker}')
        self.app_state.log(msg, "SUCCESS")
        self.app_state.signals.export.completed.emit(msg)
    
    def _on_export_error(self, error_msg: str, ticker: str):
        full_msg = f"Export fallito per {ticker}: {error_msg}"
        self.app_state.log(full_msg, "ERROR")
        self.app_state.signals.export.failed.emit(full_msg, None)

Step 2: Modificare MainWindow

File: fire/main_window.py (refactored - parte 1)

# ... imports esistenti ...
 
# NUOVO: Import dei domini orchestratori
from fire.logic.forecast_orchestrator import ForecastOrchestrator
from fire.logic.export_orchestrator import ExportOrchestrator
 
class MainWindow(QMainWindow):
    def __init__(self, app_state: AppState, settings_manager: SettingsManager, 
                 data_manager: DataManager, llm_provider: LLMProvider, 
                 parent: Optional[QWidget] = None):
        super().__init__(parent)
        self.app_state = app_state
        self.settings_manager = settings_manager
        self.data_manager = data_manager
        self.llm_provider = llm_provider
        
        # Setup base
        self.setWindowTitle(f"{UITXT.APP_TITLE} - v{__version__}")
        self.setGeometry(50, 50, 1600, 900)
        ThemeManager.load_colors(self.settings_manager)
        
        # Worker manager
        self.worker_manager = WorkerManager(self)
        
        # NUOVO: Setup orchestratori (gestiscono la business logic)
        self._setup_orchestrators()
        
        # Setup UI components
        self._setup_panels()
        self._setup_ui_manager()
        
        # Chrome manager (toolbar/menubar)
        self.chrome_manager = ChromeManager(self, self.ui_manager)
        
        # Connessioni (DRASTICAMENTE RIDOTTE)
        self._connect_signals()
        
        # Load settings
        self._load_persistent_settings()
        
        self.app_state.log(UITXT.APP_READY_MESSAGE, "INFO")
    
    def _setup_orchestrators(self):
        """Setup degli orchestratori che gestiscono la business logic"""
        # Forecast orchestrator
        self.forecast_orchestrator = ForecastOrchestrator(
            self.app_state,
            self.worker_manager,
            parent=self
        )
        
        # Export orchestrator
        self.export_orchestrator = ExportOrchestrator(
            self.app_state,
            self.data_manager,
            self.worker_manager,
            parent=self
        )
        
        # Backtest orchestrator (già esistente, ma ora usa signals)
        self.backtest_orchestrator = BacktestOrchestrator(
            app_state=self.app_state,
            data_manager=self.data_manager,
            worker_manager=self.worker_manager,
            settings_manager=self.settings_manager
        )
    
    def _setup_panels(self):
        """Crea tutti i pannelli UI"""
        # ... codice esistente per creare i pannelli ...
        self.strategy_editor_panel = StrategyEditorTab(self.app_state)
        self.controls_panel = BacktestTabWidget(
            app_state=self.app_state,
            data_manager=self.data_manager
        )
        # ... etc ...
        
        # Link panels to orchestrators
        self.backtest_orchestrator.controls_panel = self.controls_panel
        self.backtest_orchestrator.strategy_panel = self.strategy_editor_panel
    
    def _connect_signals(self):
        """
        Connette i segnali. MOLTO PIÙ SEMPLICE grazie al Signal Bus.
        MainWindow ora fa solo "UI wiring", non business logic routing.
        """
        # === Segnali di sistema ===
        self.app_state.log_message.connect(self.log_panel.add_log_message)
        self.app_state.log_level_changed.connect(self.log_panel.set_log_level)
        self.app_state.task_requested.connect(self.worker_manager.submit_task)
        self.worker_manager.operation_state_changed.connect(self.on_operation_state_changed)
        
        # === Segnali UI → Signal Bus (publishers) ===
        # Forecast button → Signal Bus
        self.chrome_manager.forecast_button.clicked.connect(
            self._request_forecast_from_ui
        )
        
        # Watchlist export → Signal Bus
        self.watchlist_panel.export_data_requested.connect(
            self._request_export_from_watchlist
        )
        
        # Explorer analysis → Signal Bus
        self.explorer_panel.analysis_requested.connect(
            self._request_analysis_from_explorer
        )
        
        # === Signal Bus → UI (subscribers) ===
        # Forecast completed → Chart
        self.app_state.signals.forecast.completed.connect(
            self._on_forecast_completed_draw_chart
        )
        
        # Charting signals → ChartingPanel
        self.app_state.signals.charting.plot_requested.connect(
            self.charting_panel.update_price_chart
        )
        self.app_state.signals.charting.clear_requested.connect(
            self.charting_panel.clear_charts
        )
        
        # === Segnali UI-to-UI diretti (solo se strettamente necessari) ===
        # Questi sono OK perché sono puramente UI, nessuna business logic
        self.watchlist_panel.ticker_selected.connect(
            lambda ticker: self.app_state.signals.ticker.selected.emit(ticker)
        )
        self.app_state.signals.ticker.selected.connect(
            self.controls_panel.set_ticker
        )
        
        # Settings
        self.chrome_manager.settings_action.triggered.connect(self.open_settings_dialog)
        self.settings_changed.connect(self.ai_assistant_tab.on_settings_changed)
        
        # ... altre connessioni puramente UI ...
    
    # === Adapter methods: UI → Signal Bus ===
    
    @Slot()
    def _request_forecast_from_ui(self):
        """
        Adapter: prende i dati dalla UI e li trasforma in ForecastRequest
        per il Signal Bus.
        """
        from fire.core.signals import ForecastRequest
        
        # Recupera i dati dalla UI
        price_data = self.controls_panel.last_price_data
        chart_type = self.app_state.current_chart_plotter_name
        
        # Crea la request
        request = ForecastRequest(
            price_data=price_data,
            chart_type=chart_type
        )
        
        # Pubblica sul bus
        self.app_state.signals.forecast.requested.emit(request)
    
    @Slot(str, str)
    def _request_export_from_watchlist(self, ticker: str, filepath: str):
        """Adapter: UI → ExportRequest → Signal Bus"""
        from fire.core.signals import ExportRequest
        
        # Recupera parametri dalla UI
        _, start_date, end_date, interval = self.controls_panel.get_current_backtest_parameters()
        
        request = ExportRequest(
            ticker=ticker,
            filepath=filepath,
            start_date=start_date,
            end_date=end_date,
            interval=interval
        )
        
        self.app_state.signals.export.requested.emit(request)
    
    @Slot(str, str, str, str, str)
    def _request_analysis_from_explorer(self, ticker, start_date, end_date, 
                                       interval, analysis_type):
        """Adapter: UI → AnalysisRequest → Signal Bus"""
        from fire.core.signals import AnalysisRequest
        
        request = AnalysisRequest(
            ticker=ticker,
            start_date=start_date,
            end_date=end_date,
            interval=interval,
            analysis_type=analysis_type
        )
        
        self.app_state.signals.analysis.requested.emit(request)
    
    # === Adapter methods: Signal Bus → UI ===
    
    @Slot(object)
    def _on_forecast_completed_draw_chart(self, result):
        """
        Adapter: riceve ForecastResult dal Signal Bus e aggiorna la UI
        """
        self.charting_panel._get_lwc_price_chart().draw_forecast_overlay(
            result.point_forecast,
            result.quantile_forecast
        )
    
    # === Metodi UI puri (mantengono la stessa logica) ===
    
    @Slot()
    def open_settings_dialog(self):
        # ... unchanged ...
        pass
    
    @Slot(str)
    def on_timeframe_changed(self, timeframe: str):
        self.app_state.current_timeframe = timeframe
    
    # ... altri metodi UI ...
    
    # ELIMINATI i seguenti metodi (ora negli orchestratori):
    # - on_forecast_requested()
    # - on_forecast_finished()
    # - on_export_data_requested()
    # - on_run_explorer_analysis()
    # - on_explorer_analysis_finished()

📈 Risultati Attesi del Refactoring

Metriche “Dopo”

MetricaPrimaDopoMiglioramento
Linee di codice MainWindow~200~12040% ↓
Metodi MainWindow20+1240% ↓
Connessioni in _connect_signals25+1252% ↓
Business logic in MainWindow8 metodi0100% ↓
TestabilitàBassaAlta++

Vantaggi Concreti

  1. ✅ MainWindow è ora un “Assembly Point”

    • Crea componenti
    • Li connette al Signal Bus
    • Nessuna business logic
  2. ✅ Business Logic Isolata

    • ForecastOrchestrator: Tutto il forecasting
    • ExportOrchestrator: Tutto l’export
    • Testabili in isolamento
  3. ✅ Cicli di Importazione Impossibili

    • Gli orchestratori importano solo AppState
    • MainWindow importa orchestratori
    • Nessun ciclo possibile
  4. ✅ Estendibilità

    • Nuova feature? → Nuovo orchestratore + nuovo signal group
    • Zero modifiche a MainWindow

🚀 Piano di Migrazione Incrementale

Week 1: Setup Infrastructure

  • Creare fire/core/signals/__init__.py con tutti i signal groups
  • Aggiungere SignalBus a AppState
  • Scrivere unit tests per SignalBus.disconnect_all()

Week 2: Migrate Forecast Domain

  • Creare ForecastOrchestrator
  • Modificare MainWindow per usare orchestrator
  • Testare forecast end-to-end
  • COMMIT: “refactor: extract forecast logic to orchestrator”

Week 3: Migrate Export Domain

  • Creare ExportOrchestrator
  • Modificare MainWindow e WatchlistPanel
  • Testare export
  • COMMIT: “refactor: extract export logic to orchestrator”

Week 4: Migrate Explorer/Analysis

  • Creare AnalysisOrchestrator
  • Migrare logica da MainWindow
  • COMMIT: “refactor: extract analysis logic to orchestrator”

Week 5: Cleanup & Documentation

  • Rimuovere metodi morti da MainWindow
  • Documentare il Signal Bus pattern
  • Aggiornare diagrammi architetturali