forecasting/backend/jobs/accuracy_calc.py
jtricerolph 75d2c1fa9d Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:49:34 +00:00

182 lines
7.3 KiB
Python

"""
Accuracy calculation job
Compares forecasts to actuals once dates have passed
"""
import logging
from datetime import date, timedelta
from sqlalchemy import text
from database import SyncSessionLocal
logger = logging.getLogger(__name__)
async def run_accuracy_calculation():
"""
Calculate forecast accuracy for dates that have passed.
Updates actual_vs_forecast table with error metrics.
"""
logger.info("Starting accuracy calculation")
db = next(iter([SyncSessionLocal()]))
try:
# Process yesterday's actuals
calc_date = date.today() - timedelta(days=1)
# Get all metrics
metrics_result = db.execute(
text("""
SELECT metric_code FROM forecast_metrics WHERE is_active = TRUE
""")
)
metrics = [row.metric_code for row in metrics_result.fetchall()]
for metric_code in metrics:
# Get actual value from daily_metrics
actual_result = db.execute(
text("""
SELECT actual_value FROM daily_metrics
WHERE date = :calc_date AND metric_code = :metric_code
"""),
{"calc_date": calc_date, "metric_code": metric_code}
)
actual_row = actual_result.fetchone()
actual_value = actual_row.actual_value if actual_row else None
if actual_value is None:
continue # Skip if no actual available
# Get forecasts for this date
forecast_result = db.execute(
text("""
SELECT model_type, predicted_value, lower_bound, upper_bound
FROM forecasts
WHERE forecast_date = :calc_date AND forecast_type = :metric_code
"""),
{"calc_date": calc_date, "metric_code": metric_code}
)
forecasts = {row.model_type: row for row in forecast_result.fetchall()}
prophet_forecast = forecasts.get('prophet')
xgboost_forecast = forecasts.get('xgboost')
pickup_forecast = forecasts.get('pickup')
catboost_forecast = forecasts.get('catboost')
# Calculate errors
def calc_error(forecast_val):
if forecast_val is None:
return None, None
error = actual_value - forecast_val
pct_error = (error / actual_value * 100) if actual_value != 0 else None
return error, pct_error
prophet_error, prophet_pct = calc_error(
prophet_forecast.predicted_value if prophet_forecast else None
)
xgboost_error, xgboost_pct = calc_error(
xgboost_forecast.predicted_value if xgboost_forecast else None
)
pickup_error, pickup_pct = calc_error(
pickup_forecast.predicted_value if pickup_forecast else None
)
catboost_error, catboost_pct = calc_error(
catboost_forecast.predicted_value if catboost_forecast else None
)
# Determine best model
errors = []
if prophet_error is not None:
errors.append(('prophet', abs(prophet_error)))
if xgboost_error is not None:
errors.append(('xgboost', abs(xgboost_error)))
if pickup_error is not None:
errors.append(('pickup', abs(pickup_error)))
if catboost_error is not None:
errors.append(('catboost', abs(catboost_error)))
best_model = min(errors, key=lambda x: x[1])[0] if errors else None
# Get budget value
budget_result = db.execute(
text("""
SELECT budget_value FROM daily_budgets
WHERE date = :calc_date AND budget_type = :metric_code
"""),
{"calc_date": calc_date, "metric_code": metric_code}
)
budget_row = budget_result.fetchone()
budget_value = budget_row.budget_value if budget_row else None
# Upsert accuracy record
db.execute(
text("""
INSERT INTO actual_vs_forecast (
date, metric_type, actual_value,
prophet_forecast, prophet_lower, prophet_upper,
xgboost_forecast, pickup_forecast,
catboost_forecast,
budget_value,
prophet_error, prophet_pct_error,
xgboost_error, xgboost_pct_error,
pickup_error, pickup_pct_error,
catboost_error, catboost_pct_error,
best_model, calculated_at
) VALUES (
:date, :metric_type, :actual,
:prophet_val, :prophet_lower, :prophet_upper,
:xgboost_val, :pickup_val,
:catboost_val,
:budget,
:prophet_error, :prophet_pct,
:xgboost_error, :xgboost_pct,
:pickup_error, :pickup_pct,
:catboost_error, :catboost_pct,
:best_model, NOW()
)
ON CONFLICT (date, metric_type) DO UPDATE SET
actual_value = :actual,
prophet_error = :prophet_error,
prophet_pct_error = :prophet_pct,
xgboost_error = :xgboost_error,
xgboost_pct_error = :xgboost_pct,
pickup_error = :pickup_error,
pickup_pct_error = :pickup_pct,
catboost_forecast = :catboost_val,
catboost_error = :catboost_error,
catboost_pct_error = :catboost_pct,
best_model = :best_model,
calculated_at = NOW()
"""),
{
"date": calc_date,
"metric_type": metric_code,
"actual": actual_value,
"prophet_val": prophet_forecast.predicted_value if prophet_forecast else None,
"prophet_lower": prophet_forecast.lower_bound if prophet_forecast else None,
"prophet_upper": prophet_forecast.upper_bound if prophet_forecast else None,
"xgboost_val": xgboost_forecast.predicted_value if xgboost_forecast else None,
"pickup_val": pickup_forecast.predicted_value if pickup_forecast else None,
"catboost_val": catboost_forecast.predicted_value if catboost_forecast else None,
"budget": budget_value,
"prophet_error": prophet_error,
"prophet_pct": prophet_pct,
"xgboost_error": xgboost_error,
"xgboost_pct": xgboost_pct,
"pickup_error": pickup_error,
"pickup_pct": pickup_pct,
"catboost_error": catboost_error,
"catboost_pct": catboost_pct,
"best_model": best_model
}
)
db.commit()
logger.info(f"Accuracy calculation completed for {calc_date}")
except Exception as e:
logger.error(f"Accuracy calculation failed: {e}")
db.rollback()
raise
finally:
db.close()