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>
182 lines
7.3 KiB
Python
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()
|