# VERSION: v1.7 - fire/ui_components/forecasting/forecast_results_widget.py (pre refactoring)
# RESP: Dashboard che visualizza e esporta report di backtest completi di metadati (frontmatter) e branding in alto a destra.
# DEPS: AppState, QCPlotlyChartWidget, pandas, plotly.
# CHANGELOG v1.7:
# - Titoli e report HTML ora mostrano dinamicamente il nome del modello usato (TimesFM o Prophet).
# TODO: N/A
import pandas as pd
import plotly.graph_objects as go
from typing import Dict, Any
from datetime import datetime
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QSplitter, QTabWidget, QHBoxLayout,
QSpacerItem, QSizePolicy, QToolButton, QFileDialog, QDockWidget
)
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QIcon
from fire.app_state import AppState
from fire.ui_components.charts.qc_plotly_chart_widget import QCPlotlyChartWidget
class ForecastResultsWidget(QWidget):
"""
Dashboard per visualizzare i risultati di un backtest di forecasting,
inclusi grafici interattivi e una barra di azioni per l'esportazione e la gestione della vista.
"""
def __init__(self, app_state: AppState, parent=None):
super().__init__(parent)
self.app_state = app_state
self.results: Dict[str, Any] = {}
self.ticker: str = ""
self.model_name: str = "" # Aggiunto
self.initial_window: int = 0
self.horizon: int = 0
self.fig_forecast_json: str = ""
self.fig_mae_json: str = ""
self.fig_rmse_json: str = ""
self.fig_da_json: str = ""
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(5, 5, 5, 5)
self.results_splitter = QSplitter(Qt.Orientation.Vertical)
self.forecast_plot_widget = QCPlotlyChartWidget(self.app_state)
analysis_tabs_widget = QWidget()
analysis_tabs_layout = QVBoxLayout(analysis_tabs_widget)
analysis_tabs_layout.setContentsMargins(0,0,0,0)
self.analysis_tabs = QTabWidget()
self.mae_plot_widget = QCPlotlyChartWidget(self.app_state)
self.rmse_plot_widget = QCPlotlyChartWidget(self.app_state)
self.da_plot_widget = QCPlotlyChartWidget(self.app_state)
self.analysis_tabs.addTab(self.mae_plot_widget, "MAE Over Time")
self.analysis_tabs.addTab(self.rmse_plot_widget, "RMSE Over Time")
self.analysis_tabs.addTab(self.da_plot_widget, "Directional Accuracy")
analysis_tabs_layout.addWidget(self.analysis_tabs)
self.results_splitter.addWidget(self.forecast_plot_widget)
self.results_splitter.addWidget(analysis_tabs_widget)
self.results_splitter.setSizes([600, 400])
action_bar_layout = QHBoxLayout()
action_bar_layout.addSpacerItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum))
self.export_button = QToolButton()
self.export_button.setIcon(QIcon.fromTheme("document-save"))
self.export_button.setText("Export to HTML")
self.export_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.export_button.setToolTip("Esporta questa dashboard in un singolo file HTML.")
self.maximize_button = QToolButton()
self.maximize_button.setIcon(QIcon.fromTheme("view-fullscreen"))
self.maximize_button.setToolTip("Maximize/Restore Window")
action_bar_layout.addWidget(self.export_button)
action_bar_layout.addWidget(self.maximize_button)
main_layout.addWidget(self.results_splitter)
main_layout.addLayout(action_bar_layout)
def _connect_signals(self):
self.maximize_button.clicked.connect(self._toggle_maximize)
self.export_button.clicked.connect(self._handle_export)
@Slot()
def _toggle_maximize(self):
parent_dock = self.parent()
while parent_dock and not isinstance(parent_dock, QDockWidget):
parent_dock = parent_dock.parent()
if parent_dock:
if parent_dock.isMaximized():
parent_dock.showNormal()
self.maximize_button.setIcon(QIcon.fromTheme("view-fullscreen"))
else:
parent_dock.showMaximized()
self.maximize_button.setIcon(QIcon.fromTheme("view-restore"))
@Slot()
def _handle_export(self):
if not self.results:
self.app_state.log("Nessun risultato da esportare.", "WARNING")
return
suggested_filename = f"forecast_backtest_{self.ticker}_{self.model_name}.html"
filepath, _ = QFileDialog.getSaveFileName(self, "Esporta Report HTML", suggested_filename, "HTML Files (*.html)")
if not filepath:
return
try:
html_content = self._generate_html_report()
with open(filepath, 'w', encoding='utf-8') as f:
f.write(html_content)
self.app_state.log(f"Report HTML esportato con successo in: {filepath}", "SUCCESS")
except Exception as e:
self.app_state.log(f"Errore durante l'esportazione del report HTML: {e}", "ERROR")
def _generate_html_report(self) -> str:
"""Crea un singolo file HTML autonomo con un frontmatter di metadati e tutti i grafici."""
report_date = datetime.now().strftime("%Y-%m-%d %H:%M")
full_series = self.results.get("full_series")
if full_series is not None and not full_series.empty:
start_date = full_series.index[0].strftime("%Y-%m-%d")
end_date = full_series.index[-1].strftime("%Y-%m-%d")
data_range_str = f"{start_date} to {end_date}"
else:
data_range_str = "N/A"
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Forecast Backtest Report for {self.ticker}</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
body {{ font-family: sans-serif; margin: 2em; background-color: #f4f4f9; }}
h1, h2 {{ color: #333; }}
.chart-container {{ border: 1px solid #ccc; margin-bottom: 2em; background-color: #fff; }}
.report-meta {{
border: 1px solid #ccc; padding: 1em; margin-bottom: 2em;
background-color: #fff; border-radius: 5px;
}}
.report-meta table {{ width: 100%; border-collapse: collapse; }}
.report-meta th, .report-meta td {{ text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }}
.report-meta th {{ width: 30%; font-weight: bold; color: #555; }}
</style>
</head>
<body>
<h1>Forecast Backtest Report for {self.ticker}</h1>
<div class="report-meta">
<h2>Analysis Parameters</h2>
<table>
<tr><th>Report Date</th><td>{report_date}</td></tr>
<tr><th>Ticker</th><td>{self.ticker}</td></tr>
<tr><th>Model Used</th><td>{self.model_name.upper()}</td></tr>
<tr><th>Historical Data Range</th><td>{data_range_str}</td></tr>
<tr><th>Initial Training Window</th><td>{self.initial_window} days</td></tr>
<tr><th>Forecast Horizon</th><td>{self.horizon} days</td></tr>
</table>
</div>
<div id="chart-forecast" class="chart-container" style="height: 60vh;"></div>
<div id="chart-mae" class="chart-container" style="height: 40vh;"></div>
<div id="chart-rmse" class="chart-container" style="height: 40vh;"></div>
<div id="chart-da" class="chart-container" style="height: 40vh;"></div>
<script>
const fig_forecast = {self.fig_forecast_json};
Plotly.newPlot('chart-forecast', fig_forecast.data, fig_forecast.layout, {{responsive: true}});
const fig_mae = {self.fig_mae_json};
Plotly.newPlot('chart-mae', fig_mae.data, fig_mae.layout, {{responsive: true}});
const fig_rmse = {self.fig_rmse_json};
Plotly.newPlot('chart-rmse', fig_rmse.data, fig_rmse.layout, {{responsive: true}});
const fig_da = {self.fig_da_json};
Plotly.newPlot('chart-da', fig_da.data, fig_da.layout, {{responsive: true}});
</script>
</body>
</html>
"""
return html
def display_results(self, results: Dict[str, Any], ticker: str):
self.results = results
self.ticker = ticker
self.model_name = results.get("model_name", "Unknown Model") # Aggiunto
self.initial_window = results.get("initial_window", "N/A")
self.horizon = results.get("horizon", "N/A")
self._plot_forecast_vs_actuals()
self._plot_mae_over_time()
self._plot_rmse_over_time()
self._plot_directional_accuracy()
def _get_fire_annotation(self):
return dict(
text="Generated by FIRE", align='left', showarrow=False,
xref='paper', yref='paper', x=0.99, y=1.05,
font=dict(size=10, color="grey"), opacity=0.7
)
def _plot_forecast_vs_actuals(self):
fig = go.Figure()
full_series = self.results["full_series"]
fig.add_trace(go.Scatter(
x=full_series.index, y=full_series,
mode='lines', name='Dati Reali', line=dict(color='blue', width=2)
))
for i, forecast_df in enumerate(self.results["all_forecasts"]):
fig.add_trace(go.Scatter(
x=forecast_df.index, y=forecast_df['forecast'],
mode='lines', name=f'Forecast Step {i+1}',
line=dict(color='red', width=2), opacity=0.8
))
fig.update_layout(
title=f"Backtest {self.model_name.upper()} (Rolling-Origin) per {self.ticker}",
legend_title="Serie",
annotations=[self._get_fire_annotation()]
)
self.fig_forecast_json = fig.to_json()
self.forecast_plot_widget.plot(self.fig_forecast_json)
def _plot_mae_over_time(self):
error_df = pd.DataFrame(self.results["error_history"])
mean_mae = self.results.get("mean_mae", 0.0)
fig = go.Figure()
fig.add_trace(go.Scatter(
x=error_df['date'], y=error_df['mae'],
mode='lines+markers', name="MAE", line=dict(color='green', width=2)
))
fig.update_layout(
title=f"MAE Over Time (Mean: {mean_mae:.2f})",
xaxis_title="Data di Inizio Previsione",
yaxis_title="Mean Absolute Error",
annotations=[self._get_fire_annotation()]
)
self.fig_mae_json = fig.to_json()
self.mae_plot_widget.plot(self.fig_mae_json)
def _plot_rmse_over_time(self):
error_df = pd.DataFrame(self.results["error_history"])
mean_rmse = self.results.get("mean_rmse", 0.0)
fig = go.Figure()
fig.add_trace(go.Scatter(
x=error_df['date'], y=error_df['rmse'],
mode='lines+markers', name="RMSE", line=dict(color='orange', width=2)
))
fig.update_layout(
title=f"RMSE Over Time (Mean: {mean_rmse:.2f})",
xaxis_title="Data di Inizio Previsione",
yaxis_title="Root Mean Square Error",
annotations=[self._get_fire_annotation()]
)
self.fig_rmse_json = fig.to_json()
self.rmse_plot_widget.plot(self.fig_rmse_json)
def _plot_directional_accuracy(self):
error_df = pd.DataFrame(self.results["error_history"])
mean_da = self.results.get("mean_directional_accuracy", 0.0)
rolling_da = error_df['directional_accuracy'].rolling(window=20, min_periods=1).mean()
fig = go.Figure()
fig.add_trace(go.Scatter(
x=error_df['date'],
y=rolling_da,
mode='lines',
name='Rolling DA (20 periods)',
line=dict(color='purple', width=2)
))
fig.update_layout(
title=f"Directional Accuracy Over Time (Mean: {mean_da:.2%})",
xaxis_title="Data di Inizio Previsione",
yaxis_title="Accuracy (Rolling Average)",
yaxis=dict(range=[0, 1], tickformat=".0%"),
annotations=[self._get_fire_annotation()]
)
self.fig_da_json = fig.to_json()
self.da_plot_widget.plot(self.fig_da_json)