forecasting/backend/jobs/batch_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

1647 lines
60 KiB
Python

"""
Batch Backtest Job
Runs forecasts from multiple perception dates and stores results for accuracy analysis.
"""
import logging
from datetime import date, datetime, timedelta
from typing import List, Optional
import pandas as pd
import numpy as np
from xgboost import XGBRegressor
import warnings
from sqlalchemy import text
from database import SyncSessionLocal
from api.special_dates import resolve_special_date
logger = logging.getLogger(__name__)
warnings.filterwarnings('ignore')
def get_metric_query_info(metric: str) -> dict:
"""
Get SQL query information for each metric.
Returns dict with:
- column_expr: SQL expression for the metric value
- needs_revenue_join: whether to join with newbook_net_revenue_data
- is_pct_metric: whether it's a percentage metric (0-100)
- is_revenue_metric: whether it's a revenue/rate metric
"""
metric_info = {
'occupancy': {
'column_expr': 's.booking_count',
'needs_revenue_join': False,
'is_pct_metric': True, # Will be converted to percentage
'is_revenue_metric': False,
},
'rooms': {
'column_expr': 's.booking_count',
'needs_revenue_join': False,
'is_pct_metric': False,
'is_revenue_metric': False,
},
'guests': {
'column_expr': 's.guests_count',
'needs_revenue_join': False,
'is_pct_metric': False,
'is_revenue_metric': False,
},
'ave_guest_rate': {
'column_expr': 'CASE WHEN s.booking_count > 0 THEN s.guest_rate_total / s.booking_count ELSE NULL END',
'needs_revenue_join': False,
'is_pct_metric': False,
'is_revenue_metric': True,
},
'arr': {
'column_expr': 'CASE WHEN s.booking_count > 0 THEN r.accommodation / s.booking_count ELSE NULL END',
'needs_revenue_join': True,
'is_pct_metric': False,
'is_revenue_metric': True,
},
'net_accom': {
'column_expr': 'r.accommodation',
'needs_revenue_join': True,
'is_pct_metric': False,
'is_revenue_metric': True,
},
'net_dry': {
'column_expr': 'r.dry',
'needs_revenue_join': True,
'is_pct_metric': False,
'is_revenue_metric': True,
},
'net_wet': {
'column_expr': 'r.wet',
'needs_revenue_join': True,
'is_pct_metric': False,
'is_revenue_metric': True,
},
}
return metric_info.get(metric, metric_info['rooms'])
def get_mondays_in_range(start_date: date, end_date: date) -> List[date]:
"""Get all Mondays between start and end date."""
mondays = []
current = start_date
# Move to first Monday
while current.weekday() != 0:
current += timedelta(days=1)
# Collect all Mondays
while current <= end_date:
mondays.append(current)
current += timedelta(days=7)
return mondays
def get_lead_time_column(days_out: int) -> str:
"""Map days out to the appropriate booking_pace column."""
if days_out <= 30:
return f"d{days_out}"
elif days_out <= 37:
return "d37"
elif days_out <= 44:
return "d44"
elif days_out <= 51:
return "d51"
elif days_out <= 58:
return "d58"
elif days_out <= 65:
return "d65"
elif days_out <= 72:
return "d72"
elif days_out <= 79:
return "d79"
elif days_out <= 86:
return "d86"
elif days_out <= 93:
return "d93"
elif days_out <= 100:
return "d100"
elif days_out <= 107:
return "d107"
elif days_out <= 114:
return "d114"
elif days_out <= 121:
return "d121"
elif days_out <= 128:
return "d128"
elif days_out <= 135:
return "d135"
elif days_out <= 142:
return "d142"
elif days_out <= 149:
return "d149"
elif days_out <= 156:
return "d156"
elif days_out <= 163:
return "d163"
elif days_out <= 170:
return "d170"
elif days_out <= 177:
return "d177"
elif days_out <= 210:
return "d210"
elif days_out <= 240:
return "d240"
elif days_out <= 270:
return "d270"
elif days_out <= 300:
return "d300"
elif days_out <= 330:
return "d330"
else:
return "d365"
async def run_batch_backtest(
start_perception: date,
end_perception: date,
forecast_days: int = 365,
metric: str = "occupancy",
models: Optional[List[str]] = None,
training_start: Optional[date] = None
) -> dict:
"""
Run backtests from multiple perception dates (every Monday in range).
Args:
start_perception: First Monday to use as perception date
end_perception: Last Monday to use as perception date
forecast_days: How many days ahead to forecast from each perception date
metric: 'occupancy' or 'rooms'
models: List of models to run (default: ['xgboost'])
training_start: Optional cutoff date for training data (e.g., 2021-05-01 to exclude COVID)
Results stored with '_postcovid' suffix when set.
Returns:
Summary of results
"""
if models is None:
models = ['xgboost']
perception_dates = get_mondays_in_range(start_perception, end_perception)
suffix = "_postcovid" if training_start else ""
logger.info(f"Running batch backtest for {len(perception_dates)} perception dates{f' (training from {training_start})' if training_start else ''}")
db = SyncSessionLocal()
total_snapshots = 0
errors = []
try:
for perception_date in perception_dates:
logger.info(f"Processing perception_date: {perception_date}")
for model in models:
try:
# Model name with suffix for post-COVID training
model_name = f"{model}{suffix}"
if model == 'xgboost':
count = await run_xgboost_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
elif model == 'prophet':
count = await run_prophet_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
elif model == 'pickup':
count = await run_pickup_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
elif model == 'pickup_avg':
count = await run_pickup_avg_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
elif model == 'catboost':
count = await run_catboost_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
elif model == 'blended':
count = await run_blended_backtest(
db, perception_date, forecast_days, metric,
training_start=training_start, model_name=model_name
)
total_snapshots += count
else:
logger.warning(f"Unknown model: {model}")
except Exception as e:
error_msg = f"Error for {perception_date}/{model}: {str(e)}"
logger.error(error_msg)
errors.append(error_msg)
db.commit()
# Backfill actuals after all backtests complete
logger.info("Backfilling actual values...")
backfill_count = await backfill_actuals()
logger.info(f"Backfilled {backfill_count} actual values")
return {
"perception_dates_processed": len(perception_dates),
"total_snapshots": total_snapshots,
"actuals_backfilled": backfill_count,
"errors": errors
}
finally:
db.close()
async def run_xgboost_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "xgboost"
) -> int:
"""
Run XGBoost forecast from a specific perception date and store snapshots.
Returns count of snapshots stored.
For occupancy/rooms: Uses pace data (OTB at different lead times) as features.
For other metrics: Uses time-series features only (like Prophet but with XGBoost).
Args:
training_start: Optional cutoff date - only use training data from this date forward
model_name: Name to store in snapshots (e.g., 'xgboost' or 'xgboost_postcovid')
"""
today = perception_date
# Check if this metric has pace data
pace_metrics = ['occupancy', 'rooms']
use_pace = metric in pace_metrics
# Get metric query info
metric_info = get_metric_query_info(metric)
column_expr = metric_info['column_expr']
needs_revenue_join = metric_info['needs_revenue_join']
is_pct_metric = metric_info['is_pct_metric']
is_revenue_metric = metric_info['is_revenue_metric']
# Get bookable rooms
bookable_result = db.execute(text("""
SELECT bookable_count
FROM newbook_bookings_stats
WHERE bookable_count IS NOT NULL AND date < :today
ORDER BY date DESC
LIMIT 1
"""), {"today": today})
bookable_row = bookable_result.fetchone()
total_rooms = int(bookable_row.bookable_count) if bookable_row else 25
# Get historical data for training (2 years before perception date, or from training_start)
history_start = today - timedelta(days=730)
if training_start and training_start > history_start:
history_start = training_start
# Build query based on metric type
if use_pace:
# Pace-based query for occupancy/rooms
history_result = db.execute(text("""
SELECT s.date as ds, s.booking_count as final,
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
FROM newbook_bookings_stats s
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
elif needs_revenue_join:
# Revenue metrics
history_result = db.execute(text(f"""
SELECT s.date as ds, {column_expr} as final
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
else:
# Stats-based metrics (guests, ave_guest_rate)
history_result = db.execute(text(f"""
SELECT s.date as ds, {column_expr} as final
FROM newbook_bookings_stats s
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient data for perception_date {perception_date}")
return 0
# Load special dates
special_date_set = set()
try:
special_dates_result = db.execute(text(
"SELECT * FROM special_dates WHERE is_active = TRUE"
))
special_dates_rows = special_dates_result.fetchall()
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
for row in special_dates_rows:
sd = {
'pattern_type': row.pattern_type,
'fixed_month': row.fixed_month,
'fixed_day': row.fixed_day,
'nth_week': row.nth_week,
'weekday': row.weekday,
'month': row.month,
'relative_to_month': row.relative_to_month,
'relative_to_day': row.relative_to_day,
'relative_weekday': row.relative_weekday,
'relative_direction': row.relative_direction,
'duration_days': row.duration_days,
'is_recurring': row.is_recurring,
'one_off_year': row.one_off_year
}
for year in years_needed:
resolved_dates = resolve_special_date(sd, year)
for d in resolved_dates:
special_date_set.add(d)
except Exception:
pass
# Build lookup dicts
final_by_date = {}
for row in history_rows:
if row.final is not None:
final_by_date[row.ds] = row.final
if use_pace:
# Pace-based training for occupancy/rooms
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
pace_by_date = {}
for row in history_rows:
pace_by_date[row.ds] = {
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
}
# Build training examples with pace features
training_rows = []
for row in history_rows:
ds = row.ds
if row.final is None:
continue
final = float(row.final)
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
continue
for lead_time in train_lead_times:
current_otb = pace_by_date.get(ds, {}).get(lead_time)
if current_otb is None:
continue
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
if prior_otb is None:
prior_otb = 0
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
training_rows.append({
'ds': ds,
'y': final,
'days_out': lead_time,
'current_otb': float(current_otb),
'prior_otb_same_lead': float(prior_otb),
'lag_364': float(prior_final),
'otb_pct_of_prior_final': otb_pct_of_prior_final
})
if len(training_rows) < 30:
logger.warning(f"Insufficient training data for perception_date {perception_date}")
return 0
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
# Convert to occupancy if needed
if metric == "occupancy" and total_rooms > 0:
df["y"] = (df["y"] / total_rooms) * 100
df["current_otb"] = (df["current_otb"] / total_rooms) * 100
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / total_rooms) * 100
df["lag_364"] = (df["lag_364"] / total_rooms) * 100
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date',
'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final']
else:
# Non-pace training for other metrics (time features + lag only)
training_rows = []
for row in history_rows:
ds = row.ds
if row.final is None:
continue
final = float(row.final)
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
prior_final = final # Use same value as fallback
training_rows.append({
'ds': ds,
'y': final,
'lag_364': float(prior_final),
})
if len(training_rows) < 30:
logger.warning(f"Insufficient training data for perception_date {perception_date}")
return 0
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
# Create time features (common to both)
df['day_of_week'] = df['ds'].dt.dayofweek
df['month'] = df['ds'].dt.month
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
df_train = df.dropna()
if len(df_train) < 30:
return 0
X_train = df_train[feature_cols]
y_train = df_train['y']
# Determine training cap for capping predictions
if is_pct_metric:
training_cap = 100
elif metric == 'rooms':
training_cap = total_rooms
elif metric == 'guests':
training_cap = df_train["y"].max() * 1.5 if len(df_train) > 0 and df_train["y"].max() > 0 else total_rooms * 3
else:
# Revenue/rate metrics - use 99th percentile * 1.5
training_cap = df_train["y"].quantile(0.99) * 1.5 if len(df_train) > 0 and df_train["y"].quantile(0.99) > 0 else 10000
# Train model
xgb_model = XGBRegressor(
n_estimators=100,
max_depth=6,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42,
n_jobs=-1
)
xgb_model.fit(X_train, y_train)
# Generate forecasts
snapshots_stored = 0
end_date = today + timedelta(days=forecast_days)
current_date = today
while current_date <= end_date:
lead_days = (current_date - today).days
prior_year_date = current_date - timedelta(days=364)
if use_pace:
# Pace-based prediction for occupancy/rooms
lead_col = get_lead_time_column(lead_days)
# Get current OTB from booking_pace at that lead time
otb_result = db.execute(text(f"""
SELECT {lead_col} as current_otb
FROM newbook_booking_pace
WHERE arrival_date = :arrival_date
"""), {"arrival_date": current_date})
otb_row = otb_result.fetchone()
# Get prior year OTB at same lead
prior_otb_result = db.execute(text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
"""), {"prior_date": prior_year_date})
prior_otb_row = prior_otb_result.fetchone()
# Get prior year final
prior_final_result = db.execute(text("""
SELECT booking_count as prior_final
FROM newbook_bookings_stats
WHERE date = :prior_date
"""), {"prior_date": prior_year_date})
prior_final_row = prior_final_result.fetchone()
current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0
# Convert to occupancy
if metric == "occupancy" and total_rooms > 0:
current_otb = (current_otb / total_rooms) * 100
prior_otb = (prior_otb / total_rooms) * 100
prior_final = (prior_final / total_rooms) * 100
lag_364_val = prior_final if prior_final else 0
otb_pct_of_prior_final = (current_otb / lag_364_val * 100) if lag_364_val > 0 else 0
# Build features
forecast_dt = pd.Timestamp(current_date)
features = pd.DataFrame([{
'day_of_week': forecast_dt.dayofweek,
'month': forecast_dt.month,
'week_of_year': forecast_dt.isocalendar().week,
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if current_date in special_date_set else 0,
'days_out': lead_days,
'current_otb': current_otb,
'prior_otb_same_lead': prior_otb,
'lag_364': lag_364_val,
'otb_pct_of_prior_final': otb_pct_of_prior_final,
}])
else:
# Non-pace prediction for other metrics
# Get prior year value for lag feature
lag_364_val = final_by_date.get(prior_year_date)
if lag_364_val is None:
lag_364_val = float(df_train["y"].mean())
else:
lag_364_val = float(lag_364_val)
forecast_dt = pd.Timestamp(current_date)
features = pd.DataFrame([{
'day_of_week': int(forecast_dt.dayofweek),
'month': int(forecast_dt.month),
'week_of_year': int(forecast_dt.isocalendar().week),
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if current_date in special_date_set else 0,
'lag_364': lag_364_val,
}])
# Predict
yhat = float(xgb_model.predict(features)[0])
# Cap based on metric type
if is_pct_metric:
yhat = min(max(yhat, 0), 100.0)
elif metric == 'rooms':
yhat = round(min(max(yhat, 0), float(total_rooms)))
elif metric == 'guests':
yhat = round(max(yhat, 0))
else:
# Revenue/rate metrics
yhat = round(max(yhat, 0), 2)
# Store snapshot
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": model_name,
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat, 2)
})
snapshots_stored += 1
current_date += timedelta(days=1)
return snapshots_stored
async def run_pickup_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "pickup"
) -> int:
"""
Run Pickup (additive) forecast from a specific perception date.
Uses: current_otb + (prior_final - prior_otb) = forecast
Args:
training_start: Not used by pickup model (no training), but accepted for API consistency
model_name: Name to store in snapshots (e.g., 'pickup' or 'pickup_postcovid')
Returns count of snapshots stored.
"""
today = perception_date
# Get bookable rooms
bookable_result = db.execute(text("""
SELECT bookable_count
FROM newbook_bookings_stats
WHERE bookable_count IS NOT NULL AND date < :today
ORDER BY date DESC
LIMIT 1
"""), {"today": today})
bookable_row = bookable_result.fetchone()
total_rooms = int(bookable_row.bookable_count) if bookable_row else 25
snapshots_stored = 0
end_date = today + timedelta(days=forecast_days)
current_date = today
while current_date <= end_date:
lead_days = (current_date - today).days
lead_col = get_lead_time_column(lead_days)
prior_year_date = current_date - timedelta(days=364)
# Get current OTB from booking_pace
otb_result = db.execute(text(f"""
SELECT {lead_col} as current_otb
FROM newbook_booking_pace
WHERE arrival_date = :arrival_date
"""), {"arrival_date": current_date})
otb_row = otb_result.fetchone()
# Get prior year OTB at same lead
prior_otb_result = db.execute(text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
"""), {"prior_date": prior_year_date})
prior_otb_row = prior_otb_result.fetchone()
# Get prior year final
prior_final_result = db.execute(text("""
SELECT booking_count as prior_final
FROM newbook_bookings_stats
WHERE date = :prior_date
"""), {"prior_date": prior_year_date})
prior_final_row = prior_final_result.fetchone()
current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0
# Convert to occupancy if needed
if metric == "occupancy" and total_rooms > 0:
current_otb = (current_otb / total_rooms) * 100
prior_otb = (prior_otb / total_rooms) * 100
prior_final = (prior_final / total_rooms) * 100
# Additive pickup: forecast = current_otb + (prior_final - prior_otb)
if prior_final > 0 and prior_otb is not None:
prior_pickup = prior_final - prior_otb
yhat = current_otb + prior_pickup
else:
yhat = current_otb
# Floor to current OTB
yhat = max(yhat, current_otb)
# Cap at max
if metric == "occupancy":
yhat = min(max(yhat, 0), 100.0)
else:
yhat = round(min(max(yhat, 0), float(total_rooms)))
# Store snapshot
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": model_name,
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat, 2)
})
snapshots_stored += 1
current_date += timedelta(days=1)
return snapshots_stored
def get_same_dow_prior_year(target_date: date, years_back: int) -> date:
"""
Get the date from N years ago that matches the same day of week.
Uses ISO week number and weekday to properly align across leap years.
Example: Monday Jan 6, 2025 -> Monday Jan 8, 2024 (same ISO week, same DOW)
"""
iso_cal = target_date.isocalendar()
target_week = iso_cal.week
target_dow = iso_cal.weekday # 1=Monday, 7=Sunday
prior_year = target_date.year - years_back
# Find the first day of the target ISO week in the prior year
# ISO week 1 is the week containing Jan 4th
jan4 = date(prior_year, 1, 4)
jan4_iso = jan4.isocalendar()
# Calculate days from Jan 4 to the start of week 1
days_to_week1_start = (jan4_iso.weekday - 1) # Days from Monday of week 1 to Jan 4
week1_monday = jan4 - timedelta(days=days_to_week1_start)
# Now find the Monday of the target week
target_week_monday = week1_monday + timedelta(weeks=target_week - 1)
# Add days to get to the correct day of week
prior_date = target_week_monday + timedelta(days=target_dow - 1)
return prior_date
async def run_pickup_avg_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "pickup_avg"
) -> int:
"""
Run Pickup (weighted 2-year average) forecast from a specific perception date.
Uses weighted average pickup from last 2 years:
current_otb + (0.7 * year1_pickup + 0.3 * year2_pickup)
Weights: 70% year 1, 30% year 2
This focuses on recent years and excludes COVID-affected periods.
Also calculates confidence bounds using min/max pickup across the 2 years.
Uses ISO week matching to ensure same day-of-week alignment across years,
properly handling leap years.
Args:
training_start: Not used by pickup model (no training), but accepted for API consistency
model_name: Name to store in snapshots (e.g., 'pickup_avg')
Returns count of snapshots stored.
"""
today = perception_date
# Get bookable rooms
bookable_result = db.execute(text("""
SELECT bookable_count
FROM newbook_bookings_stats
WHERE bookable_count IS NOT NULL AND date < :today
ORDER BY date DESC
LIMIT 1
"""), {"today": today})
bookable_row = bookable_result.fetchone()
total_rooms = int(bookable_row.bookable_count) if bookable_row else 25
snapshots_stored = 0
end_date = today + timedelta(days=forecast_days)
current_date = today
while current_date <= end_date:
lead_days = (current_date - today).days
lead_col = get_lead_time_column(lead_days)
# Get current OTB from booking_pace
otb_result = db.execute(text(f"""
SELECT {lead_col} as current_otb
FROM newbook_booking_pace
WHERE arrival_date = :arrival_date
"""), {"arrival_date": current_date})
otb_row = otb_result.fetchone()
current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0
# Convert current OTB to occupancy if needed
if metric == "occupancy" and total_rooms > 0:
current_otb = (current_otb / total_rooms) * 100
# Collect pickup values from 2 years with weights (using proper DOW alignment)
# Weights: 70% year 1, 30% year 2
year_weights = {1: 0.7, 2: 0.3}
pickups = [] # List of (pickup_value, weight) tuples
pickup_values = [] # Just values for min/max
for years_back in [1, 2]:
prior_date = get_same_dow_prior_year(current_date, years_back)
# Get prior year OTB at same lead
prior_otb_result = db.execute(text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
"""), {"prior_date": prior_date})
prior_otb_row = prior_otb_result.fetchone()
# Get prior year final
prior_final_result = db.execute(text("""
SELECT booking_count as prior_final
FROM newbook_bookings_stats
WHERE date = :prior_date
"""), {"prior_date": prior_date})
prior_final_row = prior_final_result.fetchone()
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else None
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else None
if prior_final is not None and prior_otb is not None:
# Convert to occupancy if needed
if metric == "occupancy" and total_rooms > 0:
prior_otb = (prior_otb / total_rooms) * 100
prior_final = (prior_final / total_rooms) * 100
pickup = prior_final - prior_otb
pickups.append((pickup, year_weights[years_back]))
pickup_values.append(pickup)
# Calculate weighted average, min, max pickup
if pickups:
# Normalize weights if not all years have data
total_weight = sum(w for _, w in pickups)
avg_pickup = sum(p * w for p, w in pickups) / total_weight
min_pickup = min(pickup_values)
max_pickup = max(pickup_values)
else:
avg_pickup = 0
min_pickup = 0
max_pickup = 0
# Forecast using average pickup
yhat = current_otb + avg_pickup
yhat_lower = current_otb + min_pickup
yhat_upper = current_otb + max_pickup
# Floor to current OTB
yhat = max(yhat, current_otb)
yhat_lower = max(yhat_lower, current_otb)
yhat_upper = max(yhat_upper, current_otb)
# Cap at max
if metric == "occupancy":
yhat = min(max(yhat, 0), 100.0)
yhat_lower = min(max(yhat_lower, 0), 100.0)
yhat_upper = min(max(yhat_upper, 0), 100.0)
else:
yhat = round(min(max(yhat, 0), float(total_rooms)))
yhat_lower = round(min(max(yhat_lower, 0), float(total_rooms)))
yhat_upper = round(min(max(yhat_upper, 0), float(total_rooms)))
# Store snapshot (main forecast)
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": model_name,
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat, 2)
})
# Store lower bound
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": f"{model_name}_lower",
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat_lower, 2)
})
# Store upper bound
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": f"{model_name}_upper",
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat_upper, 2)
})
snapshots_stored += 1
current_date += timedelta(days=1)
return snapshots_stored
async def run_prophet_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "prophet"
) -> int:
"""
Run Prophet forecast from a specific perception date.
Uses Facebook Prophet for time series forecasting.
Args:
training_start: Optional cutoff date - only use training data from this date forward
model_name: Name to store in snapshots (e.g., 'prophet' or 'prophet_postcovid')
Returns count of snapshots stored.
"""
from prophet import Prophet
today = perception_date
# Get metric query info
metric_info = get_metric_query_info(metric)
column_expr = metric_info['column_expr']
needs_revenue_join = metric_info['needs_revenue_join']
is_pct_metric = metric_info['is_pct_metric']
is_revenue_metric = metric_info['is_revenue_metric']
# Get bookable rooms
bookable_result = db.execute(text("""
SELECT bookable_count
FROM newbook_bookings_stats
WHERE bookable_count IS NOT NULL AND date < :today
ORDER BY date DESC
LIMIT 1
"""), {"today": today})
bookable_row = bookable_result.fetchone()
total_rooms = int(bookable_row.bookable_count) if bookable_row else 25
# Get historical data (2 years before perception date, or from training_start)
history_start = today - timedelta(days=730)
if training_start and training_start > history_start:
history_start = training_start
# Build query based on metric type
if needs_revenue_join:
query = f"""
SELECT s.date as ds, {column_expr} as y
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""
else:
query = f"""
SELECT s.date as ds, {column_expr} as y
FROM newbook_bookings_stats s
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""
history_result = db.execute(text(query), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient data for Prophet at perception_date {perception_date}")
return 0
# Build training dataframe, filtering out NULL values
df = pd.DataFrame([{"ds": row.ds, "y": float(row.y)} for row in history_rows if row.y is not None])
df['ds'] = pd.to_datetime(df['ds'])
if len(df) < 30:
logger.warning(f"Insufficient non-null data for Prophet at perception_date {perception_date}")
return 0
# Convert to occupancy percentage if needed
if metric == "occupancy" and total_rooms > 0:
df["y"] = (df["y"] / total_rooms) * 100
# Determine cap for logistic growth based on metric type
if is_pct_metric:
training_cap = 100
elif metric == 'rooms':
training_cap = total_rooms
elif metric == 'guests':
training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else total_rooms * 3
else:
# Revenue/rate metrics - use 99th percentile * 1.5
training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000
df["floor"] = 0
df["cap"] = training_cap
# Train Prophet model with logistic growth
model = Prophet(
growth='logistic',
yearly_seasonality=True,
weekly_seasonality=True,
daily_seasonality=False,
seasonality_mode='multiplicative'
)
model.fit(df)
# Create future dataframe
future = model.make_future_dataframe(periods=forecast_days)
future = future[future['ds'] >= pd.Timestamp(today)]
future["floor"] = 0
future["cap"] = training_cap
# Predict
forecast = model.predict(future)
# Store snapshots
snapshots_stored = 0
for _, row in forecast.iterrows():
target_date = row['ds'].date()
lead_days = (target_date - today).days
yhat = float(row['yhat'])
# Cap based on metric type
if is_pct_metric:
yhat = min(max(yhat, 0), 100.0)
elif metric == 'rooms':
yhat = round(min(max(yhat, 0), float(total_rooms)))
elif metric == 'guests':
yhat = round(max(yhat, 0)) # No upper cap for guests, just floor at 0
else:
# Revenue/rate metrics - round to 2 decimal places
yhat = round(max(yhat, 0), 2)
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": target_date,
"model_name": model_name,
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat, 2)
})
snapshots_stored += 1
return snapshots_stored
async def run_catboost_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "catboost"
) -> int:
"""
Run CatBoost forecast from a specific perception date and store snapshots.
For occupancy/rooms: Uses pace data (OTB at different lead times) as features.
For other metrics: Uses time-series features only.
Args:
training_start: Optional cutoff date - only use training data from this date forward
model_name: Name to store in snapshots (e.g., 'catboost' or 'catboost_postcovid')
Returns count of snapshots stored.
"""
from catboost import CatBoostRegressor
today = perception_date
# Check if this metric has pace data
pace_metrics = ['occupancy', 'rooms']
use_pace = metric in pace_metrics
# Get metric query info
metric_info = get_metric_query_info(metric)
column_expr = metric_info['column_expr']
needs_revenue_join = metric_info['needs_revenue_join']
is_pct_metric = metric_info['is_pct_metric']
is_revenue_metric = metric_info['is_revenue_metric']
# Get bookable rooms
bookable_result = db.execute(text("""
SELECT bookable_count
FROM newbook_bookings_stats
WHERE bookable_count IS NOT NULL AND date < :today
ORDER BY date DESC
LIMIT 1
"""), {"today": today})
bookable_row = bookable_result.fetchone()
total_rooms = int(bookable_row.bookable_count) if bookable_row else 25
# Get historical data for training (2 years before perception date, or from training_start)
history_start = today - timedelta(days=730)
if training_start and training_start > history_start:
history_start = training_start
# Build query based on metric type
if use_pace:
history_result = db.execute(text("""
SELECT s.date as ds, s.booking_count as final,
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
FROM newbook_bookings_stats s
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
elif needs_revenue_join:
history_result = db.execute(text(f"""
SELECT s.date as ds, {column_expr} as final
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
else:
history_result = db.execute(text(f"""
SELECT s.date as ds, {column_expr} as final
FROM newbook_bookings_stats s
WHERE s.date >= :history_start
AND s.date < :today
AND s.booking_count IS NOT NULL
ORDER BY s.date
"""), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient data for CatBoost at perception_date {perception_date}")
return 0
# Load special dates
special_date_set = set()
try:
special_dates_result = db.execute(text(
"SELECT * FROM special_dates WHERE is_active = TRUE"
))
special_dates_rows = special_dates_result.fetchall()
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
for row in special_dates_rows:
sd = {
'pattern_type': row.pattern_type,
'fixed_month': row.fixed_month,
'fixed_day': row.fixed_day,
'nth_week': row.nth_week,
'weekday': row.weekday,
'month': row.month,
'relative_to_month': row.relative_to_month,
'relative_to_day': row.relative_to_day,
'relative_weekday': row.relative_weekday,
'relative_direction': row.relative_direction,
'duration_days': row.duration_days,
'is_recurring': row.is_recurring,
'one_off_year': row.one_off_year
}
for year in years_needed:
resolved_dates = resolve_special_date(sd, year)
for d in resolved_dates:
special_date_set.add(d)
except Exception:
pass
# Build lookup dicts
final_by_date = {}
for row in history_rows:
if row.final is not None:
final_by_date[row.ds] = row.final
if use_pace:
# Pace-based training
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
pace_by_date = {}
for row in history_rows:
pace_by_date[row.ds] = {
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
}
training_rows = []
for row in history_rows:
ds = row.ds
if row.final is None:
continue
final = float(row.final)
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
continue
for lead_time in train_lead_times:
current_otb = pace_by_date.get(ds, {}).get(lead_time)
if current_otb is None:
continue
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
if prior_otb is None:
prior_otb = 0
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
training_rows.append({
'ds': ds,
'y': final,
'days_out': lead_time,
'current_otb': float(current_otb),
'prior_otb_same_lead': float(prior_otb),
'lag_364': float(prior_final),
'otb_pct_of_prior_final': otb_pct_of_prior_final
})
if len(training_rows) < 30:
logger.warning(f"Insufficient training data for CatBoost at perception_date {perception_date}")
return 0
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
# Convert to occupancy if needed
if metric == "occupancy" and total_rooms > 0:
df["y"] = (df["y"] / total_rooms) * 100
df["current_otb"] = (df["current_otb"] / total_rooms) * 100
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / total_rooms) * 100
df["lag_364"] = (df["lag_364"] / total_rooms) * 100
categorical_features = ['day_of_week', 'month']
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date',
'days_out', 'current_otb', 'prior_otb_same_lead',
'lag_364', 'otb_pct_of_prior_final']
feature_cols = categorical_features + numerical_features
else:
# Non-pace training for other metrics
training_rows = []
for row in history_rows:
ds = row.ds
if row.final is None:
continue
final = float(row.final)
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
prior_final = final
training_rows.append({
'ds': ds,
'y': final,
'lag_364': float(prior_final),
})
if len(training_rows) < 30:
logger.warning(f"Insufficient training data for CatBoost at perception_date {perception_date}")
return 0
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
categorical_features = ['day_of_week', 'month']
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
feature_cols = categorical_features + numerical_features
# Create time features - CatBoost uses categorical features natively
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical for CatBoost
df['month'] = df['ds'].dt.month.astype(str) # Categorical for CatBoost
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
df_train = df.dropna()
if len(df_train) < 30:
return 0
# Determine training cap for predictions
if is_pct_metric:
training_cap = 100
elif metric == 'rooms':
training_cap = total_rooms
elif metric == 'guests':
training_cap = df_train["y"].max() * 1.5 if len(df_train) > 0 and df_train["y"].max() > 0 else total_rooms * 3
else:
training_cap = df_train["y"].quantile(0.99) * 1.5 if len(df_train) > 0 and df_train["y"].quantile(0.99) > 0 else 10000
X_train = df_train[feature_cols]
y_train = df_train['y']
# Train CatBoost model
cat_model = CatBoostRegressor(
iterations=200,
depth=6,
learning_rate=0.1,
loss_function='RMSE',
cat_features=categorical_features,
verbose=False,
random_seed=42
)
cat_model.fit(X_train, y_train)
# Generate forecasts
snapshots_stored = 0
end_date = today + timedelta(days=forecast_days)
current_date = today
while current_date <= end_date:
lead_days = (current_date - today).days
prior_year_date = current_date - timedelta(days=364)
if use_pace:
lead_col = get_lead_time_column(lead_days)
# Get current OTB from booking_pace at that lead time
otb_result = db.execute(text(f"""
SELECT {lead_col} as current_otb
FROM newbook_booking_pace
WHERE arrival_date = :arrival_date
"""), {"arrival_date": current_date})
otb_row = otb_result.fetchone()
# Get prior year OTB at same lead
prior_otb_result = db.execute(text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
"""), {"prior_date": prior_year_date})
prior_otb_row = prior_otb_result.fetchone()
# Get prior year final
prior_final_result = db.execute(text("""
SELECT booking_count as prior_final
FROM newbook_bookings_stats
WHERE date = :prior_date
"""), {"prior_date": prior_year_date})
prior_final_row = prior_final_result.fetchone()
current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0
# Convert to occupancy
if metric == "occupancy" and total_rooms > 0:
current_otb = (current_otb / total_rooms) * 100
prior_otb = (prior_otb / total_rooms) * 100
prior_final = (prior_final / total_rooms) * 100
lag_364_val = prior_final if prior_final else 0
otb_pct_of_prior_final = (current_otb / lag_364_val * 100) if lag_364_val > 0 else 0
# Build features
forecast_dt = pd.Timestamp(current_date)
features = pd.DataFrame([{
'day_of_week': str(forecast_dt.dayofweek),
'month': str(forecast_dt.month),
'week_of_year': forecast_dt.isocalendar().week,
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if current_date in special_date_set else 0,
'days_out': lead_days,
'current_otb': current_otb,
'prior_otb_same_lead': prior_otb,
'lag_364': lag_364_val,
'otb_pct_of_prior_final': otb_pct_of_prior_final,
}])
else:
# Non-pace prediction
lag_364_val = final_by_date.get(prior_year_date)
if lag_364_val is None:
lag_364_val = float(df_train["y"].mean())
else:
lag_364_val = float(lag_364_val)
forecast_dt = pd.Timestamp(current_date)
features = pd.DataFrame([{
'day_of_week': str(forecast_dt.dayofweek),
'month': str(forecast_dt.month),
'week_of_year': int(forecast_dt.isocalendar().week),
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if current_date in special_date_set else 0,
'lag_364': lag_364_val,
}])
# Predict
yhat = float(cat_model.predict(features)[0])
# Cap based on metric type
if is_pct_metric:
yhat = min(max(yhat, 0), 100.0)
elif metric == 'rooms':
yhat = round(min(max(yhat, 0), float(total_rooms)))
elif metric == 'guests':
yhat = round(max(yhat, 0))
else:
yhat = round(max(yhat, 0), 2)
# Store snapshot
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": current_date,
"model_name": model_name,
"metric": metric,
"days_out": lead_days,
"forecast_value": round(yhat, 2)
})
snapshots_stored += 1
current_date += timedelta(days=1)
return snapshots_stored
async def run_blended_backtest(
db,
perception_date: date,
forecast_days: int,
metric: str,
training_start: Optional[date] = None,
model_name: str = "blended"
) -> int:
"""
Create blended forecast by averaging Prophet, XGBoost, and CatBoost predictions.
This function reads existing forecasts from forecast_snapshots and creates
a blended average. Run this AFTER running the individual models.
Args:
training_start: Used to determine if we should look for _postcovid model variants
model_name: Name to store in snapshots (e.g., 'blended' or 'blended_postcovid')
Returns count of snapshots stored.
"""
suffix = "_postcovid" if training_start else ""
models_to_blend = [f"prophet{suffix}", f"xgboost{suffix}", f"catboost{suffix}"]
# Get all forecasts from the three models for this perception date and metric
result = db.execute(text("""
SELECT target_date, model, forecast_value
FROM forecast_snapshots
WHERE perception_date = :perception_date
AND metric_code = :metric
AND model IN :models
ORDER BY target_date
"""), {
"perception_date": perception_date,
"metric": metric,
"models": tuple(models_to_blend)
})
rows = result.fetchall()
if not rows:
logger.warning(f"No model forecasts found for blending at {perception_date}/{metric}")
return 0
# Group by target_date
forecasts_by_date = {}
for row in rows:
if row.target_date not in forecasts_by_date:
forecasts_by_date[row.target_date] = {}
forecasts_by_date[row.target_date][row.model] = float(row.forecast_value)
# Calculate blended average for each target date
snapshots_stored = 0
for target_date, model_forecasts in forecasts_by_date.items():
# Only blend if we have at least 2 models
if len(model_forecasts) < 2:
continue
# Calculate average
values = list(model_forecasts.values())
blended_value = sum(values) / len(values)
days_out = (target_date - perception_date).days
# Store blended snapshot
db.execute(text("""
INSERT INTO forecast_snapshots
(perception_date, target_date, model, metric_code, days_out, forecast_value)
VALUES
(:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value)
ON CONFLICT (perception_date, target_date, model, metric_code)
DO UPDATE SET forecast_value = :forecast_value, created_at = NOW()
"""), {
"perception_date": perception_date,
"target_date": target_date,
"model_name": model_name,
"metric": metric,
"days_out": days_out,
"forecast_value": round(blended_value, 2)
})
snapshots_stored += 1
return snapshots_stored
async def backfill_actuals():
"""
Backfill actual_value in forecast_snapshots from newbook_bookings_stats and newbook_net_revenue_data.
Run this after target_date has passed.
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
"""
db = SyncSessionLocal()
try:
# First, update stats-based metrics (occupancy, rooms, guests, ave_guest_rate)
result1 = 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 = 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')
"""))
db.commit()
count = result1.rowcount + result2.rowcount
logger.info(f"Backfilled {count} actual values")
return count
finally:
db.close()