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:
- ✅ Gestione UI/Layout (legittimo per MainWindow)
- ❌ Orchestrazione del Backtest (
backtest_orchestrator) - ❌ Gestione Worker (
worker_manager) - ❌ Dispatching Forecast (
on_forecast_requested) - ❌ Dispatching Export (
on_export_data_requested) - ❌ Dispatching Explorer (
on_run_explorer_analysis) - ❌ Gestione Persistenza (
_load_last_*methods) - ❌ 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”
| Metrica | Valore | Soglia Salutare | Stato |
|---|---|---|---|
| Linee di codice | ~200 | < 300 | ⚠️ Warning |
| Numero di metodi | 20+ | < 15 | 🔴 Critico |
| Dipendenze dirette | 15+ | < 8 | 🔴 Critico |
| Connessioni segnali | 25+ | < 10 | 🔴 Critico |
| Responsabilità | 8 | 1-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:
passFile: 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”
| Metrica | Prima | Dopo | Miglioramento |
|---|---|---|---|
| Linee di codice MainWindow | ~200 | ~120 | 40% ↓ |
| Metodi MainWindow | 20+ | 12 | 40% ↓ |
Connessioni in _connect_signals | 25+ | 12 | 52% ↓ |
| Business logic in MainWindow | 8 metodi | 0 | 100% ↓ |
| Testabilità | Bassa | Alta | ++ |
Vantaggi Concreti
-
✅ MainWindow è ora un “Assembly Point”
- Crea componenti
- Li connette al Signal Bus
- Nessuna business logic
-
✅ Business Logic Isolata
ForecastOrchestrator: Tutto il forecastingExportOrchestrator: Tutto l’export- Testabili in isolamento
-
✅ Cicli di Importazione Impossibili
- Gli orchestratori importano solo
AppState MainWindowimporta orchestratori- Nessun ciclo possibile
- Gli orchestratori importano solo
-
✅ 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__.pycon tutti i signal groups - Aggiungere
SignalBusaAppState - Scrivere unit tests per
SignalBus.disconnect_all()
Week 2: Migrate Forecast Domain
- Creare
ForecastOrchestrator - Modificare
MainWindowper usare orchestrator - Testare forecast end-to-end
- COMMIT: “refactor: extract forecast logic to orchestrator”
Week 3: Migrate Export Domain
- Creare
ExportOrchestrator - Modificare
MainWindoweWatchlistPanel - 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