forecasting/backend/api/backtest.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

1580 lines
56 KiB
Python

"""
Backtesting API endpoints for model accuracy evaluation.
Run backtests to evaluate how well models would have performed
on historical data using only information available at the time.
"""
from datetime import date, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, BackgroundTasks, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
from utils.time_alignment import get_prior_year_daily
router = APIRouter()
@router.post("/run")
async def run_backtest(
metric_code: str = Query(..., description="Metric to backtest"),
from_date: date = Query(..., description="Start of backtest period"),
to_date: date = Query(..., description="End of backtest period"),
lead_times: str = Query("7,14,21,28", description="Comma-separated lead times in days"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Run backtest for a metric over a date range.
For each historical date, simulates what the forecast would have been
at various lead times using only data available at that time.
Uses booking_placed timestamps from newbook_bookings to reconstruct
what OTB values would have been at each simulated date.
Returns accuracy metrics comparing predicted vs actual values.
"""
lead_time_list = [int(x.strip()) for x in lead_times.split(",")]
results = []
total_rooms = 25
# Overflow room category (category_id=5) is used for chargeable no-shows/cancellations
# and should be excluded from room night counts
overflow_category_id = '5'
# Get room capacity (SUM across all room categories for a single date)
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
rooms_result = await db.execute(
text("""
SELECT COALESCE(SUM(available), 25) as total_rooms
FROM newbook_occupancy_report
WHERE date = (
SELECT MAX(date) FROM newbook_occupancy_report
WHERE date <= :from_date
)
"""),
{"from_date": from_date}
)
rooms_row = rooms_result.fetchone()
if rooms_row and rooms_row.total_rooms:
total_rooms = int(rooms_row.total_rooms)
# Process each date
current_date = from_date
while current_date <= to_date:
# Get actual value for this date
actual_result = await db.execute(
text("""
SELECT actual_value
FROM daily_metrics
WHERE date = :target_date AND metric_code = :metric
"""),
{"target_date": current_date, "metric": metric_code}
)
actual_row = actual_result.fetchone()
actual_value = float(actual_row.actual_value) if actual_row and actual_row.actual_value else None
if actual_value is None:
current_date += timedelta(days=1)
continue
# For each lead time, simulate the forecast
for lead_time in lead_time_list:
simulated_today = current_date - timedelta(days=lead_time)
# First try to get OTB from snapshots
otb_result = await db.execute(
text("""
SELECT otb_value, snapshot_date, days_out
FROM pickup_snapshots
WHERE stay_date = :target_date
AND metric_type = :metric
AND snapshot_date <= :simulated_today
ORDER BY snapshot_date DESC
LIMIT 1
"""),
{
"target_date": current_date,
"metric": metric_code,
"simulated_today": simulated_today
}
)
otb_row = otb_result.fetchone()
current_otb = None
actual_lead_time = lead_time
if otb_row:
# Use 'is not None' - 0 is valid OTB data
current_otb = float(otb_row.otb_value) if otb_row.otb_value is not None else 0
actual_lead_time = otb_row.days_out or lead_time
else:
# Reconstruct OTB from booking data using booking_placed timestamps
# EXCLUDES overflow category
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
# Count bookings that were placed before simulated_today
# and cover the target date
otb_recon_result = await db.execute(
text("""
SELECT COUNT(DISTINCT newbook_id) as otb_count
FROM newbook_bookings
WHERE arrival_date <= :target_date
AND departure_date > :target_date
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')
AND (raw_json->>'booking_placed')::timestamp <= :simulated_today::timestamp
AND (category_id IS NULL OR category_id != :overflow_cat)
"""),
{
"target_date": current_date,
"simulated_today": simulated_today,
"overflow_cat": overflow_category_id
}
)
otb_recon_row = otb_recon_result.fetchone()
if otb_recon_row:
otb_count = otb_recon_row.otb_count or 0
if metric_code == 'hotel_occupancy_pct':
current_otb = (otb_count / total_rooms) * 100 if total_rooms > 0 else 0
else:
current_otb = otb_count
if current_otb is None:
continue
# Get prior year comparison
prior_year_date = get_prior_year_daily(current_date)
prior_year_simulated_today = get_prior_year_daily(simulated_today)
# Get prior year OTB - first try snapshots, then reconstruct from bookings
prior_otb_result = await db.execute(
text("""
SELECT otb_value
FROM pickup_snapshots
WHERE stay_date = :prior_date
AND metric_type = :metric
AND snapshot_date <= :prior_simulated_today
ORDER BY snapshot_date DESC
LIMIT 1
"""),
{
"prior_date": prior_year_date,
"metric": metric_code,
"prior_simulated_today": prior_year_simulated_today
}
)
prior_otb_row = prior_otb_result.fetchone()
# Use 'is not None' - 0 is valid OTB data
prior_otb = float(prior_otb_row.otb_value) if prior_otb_row and prior_otb_row.otb_value is not None else None
# If no snapshot, reconstruct prior year OTB from booking data
# EXCLUDES overflow category
if prior_otb is None and metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
prior_otb_recon_result = await db.execute(
text("""
SELECT COUNT(DISTINCT newbook_id) as otb_count
FROM newbook_bookings
WHERE arrival_date <= :prior_date
AND departure_date > :prior_date
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')
AND (raw_json->>'booking_placed')::timestamp <= :prior_simulated_today::timestamp
AND (category_id IS NULL OR category_id != :overflow_cat)
"""),
{
"prior_date": prior_year_date,
"prior_simulated_today": prior_year_simulated_today,
"overflow_cat": overflow_category_id
}
)
prior_otb_recon_row = prior_otb_recon_result.fetchone()
if prior_otb_recon_row:
prior_otb_count = prior_otb_recon_row.otb_count or 0
if metric_code == 'hotel_occupancy_pct':
prior_otb = (prior_otb_count / total_rooms) * 100 if total_rooms > 0 else 0
else:
prior_otb = prior_otb_count
# Get prior year final
prior_final_result = await db.execute(
text("""
SELECT actual_value
FROM daily_metrics
WHERE date = :prior_date AND metric_code = :metric
"""),
{"prior_date": prior_year_date, "metric": metric_code}
)
prior_final_row = prior_final_result.fetchone()
prior_final = float(prior_final_row.actual_value) if prior_final_row and prior_final_row.actual_value else None
# Calculate forecast using ADDITIVE method
projected_value = current_otb
projection_method = 'current_otb'
if prior_otb is not None and prior_final is not None:
prior_pickup = prior_final - prior_otb
projected_value = current_otb + prior_pickup
if projected_value < current_otb:
projected_value = current_otb
projection_method = 'additive_floor'
else:
projection_method = 'additive'
# Apply caps
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
projected_value = 100
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
projected_value = total_rooms
elif prior_final is not None and prior_final > 0:
# Implied additive
if lead_time >= 28:
estimated_pct = 0.35
elif lead_time >= 14:
estimated_pct = 0.55
elif lead_time >= 7:
estimated_pct = 0.75
else:
estimated_pct = 0.90
implied_prior_otb = prior_final * estimated_pct
implied_pickup = prior_final - implied_prior_otb
projected_value = current_otb + implied_pickup
projected_value = max(projected_value, current_otb)
projection_method = 'implied_additive'
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
projected_value = 100
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
projected_value = total_rooms
# Calculate errors
error = projected_value - actual_value
abs_error = abs(error)
pct_error = (error / actual_value * 100) if actual_value != 0 else None
abs_pct_error = abs(pct_error) if pct_error is not None else None
result_record = {
"target_date": str(current_date),
"lead_time": lead_time,
"actual_lead_time": actual_lead_time,
"simulated_today": str(simulated_today),
"current_otb": round(current_otb, 2),
"prior_otb": round(prior_otb, 2) if prior_otb is not None else None,
"prior_final": round(prior_final, 2) if prior_final is not None else None,
"projected_value": round(projected_value, 2),
"actual_value": round(actual_value, 2),
"error": round(error, 2),
"abs_error": round(abs_error, 2),
"pct_error": round(pct_error, 2) if pct_error is not None else None,
"abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None,
"projection_method": projection_method
}
results.append(result_record)
# Store result
try:
await db.execute(
text("""
INSERT INTO backtest_results (
target_date, metric_code, lead_time, simulated_today,
current_otb, prior_otb, prior_final,
projected_value, actual_value,
error, abs_error, pct_error, abs_pct_error,
projection_method, created_at
) VALUES (
:target_date, :metric, :lead_time, :simulated_today,
:current_otb, :prior_otb, :prior_final,
:projected_value, :actual_value,
:error, :abs_error, :pct_error, :abs_pct_error,
:projection_method, NOW()
)
ON CONFLICT (target_date, metric_code, lead_time) DO UPDATE SET
projected_value = :projected_value,
actual_value = :actual_value,
error = :error,
abs_error = :abs_error,
pct_error = :pct_error,
abs_pct_error = :abs_pct_error,
projection_method = :projection_method,
created_at = NOW()
"""),
{
"target_date": current_date,
"metric": metric_code,
"lead_time": lead_time,
"simulated_today": simulated_today,
"current_otb": round(current_otb, 2),
"prior_otb": round(prior_otb, 2) if prior_otb is not None else None,
"prior_final": round(prior_final, 2) if prior_final is not None else None,
"projected_value": round(projected_value, 2),
"actual_value": round(actual_value, 2),
"error": round(error, 2),
"abs_error": round(abs_error, 2),
"pct_error": round(pct_error, 2) if pct_error is not None else None,
"abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None,
"projection_method": projection_method
}
)
except Exception:
pass # Continue on storage errors
current_date += timedelta(days=1)
await db.commit()
# Calculate summary statistics
summary = _calculate_summary(results, lead_time_list)
return {
"metric_code": metric_code,
"backtest_from": str(from_date),
"backtest_to": str(to_date),
"lead_times": lead_time_list,
"total_forecasts": len(results),
"summary": summary,
"results": results
}
def _calculate_summary(results: List[dict], lead_times: List[int]) -> dict:
"""Calculate summary statistics from backtest results."""
if not results:
return {}
summary = {"overall": {}, "by_lead_time": {}, "by_method": {}}
# Overall
all_errors = [r['abs_error'] for r in results if r['abs_error'] is not None]
all_pct_errors = [r['abs_pct_error'] for r in results if r['abs_pct_error'] is not None]
if all_errors:
summary["overall"] = {
"mae": round(sum(all_errors) / len(all_errors), 2),
"mape": round(sum(all_pct_errors) / len(all_pct_errors), 2) if all_pct_errors else None,
"count": len(all_errors)
}
# By lead time
for lt in lead_times:
lt_results = [r for r in results if r['lead_time'] == lt]
lt_errors = [r['abs_error'] for r in lt_results if r['abs_error'] is not None]
lt_pct_errors = [r['abs_pct_error'] for r in lt_results if r['abs_pct_error'] is not None]
if lt_errors:
summary["by_lead_time"][str(lt)] = {
"mae": round(sum(lt_errors) / len(lt_errors), 2),
"mape": round(sum(lt_pct_errors) / len(lt_pct_errors), 2) if lt_pct_errors else None,
"count": len(lt_errors)
}
# By method
methods = set(r['projection_method'] for r in results)
for method in methods:
method_results = [r for r in results if r['projection_method'] == method]
method_errors = [r['abs_error'] for r in method_results if r['abs_error'] is not None]
method_pct_errors = [r['abs_pct_error'] for r in method_results if r['abs_pct_error'] is not None]
if method_errors:
summary["by_method"][method] = {
"mae": round(sum(method_errors) / len(method_errors), 2),
"mape": round(sum(method_pct_errors) / len(method_pct_errors), 2) if method_pct_errors else None,
"count": len(method_errors)
}
return summary
@router.get("/results")
async def get_backtest_results(
metric_code: str = Query(..., description="Metric code"),
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
lead_time: Optional[int] = Query(None, description="Filter by specific lead time"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Retrieve stored backtest results.
"""
query = """
SELECT
target_date, metric_code, lead_time, simulated_today,
current_otb, prior_otb, prior_final,
projected_value, actual_value,
error, abs_error, pct_error, abs_pct_error,
projection_method, created_at
FROM backtest_results
WHERE metric_code = :metric
"""
params = {"metric": metric_code}
if from_date:
query += " AND target_date >= :from_date"
params["from_date"] = from_date
if to_date:
query += " AND target_date <= :to_date"
params["to_date"] = to_date
if lead_time:
query += " AND lead_time = :lead_time"
params["lead_time"] = lead_time
query += " ORDER BY target_date, lead_time"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"target_date": str(row.target_date),
"metric_code": row.metric_code,
"lead_time": row.lead_time,
"simulated_today": str(row.simulated_today) if row.simulated_today else None,
"current_otb": float(row.current_otb) if row.current_otb is not None else None,
"prior_otb": float(row.prior_otb) if row.prior_otb is not None else None,
"prior_final": float(row.prior_final) if row.prior_final is not None else None,
"projected_value": float(row.projected_value) if row.projected_value is not None else None,
"actual_value": float(row.actual_value) if row.actual_value is not None else None,
"error": float(row.error) if row.error is not None else None,
"abs_error": float(row.abs_error) if row.abs_error is not None else None,
"pct_error": float(row.pct_error) if row.pct_error is not None else None,
"abs_pct_error": float(row.abs_pct_error) if row.abs_pct_error is not None else None,
"projection_method": row.projection_method
}
for row in rows
]
@router.get("/summary")
async def get_backtest_summary(
metric_code: str = Query(..., description="Metric code"),
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get summary accuracy metrics from backtest results.
"""
base_filter = "WHERE metric_code = :metric"
params = {"metric": metric_code}
if from_date:
base_filter += " AND target_date >= :from_date"
params["from_date"] = from_date
if to_date:
base_filter += " AND target_date <= :to_date"
params["to_date"] = to_date
# Overall summary
overall_query = f"""
SELECT
COUNT(*) as count,
AVG(abs_error) as mae,
AVG(abs_pct_error) as mape,
MIN(target_date) as min_date,
MAX(target_date) as max_date
FROM backtest_results
{base_filter}
"""
overall_result = await db.execute(text(overall_query), params)
overall = overall_result.fetchone()
# By lead time
lead_query = f"""
SELECT
lead_time,
COUNT(*) as count,
AVG(abs_error) as mae,
AVG(abs_pct_error) as mape
FROM backtest_results
{base_filter}
GROUP BY lead_time
ORDER BY lead_time
"""
lead_result = await db.execute(text(lead_query), params)
lead_rows = lead_result.fetchall()
# By projection method
method_query = f"""
SELECT
projection_method,
COUNT(*) as count,
AVG(abs_error) as mae,
AVG(abs_pct_error) as mape
FROM backtest_results
{base_filter}
GROUP BY projection_method
ORDER BY count DESC
"""
method_result = await db.execute(text(method_query), params)
method_rows = method_result.fetchall()
return {
"metric_code": metric_code,
"overall": {
"count": overall.count if overall else 0,
"mae": round(float(overall.mae), 2) if overall and overall.mae else None,
"mape": round(float(overall.mape), 2) if overall and overall.mape else None,
"date_range": {
"from": str(overall.min_date) if overall and overall.min_date else None,
"to": str(overall.max_date) if overall and overall.max_date else None
}
},
"by_lead_time": [
{
"lead_time": row.lead_time,
"count": row.count,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None
}
for row in lead_rows
],
"by_method": [
{
"method": row.projection_method,
"count": row.count,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None
}
for row in method_rows
]
}
@router.post("/historical-forecast")
async def run_historical_forecasts(
simulated_dates: str = Query(..., description="Comma-separated dates to simulate (YYYY-MM-DD)"),
metrics: str = Query("hotel_room_nights,hotel_occupancy_pct,resos_dinner_covers,resos_lunch_covers", description="Comma-separated metrics"),
models: str = Query("prophet,xgboost,pickup", description="Comma-separated models to run"),
forecast_days: int = Query(60, description="Days to forecast from each simulated date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Run all forecast models as if it were specific historical dates.
This populates the forecasts table with historical predictions
that can then be evaluated against actual outcomes using the
existing accuracy tracking pages.
Example: Run forecasts as if it were April 1, 2025:
- Prophet, XGBoost, and Pickup models will only use data before April 1
- Forecasts will be generated for April 2 - May 31 (60 days)
- Results stored with generated_at = 2025-04-01
Use this to backtest all models and populate the forecast evaluation pages.
"""
from services.forecasting.historical_forecast import run_historical_forecast
# Parse inputs
date_list = [date.fromisoformat(d.strip()) for d in simulated_dates.split(",")]
metric_list = [m.strip() for m in metrics.split(",")]
model_list = [m.strip() for m in models.split(",")]
all_results = []
for sim_date in date_list:
try:
result = await run_historical_forecast(
db=db,
simulated_today=sim_date,
metric_codes=metric_list,
models=model_list,
forecast_days=forecast_days
)
all_results.append(result)
except Exception as e:
all_results.append({
"simulated_today": str(sim_date),
"error": str(e)
})
return {
"status": "complete",
"dates_processed": len(date_list),
"results": all_results
}
@router.get("/historical-forecast/status")
async def get_historical_forecast_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get summary of historical forecasts that have been generated.
Shows which dates have been used as "simulated today" and
how many forecasts exist for each.
"""
query = """
SELECT
DATE(generated_at) as simulated_date,
model_type,
forecast_type,
COUNT(*) as forecast_count,
MIN(forecast_date) as forecast_from,
MAX(forecast_date) as forecast_to
FROM forecasts
WHERE DATE(generated_at) < CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(generated_at), model_type, forecast_type
ORDER BY DATE(generated_at) DESC, model_type, forecast_type
LIMIT 100
"""
result = await db.execute(text(query))
rows = result.fetchall()
return [
{
"simulated_date": str(row.simulated_date),
"model": row.model_type,
"metric": row.forecast_type,
"count": row.forecast_count,
"forecast_range": f"{row.forecast_from} to {row.forecast_to}"
}
for row in rows
]
# ============================================
# BATCH BACKTEST FOR MODEL ACCURACY & WEIGHTING
# ============================================
@router.post("/batch")
async def run_batch_backtest_endpoint(
background_tasks: BackgroundTasks,
start_perception: date = Query(..., description="First Monday to use as perception date"),
end_perception: date = Query(..., description="Last Monday to use as perception date"),
forecast_days: int = Query(365, description="Days ahead to forecast from each perception date"),
metric: str = Query("occupancy", description="Metric to backtest"),
model: str = Query("xgboost", description="Model to backtest: xgboost, prophet, pickup, or catboost"),
exclude_covid: bool = Query(False, description="Exclude pre-COVID data (train from May 2021+ only)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Run batch backtests from multiple perception dates (every Monday in range).
Stores forecasts in forecast_snapshots table for accuracy analysis.
Use this to generate data for model weighting by lead time bracket.
Specify model to run one at a time - allows adding new model backtests
without re-running existing ones.
Metrics:
- occupancy, rooms: All models supported (uses booking pace data)
- guests, ave_guest_rate, arr, net_accom, net_dry, net_wet: Prophet only (no pace data)
Set exclude_covid=true to train models only on post-COVID data (May 2021+).
Results will be stored with '_postcovid' suffix (e.g., 'xgboost_postcovid')
so you can compare accuracy with vs without COVID-era training data.
Example: Run XGBoost for all Mondays of 2024:
- start_perception: 2024-01-01
- end_perception: 2024-12-31
- forecast_days: 365
- model: xgboost
Results can be analyzed via /backtest/accuracy-by-bracket endpoint.
"""
from jobs.batch_backtest import run_batch_backtest
valid_models = ['xgboost', 'prophet', 'pickup', 'pickup_avg', 'catboost', 'blended']
valid_metrics = ['occupancy', 'rooms', 'guests', 'ave_guest_rate', 'arr', 'net_accom', 'net_dry', 'net_wet']
# Pickup models require pace data - only work with occupancy and rooms
pace_only_metrics = ['occupancy', 'rooms']
pickup_models = ['pickup', 'pickup_avg']
# Blended model requires existing prophet, xgboost, catboost forecasts
derived_models = ['blended']
if model not in valid_models:
raise HTTPException(status_code=400, detail=f"Invalid model. Must be one of: {valid_models}")
if metric not in valid_metrics:
raise HTTPException(status_code=400, detail=f"Invalid metric. Must be one of: {valid_metrics}")
# Pickup models require pace data
if model in pickup_models and metric not in pace_only_metrics:
raise HTTPException(
status_code=400,
detail=f"Model '{model}' only supports occupancy and rooms metrics (requires booking pace data). "
f"Use XGBoost, CatBoost, or Prophet for {metric}."
)
# Blended model requires existing prophet, xgboost, catboost forecasts
if model in derived_models:
# Note: blended model averages existing forecasts, doesn't train from scratch
pass
# Training cutoff for post-COVID: May 1, 2021
training_start = date(2021, 5, 1) if exclude_covid else None
# Run in background
background_tasks.add_task(
run_batch_backtest,
start_perception,
end_perception,
forecast_days,
metric,
[model], # Single model at a time
training_start # Training cutoff date
)
return {
"status": "started",
"message": f"Batch backtest for {model}{' (post-COVID)' if exclude_covid else ''} running in background",
"params": {
"start_perception": str(start_perception),
"end_perception": str(end_perception),
"forecast_days": forecast_days,
"metric": metric,
"model": model,
"exclude_covid": exclude_covid,
"training_start": str(training_start) if training_start else None
}
}
@router.get("/batch/status")
async def get_batch_backtest_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get status of batch backtests - snapshot counts per model/metric and perception date range.
"""
result = await db.execute(text("""
SELECT
model,
metric_code,
COUNT(*) as total_snapshots,
COUNT(actual_value) as with_actuals,
MIN(perception_date) as first_perception,
MAX(perception_date) as last_perception,
COUNT(DISTINCT perception_date) as perception_dates
FROM forecast_snapshots
GROUP BY model, metric_code
ORDER BY metric_code, model
"""))
rows = result.fetchall()
return [
{
"model": row.model,
"metric_code": row.metric_code,
"total_snapshots": row.total_snapshots,
"with_actuals": row.with_actuals,
"first_perception": str(row.first_perception),
"last_perception": str(row.last_perception),
"perception_dates": row.perception_dates
}
for row in rows
]
@router.get("/snapshots")
async def get_forecast_snapshots(
perception_date: Optional[date] = Query(None, description="Filter by perception date"),
target_date: Optional[date] = Query(None, description="Filter by target date"),
model: Optional[str] = Query(None, description="Filter by model"),
metric_code: str = Query("occupancy", description="Metric code"),
limit: int = Query(100, description="Max rows to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get stored forecast snapshots."""
query = """
SELECT perception_date, target_date, model, metric_code, days_out,
forecast_value, actual_value, created_at
FROM forecast_snapshots
WHERE metric_code = :metric_code
"""
params = {"metric_code": metric_code}
if perception_date:
query += " AND perception_date = :perception_date"
params["perception_date"] = perception_date
if target_date:
query += " AND target_date = :target_date"
params["target_date"] = target_date
if model:
query += " AND model = :model"
params["model"] = model
query += " ORDER BY perception_date DESC, target_date LIMIT :limit"
params["limit"] = limit
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"perception_date": str(row.perception_date),
"target_date": str(row.target_date),
"model": row.model,
"metric_code": row.metric_code,
"days_out": row.days_out,
"forecast_value": float(row.forecast_value) if row.forecast_value else None,
"actual_value": float(row.actual_value) if row.actual_value else None
}
for row in rows
]
@router.get("/accuracy-by-bracket")
async def get_accuracy_by_bracket(
metric_code: str = Query("occupancy", description="Metric code"),
model: Optional[str] = Query(None, description="Filter by model (default: all)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get model accuracy (MAE, MAPE) by lead time bracket.
Returns accuracy metrics grouped by:
- 0-7 days out
- 8-14 days out
- 15-30 days out
- 31-60 days out
- 61-90 days out
- 90+ days out
Use this to derive weights for ensemble forecasting.
"""
model_filter = "AND model = :model" if model else ""
params = {"metric_code": metric_code}
if model:
params["model"] = model
query = f"""
SELECT
model,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN '0-7'
WHEN days_out BETWEEN 8 AND 14 THEN '8-14'
WHEN days_out BETWEEN 15 AND 30 THEN '15-30'
WHEN days_out BETWEEN 31 AND 60 THEN '31-60'
WHEN days_out BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END as lead_bracket,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN 1
WHEN days_out BETWEEN 8 AND 14 THEN 2
WHEN days_out BETWEEN 15 AND 30 THEN 3
WHEN days_out BETWEEN 31 AND 60 THEN 4
WHEN days_out BETWEEN 61 AND 90 THEN 5
ELSE 6
END as sort_order,
COUNT(*) as n,
AVG(ABS(forecast_value - actual_value)) as mae,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND metric_code = :metric_code
{model_filter}
GROUP BY model,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN '0-7'
WHEN days_out BETWEEN 8 AND 14 THEN '8-14'
WHEN days_out BETWEEN 15 AND 30 THEN '15-30'
WHEN days_out BETWEEN 31 AND 60 THEN '31-60'
WHEN days_out BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN 1
WHEN days_out BETWEEN 8 AND 14 THEN 2
WHEN days_out BETWEEN 15 AND 30 THEN 3
WHEN days_out BETWEEN 31 AND 60 THEN 4
WHEN days_out BETWEEN 61 AND 90 THEN 5
ELSE 6
END
ORDER BY model, sort_order
"""
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"model": row.model,
"lead_bracket": row.lead_bracket,
"n": row.n,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None
}
for row in rows
]
@router.get("/accuracy-by-day-of-week")
async def get_accuracy_by_day_of_week(
metric_code: str = Query("occupancy", description="Metric code"),
model: Optional[str] = Query(None, description="Filter by model (default: all)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get model accuracy (MAE, MAPE) by day of week.
Returns accuracy metrics grouped by:
- Sunday (0)
- Monday (1)
- Tuesday (2)
- Wednesday (3)
- Thursday (4)
- Friday (5)
- Saturday (6)
Useful for identifying if models perform better on certain days.
"""
model_filter = "AND model = :model" if model else ""
params = {"metric_code": metric_code}
if model:
params["model"] = model
query = f"""
SELECT
model,
EXTRACT(DOW FROM target_date)::int as dow_num,
CASE EXTRACT(DOW FROM target_date)::int
WHEN 0 THEN 'Sunday'
WHEN 1 THEN 'Monday'
WHEN 2 THEN 'Tuesday'
WHEN 3 THEN 'Wednesday'
WHEN 4 THEN 'Thursday'
WHEN 5 THEN 'Friday'
WHEN 6 THEN 'Saturday'
END as day_name,
COUNT(*) as n,
AVG(ABS(forecast_value - actual_value)) as mae,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND metric_code = :metric_code
{model_filter}
GROUP BY model,
EXTRACT(DOW FROM target_date)::int,
CASE EXTRACT(DOW FROM target_date)::int
WHEN 0 THEN 'Sunday'
WHEN 1 THEN 'Monday'
WHEN 2 THEN 'Tuesday'
WHEN 3 THEN 'Wednesday'
WHEN 4 THEN 'Thursday'
WHEN 5 THEN 'Friday'
WHEN 6 THEN 'Saturday'
END
ORDER BY model, dow_num
"""
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"model": row.model,
"dow_num": row.dow_num,
"day_name": row.day_name,
"n": row.n,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None
}
for row in rows
]
@router.get("/accuracy-by-month")
async def get_accuracy_by_month(
metric_code: str = Query("occupancy", description="Metric code"),
model: Optional[str] = Query(None, description="Filter by model (default: all)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get model accuracy (MAE, MAPE) by month of year.
Returns accuracy metrics grouped by month (January through December).
Useful for identifying seasonal patterns in model accuracy.
"""
model_filter = "AND model = :model" if model else ""
params = {"metric_code": metric_code}
if model:
params["model"] = model
query = f"""
SELECT
model,
EXTRACT(MONTH FROM target_date)::int as month_num,
CASE EXTRACT(MONTH FROM target_date)::int
WHEN 1 THEN 'January'
WHEN 2 THEN 'February'
WHEN 3 THEN 'March'
WHEN 4 THEN 'April'
WHEN 5 THEN 'May'
WHEN 6 THEN 'June'
WHEN 7 THEN 'July'
WHEN 8 THEN 'August'
WHEN 9 THEN 'September'
WHEN 10 THEN 'October'
WHEN 11 THEN 'November'
WHEN 12 THEN 'December'
END as month_name,
COUNT(*) as n,
AVG(ABS(forecast_value - actual_value)) as mae,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND metric_code = :metric_code
{model_filter}
GROUP BY model,
EXTRACT(MONTH FROM target_date)::int,
CASE EXTRACT(MONTH FROM target_date)::int
WHEN 1 THEN 'January'
WHEN 2 THEN 'February'
WHEN 3 THEN 'March'
WHEN 4 THEN 'April'
WHEN 5 THEN 'May'
WHEN 6 THEN 'June'
WHEN 7 THEN 'July'
WHEN 8 THEN 'August'
WHEN 9 THEN 'September'
WHEN 10 THEN 'October'
WHEN 11 THEN 'November'
WHEN 12 THEN 'December'
END
ORDER BY model, month_num
"""
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"model": row.model,
"month_num": row.month_num,
"month_name": row.month_name,
"n": row.n,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None
}
for row in rows
]
@router.get("/model-weights")
async def get_model_weights(
metric_code: str = Query("occupancy", description="Metric code"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Calculate model weights based on inverse MAPE by lead time bracket.
Lower MAPE = higher weight.
Weights are normalized to sum to 1.0 within each bracket.
Use these weights for ensemble forecasting.
"""
query = """
WITH accuracy AS (
SELECT
model,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN '0-7'
WHEN days_out BETWEEN 8 AND 14 THEN '8-14'
WHEN days_out BETWEEN 15 AND 30 THEN '15-30'
WHEN days_out BETWEEN 31 AND 60 THEN '31-60'
WHEN days_out BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END as lead_bracket,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND metric_code = :metric_code
GROUP BY model,
CASE
WHEN days_out BETWEEN 0 AND 7 THEN '0-7'
WHEN days_out BETWEEN 8 AND 14 THEN '8-14'
WHEN days_out BETWEEN 15 AND 30 THEN '15-30'
WHEN days_out BETWEEN 31 AND 60 THEN '31-60'
WHEN days_out BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END
),
inverse_mape AS (
SELECT
model,
lead_bracket,
mape,
CASE WHEN mape > 0 THEN 1.0 / mape ELSE 0 END as inv_mape
FROM accuracy
),
bracket_totals AS (
SELECT lead_bracket, SUM(inv_mape) as total_inv
FROM inverse_mape
GROUP BY lead_bracket
)
SELECT
i.model,
i.lead_bracket,
i.mape,
CASE WHEN b.total_inv > 0 THEN i.inv_mape / b.total_inv ELSE 0 END as weight
FROM inverse_mape i
JOIN bracket_totals b ON i.lead_bracket = b.lead_bracket
ORDER BY
i.lead_bracket,
weight DESC
"""
result = await db.execute(text(query), {"metric_code": metric_code})
rows = result.fetchall()
return [
{
"model": row.model,
"lead_bracket": row.lead_bracket,
"mape": round(float(row.mape), 2) if row.mape else None,
"weight": round(float(row.weight), 4) if row.weight else 0
}
for row in rows
]
@router.post("/backfill-actuals")
async def backfill_snapshot_actuals(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Backfill actual_value in forecast_snapshots from newbook_bookings_stats and newbook_net_revenue_data.
Run this after target dates have passed to populate actual values
for accuracy analysis.
Handles all metrics:
- occupancy: booking_count / bookable_count * 100
- rooms: booking_count
- guests: guests_count
- ave_guest_rate: guest_rate_total / booking_count
- arr: accommodation / booking_count (from revenue data)
- net_accom, net_dry, net_wet: from revenue data
"""
# First, update stats-based metrics (occupancy, rooms, guests, ave_guest_rate)
result1 = await db.execute(text("""
UPDATE forecast_snapshots fs
SET actual_value = CASE
WHEN fs.metric_code = 'occupancy' THEN
(s.booking_count::decimal / NULLIF(s.bookable_count, 0)) * 100
WHEN fs.metric_code = 'rooms' THEN
s.booking_count
WHEN fs.metric_code = 'guests' THEN
s.guests_count
WHEN fs.metric_code = 'ave_guest_rate' THEN
s.guest_rate_total / NULLIF(s.booking_count, 0)
ELSE NULL
END
FROM newbook_bookings_stats s
WHERE fs.target_date = s.date
AND fs.actual_value IS NULL
AND fs.target_date < CURRENT_DATE
AND fs.metric_code IN ('occupancy', 'rooms', 'guests', 'ave_guest_rate')
AND s.booking_count IS NOT NULL
"""))
# Then, update revenue-based metrics (arr, net_accom, net_dry, net_wet)
result2 = await db.execute(text("""
UPDATE forecast_snapshots fs
SET actual_value = CASE
WHEN fs.metric_code = 'arr' THEN
r.accommodation / NULLIF(s.booking_count, 0)
WHEN fs.metric_code = 'net_accom' THEN
r.accommodation
WHEN fs.metric_code = 'net_dry' THEN
r.dry
WHEN fs.metric_code = 'net_wet' THEN
r.wet
ELSE NULL
END
FROM newbook_net_revenue_data r
JOIN newbook_bookings_stats s ON r.date = s.date
WHERE fs.target_date = r.date
AND fs.actual_value IS NULL
AND fs.target_date < CURRENT_DATE
AND fs.metric_code IN ('arr', 'net_accom', 'net_dry', 'net_wet')
"""))
await db.commit()
return {
"status": "complete",
"rows_updated": result1.rowcount + result2.rowcount
}
@router.delete("/snapshots/{model}")
async def delete_model_snapshots(
model: str,
metric_code: Optional[str] = Query(None, description="Optional: Only delete for this metric"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Delete all backtest snapshots for a specific model.
Use this to remove backtest data for models you want to exclude from
accuracy analysis (e.g., models affected by COVID training data).
The model name must match exactly (e.g., 'xgboost', 'prophet', 'pickup_postcovid').
"""
query = "DELETE FROM forecast_snapshots WHERE model = :model"
params = {"model": model}
if metric_code:
query += " AND metric_code = :metric_code"
params["metric_code"] = metric_code
result = await db.execute(text(query), params)
await db.commit()
return {
"status": "complete",
"model": model,
"metric_code": metric_code,
"rows_deleted": result.rowcount
}
@router.post("/fill-to-today")
async def fill_backtests_to_today(
background_tasks: BackgroundTasks,
metric: str = Query("occupancy", description="Metric to backtest"),
models: str = Query("prophet,xgboost,catboost,blended", description="Comma-separated models to run (blended runs last)"),
forecast_days: int = Query(365, description="Days ahead to forecast from each perception date"),
exclude_covid: bool = Query(True, description="Exclude pre-COVID data (train from May 2021+ only)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fill in backtests from earliest available data up to today.
Finds missing perception dates (Mondays) and runs backtests for each model.
Use this to backfill historical forecast data for accuracy analysis and 3D visualization.
The 'blended' model averages prophet, xgboost, and catboost - those must run first.
If included, blended is automatically moved to run last.
"""
from jobs.batch_backtest import run_batch_backtest
model_list = [m.strip() for m in models.split(",")]
valid_models = ['xgboost', 'prophet', 'pickup', 'pickup_avg', 'catboost', 'blended']
for m in model_list:
if m not in valid_models:
raise HTTPException(status_code=400, detail=f"Invalid model: {m}")
# Ensure blended runs last (it depends on other model outputs)
if 'blended' in model_list:
model_list.remove('blended')
model_list.append('blended')
# Find earliest historical data date
earliest_result = await db.execute(text("""
SELECT MIN(date) as earliest
FROM newbook_bookings_stats
WHERE booking_count IS NOT NULL
"""))
earliest_row = earliest_result.fetchone()
earliest_data = earliest_row.earliest if earliest_row else None
if not earliest_data:
raise HTTPException(status_code=400, detail="No historical data available")
# Start from 2 years after earliest data (need training history)
start_date = earliest_data + timedelta(days=730)
# Find most recent Monday before today
today = date.today()
days_since_monday = today.weekday() # Monday=0
last_monday = today - timedelta(days=days_since_monday)
# Move start_date to first Monday
while start_date.weekday() != 0:
start_date += timedelta(days=1)
# Training cutoff for post-COVID: May 1, 2021
training_start = date(2021, 5, 1) if exclude_covid else None
# Check existing perception dates
existing_result = await db.execute(text("""
SELECT DISTINCT perception_date
FROM forecast_snapshots
WHERE metric_code = :metric
AND model = :first_model
ORDER BY perception_date
"""), {"metric": metric, "first_model": f"{model_list[0]}{'_postcovid' if exclude_covid else ''}"})
existing_dates = {row.perception_date for row in existing_result.fetchall()}
# Count how many Mondays need processing
check_date = start_date
missing_count = 0
while check_date <= last_monday:
if check_date not in existing_dates:
missing_count += 1
check_date += timedelta(days=7)
# Run in background
background_tasks.add_task(
run_batch_backtest,
start_date,
last_monday,
forecast_days,
metric,
model_list,
training_start
)
return {
"status": "started",
"message": f"Filling backtests from {start_date} to {last_monday}",
"params": {
"start_perception": str(start_date),
"end_perception": str(last_monday),
"forecast_days": forecast_days,
"metric": metric,
"models": model_list,
"exclude_covid": exclude_covid,
"existing_perception_dates": len(existing_dates),
"missing_perception_dates": missing_count
}
}
@router.get("/3d-data")
async def get_3d_forecast_data(
metric_code: str = Query("occupancy", description="Metric code"),
target_date: date = Query(..., description="Target date to analyze"),
model: str = Query("blended", description="Model to show"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get forecast evolution data for 3D visualization.
Returns all forecasts for a specific target date from different perception dates,
showing how the forecast changed as the target date approached.
Data structure for 3D chart:
- X axis: perception_date (when forecast was made)
- Y axis: days_out (lead time)
- Z axis: forecast_value
This shows how forecast accuracy improves as lead time decreases.
"""
query = """
SELECT
perception_date,
target_date,
days_out,
forecast_value,
actual_value
FROM forecast_snapshots
WHERE target_date = :target_date
AND metric_code = :metric_code
AND model = :model
ORDER BY perception_date
"""
result = await db.execute(text(query), {
"target_date": target_date,
"metric_code": metric_code,
"model": model
})
rows = result.fetchall()
return {
"target_date": str(target_date),
"metric_code": metric_code,
"model": model,
"actual_value": float(rows[0].actual_value) if rows and rows[0].actual_value else None,
"snapshots": [
{
"perception_date": str(row.perception_date),
"days_out": row.days_out,
"forecast_value": float(row.forecast_value) if row.forecast_value else None
}
for row in rows
]
}
@router.get("/3d-surface")
async def get_3d_surface_data(
metric_code: str = Query("occupancy", description="Metric code"),
from_date: date = Query(..., description="Start of target date range"),
to_date: date = Query(..., description="End of target date range"),
model: str = Query("blended", description="Model to show"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get forecast surface data for 3D visualization across multiple target dates.
Returns a grid of forecasts where:
- Rows = target dates
- Columns = days_out (lead time brackets)
- Values = forecast values
This creates a surface showing forecast evolution across both time and lead time.
"""
query = """
SELECT
target_date,
days_out,
AVG(forecast_value) as avg_forecast,
AVG(actual_value) as avg_actual
FROM forecast_snapshots
WHERE target_date BETWEEN :from_date AND :to_date
AND metric_code = :metric_code
AND model = :model
GROUP BY target_date, days_out
ORDER BY target_date, days_out
"""
result = await db.execute(text(query), {
"from_date": from_date,
"to_date": to_date,
"metric_code": metric_code,
"model": model
})
rows = result.fetchall()
# Organize into grid format
grid_data = {}
for row in rows:
date_str = str(row.target_date)
if date_str not in grid_data:
grid_data[date_str] = {"actual": float(row.avg_actual) if row.avg_actual else None}
grid_data[date_str][row.days_out] = float(row.avg_forecast) if row.avg_forecast else None
return {
"metric_code": metric_code,
"model": model,
"from_date": str(from_date),
"to_date": str(to_date),
"data": grid_data
}
@router.get("/3d-monthly-progress")
async def get_3d_monthly_progress(
metric_code: str = Query("occupancy", description="Metric code"),
year: int = Query(..., description="Year (e.g., 2025)"),
month: int = Query(..., description="Month (1-12)"),
model: str = Query("blended", description="Model to show"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get forecast progress data for 3D visualization of a specific month.
Shows how forecasts for a given month evolved over time as perception dates
approached the target dates.
Returns:
- target_dates: Array of dates in the selected month (X axis)
- perception_dates: Array of unique perception dates (Y axis)
- surface_data: 2D array [perception_idx][target_idx] of forecast values (Z axis)
- actuals: Array of actual values for each target date
This visualization shows how forecast accuracy improved over time.
"""
from calendar import monthrange
# Calculate date range for the selected month
_, last_day = monthrange(year, month)
from_date = date(year, month, 1)
to_date = date(year, month, last_day)
query = """
SELECT
perception_date,
target_date,
forecast_value,
actual_value
FROM forecast_snapshots
WHERE target_date BETWEEN :from_date AND :to_date
AND metric_code = :metric_code
AND model = :model
ORDER BY perception_date, target_date
"""
result = await db.execute(text(query), {
"from_date": from_date,
"to_date": to_date,
"metric_code": metric_code,
"model": model
})
rows = result.fetchall()
if not rows:
return {
"metric_code": metric_code,
"model": model,
"year": year,
"month": month,
"target_dates": [],
"perception_dates": [],
"surface_data": [],
"actuals": []
}
# Extract unique dates and build lookup
target_dates_set = set()
perception_dates_set = set()
forecasts = {} # (perception_date, target_date) -> forecast_value
actuals_map = {} # target_date -> actual_value
for row in rows:
target_dates_set.add(row.target_date)
perception_dates_set.add(row.perception_date)
forecasts[(row.perception_date, row.target_date)] = row.forecast_value
if row.actual_value is not None:
actuals_map[row.target_date] = row.actual_value
# Sort dates
target_dates = sorted(target_dates_set)
perception_dates = sorted(perception_dates_set)
# Build surface data: surface_data[perception_idx][target_idx]
surface_data = []
for p_date in perception_dates:
row_data = []
for t_date in target_dates:
val = forecasts.get((p_date, t_date))
row_data.append(float(val) if val is not None else None)
surface_data.append(row_data)
# Actuals array aligned with target_dates
actuals = [float(actuals_map.get(t)) if t in actuals_map else None for t in target_dates]
return {
"metric_code": metric_code,
"model": model,
"year": year,
"month": month,
"target_dates": [str(d) for d in target_dates],
"perception_dates": [str(d) for d in perception_dates],
"surface_data": surface_data,
"actuals": actuals
}