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>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

View file

@ -0,0 +1 @@
# Services

View file

@ -0,0 +1,539 @@
"""
Backup and Restore Service
Handles creating full backups of the database and files, and restoring from backups.
"""
import os
import json
import logging
import shutil
import subprocess
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple, Dict, Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# Backup storage directory
BACKUP_DIR = Path("/app/data/backups")
DATA_DIR = Path("/app/data")
class BackupService:
"""Service for managing backups and restores"""
def __init__(self, db: AsyncSession):
self.db = db
self.ensure_backup_dir()
def ensure_backup_dir(self):
"""Ensure backup directory exists"""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
async def ensure_backup_table_exists(self):
"""Create backup_history table if it doesn't exist"""
await self.db.execute(text("""
CREATE TABLE IF NOT EXISTS backup_history (
id SERIAL PRIMARY KEY,
backup_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
filename VARCHAR(255),
file_path TEXT,
file_size_bytes BIGINT,
snapshot_count INTEGER,
file_count INTEGER,
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
error_message TEXT,
created_by VARCHAR(100)
)
"""))
await self.db.commit()
async def get_backup_settings(self) -> Dict[str, Any]:
"""Get backup configuration from system_config"""
settings = {
'backup_frequency': 'manual',
'backup_retention_count': 7,
'backup_destination': 'local',
'backup_time': None,
'backup_last_run_at': None,
'backup_last_status': None
}
result = await self.db.execute(text("""
SELECT config_key, config_value
FROM system_config
WHERE config_key LIKE 'backup_%'
"""))
rows = result.fetchall()
for row in rows:
settings[row.config_key] = row.config_value
return settings
async def update_backup_settings(self, updates: Dict[str, Any]) -> bool:
"""Update backup configuration"""
try:
for key, value in updates.items():
if not key.startswith('backup_'):
key = f'backup_{key}'
await self.db.execute(text("""
INSERT INTO system_config (config_key, config_value, updated_at)
VALUES (:key, :value, NOW())
ON CONFLICT (config_key) DO UPDATE
SET config_value = :value, updated_at = NOW()
"""), {'key': key, 'value': str(value)})
await self.db.commit()
return True
except Exception as e:
logger.error(f"Failed to update backup settings: {e}")
await self.db.rollback()
return False
async def create_backup(
self,
backup_type: str = 'manual',
created_by: Optional[str] = None
) -> Tuple[bool, str, Optional[int]]:
"""
Create a full backup of database and files
Returns: (success, message, backup_id)
"""
backup_id = None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"backup_{timestamp}.zip"
filepath = BACKUP_DIR / filename
try:
# Create backup record
result = await self.db.execute(text("""
INSERT INTO backup_history (
backup_type, status, filename, started_at, created_by
) VALUES (
:backup_type, 'running', :filename, NOW(), :created_by
) RETURNING id
"""), {
'backup_type': backup_type,
'filename': filename,
'created_by': created_by
})
await self.db.commit()
row = result.fetchone()
backup_id = row.id if row else None
# Create temporary directory for backup contents
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# 1. Create PostgreSQL dump
db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast')
db_parts = db_url.replace('postgresql://', '').split('@')
user_pass = db_parts[0].split(':')
host_db = db_parts[1].split('/')
db_dump_path = temp_path / 'database.sql'
env = os.environ.copy()
env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else ''
pg_dump_cmd = [
'pg_dump',
'-h', host_db[0].split(':')[0],
'-U', user_pass[0],
'-d', host_db[1] if len(host_db) > 1 else 'forecast',
'-f', str(db_dump_path),
'--no-owner',
'--no-acl'
]
subprocess.run(pg_dump_cmd, env=env, check=True, capture_output=True)
# 2. Create JSON export
db_json_path = temp_path / 'database.json'
snapshot_count = await self._export_database_json(db_json_path)
# 3. Copy files
files_dir = temp_path / 'files'
file_count = self._copy_data_files(files_dir)
# 4. Create metadata
metadata = {
'version': '1.0',
'timestamp': datetime.now().isoformat(),
'snapshot_count': snapshot_count,
'file_count': file_count,
'database_url': db_url.split('@')[1] # Only host/db, not credentials
}
metadata_path = temp_path / 'metadata.json'
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# 5. Create ZIP file
with zipfile.ZipFile(filepath, 'w', zipfile.ZIP_DEFLATED) as zf:
for file in temp_path.rglob('*'):
if file.is_file():
arcname = file.relative_to(temp_path)
zf.write(file, arcname)
# Update backup record with success
file_size = filepath.stat().st_size
await self.db.execute(text("""
UPDATE backup_history
SET status = 'success',
file_path = :file_path,
file_size_bytes = :file_size,
snapshot_count = :snapshot_count,
file_count = :file_count,
completed_at = NOW()
WHERE id = :backup_id
"""), {
'backup_id': backup_id,
'file_path': str(filepath),
'file_size': file_size,
'snapshot_count': snapshot_count,
'file_count': file_count
})
await self.db.commit()
# Update last backup settings
await self.update_backup_settings({
'backup_last_run_at': datetime.now().isoformat(),
'backup_last_status': 'success'
})
# Enforce retention policy
await self._enforce_retention()
return True, f"Backup created successfully: {filename}", backup_id
except Exception as e:
logger.error(f"Backup creation failed: {e}")
# Update backup record with failure
if backup_id:
await self.db.execute(text("""
UPDATE backup_history
SET status = 'failed',
error_message = :error,
completed_at = NOW()
WHERE id = :backup_id
"""), {
'backup_id': backup_id,
'error': str(e)
})
await self.db.commit()
# Update last backup status
await self.update_backup_settings({
'backup_last_run_at': datetime.now().isoformat(),
'backup_last_status': 'failed'
})
return False, f"Backup failed: {str(e)}", backup_id
async def _export_database_json(self, output_path: Path) -> int:
"""Export database tables to JSON format"""
export_data = {
'exported_at': datetime.now().isoformat(),
'tables': {}
}
# Tables to export
tables = [
'forecast_snapshots',
'special_dates',
'newbook_bookings_data',
'newbook_bookings_stats',
'newbook_booking_pace',
'newbook_occupancy_report_data',
'newbook_room_categories',
'monthly_budgets',
'system_config',
'users'
]
snapshot_count = 0
for table in tables:
try:
result = await self.db.execute(text(f"SELECT * FROM {table}"))
rows = result.fetchall()
columns = result.keys()
export_data['tables'][table] = [
{col: self._serialize_value(getattr(row, col)) for col in columns}
for row in rows
]
if table == 'forecast_snapshots':
snapshot_count = len(rows)
except Exception as e:
logger.warning(f"Could not export table {table}: {e}")
export_data['tables'][table] = []
with open(output_path, 'w') as f:
json.dump(export_data, f, indent=2, default=str)
return snapshot_count
def _serialize_value(self, value):
"""Convert value to JSON-serializable format"""
if isinstance(value, (datetime,)):
return value.isoformat()
return value
def _copy_data_files(self, dest_dir: Path) -> int:
"""Copy all data files to backup directory"""
dest_dir.mkdir(parents=True, exist_ok=True)
file_count = 0
# Skip backup directory itself
for root, dirs, files in os.walk(DATA_DIR):
# Remove backup directory from traversal
dirs[:] = [d for d in dirs if d != 'backups']
for file in files:
src_file = Path(root) / file
rel_path = src_file.relative_to(DATA_DIR)
dest_file = dest_dir / rel_path
dest_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dest_file)
file_count += 1
return file_count
async def list_backups(self, limit: int = 50) -> list:
"""List all backups, newest first"""
result = await self.db.execute(text("""
SELECT
id, backup_type, status, filename, file_path,
file_size_bytes, snapshot_count, file_count,
started_at, completed_at, error_message, created_by
FROM backup_history
ORDER BY started_at DESC
LIMIT :limit
"""), {'limit': limit})
rows = result.fetchall()
return [
{
'id': row.id,
'backup_type': row.backup_type,
'status': row.status,
'filename': row.filename,
'file_path': row.file_path,
'file_size_bytes': row.file_size_bytes,
'snapshot_count': row.snapshot_count,
'file_count': row.file_count,
'started_at': row.started_at.isoformat() if row.started_at else None,
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
'error_message': row.error_message,
'created_by': row.created_by
}
for row in rows
]
async def get_backup(self, backup_id: int) -> Optional[Dict]:
"""Get a specific backup by ID"""
result = await self.db.execute(text("""
SELECT
id, backup_type, status, filename, file_path,
file_size_bytes, snapshot_count, file_count,
started_at, completed_at, error_message, created_by
FROM backup_history
WHERE id = :backup_id
"""), {'backup_id': backup_id})
row = result.fetchone()
if not row:
return None
return {
'id': row.id,
'backup_type': row.backup_type,
'status': row.status,
'filename': row.filename,
'file_path': row.file_path,
'file_size_bytes': row.file_size_bytes,
'snapshot_count': row.snapshot_count,
'file_count': row.file_count,
'started_at': row.started_at.isoformat() if row.started_at else None,
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
'error_message': row.error_message,
'created_by': row.created_by
}
async def delete_backup(self, backup_id: int) -> Tuple[bool, str]:
"""Delete a backup file and record"""
try:
# Get backup info
backup = await self.get_backup(backup_id)
if not backup:
return False, "Backup not found"
# Delete file if it exists
if backup['file_path']:
file_path = Path(backup['file_path'])
if file_path.exists():
file_path.unlink()
# Delete database record
await self.db.execute(text("""
DELETE FROM backup_history WHERE id = :backup_id
"""), {'backup_id': backup_id})
await self.db.commit()
return True, "Backup deleted successfully"
except Exception as e:
logger.error(f"Failed to delete backup: {e}")
await self.db.rollback()
return False, f"Failed to delete backup: {str(e)}"
async def restore_from_backup(self, backup_id: int) -> Tuple[bool, str]:
"""Restore database and files from a backup"""
try:
# Get backup info
backup = await self.get_backup(backup_id)
if not backup:
return False, "Backup not found"
if backup['status'] != 'success':
return False, "Cannot restore from failed backup"
backup_path = Path(backup['file_path'])
if not backup_path.exists():
return False, "Backup file not found"
return await self._restore_from_file(backup_path)
except Exception as e:
logger.error(f"Restore failed: {e}")
return False, f"Restore failed: {str(e)}"
async def restore_from_upload(self, file_content: bytes, filename: str) -> Tuple[bool, str]:
"""Restore from an uploaded backup file"""
try:
# Save uploaded file temporarily
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
temp_file.write(file_content)
temp_path = Path(temp_file.name)
try:
# Validate ZIP file
if not zipfile.is_zipfile(temp_path):
return False, "Invalid backup file (not a ZIP file)"
return await self._restore_from_file(temp_path)
finally:
# Clean up temp file
if temp_path.exists():
temp_path.unlink()
except Exception as e:
logger.error(f"Upload restore failed: {e}")
return False, f"Restore failed: {str(e)}"
async def _restore_from_file(self, backup_path: Path) -> Tuple[bool, str]:
"""Internal method to restore from a backup ZIP file"""
try:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Extract ZIP
with zipfile.ZipFile(backup_path, 'r') as zf:
zf.extractall(temp_path)
# Validate required files
db_sql_path = temp_path / 'database.sql'
if not db_sql_path.exists():
return False, "Invalid backup: missing database.sql"
# Restore database
db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast')
db_parts = db_url.replace('postgresql://', '').split('@')
user_pass = db_parts[0].split(':')
host_db = db_parts[1].split('/')
env = os.environ.copy()
env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else ''
psql_cmd = [
'psql',
'-h', host_db[0].split(':')[0],
'-U', user_pass[0],
'-d', host_db[1] if len(host_db) > 1 else 'forecast',
'-f', str(db_sql_path)
]
result = subprocess.run(psql_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"Database restore failed: {result.stderr}")
return False, f"Database restore failed: {result.stderr}"
# Restore files
files_dir = temp_path / 'files'
if files_dir.exists():
file_count = 0
for root, dirs, files in os.walk(files_dir):
for file in files:
src_file = Path(root) / file
rel_path = src_file.relative_to(files_dir)
dest_file = DATA_DIR / rel_path
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Don't overwrite existing files
if not dest_file.exists():
shutil.copy2(src_file, dest_file)
file_count += 1
logger.info(f"Restored {file_count} files")
return True, "Backup restored successfully"
except Exception as e:
logger.error(f"Restore from file failed: {e}")
return False, f"Restore failed: {str(e)}"
async def _enforce_retention(self):
"""Delete old backups beyond retention count"""
try:
settings = await self.get_backup_settings()
retention_count = int(settings.get('backup_retention_count', 7))
# Get backups to delete (beyond retention count)
result = await self.db.execute(text("""
SELECT id, file_path
FROM backup_history
WHERE status = 'success'
ORDER BY started_at DESC
OFFSET :retention_count
"""), {'retention_count': retention_count})
rows = result.fetchall()
for row in rows:
# Delete file
if row.file_path:
file_path = Path(row.file_path)
if file_path.exists():
file_path.unlink()
# Delete record
await self.db.execute(text("""
DELETE FROM backup_history WHERE id = :backup_id
"""), {'backup_id': row.id})
await self.db.commit()
logger.info(f"Retention policy: deleted {len(rows)} old backups")
except Exception as e:
logger.error(f"Failed to enforce retention policy: {e}")

View file

@ -0,0 +1,829 @@
"""
Booking.com Rate Scraper Service
Main service for scraping competitor rates from booking.com.
Uses pluggable backends (Playwright local, proxy, Apify) via factory pattern.
Features:
- Location-based search (1 query = 40+ hotels)
- Hotel discovery and tier management
- Rate extraction with availability status
- Anti-scrape detection and pause/resume
"""
import logging
import uuid
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from .scraper_backends import ScraperBackend, PlaywrightLocalBackend, HotelData, RateData, AvailabilityStatus
logger = logging.getLogger(__name__)
def get_scraper_backend(db: Session) -> ScraperBackend:
"""
Factory to get configured scraper backend.
Reads backend type from system_config and returns appropriate instance.
"""
# Get backend configuration
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_backend'")
).fetchone()
backend_type = result.config_value if result and result.config_value else 'playwright_local'
if backend_type == 'playwright_local':
return PlaywrightLocalBackend()
elif backend_type == 'playwright_proxy':
# Get proxy config
proxy_result = db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN ('booking_scraper_proxy_url', 'booking_scraper_proxy_username', 'booking_scraper_proxy_password')
""")
)
proxy_config = {row.config_key: row.config_value for row in proxy_result.fetchall()}
return PlaywrightLocalBackend(proxy_config=proxy_config)
elif backend_type == 'apify':
# Future: Apify backend
raise NotImplementedError("Apify backend not yet implemented")
else:
logger.warning(f"Unknown backend type '{backend_type}', falling back to playwright_local")
return PlaywrightLocalBackend()
def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]:
"""Get the active scrape location configuration."""
result = db.execute(
text("""
SELECT id, location_name, location_search_url, pages_to_scrape, adults
FROM booking_scrape_config
WHERE is_active = TRUE
ORDER BY id
LIMIT 1
""")
).fetchone()
if not result:
return None
return {
'id': result.id,
'location_name': result.location_name,
'location_search_url': result.location_search_url,
'pages_to_scrape': result.pages_to_scrape or 2,
'adults': result.adults or 2,
}
async def is_scraper_paused(db: Session) -> bool:
"""Check if scraper is currently paused due to blocking."""
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
).fetchone()
if not result or result.config_value != 'true':
return False
# Check if pause period has expired
pause_until_result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_pause_until'")
).fetchone()
if pause_until_result and pause_until_result.config_value:
try:
pause_until = datetime.fromisoformat(pause_until_result.config_value)
if datetime.now() >= pause_until:
# Pause expired, reset
db.execute(
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
)
db.commit()
return False
except ValueError:
pass
return True
async def set_scraper_paused(db: Session, paused: bool, hours: int = 2):
"""Set scraper pause status."""
db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_paused'"),
{'val': 'true' if paused else 'false'}
)
if paused:
pause_until = (datetime.now() + timedelta(hours=hours)).isoformat()
db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_pause_until'"),
{'val': pause_until}
)
db.commit()
def save_hotel(db: Session, hotel: HotelData) -> int:
"""
Save or update a hotel in the database.
Returns the hotel's database ID.
"""
# Check if hotel exists
existing = db.execute(
text("SELECT id FROM booking_com_hotels WHERE booking_com_id = :bid"),
{'bid': hotel.booking_com_id}
).fetchone()
if existing:
# Update last_seen_at and any changed fields
db.execute(
text("""
UPDATE booking_com_hotels SET
name = COALESCE(:name, name),
booking_com_url = COALESCE(:url, booking_com_url),
star_rating = COALESCE(:stars, star_rating),
review_score = COALESCE(:score, review_score),
review_count = COALESCE(:count, review_count),
last_seen_at = NOW()
WHERE booking_com_id = :bid
"""),
{
'bid': hotel.booking_com_id,
'name': hotel.name,
'url': hotel.booking_com_url,
'stars': float(hotel.star_rating) if hotel.star_rating else None,
'score': float(hotel.review_score) if hotel.review_score else None,
'count': hotel.review_count,
}
)
return existing.id
else:
# Insert new hotel (default tier is 'market')
result = db.execute(
text("""
INSERT INTO booking_com_hotels
(booking_com_id, name, booking_com_url, star_rating, review_score, review_count, tier)
VALUES (:bid, :name, :url, :stars, :score, :count, 'market')
RETURNING id
"""),
{
'bid': hotel.booking_com_id,
'name': hotel.name,
'url': hotel.booking_com_url,
'stars': float(hotel.star_rating) if hotel.star_rating else None,
'score': float(hotel.review_score) if hotel.review_score else None,
'count': hotel.review_count,
}
)
return result.fetchone().id
def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID):
"""Save a rate to the database."""
db.execute(
text("""
INSERT INTO booking_com_rates
(hotel_id, rate_date, availability_status, rate_gross, currency, room_type,
breakfast_included, free_cancellation, no_prepayment, rooms_left, scrape_batch_id)
VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type,
:breakfast, :cancel, :prepay, :rooms_left, :batch_id)
"""),
{
'hotel_id': hotel_id,
'rate_date': rate.rate_date,
'status': rate.availability_status.value,
'rate': float(rate.rate_gross) if rate.rate_gross else None,
'currency': rate.currency,
'room_type': rate.room_type,
'breakfast': rate.breakfast_included,
'cancel': rate.free_cancellation,
'prepay': rate.no_prepayment,
'rooms_left': rate.rooms_left,
'batch_id': str(batch_id),
}
)
def create_scrape_batch(db: Session, scrape_type: str) -> uuid.UUID:
"""Create a new scrape batch log entry."""
batch_id = uuid.uuid4()
db.execute(
text("""
INSERT INTO booking_scrape_log
(batch_id, scrape_type, started_at, status)
VALUES (:batch_id, :scrape_type, NOW(), 'running')
"""),
{'batch_id': str(batch_id), 'scrape_type': scrape_type}
)
db.commit()
return batch_id
def update_scrape_batch(
db: Session,
batch_id: uuid.UUID,
status: str,
hotels_found: int = 0,
rates_scraped: int = 0,
error_message: str = None,
blocked: bool = False
):
"""Update scrape batch log with results."""
db.execute(
text("""
UPDATE booking_scrape_log SET
completed_at = CASE WHEN :status IN ('completed', 'failed', 'blocked') THEN NOW() ELSE NULL END,
status = :status,
hotels_found = :hotels,
rates_scraped = :rates,
error_message = :error,
blocked_at = CASE WHEN :blocked THEN NOW() ELSE NULL END,
resume_after = CASE WHEN :blocked THEN NOW() + INTERVAL '2 hours' ELSE NULL END
WHERE batch_id = :batch_id
"""),
{
'batch_id': str(batch_id),
'status': status,
'hotels': hotels_found,
'rates': rates_scraped,
'error': error_message,
'blocked': blocked,
}
)
db.commit()
def cleanup_stale_batches(db: Session, max_age_minutes: int = 60):
"""
Mark any 'running' scrape batches as 'failed' if they've been running
longer than max_age_minutes. This handles orphaned batches from
container restarts or crashes.
"""
result = db.execute(
text("""
UPDATE booking_scrape_log SET
status = 'failed',
completed_at = NOW(),
error_message = 'Interrupted (container restart or timeout)'
WHERE status = 'running'
AND started_at < NOW() - INTERVAL ':mins minutes'
RETURNING batch_id
""".replace(':mins', str(int(max_age_minutes))))
)
cleaned = result.fetchall()
db.commit()
if cleaned:
logger.info(f"Cleaned up {len(cleaned)} stale running scrape batch(es)")
return len(cleaned)
async def scrape_date(
db: Session,
rate_date: date,
backend: ScraperBackend,
config: Dict[str, Any],
batch_id: uuid.UUID
) -> Dict[str, Any]:
"""
Scrape rates for a single date.
Args:
db: Database session
rate_date: Date to scrape rates for
backend: Scraper backend instance
config: Scrape configuration
batch_id: Current batch ID
Returns:
Dict with 'success', 'blocked', 'hotels_count', 'rates_count'
"""
check_in = rate_date
check_out = rate_date + timedelta(days=1) # Single night
result = await backend.scrape_location_search(
location=config['location_name'],
check_in=check_in,
check_out=check_out,
adults=config['adults'],
pages=config['pages_to_scrape']
)
if result.blocked:
return {
'success': False,
'blocked': True,
'block_reason': result.block_reason,
'hotels_count': 0,
'rates_count': 0,
}
if not result.success:
return {
'success': False,
'blocked': False,
'error': result.error_message,
'hotels_count': 0,
'rates_count': 0,
}
# Save hotels and rates
hotels_saved = 0
rates_saved = 0
for hotel, rate in zip(result.hotels, result.rates):
if not hotel.booking_com_id:
continue
try:
hotel_id = save_hotel(db, hotel)
save_rate(db, rate, hotel_id, batch_id)
hotels_saved += 1
rates_saved += 1
except Exception as e:
logger.warning(f"Error saving hotel/rate: {e}")
continue
db.commit()
return {
'success': True,
'blocked': False,
'hotels_count': hotels_saved,
'rates_count': rates_saved,
}
async def run_manual_scrape(
db: Session,
from_date: date,
to_date: date = None
) -> Dict[str, Any]:
"""
Run a manual scrape for testing/on-demand use.
Args:
db: Database session
from_date: Start date
to_date: End date (defaults to from_date for single day)
Returns:
Dict with scrape results summary
"""
if to_date is None:
to_date = from_date
# Check if paused
if await is_scraper_paused(db):
return {
'success': False,
'error': 'Scraper is currently paused due to blocking. Try again later.',
}
# Get config
config = get_scrape_config(db)
if not config:
return {
'success': False,
'error': 'No scrape location configured. Add a location in settings.',
}
# Create batch
batch_id = create_scrape_batch(db, 'manual')
# Get backend
backend = get_scraper_backend(db)
total_hotels = 0
total_rates = 0
dates_completed = 0
dates_failed = 0
try:
current_date = from_date
while current_date <= to_date:
logger.info(f"Scraping date: {current_date}")
result = await scrape_date(db, current_date, backend, config, batch_id)
if result['blocked']:
# Blocking detected - pause and exit
await set_scraper_paused(db, True, hours=2)
update_scrape_batch(
db, batch_id,
status='blocked',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
blocked=True
)
return {
'success': False,
'blocked': True,
'block_reason': result.get('block_reason'),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
if result['success']:
total_hotels += result['hotels_count']
total_rates += result['rates_count']
dates_completed += 1
else:
dates_failed += 1
logger.warning(f"Failed to scrape {current_date}: {result.get('error')}")
current_date += timedelta(days=1)
# Update batch as completed
update_scrape_batch(
db, batch_id,
status='completed',
hotels_found=total_hotels,
rates_scraped=total_rates
)
return {
'success': True,
'blocked': False,
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
except Exception as e:
logger.error(f"Scrape error: {e}")
update_scrape_batch(
db, batch_id,
status='failed',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=str(e)
)
return {
'success': False,
'error': str(e),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
finally:
await backend.close()
# ============================================
# QUEUE MANAGEMENT
# ============================================
def populate_queue(db: Session, dates: List[date], priorities: Dict[date, int] = None):
"""
Add dates to the scrape queue, skipping any already pending/processing.
Args:
db: Database session
dates: Dates to add to the queue
priorities: Optional priority map (higher = scraped first). Default: 0
"""
if not dates:
return 0
added = 0
for rate_date in dates:
priority = (priorities or {}).get(rate_date, 0)
try:
db.execute(
text("""
INSERT INTO booking_scrape_queue (rate_date, status, priority)
VALUES (:rate_date, 'pending', :priority)
ON CONFLICT (rate_date, status) DO UPDATE SET
priority = GREATEST(booking_scrape_queue.priority, :priority)
"""),
{'rate_date': rate_date, 'priority': priority}
)
added += 1
except Exception:
# Ignore duplicates or constraint issues
pass
db.commit()
logger.info(f"Queue: added/updated {added} dates")
return added
def get_pending_queue_items(db: Session, limit: int = 50) -> List[Dict[str, Any]]:
"""Get pending queue items ordered by priority (highest first), then date."""
result = db.execute(
text("""
SELECT id, rate_date, priority, attempts, max_attempts
FROM booking_scrape_queue
WHERE status = 'pending' AND attempts < max_attempts
ORDER BY priority DESC, rate_date ASC
LIMIT :limit
"""),
{'limit': limit}
)
return [dict(row._mapping) for row in result.fetchall()]
def mark_queue_item(db: Session, queue_id: int, status: str, error_message: str = None):
"""Update a queue item's status."""
if status == 'completed':
db.execute(
text("""
UPDATE booking_scrape_queue SET
status = 'completed',
completed_at = NOW(),
last_attempt_at = NOW(),
attempts = attempts + 1
WHERE id = :id
"""),
{'id': queue_id}
)
elif status == 'failed':
db.execute(
text("""
UPDATE booking_scrape_queue SET
status = CASE
WHEN attempts + 1 >= max_attempts THEN 'failed'
ELSE 'pending'
END,
last_attempt_at = NOW(),
attempts = attempts + 1,
error_message = :error
WHERE id = :id
"""),
{'id': queue_id, 'error': error_message}
)
db.commit()
def clear_old_queue_items(db: Session, days: int = 7):
"""Remove completed/failed queue items older than N days."""
db.execute(
text("""
DELETE FROM booking_scrape_queue
WHERE status IN ('completed', 'failed')
AND created_at < NOW() - INTERVAL ':days days'
""".replace(':days', str(int(days))))
)
db.commit()
async def process_queue(db: Session) -> Dict[str, Any]:
"""
Process pending items from the scrape queue.
Picks up pending items in priority order, scrapes each date,
and handles blocking/retries.
Returns:
Dict with processing results
"""
# Check if paused
if await is_scraper_paused(db):
return {
'success': False,
'error': 'Scraper is currently paused due to blocking.',
}
# Get config
config = get_scrape_config(db)
if not config:
return {
'success': False,
'error': 'No scrape location configured.',
}
# Get pending items
items = get_pending_queue_items(db, limit=200)
if not items:
return {'success': True, 'dates_completed': 0, 'message': 'Queue empty'}
# Create batch
batch_id = create_scrape_batch(db, 'scheduled')
# Update batch with queue count
db.execute(
text("UPDATE booking_scrape_log SET dates_queued = :count WHERE batch_id = :bid"),
{'count': len(items), 'bid': str(batch_id)}
)
db.commit()
# Get backend
backend = get_scraper_backend(db)
total_hotels = 0
total_rates = 0
dates_completed = 0
dates_failed = 0
try:
for item in items:
rate_date = item['rate_date']
queue_id = item['id']
logger.info(f"Queue processing: {rate_date} (priority={item['priority']}, attempt={item['attempts']+1})")
result = await scrape_date(db, rate_date, backend, config, batch_id)
if result['blocked']:
# Mark this item as failed, pause, and stop
mark_queue_item(db, queue_id, 'failed', f"Blocked: {result.get('block_reason')}")
await set_scraper_paused(db, True, hours=2)
update_scrape_batch(
db, batch_id,
status='blocked',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
blocked=True
)
# Update dates counters
db.execute(
text("""
UPDATE booking_scrape_log SET
dates_completed = :completed,
dates_failed = :failed
WHERE batch_id = :bid
"""),
{'completed': dates_completed, 'failed': dates_failed + 1, 'bid': str(batch_id)}
)
db.commit()
return {
'success': False,
'blocked': True,
'block_reason': result.get('block_reason'),
'dates_completed': dates_completed,
'dates_failed': dates_failed + 1,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
if result['success']:
mark_queue_item(db, queue_id, 'completed')
total_hotels += result['hotels_count']
total_rates += result['rates_count']
dates_completed += 1
else:
mark_queue_item(db, queue_id, 'failed', result.get('error'))
dates_failed += 1
logger.warning(f"Queue: failed to scrape {rate_date}: {result.get('error')}")
# Update batch as completed
update_scrape_batch(
db, batch_id,
status='completed',
hotels_found=total_hotels,
rates_scraped=total_rates
)
db.execute(
text("""
UPDATE booking_scrape_log SET
dates_completed = :completed,
dates_failed = :failed
WHERE batch_id = :bid
"""),
{'completed': dates_completed, 'failed': dates_failed, 'bid': str(batch_id)}
)
db.commit()
return {
'success': True,
'blocked': False,
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
except Exception as e:
logger.error(f"Queue processing error: {e}")
update_scrape_batch(
db, batch_id,
status='failed',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=str(e)
)
return {
'success': False,
'error': str(e),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
}
finally:
await backend.close()
def get_competitor_matrix(
db: Session,
from_date: date,
to_date: date,
include_market: bool = False
) -> List[Dict[str, Any]]:
"""
Get rate comparison matrix for competitors.
Args:
db: Database session
from_date: Start date
to_date: End date
include_market: Include market tier hotels
Returns:
List of rate records for matrix display
"""
tier_filter = "h.tier IN ('own', 'competitor')"
if include_market:
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
result = db.execute(
text(f"""
SELECT
r.rate_date,
h.id AS hotel_id,
h.name AS hotel_name,
h.tier,
h.display_order,
h.star_rating,
h.review_score,
r.availability_status,
r.rate_gross,
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.no_prepayment,
r.rooms_left,
r.scraped_at
FROM booking_latest_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE {tier_filter}
AND h.is_active = TRUE
AND r.rate_date BETWEEN :from_date AND :to_date
ORDER BY r.rate_date, h.display_order, h.name
"""),
{'from_date': from_date, 'to_date': to_date}
)
return [dict(row._mapping) for row in result.fetchall()]
def get_hotels_list(db: Session, tier: str = None) -> List[Dict[str, Any]]:
"""
Get list of discovered hotels.
Args:
db: Database session
tier: Filter by tier ('own', 'competitor', 'market') or None for all
Returns:
List of hotel records
"""
where_clause = "WHERE is_active = TRUE"
if tier:
where_clause += f" AND tier = '{tier}'"
result = db.execute(
text(f"""
SELECT
id, booking_com_id, name, booking_com_url,
star_rating, review_score, review_count,
tier, display_order, notes,
first_seen_at, last_seen_at
FROM booking_com_hotels
{where_clause}
ORDER BY display_order, name
""")
)
return [dict(row._mapping) for row in result.fetchall()]
def update_hotel_tier(db: Session, hotel_id: int, tier: str, display_order: int = None):
"""Update a hotel's tier and display order."""
if tier not in ('own', 'competitor', 'market'):
raise ValueError(f"Invalid tier: {tier}")
params = {'hotel_id': hotel_id, 'tier': tier}
set_clause = "tier = :tier"
if display_order is not None:
set_clause += ", display_order = :order"
params['order'] = display_order
db.execute(
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id"),
params
)
db.commit()

View file

@ -0,0 +1 @@
# Forecasting models

View file

@ -0,0 +1,401 @@
"""
Backtesting service for forecast model evaluation.
Simulates historical forecasts using only data that would have been
available at the time, then compares to actual outcomes.
This allows model accuracy evaluation without waiting for real-time
data to accumulate.
"""
import logging
from datetime import date, timedelta
from typing import List, Optional, Dict
from sqlalchemy import text
from utils.time_alignment import get_prior_year_daily
logger = logging.getLogger(__name__)
async def run_backtest(
db,
metric_code: str,
backtest_from: date,
backtest_to: date,
lead_times: List[int] = None
) -> Dict:
"""
Run backtesting for a metric over a date range.
For each date in the range, simulates what the forecast would have been
at various lead times, using only data available at that time.
Args:
db: Database session
metric_code: Metric to backtest (e.g., 'hotel_room_nights')
backtest_from: Start of backtest period
backtest_to: End of backtest period
lead_times: List of lead times to test (days out). Default: [7, 14, 21, 28]
Returns:
Dict with backtest results and accuracy metrics
"""
if lead_times is None:
lead_times = [7, 14, 21, 28]
logger.info(f"Running backtest for {metric_code} from {backtest_from} to {backtest_to}")
results = []
total_rooms = 25 # Default capacity
# Get room capacity (SUM across all room categories for a single date)
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
rooms_result = 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": backtest_from}
)
rooms_row = rooms_result.fetchone()
if rooms_row and rooms_row.total_rooms:
total_rooms = int(rooms_row.total_rooms)
# For each date in backtest range
current_date = backtest_from
while current_date <= backtest_to:
# Get actual value for this date
actual_result = db.execute(
text("""
SELECT actual_value
FROM daily_metrics
WHERE date = :target_date AND metric_code = :metric
"""),
{"target_date": current_date, "metric": metric_code}
).fetchone()
actual_value = float(actual_result.actual_value) if actual_result and actual_result.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_times:
# The "simulated today" is lead_time days before the target date
simulated_today = current_date - timedelta(days=lead_time)
# Get OTB snapshot that would have been available
# Look for snapshot closest to simulated_today
otb_result = 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
}
).fetchone()
if not otb_result:
continue
# Use 'is not None' - 0 is valid OTB data
current_otb = float(otb_result.otb_value) if otb_result.otb_value is not None else 0
actual_lead_time = otb_result.days_out or lead_time
# Get prior year comparison data (same day of week)
prior_year_date = get_prior_year_daily(current_date)
prior_year_simulated_today = get_prior_year_daily(simulated_today)
# Get prior year OTB at same lead time
prior_otb_result = 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
}
).fetchone()
# Use 'is not None' - 0 is valid OTB data
prior_otb = float(prior_otb_result.otb_value) if prior_otb_result and prior_otb_result.otb_value is not None else None
# Get prior year final actual
prior_final_result = 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}
).fetchone()
prior_final = float(prior_final_result.actual_value) if prior_final_result and prior_final_result.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:
# Additive method: current + expected pickup
prior_pickup = prior_final - prior_otb
projected_value = current_otb + prior_pickup
# Floor at current OTB
if projected_value < current_otb:
projected_value = current_otb
projection_method = 'additive_floor'
else:
projection_method = 'additive'
# Apply physical 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 method
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'
# 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
# Calculate error metrics
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": current_date,
"lead_time": lead_time,
"actual_lead_time": actual_lead_time,
"simulated_today": simulated_today,
"current_otb": current_otb,
"prior_otb": prior_otb,
"prior_final": prior_final,
"projected_value": round(projected_value, 2),
"actual_value": actual_value,
"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 in backtest_results table
try:
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": current_otb,
"prior_otb": prior_otb,
"prior_final": prior_final,
"projected_value": round(projected_value, 2),
"actual_value": actual_value,
"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 as e:
logger.warning(f"Could not store backtest result: {e}")
current_date += timedelta(days=1)
db.commit()
# Calculate summary statistics
summary = calculate_backtest_summary(results, lead_times)
logger.info(f"Backtest complete: {len(results)} forecasts evaluated")
return {
"metric_code": metric_code,
"backtest_from": str(backtest_from),
"backtest_to": str(backtest_to),
"lead_times": lead_times,
"total_forecasts": len(results),
"results": results,
"summary": summary
}
def calculate_backtest_summary(results: List[dict], lead_times: List[int]) -> Dict:
"""
Calculate summary accuracy metrics from backtest results.
"""
if not results:
return {}
summary = {
"overall": {},
"by_lead_time": {}
}
# Overall metrics
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), # Mean Absolute Error
"mape": round(sum(all_pct_errors) / len(all_pct_errors), 2) if all_pct_errors else None, # Mean Absolute Percentage Error
"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"][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 projection method
methods = set(r['projection_method'] for r in results)
summary["by_method"] = {}
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
async def get_backtest_results(
db,
metric_code: str,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
lead_time: Optional[int] = None
) -> List[dict]:
"""
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 = db.execute(text(query), params)
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 result.fetchall()
]

View file

@ -0,0 +1,159 @@
"""
Centralized Blended Forecasting Model Service
This is the single source of truth for blended forecasts.
Used by:
- Weekly snapshots (saves to DB)
- Frontend live previews (on-the-fly)
- External apps (reads saved snapshots)
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
from sqlalchemy import text
logger = logging.getLogger(__name__)
async def run_blended_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
save_to_db: bool = False,
run_id: Optional[str] = None
) -> List[Dict]:
"""
Generate blended forecast by running Prophet, XGBoost, CatBoost and blending with accuracy weights.
This is the centralized blended model used everywhere in the application.
Args:
db: Database session
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
start_date: Start date for forecast
end_date: End date for forecast
save_to_db: If True, saves forecasts to database (for snapshots)
run_id: Run ID for tracking (required if save_to_db=True)
Returns:
List of forecast dicts with date and predicted_value
"""
logger.info(f"Running blended forecast for {metric_code}: {start_date} to {end_date}")
# Use simple equal-weight averaging to match frontend behavior
# Frontend: (prophet + xgboost + catboost) / 3
logger.info(f"Using simple equal-weight averaging for {metric_code}")
# Step 1: Run individual models
forecasts_by_date = {}
# Run Prophet
try:
from services.forecasting.prophet_model import run_prophet_forecast
prophet_forecasts = await run_prophet_forecast(db, metric_code, start_date, end_date)
for fc in prophet_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
logger.info(f"Prophet generated {len(prophet_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"Prophet forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run XGBoost
try:
from services.forecasting.xgboost_model import run_xgboost_forecast
xgboost_forecasts = await run_xgboost_forecast(db, metric_code, start_date, end_date)
for fc in xgboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
logger.info(f"XGBoost generated {len(xgboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"XGBoost forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run CatBoost
try:
from services.forecasting.catboost_model import run_catboost_forecast
catboost_forecasts = await run_catboost_forecast(db, metric_code, start_date, end_date)
for fc in catboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
logger.info(f"CatBoost generated {len(catboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"CatBoost forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run Pickup-V2 for revenue metrics (net_accom, hotel_accommodation_rev)
if metric_code in ('net_accom', 'hotel_accommodation_rev'):
try:
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
pickup_v2_forecasts = await run_pickup_v2_forecast(db, 'net_accom', start_date, end_date)
for fc in pickup_v2_forecasts:
fc_date = str(fc['date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['pickup_v2'] = float(fc['predicted_value'])
logger.info(f"Pickup-V2 generated {len(pickup_v2_forecasts)} forecasts for {metric_code}")
db.commit()
except Exception as e:
logger.error(f"Pickup-V2 forecast failed for {metric_code}: {e}")
db.rollback()
db.commit()
# Step 3: Calculate simple average blended forecast
# Match frontend logic: (prophet + xgboost + catboost) / 3
blended_forecasts = []
for fc_date, model_forecasts in forecasts_by_date.items():
# Need at least 2 models to blend
if len(model_forecasts) < 2:
continue
# Simple average of all available models
blended_value = sum(model_forecasts.values()) / len(model_forecasts)
blended_forecasts.append({
'date': fc_date,
'predicted_value': round(blended_value, 2)
})
logger.info(f"Generated {len(blended_forecasts)} blended forecasts for {metric_code}")
# Step 4: Optionally save to database (for snapshots)
if save_to_db and run_id:
try:
for fc in blended_forecasts:
db.execute(
text("""
INSERT INTO forecasts
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
VALUES
(:run_id, :forecast_date, :forecast_type, 'blended', :predicted_value, NOW())
"""),
{
"run_id": run_id,
"forecast_date": fc['date'],
"forecast_type": metric_code,
"predicted_value": fc['predicted_value']
}
)
db.commit()
logger.info(f"Saved {len(blended_forecasts)} blended forecasts to database")
except Exception as e:
logger.error(f"Failed to save blended forecasts to database: {e}")
db.rollback()
raise
return blended_forecasts

View file

@ -0,0 +1,164 @@
"""
Blended Tuned Model Service
This blends the production-tuned models using the exact same logic as the frontend.
Ensures backend snapshots match frontend preview values.
Blending Logic:
- Pace metrics (rooms/occupancy): Prophet + XGBoost + CatBoost + Pickup (25% each)
- Other metrics: Prophet + XGBoost + CatBoost (33.3% each)
This is the single source of truth for blended forecast snapshots.
"""
import logging
from datetime import date
from typing import List, Dict, Optional
from sqlalchemy import text
logger = logging.getLogger(__name__)
async def run_blended_tuned_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
save_to_db: bool = False,
run_id: Optional[str] = None
) -> List[Dict]:
"""
Generate blended forecast using production-tuned models.
This uses the exact same logic as the frontend Live Blended view to ensure
backend snapshots match frontend preview values.
Args:
db: Database session
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
start_date: Start date for forecast
end_date: End date for forecast
save_to_db: If True, saves forecasts to database (for snapshots)
run_id: Run ID for tracking (required if save_to_db=True)
Returns:
List of forecast dicts with date and predicted_value
"""
logger.info(f"Running blended tuned forecast for {metric_code}: {start_date} to {end_date}")
# Check if metric is a pace metric (uses pickup)
is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights')
# Step 1: Run individual tuned models
forecasts_by_date = {}
# Run Prophet Tuned
try:
from services.forecasting.prophet_tuned import run_prophet_tuned_forecast
prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date)
for fc in prophet_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run XGBoost Tuned
try:
from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast
xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date)
for fc in xgboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run CatBoost Tuned
try:
from services.forecasting.catboost_tuned import run_catboost_tuned_forecast
catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date)
for fc in catboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run Pickup Tuned (only for pace metrics)
if is_pace_metric:
try:
from services.forecasting.pickup_tuned import run_pickup_tuned_forecast
pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date)
for fc in pickup_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value'])
logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Step 2: Calculate simple average blended forecast
# Match frontend logic:
# - Pace metrics: (prophet + xgboost + catboost + pickup) / 4
# - Other metrics: (prophet + xgboost + catboost) / 3
blended_forecasts = []
for fc_date, model_forecasts in forecasts_by_date.items():
# Need at least 2 models to blend
if len(model_forecasts) < 2:
continue
# Simple average of all available models
blended_value = sum(model_forecasts.values()) / len(model_forecasts)
blended_forecasts.append({
'date': fc_date,
'predicted_value': round(blended_value, 2)
})
logger.info(f"Generated {len(blended_forecasts)} blended tuned forecasts for {metric_code}")
# Step 3: Optionally save to database (for snapshots)
if save_to_db and run_id:
try:
for fc in blended_forecasts:
db.execute(
text("""
INSERT INTO forecasts
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
VALUES
(:run_id, :forecast_date, :forecast_type, 'blended_tuned', :predicted_value, NOW())
"""),
{
"run_id": run_id,
"forecast_date": fc['date'],
"forecast_type": metric_code,
"predicted_value": fc['predicted_value']
}
)
db.commit()
logger.info(f"Saved {len(blended_forecasts)} blended tuned forecasts to database")
except Exception as e:
logger.error(f"Failed to save blended tuned forecasts to database: {e}")
db.rollback()
raise
return blended_forecasts

View file

@ -0,0 +1,336 @@
"""
Blended Tuned Model Service (Accuracy-Weighted + 60/40 Prior Year/Budget)
Two-stage blending:
1. MAPE-weighted model blend using backtest accuracy data
2. 60/40 blend with prior year actual or budget
Stage 1 - Model Weighting (MAPE-based):
- Query forecast_snapshots table for backtest MAPE scores
- Calculate inverse-MAPE weights (lower MAPE = higher weight)
- Pace metrics: weighted blend of Prophet + XGBoost + CatBoost + Pickup
- Other metrics: weighted blend of Prophet + XGBoost + CatBoost
Stage 2 - 60/40 Blend:
- Revenue metrics: 60% weighted model blend + 40% budget
- Non-revenue metrics: 60% weighted model blend + 40% prior year actual
Falls back to 100% model blend if prior year/budget data unavailable.
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
from sqlalchemy import text
logger = logging.getLogger(__name__)
async def get_model_weights(db, metric_code: str, is_pace_metric: bool) -> Dict[str, float]:
"""
Calculate accuracy-based weights for each model using MAPE scores from backtest data.
Args:
db: Database session
metric_code: Metric to forecast
is_pace_metric: Whether this is a pace metric (uses pickup)
Returns:
Dict of model names to weights (normalized to sum to 1.0)
"""
# Map metric codes to forecast_snapshots metric codes
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
'hotel_arr': 'arr',
'ave_guest_rate': 'ave_guest_rate',
'net_accom': 'net_accom',
'net_dry': 'net_dry',
'net_wet': 'net_wet',
'total_rev': 'total_rev',
}
snapshot_metric = metric_map.get(metric_code, 'rooms')
try:
# Query MAPE from forecast_snapshots where we have actuals
# Calculate MAPE for each model separately
models_to_query = ['prophet', 'xgboost', 'catboost']
if is_pace_metric:
models_to_query.append('pickup')
mape_scores = {}
for model in models_to_query:
query = text("""
SELECT AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND actual_value != 0
AND forecast_value IS NOT NULL
AND metric_code = :metric_code
AND model = :model
""")
result = await db.execute(query, {"metric_code": snapshot_metric, "model": model})
row = result.fetchone()
if row and row.mape is not None:
mape_scores[model] = float(row.mape)
else:
mape_scores[model] = 100 # Default high MAPE if no data
# Check if we have valid MAPE data
if all(score == 100 for score in mape_scores.values()):
logger.warning(f"No MAPE data found for {snapshot_metric}, using equal weights")
# Fall back to equal weights
if is_pace_metric:
return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25}
else:
return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334}
logger.info(f"MAPE scores for {snapshot_metric}: " +
", ".join([f"{k}={v:.2f}%" for k, v in mape_scores.items()]))
# Calculate inverse-MAPE weights (lower MAPE = higher weight)
weights = {model: 1.0 / max(mape, 0.1) for model, mape in mape_scores.items()}
# Normalize weights to sum to 1.0
weight_sum = sum(weights.values())
normalized_weights = {k: v / weight_sum for k, v in weights.items()}
logger.info(f"Normalized weights for {snapshot_metric}: " +
", ".join([f"{k}={v:.4f}" for k, v in normalized_weights.items()]))
return normalized_weights
except Exception as e:
logger.error(f"Failed to calculate model weights: {e}")
import traceback
traceback.print_exc()
# Fall back to equal weights
if is_pace_metric:
return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25}
else:
return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334}
async def run_blended_tuned_weighted_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
save_to_db: bool = False,
run_id: Optional[str] = None,
perception_date: Optional[date] = None,
apply_60_40_blend: bool = True
) -> List[Dict]:
"""
Generate blended forecast using production-tuned models with accuracy-based weighting.
This uses MAPE scores from the last 90 days to weight models by accuracy.
Lower MAPE (more accurate) models receive higher weights.
Args:
db: Database session
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
start_date: Start date for forecast
end_date: End date for forecast
save_to_db: If True, saves forecasts to database (for snapshots)
run_id: Run ID for tracking (required if save_to_db=True)
perception_date: Optional date to generate forecast as-of (for backtesting)
apply_60_40_blend: If True, applies 60/40 blend with budget/prior year (default True)
Returns:
List of forecast dicts with date and predicted_value
"""
logger.info(f"Running blended tuned WEIGHTED forecast for {metric_code}: {start_date} to {end_date}")
# Check if metric is a pace metric (uses pickup)
is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights')
# Get accuracy-based weights
weights = await get_model_weights(db, metric_code, is_pace_metric)
# Step 1: Run individual tuned models
forecasts_by_date = {}
# Run Prophet Tuned
try:
from services.forecasting.prophet_tuned import run_prophet_tuned_forecast
prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
for fc in prophet_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run XGBoost Tuned
try:
from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast
xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
for fc in xgboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run CatBoost Tuned
try:
from services.forecasting.catboost_tuned import run_catboost_tuned_forecast
catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
for fc in catboost_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Run Pickup Tuned (only for pace metrics)
if is_pace_metric:
try:
from services.forecasting.pickup_tuned import run_pickup_tuned_forecast
pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
for fc in pickup_forecasts:
fc_date = str(fc['forecast_date'])
if fc_date not in forecasts_by_date:
forecasts_by_date[fc_date] = {}
forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value'])
logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}")
db.commit() # Commit after successful model run
except Exception as e:
logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}")
db.rollback()
db.commit() # Start fresh transaction
# Step 2: Calculate MAPE-weighted model blend, then apply 60/40 with prior year/budget
# Determine if this is a revenue metric (uses budget instead of prior year)
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev', 'hotel_arr']
is_revenue_metric = metric_code in revenue_metrics
blended_forecasts = []
for fc_date, model_forecasts in forecasts_by_date.items():
# Need at least 2 models to blend
if len(model_forecasts) < 2:
continue
# Apply accuracy-based weights to get weighted model blend
weighted_sum = 0.0
weight_total = 0.0
for model_name, forecast_value in model_forecasts.items():
if model_name in weights:
weighted_sum += forecast_value * weights[model_name]
weight_total += weights[model_name]
# Calculate weighted model average
if weight_total > 0:
weighted_model_blend = weighted_sum / weight_total
else:
# Fall back to simple average if weights are missing
weighted_model_blend = sum(model_forecasts.values()) / len(model_forecasts)
# Apply 60/40 blend with prior year or budget (if enabled)
final_value = weighted_model_blend # Default: use model blend only
if apply_60_40_blend:
try:
forecast_date_obj = date.fromisoformat(fc_date)
if is_revenue_metric:
# Revenue metrics: 60% model + 40% budget
budget_query = text("""
SELECT budget_value
FROM daily_budgets
WHERE date = :fc_date AND budget_type = :metric_code
""")
budget_result = await db.execute(budget_query, {
"fc_date": forecast_date_obj,
"metric_code": metric_code
})
budget_row = budget_result.fetchone()
if budget_row and budget_row.budget_value is not None:
budget_value = float(budget_row.budget_value)
final_value = 0.6 * weighted_model_blend + 0.4 * budget_value
logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, Budget={budget_value:.2f}, Final={final_value:.2f}")
else:
# Non-revenue metrics: 60% model + 40% prior year
# Map metric codes for daily_metrics table
daily_metric_map = {
'hotel_occupancy_pct': 'hotel_occupancy_pct',
'hotel_room_nights': 'hotel_room_nights',
'hotel_guests': 'hotel_guests',
'ave_guest_rate': 'ave_guest_rate',
}
daily_metric_code = daily_metric_map.get(metric_code, metric_code)
# Get prior year date (same day of week, ~52 weeks back)
from utils.time_alignment import get_prior_year_daily
prior_year_date = get_prior_year_daily(forecast_date_obj)
prior_query = text("""
SELECT actual_value
FROM daily_metrics
WHERE date = :prior_date AND metric_code = :metric_code
""")
prior_result = await db.execute(prior_query, {
"prior_date": prior_year_date,
"metric_code": daily_metric_code
})
prior_row = prior_result.fetchone()
if prior_row and prior_row.actual_value is not None:
prior_value = float(prior_row.actual_value)
final_value = 0.6 * weighted_model_blend + 0.4 * prior_value
logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, PriorYear={prior_value:.2f}, Final={final_value:.2f}")
except Exception as e:
logger.warning(f"Could not apply 60/40 blend for {fc_date}: {e}, using model blend only")
final_value = weighted_model_blend
blended_forecasts.append({
'date': fc_date,
'predicted_value': round(final_value, 2)
})
logger.info(f"Generated {len(blended_forecasts)} blended tuned WEIGHTED forecasts for {metric_code}")
# Step 3: Optionally save to database (for snapshots)
if save_to_db and run_id:
try:
for fc in blended_forecasts:
db.execute(
text("""
INSERT INTO forecasts
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
VALUES
(:run_id, :forecast_date, :forecast_type, 'blended_tuned_weighted', :predicted_value, NOW())
"""),
{
"run_id": run_id,
"forecast_date": fc['date'],
"forecast_type": metric_code,
"predicted_value": fc['predicted_value']
}
)
db.commit()
logger.info(f"Saved {len(blended_forecasts)} blended tuned weighted forecasts to database")
except Exception as e:
logger.error(f"Failed to save blended tuned weighted forecasts to database: {e}")
db.rollback()
raise
return blended_forecasts

View file

@ -0,0 +1,254 @@
"""
Budget distribution service
Distributes monthly budgets to daily values using prior year patterns
"""
import logging
from datetime import date, timedelta
from calendar import monthrange
from typing import Optional, Dict, List
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Map budget_type to column name in newbook_net_revenue_data
BUDGET_TYPE_TO_COLUMN = {
'net_accom': 'accommodation',
'net_dry': 'dry',
'net_wet': 'wet',
}
async def distribute_budget(
db,
year: int,
month: int,
budget_type: Optional[str] = None
) -> dict:
"""
Distribute monthly budget to daily values using DOW-aligned prior year patterns.
Uses 364-day offset (52 weeks) so that days of week align between years.
This ensures weekday/weekend patterns are preserved in the distribution.
Logic:
1. Get monthly budget from FD-provided values
2. For each day in target month, find DOW-aligned date 364 days prior
3. Get prior year actual values for those DOW-aligned dates
4. Calculate percentages and distribute budget accordingly
Example:
- Feb 1, 2026 (Sunday) - 364 days = Feb 2, 2025 (Sunday)
- If Feb 2, 2025 was 4% of the DOW-aligned period total
- Feb 1, 2026 daily budget = monthly_budget × 4%
Args:
db: Database session
year: Year to distribute for
month: Month to distribute (1-12)
budget_type: Optional specific budget type, or all if None
Returns:
Dict with distribution results
"""
logger.info(f"Distributing budget for {year}-{month:02d} using DOW-aligned prior year patterns")
# Get monthly budgets
query = """
SELECT id, budget_type, budget_value
FROM monthly_budgets
WHERE year = :year AND month = :month
"""
params = {"year": year, "month": month}
if budget_type:
query += " AND budget_type = :budget_type"
params["budget_type"] = budget_type
result = await db.execute(text(query), params)
monthly_budgets = result.fetchall()
if not monthly_budgets:
logger.warning(f"No monthly budgets found for {year}-{month:02d}")
return {"days_distributed": 0, "status": "no_budgets_found"}
# Get days in target month
_, days_in_month = monthrange(year, month)
days_distributed = 0
for budget in monthly_budgets:
budget_id = budget.id
btype = budget.budget_type
monthly_value = float(budget.budget_value)
# Get column name for this budget type
column_name = BUDGET_TYPE_TO_COLUMN.get(btype)
if not column_name:
logger.warning(f"Unknown budget type: {btype}, skipping")
continue
logger.info(f"Distributing {btype}: £{monthly_value:,.2f}")
# Build list of target dates and their DOW-aligned prior year dates
target_dates = []
prior_dates = []
for day in range(1, days_in_month + 1):
target_date = date(year, month, day)
prior_date = target_date - timedelta(days=364) # 52 weeks back, DOW aligned
target_dates.append(target_date)
prior_dates.append(prior_date)
# Get prior year actual values for the DOW-aligned dates from newbook_net_revenue_data
prior_result = await db.execute(
text(f"""
SELECT date, {column_name} as value
FROM newbook_net_revenue_data
WHERE date = ANY(:dates)
AND {column_name} IS NOT NULL
ORDER BY date
"""),
{"dates": prior_dates}
)
prior_rows = prior_result.fetchall()
prior_values = {row.date: float(row.value) for row in prior_rows}
# Calculate total of prior year values for percentage calculation
total_prior = sum(prior_values.get(d, 0) for d in prior_dates)
logger.info(f"Prior year DOW-aligned total for {btype}: £{total_prior:,.2f} ({len(prior_values)} days with data)")
if total_prior > 0:
# Distribute using DOW-aligned prior year percentages
for target_date, prior_date in zip(target_dates, prior_dates):
prior_value = prior_values.get(prior_date, 0)
pct_of_total = prior_value / total_prior if total_prior > 0 else (1 / days_in_month)
daily_budget = monthly_value * pct_of_total
await db.execute(
text("""
INSERT INTO daily_budgets (
date, budget_type, budget_value, distribution_method,
prior_year_pct, monthly_budget_id, calculated_at
) VALUES (
:date, :btype, :value, 'dow_aligned',
:pct, :budget_id, NOW()
)
ON CONFLICT (date, budget_type) DO UPDATE SET
budget_value = :value,
distribution_method = 'dow_aligned',
prior_year_pct = :pct,
calculated_at = NOW()
"""),
{
"date": target_date,
"btype": btype,
"value": round(daily_budget, 2),
"pct": round(pct_of_total, 6),
"budget_id": budget_id
}
)
days_distributed += 1
logger.info(f"Distributed {btype} using DOW-aligned prior year patterns")
else:
# No prior year data - distribute evenly
logger.warning(f"No prior year DOW-aligned data for {btype}, using even distribution")
daily_budget = monthly_value / days_in_month
for target_date in target_dates:
await db.execute(
text("""
INSERT INTO daily_budgets (
date, budget_type, budget_value, distribution_method,
prior_year_pct, monthly_budget_id, calculated_at
) VALUES (
:date, :btype, :value, 'even',
:pct, :budget_id, NOW()
)
ON CONFLICT (date, budget_type) DO UPDATE SET
budget_value = :value,
distribution_method = 'even',
prior_year_pct = :pct,
calculated_at = NOW()
"""),
{
"date": target_date,
"btype": btype,
"value": round(daily_budget, 2),
"pct": round(1 / days_in_month, 6),
"budget_id": budget_id
}
)
days_distributed += 1
await db.commit()
logger.info(f"Budget distribution complete: {days_distributed} days")
return {"days_distributed": days_distributed, "status": "success"}
async def calculate_prior_year_percentages(db, metric_type: str):
"""
Calculate and store prior year daily percentages for budget distribution
For each month, calculates what percentage each day was of the monthly total
"""
logger.info(f"Calculating prior year percentages for {metric_type}")
# Get all daily values from prior year
result = await db.execute(
text("""
WITH monthly_totals AS (
SELECT
EXTRACT(YEAR FROM date) as year,
EXTRACT(MONTH FROM date) as month,
SUM(actual_value) as month_total
FROM daily_metrics
WHERE metric_code = :metric_type
AND date >= CURRENT_DATE - INTERVAL '2 years'
AND actual_value IS NOT NULL
GROUP BY EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date)
)
SELECT
dm.date,
dm.actual_value,
mt.month_total,
dm.actual_value / NULLIF(mt.month_total, 0) as pct_of_month
FROM daily_metrics dm
JOIN monthly_totals mt ON
EXTRACT(YEAR FROM dm.date) = mt.year AND
EXTRACT(MONTH FROM dm.date) = mt.month
WHERE dm.metric_code = :metric_type
AND dm.actual_value IS NOT NULL
ORDER BY dm.date
"""),
{"metric_type": metric_type}
)
count = 0
for row in result.fetchall():
await db.execute(
text("""
INSERT INTO prior_year_daily (
date, metric_type, actual_value, month_total, pct_of_month, fetched_at
) VALUES (
:date, :metric, :actual, :month_total, :pct, NOW()
)
ON CONFLICT (date, metric_type) DO UPDATE SET
actual_value = :actual,
month_total = :month_total,
pct_of_month = :pct,
fetched_at = NOW()
"""),
{
"date": row.date,
"metric": metric_type,
"actual": row.actual_value,
"month_total": row.month_total,
"pct": row.pct_of_month
}
)
count += 1
await db.commit()
logger.info(f"Prior year percentages calculated: {count} records for {metric_type}")
return count

View file

@ -0,0 +1,450 @@
"""
CatBoost forecasting model
Gradient boosting with native categorical feature support and better out-of-box performance.
Similar to XGBoost but handles categorical features natively without encoding.
"""
import logging
from datetime import date, timedelta
from typing import List, Optional
import pandas as pd
import numpy as np
import json
from sqlalchemy import text
logger = logging.getLogger(__name__)
def create_features(df: pd.DataFrame, special_dates: set = None) -> pd.DataFrame:
"""
Create features for CatBoost model.
CatBoost handles categorical features natively, so we keep day_of_week as categorical
instead of using cyclical encoding.
"""
df = df.copy()
# Date features - keep as categorical for CatBoost
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical
df['month'] = df['ds'].dt.month.astype(str) # Categorical
df['day_of_month'] = df['ds'].dt.day
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
# Special dates / holidays
if special_dates and len(special_dates) > 0:
df['is_holiday'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_dates else 0)
# Days to nearest special date
def days_to_nearest(d):
if not special_dates:
return 30
future = [s for s in special_dates if s >= d]
if not future:
return 30
return min((s - d).days for s in future)
df['days_to_holiday'] = df['ds'].dt.date.apply(days_to_nearest)
else:
df['is_holiday'] = 0
df['days_to_holiday'] = 30
# Lag features
for lag in [7, 14, 21, 28]:
df[f'lag_{lag}'] = df['y'].shift(lag)
# Rolling averages
for window in [7, 14, 28]:
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0)
# Year-over-year feature (364 days for DOW alignment)
if len(df) > 364:
df['lag_364'] = df['y'].shift(364)
return df
async def run_catboost_forecast(
db,
metric_code: str,
forecast_from: date,
forecast_to: date,
training_days: int = 2555, # ~7 years
use_special_dates: bool = True,
use_otb_data: bool = True
) -> List[dict]:
"""
Run CatBoost forecast for a metric.
Args:
db: Database session
metric_code: Metric to forecast
forecast_from: Start date for forecasts
forecast_to: End date for forecasts
training_days: Days of historical data to use
use_special_dates: Include holiday features
use_otb_data: Include OTB pickup features
Returns:
List of forecast records
"""
try:
from catboost import CatBoostRegressor
# Get historical data
training_from = forecast_from - timedelta(days=training_days + 400) # Extra for lag features
# Revenue metrics use earned_revenue_data joined with gl_accounts
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
if metric_code in revenue_metrics:
revenue_departments = {
'net_accom': 'accommodation',
'net_dry': 'dry',
'net_wet': 'wet',
'total_rev': None, # All departments
}
department = revenue_departments.get(metric_code)
if department is None and metric_code != 'total_rev':
logger.warning(f"Unknown revenue metric for CatBoost: {metric_code}")
return []
if metric_code == 'total_rev':
# Total revenue across all departments
result = db.execute(
text("""
SELECT date, SUM(amount_net) as actual_value
FROM newbook_earned_revenue_data
WHERE date BETWEEN :from_date AND :to_date
GROUP BY date
HAVING SUM(amount_net) IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
else:
# Revenue by department
result = db.execute(
text("""
SELECT r.date, SUM(r.amount_net) as actual_value
FROM newbook_earned_revenue_data r
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
WHERE r.date BETWEEN :from_date AND :to_date
AND g.department = :department
GROUP BY r.date
HAVING SUM(r.amount_net) IS NOT NULL
ORDER BY r.date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
)
else:
# Hotel metrics use newbook_bookings_stats table
metric_column_map = {
'hotel_occupancy_pct': 'total_occupancy_pct',
'hotel_room_nights': 'booking_count',
'hotel_guests': 'guests_count',
}
column_name = metric_column_map.get(metric_code)
if not column_name:
logger.warning(f"Unknown metric_code for CatBoost: {metric_code}")
return []
result = db.execute(
text(f"""
SELECT date, {column_name} as actual_value
FROM newbook_bookings_stats
WHERE date BETWEEN :from_date AND :to_date
AND {column_name} IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
rows = result.fetchall()
if len(rows) < 60:
logger.warning(f"Insufficient data for CatBoost: {metric_code} has {len(rows)} records")
return []
# Prepare DataFrame
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
df = df.sort_values('ds').reset_index(drop=True)
# Load special dates if enabled
special_dates = None
if use_special_dates:
special_dates = await _load_special_dates(db, forecast_from, forecast_to)
# Create features
df = create_features(df, special_dates)
# Add OTB features if enabled
if use_otb_data:
otb_df = _load_otb_data(db, training_from, forecast_from)
if otb_df is not None and len(otb_df) > 0:
df = _add_otb_features(df, otb_df)
logger.info("Added OTB features to CatBoost training data")
# Remove rows with NaN from lag features
df = df.dropna(subset=['lag_7', 'lag_14', 'lag_21', 'lag_28'])
# Define feature columns
categorical_features = ['day_of_week', 'month']
numerical_features = [
'day_of_month', 'week_of_year', 'is_weekend',
'is_holiday', 'days_to_holiday',
'lag_7', 'lag_14', 'lag_21', 'lag_28',
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
]
# Add lag_364 if available
if 'lag_364' in df.columns and df['lag_364'].notna().sum() > 30:
numerical_features.append('lag_364')
# Add OTB features if present
otb_cols = ['otb_at_30d', 'otb_at_14d', 'otb_at_7d',
'pickup_30d_to_14d', 'pickup_14d_to_7d',
'otb_pct_at_30d', 'otb_pct_at_14d', 'otb_pct_at_7d']
for col in otb_cols:
if col in df.columns:
numerical_features.append(col)
feature_cols = categorical_features + numerical_features
X = df[feature_cols].copy()
y = df['y']
# Train CatBoost model
model = CatBoostRegressor(
iterations=200,
depth=6,
learning_rate=0.1,
loss_function='RMSE',
cat_features=categorical_features,
verbose=False,
random_seed=42
)
model.fit(X, y)
# Generate forecasts
forecasts = []
current_df = df.copy()
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
# Create row for forecast date
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
current_df = pd.concat([current_df, new_row], ignore_index=True)
current_df = create_features(current_df, special_dates)
# Add OTB features for future dates if available
if use_otb_data:
current_df = _add_otb_features(current_df, otb_df)
# Get features for prediction
X_pred = current_df[feature_cols].iloc[-1:].copy()
# Forward fill any NaN values
for col in numerical_features:
if col in X_pred.columns:
X_pred[col] = X_pred[col].ffill()
if X_pred[col].isna().any():
X_pred[col] = X_pred[col].fillna(0)
# Make prediction
prediction = float(model.predict(X_pred)[0])
# Ensure non-negative
prediction = max(0, prediction)
# Update y value for lag features
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
forecast_record = {
"forecast_date": forecast_date.date(),
"forecast_type": metric_code,
"model_type": "catboost",
"predicted_value": round(float(prediction), 2)
}
forecasts.append(forecast_record)
# Store in database
db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
)
"""),
forecast_record
)
db.commit()
# Calculate feature importance for explainability
try:
feature_importance = dict(zip(feature_cols, model.feature_importances_.tolist()))
top_features = sorted(
[{"feature": k, "importance": v} for k, v in feature_importance.items()],
key=lambda x: x["importance"], reverse=True
)[:10]
logger.info(f"CatBoost top features: {[f['feature'] for f in top_features[:5]]}")
except Exception as e:
logger.warning(f"Feature importance calculation failed: {e}")
logger.info(f"CatBoost forecast generated for {metric_code}: {len(forecasts)} records")
return forecasts
except ImportError as e:
logger.error(f"CatBoost not installed: {e}")
return []
except Exception as e:
logger.error(f"CatBoost forecast failed for {metric_code}: {e}")
import traceback
logger.error(traceback.format_exc())
return []
async def _load_special_dates(db, from_date: date, to_date: date) -> set:
"""Load special dates from system_config."""
try:
result = db.execute(text("""
SELECT config_value FROM system_config
WHERE config_key = 'special_dates'
"""))
row = result.fetchone()
if not row or not row.config_value:
return set()
dates_json = json.loads(row.config_value)
special_dates = set()
for item in dates_json:
if isinstance(item, dict) and 'date' in item:
try:
d = pd.to_datetime(item['date']).date()
special_dates.add(d)
except:
pass
logger.info(f"Loaded {len(special_dates)} special dates for CatBoost")
return special_dates
except Exception as e:
logger.warning(f"Failed to load special dates: {e}")
return set()
def _load_otb_data(db, from_date: date, to_date: date) -> Optional[pd.DataFrame]:
"""Load OTB (On-The-Books) data."""
try:
result = db.execute(text("""
SELECT
arrival_date,
d93 as otb_at_90d,
d65 as otb_at_60d,
d30 as otb_at_30d,
d14 as otb_at_14d,
d7 as otb_at_7d,
d0 as final_bookings
FROM newbook_booking_pace
WHERE arrival_date BETWEEN :from_date AND :to_date
ORDER BY arrival_date
"""), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
if not rows:
return None
df = pd.DataFrame([{
"arrival_date": row.arrival_date,
"otb_at_90d": float(row.otb_at_90d) if row.otb_at_90d else 0,
"otb_at_60d": float(row.otb_at_60d) if row.otb_at_60d else 0,
"otb_at_30d": float(row.otb_at_30d) if row.otb_at_30d else 0,
"otb_at_14d": float(row.otb_at_14d) if row.otb_at_14d else 0,
"otb_at_7d": float(row.otb_at_7d) if row.otb_at_7d else 0,
"final_bookings": float(row.final_bookings) if row.final_bookings else 0
} for row in rows])
return df
except Exception as e:
logger.warning(f"Failed to load OTB data: {e}")
return None
def _add_otb_features(df: pd.DataFrame, otb_df: Optional[pd.DataFrame]) -> pd.DataFrame:
"""Add OTB features to DataFrame."""
df = df.copy()
if otb_df is None or len(otb_df) == 0:
# No OTB data - set defaults
df['otb_at_30d'] = 0
df['otb_at_14d'] = 0
df['otb_at_7d'] = 0
df['pickup_30d_to_14d'] = 0
df['pickup_14d_to_7d'] = 0
df['otb_pct_at_30d'] = 0
df['otb_pct_at_14d'] = 0
df['otb_pct_at_7d'] = 0
return df
# Create date column for merging
df['date_only'] = df['ds'].dt.date
# Merge OTB data
otb_df = otb_df.copy()
otb_df['date_only'] = pd.to_datetime(otb_df['arrival_date']).dt.date
# Check if columns already exist (avoid duplicates)
merge_cols = ['date_only']
for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']:
if col not in df.columns:
merge_cols.append(col)
if len(merge_cols) > 1:
df = df.merge(
otb_df[merge_cols],
on='date_only',
how='left'
)
# Fill NaN with 0
for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']:
if col in df.columns:
df[col] = df[col].fillna(0)
# Calculate pickup between windows
if 'pickup_30d_to_14d' not in df.columns:
df['pickup_30d_to_14d'] = df['otb_at_14d'] - df['otb_at_30d']
if 'pickup_14d_to_7d' not in df.columns:
df['pickup_14d_to_7d'] = df['otb_at_7d'] - df['otb_at_14d']
# Calculate OTB as percentage of final (capped at 100%)
if 'otb_pct_at_30d' not in df.columns:
df['otb_pct_at_30d'] = np.where(
df['final_bookings'] > 0,
np.minimum(df['otb_at_30d'] / df['final_bookings'] * 100, 100),
0
)
if 'otb_pct_at_14d' not in df.columns:
df['otb_pct_at_14d'] = np.where(
df['final_bookings'] > 0,
np.minimum(df['otb_at_14d'] / df['final_bookings'] * 100, 100),
0
)
if 'otb_pct_at_7d' not in df.columns:
df['otb_pct_at_7d'] = np.where(
df['final_bookings'] > 0,
np.minimum(df['otb_at_7d'] / df['final_bookings'] * 100, 100),
0
)
# Drop temporary column
df = df.drop(columns=['date_only'], errors='ignore')
return df

View file

@ -0,0 +1,449 @@
"""
CatBoost Tuned Model Service
This is the production-tuned CatBoost model extracted from the catboost-preview endpoint.
Uses the exact same logic as the frontend preview to ensure value consistency.
Features:
- 2 years of training data
- Native categorical feature support (day_of_week, month)
- Pace features (OTB at different lead times) for room-based metrics
- Time-based features
- Lag features from prior year
- OTB floor capping
- Per-date bookable cap adjustments
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
import pandas as pd
import numpy as np
from catboost import CatBoostRegressor
import warnings
from sqlalchemy import text
from utils.capacity import get_bookable_cap
from api.special_dates import resolve_special_date
logger = logging.getLogger(__name__)
warnings.filterwarnings('ignore')
# Metric configuration mapping
METRIC_COLUMN_MAP = {
'occupancy': ('s.occupancy_pct', False, True),
'rooms': ('s.booking_count', False, False),
'guests': ('s.guest_count', False, False),
'ave_guest_rate': ('s.arr_net', False, False),
'arr': ('s.arr_net', False, False),
'net_accom': ('r.accommodation', True, False),
'net_dry': ('r.dry', True, False),
'net_wet': ('r.wet', True, False),
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
}
def get_metric_query_parts(metric: str) -> tuple:
"""Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)"""
if metric not in METRIC_COLUMN_MAP:
metric = 'rooms'
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
if needs_revenue:
from_clause = """
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
"""
else:
from_clause = "FROM newbook_bookings_stats s"
return col_expr, from_clause, is_pct
def get_lead_time_column(lead_days: int) -> str:
"""Map lead days to the appropriate column in newbook_booking_pace."""
if lead_days <= 0:
return "d0"
elif lead_days <= 30:
return f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
for col in weekly_cols:
if lead_days <= col:
return f"d{col}"
return "d177"
else:
monthly_cols = [210, 240, 270, 300, 330, 365]
for col in monthly_cols:
if lead_days <= col:
return f"d{col}"
return "d365"
def round_towards_reference(value: float, reference: Optional[float]) -> int:
"""Round a forecast value towards a reference value (prior year actual)."""
if reference is None:
return round(value)
if value < reference:
return int(np.ceil(value))
else:
return int(np.floor(value))
async def run_catboost_tuned_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
perception_date: Optional[date] = None
) -> List[Dict]:
"""
Generate CatBoost forecast using production-tuned model.
This uses the exact same logic as the catboost-preview endpoint to ensure
backend snapshots match frontend preview values.
Args:
db: Database session
metric_code: Metric to forecast
start_date: Start date for forecast
end_date: End date for forecast
perception_date: Optional date to generate forecast as-of (for backtesting)
Returns:
List of forecast dicts with forecast_date and predicted_value
"""
logger.info(f"Running CatBoost tuned forecast for {metric_code}: {start_date} to {end_date}")
# Map metric codes to preview endpoint metric names
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
'hotel_arr': 'arr',
'ave_guest_rate': 'ave_guest_rate',
'net_accom': 'net_accom',
'net_dry': 'net_dry',
'net_wet': 'net_wet',
'total_rev': 'total_rev',
}
metric = metric_map.get(metric_code, 'rooms')
# Use perception_date if provided, otherwise use actual today
today = perception_date if perception_date else date.today()
# Get default bookable cap
default_bookable_cap = await get_bookable_cap(db)
# Get metric column and query parts
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
is_room_based = metric in ('occupancy', 'rooms')
# Get historical data (2+ years for YoY features)
history_start = today - timedelta(days=730)
# Lead times to train on (only used for room-based metrics)
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
# Get final values (and pace data for room-based metrics)
if is_room_based:
history_result = await 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})
else:
# Non-room metrics: get values without pace join
history_query = f"""
SELECT s.date as ds, {col_expr} as final
{from_clause}
WHERE s.date >= :history_start
AND s.date < :today
AND {col_expr} IS NOT NULL
ORDER BY s.date
"""
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient historical data for CatBoost model: {len(history_rows)} rows")
return []
# Load special dates for feature
special_date_set = set()
try:
special_dates_result = await 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 as e:
logger.warning(f"Could not load special dates: {e}")
# Build lookup dicts
final_by_date = {}
pace_by_date = {}
for row in history_rows:
final_by_date[row.ds] = row.final
if is_room_based and hasattr(row, 'd0'):
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
training_rows = []
if is_room_based:
# Room-based metrics: use pace features (one per date,lead_time combo)
for row in history_rows:
ds = row.ds
final = float(row.final) if row.final else 0
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
})
else:
# Non-room metrics: use time features only (one per date)
for row in history_rows:
ds = row.ds
final = float(row.final) if row.final else 0
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
prior_final = 0 # Allow training even without prior year for revenue metrics
training_rows.append({
'ds': ds,
'y': final,
'lag_364': float(prior_final) if prior_final else 0
})
if len(training_rows) < 30:
logger.warning(f"Insufficient data for CatBoost training: {len(training_rows)} rows")
return []
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
# Convert to occupancy if needed
if metric == "occupancy" and default_bookable_cap > 0:
df["y"] = (df["y"] / default_bookable_cap) * 100
if "current_otb" in df.columns:
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
if "prior_otb_same_lead" in df.columns:
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
# Create features - CatBoost handles categoricals natively
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical
df['month'] = df['ds'].dt.month.astype(str) # Categorical
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:
logger.warning(f"Insufficient data after creating features: {len(df_train)} rows")
return []
# Define features based on metric type - categoricals handled natively by CatBoost
categorical_features = ['day_of_week', 'month']
if is_room_based:
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']
else:
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
feature_cols = categorical_features + numerical_features
X_train = df_train[feature_cols]
y_train = df_train['y']
# Train CatBoost model
model = CatBoostRegressor(
iterations=150,
depth=6,
learning_rate=0.1,
loss_function='RMSE',
cat_features=categorical_features,
verbose=False,
random_seed=42
)
model.fit(X_train, y_train)
# Create future dataframe for forecast period
future_dates = []
current_date = start_date
while current_date <= end_date:
if (current_date - today).days >= 0:
future_dates.append(current_date)
current_date += timedelta(days=1)
if not future_dates:
logger.warning("No future dates to forecast")
return []
# Generate forecasts for each date
forecasts = []
for forecast_date in future_dates:
lead_days = (forecast_date - today).days
lead_col = get_lead_time_column(lead_days)
prior_year_date = forecast_date - timedelta(days=364)
# Get OTB data only for room-based metrics
current_otb = None
if is_room_based:
current_query = text("""
SELECT booking_count as current_otb
FROM newbook_bookings_stats
WHERE date = :arrival_date
""")
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
current_row = current_result.fetchone()
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
# Get prior year final using metric mapping
prior_query = f"""
SELECT {col_expr} as prior_final
{from_clause}
WHERE s.date = :prior_date
"""
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
prior_row = prior_result.fetchone()
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
# Get per-date bookable cap
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
forecast_dt = pd.Timestamp(forecast_date)
lag_364_val = prior_final if prior_final else 0
# Convert to occupancy if needed
if metric == "occupancy" and date_bookable_cap > 0:
if current_otb is not None:
current_otb = (current_otb / date_bookable_cap) * 100
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
# Build features based on metric type
if is_room_based:
# Get prior OTB at same lead time
prior_year_for_otb = forecast_date - timedelta(days=364)
prior_otb_query = text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
""")
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
prior_otb_row = prior_otb_result.fetchone()
prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0
if metric == "occupancy" and date_bookable_cap > 0:
prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0
current_otb_val = current_otb if current_otb is not None else 0
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
features = pd.DataFrame([{
'day_of_week': str(forecast_dt.dayofweek), # Categorical
'month': str(forecast_dt.month), # Categorical
'week_of_year': forecast_dt.isocalendar().week,
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if forecast_date in special_date_set else 0,
'days_out': lead_days,
'current_otb': current_otb_val,
'prior_otb_same_lead': prior_otb_same_lead,
'lag_364': lag_364_val,
'otb_pct_of_prior_final': otb_pct_of_prior_final,
}])
else:
features = pd.DataFrame([{
'day_of_week': str(forecast_dt.dayofweek), # Categorical
'month': str(forecast_dt.month), # Categorical
'week_of_year': forecast_dt.isocalendar().week,
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
'is_special_date': 1 if forecast_date in special_date_set else 0,
'lag_364': lag_364_val,
}])
# Predict
yhat = float(model.predict(features)[0])
# Cap at max capacity based on metric type (uses per-date bookable cap)
if is_pct_metric:
yhat = min(max(yhat, 0), 100.0)
elif metric == 'rooms':
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
elif metric == 'guests':
yhat = round(max(yhat, 0))
else:
# Revenue/rate metrics: just ensure non-negative
yhat = max(yhat, 0)
# Floor forecast to current OTB (room-based only)
if is_room_based and current_otb is not None and yhat < current_otb:
yhat = current_otb
# Round based on metric type
if metric == "occupancy":
yhat = round(yhat, 1)
else:
yhat = round_towards_reference(yhat, prior_final)
forecasts.append({
'forecast_date': forecast_date,
'predicted_value': yhat
})
logger.info(f"CatBoost tuned generated {len(forecasts)} forecasts for {metric_code}")
return forecasts

View file

@ -0,0 +1,866 @@
"""
Restaurant Covers Forecast Model
Forecasts restaurant covers based on:
- Breakfast: Previous night's hotel occupancy (guests expected at breakfast)
- Lunch: OTB bookings + non-resident pickup based on lead time
- Dinner: OTB bookings split by hotel guest/non-resident + pickup for each segment
Key segments:
- Resident (hotel guest): Based on hotel occupancy, booking patterns, DBB packages
- Non-resident: Based on historical pickup patterns at lead time
"""
import logging
import math
from datetime import date, timedelta
from decimal import Decimal
from typing import Dict, List, Optional, Any
from collections import defaultdict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from services.forecasting.pickup_v2_model import forecast_rooms_for_date, get_prior_year_date as get_py_date
logger = logging.getLogger(__name__)
# Valid booking statuses for counting
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
async def get_hotel_bookings_with_dinner_reservation(
db: AsyncSession,
target_date: date
) -> Dict[str, int]:
"""
Get actual count of hotel bookings that have dinner reservations for a date.
Queries resos_bookings_data to find distinct hotel_booking_numbers
that have dinner reservations, then compares to total hotel bookings.
Returns:
{
"rooms_with_dinner": int, # Hotel bookings with dinner reservation
"total_hotel_rooms": int, # Total hotel bookings for this date
"rooms_without_dinner": int # Difference
}
"""
# Count distinct hotel bookings with dinner reservations
result = await db.execute(
text("""
SELECT COUNT(DISTINCT hotel_booking_number) as rooms_with_dinner
FROM resos_bookings_data
WHERE booking_date = :target_date
AND is_hotel_guest = true
AND period_type = 'dinner'
AND hotel_booking_number IS NOT NULL
AND hotel_booking_number != ''
AND status IN ('approved', 'arrived', 'seated', 'left')
"""),
{"target_date": target_date}
)
row = result.fetchone()
rooms_with_dinner = row.rooms_with_dinner if row else 0
# Get total hotel bookings from stats
result = await db.execute(
text("""
SELECT COALESCE(booking_count, 0) as total_rooms
FROM newbook_bookings_stats
WHERE date = :target_date
"""),
{"target_date": target_date}
)
row = result.fetchone()
total_hotel_rooms = row.total_rooms if row else 0
rooms_without_dinner = max(0, total_hotel_rooms - rooms_with_dinner)
return {
"rooms_with_dinner": rooms_with_dinner,
"total_hotel_rooms": total_hotel_rooms,
"rooms_without_dinner": rooms_without_dinner,
}
def get_prior_year_date(target_date: date) -> date:
"""
Get prior year date with 364-day offset for day-of-week alignment.
52 weeks = 364 days, so Monday aligns with Monday.
"""
return target_date - timedelta(days=364)
async def get_hotel_occupancy_for_date(db: AsyncSession, stay_date: date) -> Dict[str, Any]:
"""
Get hotel room occupancy for a specific date from aggregated stats.
Returns occupied rooms, total capacity, and occupancy percentage.
Uses newbook_bookings_stats which is pre-aggregated with is_included filtering.
"""
# Query from aggregated stats table - more reliable and already filtered
result = await db.execute(
text("""
SELECT
COALESCE(booking_count, 0) as room_count,
COALESCE(guests_count, 0) as guest_count,
COALESCE(bookable_count, 0) as total_rooms,
COALESCE(bookable_occupancy_pct, 0) as occupancy_pct
FROM newbook_bookings_stats
WHERE date = :stay_date
"""),
{"stay_date": stay_date}
)
row = result.fetchone()
if row:
return {
"occupied_rooms": row.room_count,
"total_rooms": row.total_rooms,
"occupancy_pct": round(float(row.occupancy_pct), 1) if row.occupancy_pct else 0,
"guests": row.guest_count
}
# No stats for this date - return empty
return {"occupied_rooms": 0, "total_rooms": 0, "occupancy_pct": 0, "guests": 0}
async def get_resos_covers_for_date(
db: AsyncSession,
target_date: date,
period_type: Optional[str] = None
) -> Dict[str, Any]:
"""
Get restaurant booking covers for a specific date from aggregated stats table.
Returns covers by period (breakfast, lunch, dinner, etc.)
"""
# Query from aggregated stats table - more efficient and reliable
result = await db.execute(
text("""
SELECT
COALESCE(breakfast_covers, 0) as breakfast_covers,
COALESCE(lunch_covers, 0) as lunch_covers,
COALESCE(afternoon_covers, 0) as afternoon_covers,
COALESCE(dinner_covers, 0) as dinner_covers,
COALESCE(other_covers, 0) as other_covers,
COALESCE(total_covers, 0) as total_covers,
COALESCE(hotel_guest_covers, 0) as hotel_guest_covers,
COALESCE(non_hotel_guest_covers, 0) as non_hotel_guest_covers,
COALESCE(dbb_covers, 0) as dbb_covers,
COALESCE(total_bookings, 0) as total_bookings
FROM resos_bookings_stats
WHERE date = :target_date
"""),
{"target_date": target_date}
)
row = result.fetchone()
if not row:
# No data for this date - return empty structure
return {
"breakfast": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
"lunch": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
"dinner": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
}
# Calculate resident/non-resident split proportionally for each period
# (stats table has overall split but not per-period, so we estimate based on ratio)
total = row.total_covers or 1 # Avoid division by zero
hotel_ratio = row.hotel_guest_covers / total if total > 0 else 0
non_hotel_ratio = row.non_hotel_guest_covers / total if total > 0 else 0
covers_by_period = {
"breakfast": {
"total_covers": row.breakfast_covers,
"booking_count": 0, # Not tracked per period in stats
"resident_covers": int(row.breakfast_covers * hotel_ratio),
"non_resident_covers": int(row.breakfast_covers * non_hotel_ratio),
"dbb_covers": 0
},
"lunch": {
"total_covers": row.lunch_covers,
"booking_count": 0,
"resident_covers": int(row.lunch_covers * hotel_ratio),
"non_resident_covers": int(row.lunch_covers * non_hotel_ratio),
"dbb_covers": 0
},
"dinner": {
"total_covers": row.dinner_covers,
"booking_count": 0,
"resident_covers": int(row.dinner_covers * hotel_ratio),
"non_resident_covers": int(row.dinner_covers * non_hotel_ratio),
"dbb_covers": row.dbb_covers
},
}
return covers_by_period
async def get_historical_breakfast_rate(db: AsyncSession, lookback_days: int = 90) -> float:
"""
Calculate historical breakfast attendance rate as covers per occupied room.
Uses past data to determine typical breakfast covers per hotel room.
Uses aggregated stats tables for reliability.
"""
# Join resos stats with newbook stats to get breakfast covers and occupancy
result = await db.execute(
text("""
SELECT
SUM(rbs.breakfast_covers) as total_breakfast,
SUM(nbs.booking_count) as total_room_nights
FROM resos_bookings_stats rbs
JOIN newbook_bookings_stats nbs ON rbs.date = nbs.date
WHERE rbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND rbs.date < CURRENT_DATE
AND rbs.breakfast_covers > 0
AND nbs.booking_count > 0
"""),
{"lookback_days": lookback_days}
)
row = result.fetchone()
if row and row.total_room_nights and row.total_room_nights > 0:
# Calculate covers per room night
rate = float(row.total_breakfast) / float(row.total_room_nights)
return rate
# Default: assume 1.8 covers per room (average party size for breakfast)
return 1.8
async def get_lunch_pickup_by_lead_time(
db: AsyncSession,
target_date: date,
lead_days: int,
lookback_weeks: int = 8
) -> int:
"""
Get the median pickup COUNT for lunch at a given lead time for the same DOW.
Pickup = final_covers - otb_at_lead
This tells us how many covers typically come in AFTER this lead time.
More stable than ratio-based approach because it doesn't inflate
when current OTB is higher than historical OTB.
Args:
db: Database session
target_date: Date we're forecasting (to get DOW)
lead_days: Days until the target date
lookback_weeks: Weeks of history to use
Returns:
Median pickup count (integer), or 0 if no data
"""
# Get day of week - convert Python (0=Mon) to PostgreSQL (0=Sun, 1=Mon...6=Sat)
python_dow = target_date.weekday()
pg_dow = (python_dow + 1) % 7
# Determine which pace column to use based on lead days
if lead_days <= 0:
return 0 # No pickup for past dates
elif lead_days <= 30:
pace_col = f"d{lead_days}"
elif lead_days <= 177:
# Weekly intervals - find closest
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
else:
pace_col = "d177" # Cap at max tracked
# Query pace data for same DOW to calculate pickup counts
# pace_type 'total' gives us overall covers
result = await db.execute(
text(f"""
SELECT
COALESCE({pace_col}, 0) as otb_at_lead,
COALESCE(d0, 0) as final_covers
FROM resos_booking_pace
WHERE EXTRACT(DOW FROM booking_date) = :dow
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND booking_date < CURRENT_DATE
AND d0 > 0
AND pace_type = 'total'
ORDER BY booking_date DESC
LIMIT :max_weeks
"""),
{
"dow": pg_dow,
"lookback_days": lookback_weeks * 7,
"max_weeks": lookback_weeks
}
)
rows = result.fetchall()
if not rows:
# No pace data - return 0 (no pickup estimate available)
return 0
# Calculate pickup counts for each historical day
pickups = []
for row in rows:
otb_at_lead = row.otb_at_lead or 0
final = row.final_covers or 0
# Pickup = how many came in after this lead time
pickup = max(0, final - otb_at_lead) # Floor at 0 (cancellations shouldn't give negative)
pickups.append(pickup)
if not pickups:
return 0
# Calculate median pickup count
pickups_sorted = sorted(pickups)
n = len(pickups_sorted)
if n % 2 == 0:
median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2
else:
median = pickups_sorted[n // 2]
return math.ceil(median) # Round up
async def get_dinner_non_resident_pickup_by_lead_time(
db: AsyncSession,
target_date: date,
lead_days: int,
lookback_weeks: int = 8
) -> int:
"""
Get median pickup count for non-resident dinner at a given lead time.
Same logic as lunch - straight pickup count based on historical pace data.
"""
python_dow = target_date.weekday()
pg_dow = (python_dow + 1) % 7
if lead_days <= 0:
return 0
elif lead_days <= 30:
pace_col = f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
else:
pace_col = "d177"
# Query pace data for non_resident type
result = await db.execute(
text(f"""
SELECT
COALESCE({pace_col}, 0) as otb_at_lead,
COALESCE(d0, 0) as final_covers
FROM resos_booking_pace
WHERE EXTRACT(DOW FROM booking_date) = :dow
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND booking_date < CURRENT_DATE
AND d0 > 0
AND pace_type = 'non_resident'
ORDER BY booking_date DESC
LIMIT :max_weeks
"""),
{
"dow": pg_dow,
"lookback_days": lookback_weeks * 7,
"max_weeks": lookback_weeks
}
)
rows = result.fetchall()
if not rows:
return 0
pickups = []
for row in rows:
otb_at_lead = row.otb_at_lead or 0
final = row.final_covers or 0
pickup = max(0, final - otb_at_lead)
pickups.append(pickup)
if not pickups:
return 0
pickups_sorted = sorted(pickups)
n = len(pickups_sorted)
if n % 2 == 0:
median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2
else:
median = pickups_sorted[n // 2]
return math.ceil(median)
async def get_resident_dining_rate(
db: AsyncSession,
target_date: date,
lookback_weeks: int = 4
) -> float:
"""
Calculate what % of hotel guests typically dine at the restaurant (resident covers).
Simple approach: resident_covers / hotel_guests for same DOW over last N weeks.
Returns median rate to apply to forecasted hotel guests.
Args:
db: Database session
target_date: Date we're forecasting (to get DOW)
lookback_weeks: Weeks of history to analyze
Returns:
Median dining rate (0.0 to 1.0)
"""
python_dow = target_date.weekday()
pg_dow = (python_dow + 1) % 7
# Query resident covers and hotel guests for same DOW
result = await db.execute(
text("""
SELECT
nbs.date,
COALESCE(nbs.guests_count, 0) as hotel_guests,
COALESCE(rbs.hotel_guest_covers, 0) as resident_covers
FROM newbook_bookings_stats nbs
JOIN resos_bookings_stats rbs ON nbs.date = rbs.date
WHERE EXTRACT(DOW FROM nbs.date) = :dow
AND nbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND nbs.date < CURRENT_DATE
AND nbs.guests_count > 0
ORDER BY nbs.date DESC
LIMIT :max_weeks
"""),
{
"dow": pg_dow,
"lookback_days": lookback_weeks * 7,
"max_weeks": lookback_weeks
}
)
rows = result.fetchall()
if not rows:
return 0.4 # Default 40% if no data
# Calculate dining rate for each week
dining_rates = []
for row in rows:
if row.hotel_guests > 0:
rate = min(1.0, row.resident_covers / row.hotel_guests)
dining_rates.append(rate)
if not dining_rates:
return 0.4
# Return median
sorted_rates = sorted(dining_rates)
n = len(sorted_rates)
if n % 2 == 0:
return (sorted_rates[n // 2 - 1] + sorted_rates[n // 2]) / 2
return sorted_rates[n // 2]
async def get_historical_pickup_by_lead_time(
db: AsyncSession,
period_type: str,
is_resident: bool,
lead_days: int,
lookback_weeks: int = 12
) -> Dict[str, float]:
"""
Calculate historical pickup patterns for a period/segment at a given lead time.
Returns average pickup and pickup rate compared to final.
"""
# Get column name for this lead time
column = f"d{lead_days}" if lead_days <= 30 else f"d{lead_days}" # Use same format for all
# For lead times with pace data, use pace table
pace_type = 'resident' if is_resident else 'non_resident'
if lead_days <= 365: # We have pace columns up to d365
result = await db.execute(
text(f"""
SELECT
AVG(COALESCE({column}, 0)) as avg_at_lead,
AVG(COALESCE(d0, 0)) as avg_final
FROM resos_booking_pace
WHERE pace_type = :pace_type
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND booking_date < CURRENT_DATE
"""),
{"pace_type": pace_type, "lookback_days": lookback_weeks * 7}
)
else:
# Use aggregated stats table for period-specific analysis
result = await db.execute(
text("""
SELECT
AVG(CASE WHEN :is_resident THEN hotel_guest_covers ELSE non_hotel_guest_covers END) as avg_covers
FROM resos_bookings_stats
WHERE date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
AND date < CURRENT_DATE
"""),
{"is_resident": is_resident, "lookback_days": lookback_weeks * 7}
)
row = result.fetchone()
return {
"avg_at_lead": row.avg_at_lead if row and row.avg_at_lead else 0,
"avg_final": row.avg_final if row and row.avg_final else 0
}
async def forecast_covers_for_date(
db: AsyncSession,
target_date: date,
include_details: bool = False
) -> Dict[str, Any]:
"""
Generate covers forecast for a specific date.
Returns breakdown by period and segment:
- Breakfast: Based on previous night's occupancy
- Lunch: OTB + non-resident pickup
- Dinner: OTB (resident + non-resident) + pickup for each
"""
today = date.today()
lead_days = (target_date - today).days
prior_year_date = get_prior_year_date(target_date)
# Get current OTB covers
current_covers = await get_resos_covers_for_date(db, target_date)
# Get prior year covers
prior_covers = await get_resos_covers_for_date(db, prior_year_date)
# Get hotel occupancy for the night before (for breakfast)
night_before = target_date - timedelta(days=1)
prior_year_night_before = get_prior_year_date(night_before)
# Get current hotel OTB for night before
hotel_otb = await get_hotel_occupancy_for_date(db, night_before)
# Get prior year hotel occupancy for night before (tells us expected final)
hotel_prior = await get_hotel_occupancy_for_date(db, prior_year_night_before)
# Get breakfast rate (covers per room)
breakfast_rate = await get_historical_breakfast_rate(db)
# Calculate forecasts by period
result = {
"date": target_date.isoformat(),
"day_of_week": target_date.strftime("%a"),
"lead_days": lead_days,
"prior_year_date": prior_year_date.isoformat(),
}
# ============ BREAKFAST ============
# Breakfast = hotel guests from night before (guests eat breakfast, not rooms)
# Past: use actual hotel guest count
# Future: OTB guests + pickup from pickupv2 hotel forecast
hotel_guests_otb = hotel_otb["guests"]
hotel_rooms_otb = hotel_otb["occupied_rooms"]
hotel_guests_prior = hotel_prior["guests"]
hotel_rooms_prior = hotel_prior["occupied_rooms"]
# Calculate guests per room ratio for converting room forecast to guests
# Use prior year ratio (more stable/representative of final state) with fallbacks
if hotel_rooms_prior > 0:
guests_per_room = hotel_guests_prior / hotel_rooms_prior
elif hotel_rooms_otb > 0:
guests_per_room = hotel_guests_otb / hotel_rooms_otb
else:
guests_per_room = 1.8 # Default fallback
breakfast_calc = None
if lead_days <= 0:
# PAST: Use actual hotel guest count
breakfast_otb = hotel_guests_otb
breakfast_pickup = 0
breakfast_forecast = breakfast_otb
else:
# FUTURE: Use pickupv2 model for room forecast
breakfast_otb = hotel_guests_otb
# Get pickupv2 room forecast for the night before
# (night_before lead_days = lead_days for target_date since breakfast is next morning)
night_before_lead_days = lead_days - 1 # Night before has 1 less lead day
pickup_rooms = 0
try:
pickupv2_forecast = await forecast_rooms_for_date(
db,
night_before,
night_before_lead_days,
prior_year_night_before,
'hotel_room_nights'
)
if pickupv2_forecast:
# Get forecasted rooms and pickup from pickupv2
forecasted_rooms = pickupv2_forecast.get('predicted_value', hotel_rooms_otb)
pickup_rooms = pickupv2_forecast.get('pickup_rooms_total', 0)
# Convert pickup rooms to guests using the ratio (round up)
breakfast_pickup = math.ceil(pickup_rooms * guests_per_room)
# Forecast = OTB + pickup (floor is always OTB guests)
breakfast_forecast = breakfast_otb + breakfast_pickup
# Store calculation details
breakfast_calc = {
"night_before": night_before.isoformat(),
"hotel_rooms_otb": hotel_rooms_otb,
"hotel_guests_otb": hotel_guests_otb,
"pickup_rooms": round(pickup_rooms, 1),
"guests_per_room": round(guests_per_room, 2),
"source": "pickupv2",
}
else:
# Fallback to prior year pattern
breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb)
breakfast_forecast = breakfast_otb + breakfast_pickup
breakfast_calc = {
"night_before": night_before.isoformat(),
"hotel_guests_prior": hotel_guests_prior,
"source": "prior_year_fallback",
}
except Exception as e:
logger.warning(f"Pickupv2 forecast failed for {night_before}: {e}")
# Fallback to prior year pattern
breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb)
breakfast_forecast = breakfast_otb + breakfast_pickup
breakfast_calc = {
"night_before": night_before.isoformat(),
"hotel_guests_prior": hotel_guests_prior,
"source": "prior_year_fallback",
}
# Prior year breakfast (for comparison)
prior_breakfast = hotel_guests_prior
result["breakfast"] = {
"otb": breakfast_otb,
"pickup": breakfast_pickup,
"forecast": breakfast_forecast,
"prior_year": prior_breakfast,
"hotel_guests_otb": hotel_guests_otb,
"hotel_guests_prior": hotel_guests_prior,
"calc": breakfast_calc,
}
# ============ LUNCH ============
# Lunch: OTB + pickup based on median historical pickup at lead time
# Uses straight pickup count (not ratio) for stability
lunch_data = current_covers.get("lunch", {})
lunch_otb = lunch_data.get("total_covers", 0)
prior_lunch = prior_covers.get("lunch", {}).get("total_covers", 0)
# Get median pickup count for this lead time and DOW
lunch_pickup = await get_lunch_pickup_by_lead_time(db, target_date, lead_days)
# Determine pace column for tooltip
if lead_days <= 30:
lunch_pace_col = f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
lunch_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
else:
lunch_pace_col = "d177"
lunch_calc = None
# For future dates, add pickup to OTB
if lead_days > 0:
lunch_forecast = lunch_otb + lunch_pickup
lunch_calc = {
"day_of_week": target_date.strftime("%A"),
"lead_days": lead_days,
"pace_column": lunch_pace_col,
"lookback_weeks": 8,
"median_pickup": lunch_pickup,
"source": "resos_booking_pace (total)",
}
else:
# Past date - no pickup
lunch_pickup = 0
lunch_forecast = lunch_otb
result["lunch"] = {
"otb": lunch_otb,
"pickup": lunch_pickup,
"forecast": lunch_forecast,
"prior_year": prior_lunch,
"calc": lunch_calc,
}
# ============ DINNER ============
# Dinner: More sophisticated calculation
# - Non-resident: Lead-time based median pickup (like lunch)
# - Resident: Based on hotel guests without dinner reservations + conversion rate
dinner_data = current_covers.get("dinner", {})
dinner_otb = dinner_data.get("total_covers", 0)
dinner_resident_otb = dinner_data.get("resident_covers", 0)
dinner_non_resident_otb = dinner_data.get("non_resident_covers", 0)
dinner_dbb_otb = dinner_data.get("dbb_covers", 0)
prior_dinner = prior_covers.get("dinner", {}).get("total_covers", 0)
prior_dinner_resident = prior_covers.get("dinner", {}).get("resident_covers", 0)
prior_dinner_non_resident = prior_covers.get("dinner", {}).get("non_resident_covers", 0)
# Determine pace column for non-resident tooltip
if lead_days <= 30:
dinner_pace_col = f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
dinner_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
else:
dinner_pace_col = "d177"
non_resident_calc = None
if lead_days > 0:
# ---- NON-RESIDENT PICKUP ----
# Use lead-time based median pickup (same logic as lunch)
non_resident_pickup = await get_dinner_non_resident_pickup_by_lead_time(db, target_date, lead_days)
non_resident_calc = {
"day_of_week": target_date.strftime("%A"),
"lead_days": lead_days,
"pace_column": dinner_pace_col,
"lookback_weeks": 8,
"median_pickup": non_resident_pickup,
"source": "resos_booking_pace (non_resident)",
}
# ---- RESIDENT PICKUP ----
# Simple approach: % of hotel guests who dine, applied to forecasted guests
# Get hotel occupancy for target_date (dinner is same night as stay)
hotel_tonight = await get_hotel_occupancy_for_date(db, target_date)
hotel_guests_otb = hotel_tonight["guests"]
hotel_rooms_otb = hotel_tonight["occupied_rooms"]
# Calculate guests per room (use prior year ratio if current is 0)
prior_year_hotel = await get_hotel_occupancy_for_date(db, prior_year_date)
if hotel_rooms_otb > 0:
guests_per_room = hotel_guests_otb / hotel_rooms_otb
elif prior_year_hotel["occupied_rooms"] > 0:
guests_per_room = prior_year_hotel["guests"] / prior_year_hotel["occupied_rooms"]
else:
guests_per_room = 1.8 # Default
# Get pickupv2 room forecast for tonight
pickup_rooms = 0
try:
pickupv2_dinner = await forecast_rooms_for_date(
db, target_date, lead_days, prior_year_date, 'hotel_room_nights'
)
if pickupv2_dinner:
pickup_rooms = pickupv2_dinner.get('pickup_rooms_total', 0)
except Exception as e:
logger.warning(f"Pickupv2 forecast failed for dinner {target_date}: {e}")
# Calculate forecasted hotel guests (OTB + pickup)
pickup_guests = pickup_rooms * guests_per_room
forecasted_guests = hotel_guests_otb + pickup_guests
# Get historical resident dining rate (% of hotel guests who dine)
dining_rate = await get_resident_dining_rate(db, target_date)
# Calculate expected resident covers
# forecasted_resident_covers = forecasted_guests × dining_rate
forecasted_resident_covers = forecasted_guests * dining_rate
# Resident pickup = expected total - current OTB resident covers
resident_pickup = max(0, math.ceil(forecasted_resident_covers) - dinner_resident_otb)
dinner_forecast = dinner_otb + resident_pickup + non_resident_pickup
# Store calculation details for tooltip
resident_calc = {
"hotel_guests_otb": hotel_guests_otb,
"pickup_rooms": round(pickup_rooms, 1),
"guests_per_room": round(guests_per_room, 2),
"pickup_guests": round(pickup_guests, 1),
"forecasted_guests": round(forecasted_guests, 1),
"dining_rate": round(dining_rate * 100, 1), # As percentage
"forecasted_resident_covers": round(forecasted_resident_covers, 1),
"resident_otb": dinner_resident_otb,
"source": "last 4 weeks same DOW",
}
else:
# Past date - no pickup
dinner_forecast = dinner_otb
resident_pickup = 0
non_resident_pickup = 0
resident_calc = None
non_resident_calc = None
result["dinner"] = {
"otb": dinner_otb,
"resident_otb": dinner_resident_otb,
"non_resident_otb": dinner_non_resident_otb,
"dbb_otb": dinner_dbb_otb,
"resident_pickup": resident_pickup,
"non_resident_pickup": non_resident_pickup,
"forecast": dinner_forecast,
"prior_year": prior_dinner,
"prior_resident": prior_dinner_resident,
"prior_non_resident": prior_dinner_non_resident,
"resident_calc": resident_calc,
"non_resident_calc": non_resident_calc,
}
# Totals
total_otb = breakfast_otb + lunch_otb + dinner_otb
total_forecast = breakfast_forecast + lunch_forecast + dinner_forecast
prior_total = prior_breakfast + prior_lunch + prior_dinner
result["totals"] = {
"otb": total_otb,
"forecast": total_forecast,
"prior_year": prior_total,
"pace_vs_prior_pct": round((total_otb / prior_total * 100), 1) if prior_total > 0 else None
}
# Add hotel occupancy context
result["hotel_context"] = {
"night_before_occupancy": hotel_otb["occupancy_pct"],
"night_before_rooms": hotel_otb["occupied_rooms"],
"night_before_guests": hotel_otb["guests"]
}
return result
async def forecast_covers_range(
db: AsyncSession,
start_date: date,
end_date: date,
include_details: bool = False
) -> Dict[str, Any]:
"""
Generate covers forecast for a date range.
"""
forecasts = []
current = start_date
while current <= end_date:
try:
day_forecast = await forecast_covers_for_date(db, current, include_details)
forecasts.append(day_forecast)
except Exception as e:
logger.warning(f"Failed to forecast covers for {current}: {e}")
current += timedelta(days=1)
# Calculate summary
summary = {
"breakfast_otb": sum(f["breakfast"]["otb"] for f in forecasts),
"breakfast_forecast": sum(f["breakfast"]["forecast"] for f in forecasts),
"breakfast_prior": sum(f["breakfast"]["prior_year"] for f in forecasts),
"lunch_otb": sum(f["lunch"]["otb"] for f in forecasts),
"lunch_forecast": sum(f["lunch"]["forecast"] for f in forecasts),
"lunch_prior": sum(f["lunch"]["prior_year"] for f in forecasts),
"dinner_otb": sum(f["dinner"]["otb"] for f in forecasts),
"dinner_forecast": sum(f["dinner"]["forecast"] for f in forecasts),
"dinner_prior": sum(f["dinner"]["prior_year"] for f in forecasts),
"total_otb": sum(f["totals"]["otb"] for f in forecasts),
"total_forecast": sum(f["totals"]["forecast"] for f in forecasts),
"total_prior": sum(f["totals"]["prior_year"] for f in forecasts),
"days_count": len(forecasts)
}
return {
"data": forecasts,
"summary": summary
}

View file

@ -0,0 +1,659 @@
"""
Historical Forecast Runner
Runs all models as if it were a specific historical date.
This allows backtesting of Prophet, XGBoost, and Pickup models
by only using data that would have been available at that time.
"""
import logging
from datetime import date, timedelta
from typing import List, Optional
import pandas as pd
import numpy as np
import json
from sqlalchemy import text
from utils.time_alignment import get_prior_year_daily
logger = logging.getLogger(__name__)
async def run_historical_forecast(
db,
simulated_today: date,
metric_codes: List[str] = None,
models: List[str] = None,
forecast_days: int = 60
) -> dict:
"""
Run forecasts as if today were a specific historical date.
Only uses data that would have been available on simulated_today.
Args:
db: Database session
simulated_today: The date to pretend "today" is
metric_codes: List of metrics to forecast (default: all main metrics)
models: List of models to run (default: all)
forecast_days: Number of days to forecast (default: 60)
Returns:
Dict with results summary
"""
if metric_codes is None:
metric_codes = ['hotel_room_nights', 'hotel_occupancy_pct', 'resos_dinner_covers', 'resos_lunch_covers']
if models is None:
models = ['prophet', 'xgboost', 'pickup', 'catboost']
forecast_from = simulated_today + timedelta(days=1)
forecast_to = simulated_today + timedelta(days=forecast_days)
results = {
"simulated_today": str(simulated_today),
"forecast_from": str(forecast_from),
"forecast_to": str(forecast_to),
"metrics": {},
"total_forecasts": 0
}
for metric_code in metric_codes:
results["metrics"][metric_code] = {}
for model in models:
try:
if model == 'prophet':
forecasts = await _run_prophet_historical(
db, metric_code, simulated_today, forecast_from, forecast_to
)
elif model == 'xgboost':
forecasts = await _run_xgboost_historical(
db, metric_code, simulated_today, forecast_from, forecast_to
)
elif model == 'pickup':
forecasts = await _run_pickup_historical(
db, metric_code, simulated_today, forecast_from, forecast_to
)
elif model == 'catboost':
forecasts = await _run_catboost_historical(
db, metric_code, simulated_today, forecast_from, forecast_to
)
else:
continue
results["metrics"][metric_code][model] = len(forecasts)
results["total_forecasts"] += len(forecasts)
await db.commit() # Commit after each successful model run
except Exception as e:
logger.error(f"Historical {model} forecast failed for {metric_code}: {e}")
await db.rollback() # Rollback on error to clear failed transaction
results["metrics"][metric_code][model] = f"error: {str(e)[:100]}"
logger.info(f"Historical forecasts complete for {simulated_today}: {results['total_forecasts']} total forecasts")
return results
async def _run_prophet_historical(
db,
metric_code: str,
simulated_today: date,
forecast_from: date,
forecast_to: date,
training_days: int = 2555
) -> List[dict]:
"""Run Prophet using only data available before simulated_today."""
try:
from prophet import Prophet
# Get room capacity for capping (sum across all room categories for a single date)
total_rooms = 25
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 <= :simulated_today
)
"""),
{"simulated_today": simulated_today}
)
rooms_row = rooms_result.fetchone()
if rooms_row and rooms_row.total_rooms:
total_rooms = int(rooms_row.total_rooms)
# Training data ends at simulated_today - 1 (yesterday from simulated perspective)
training_to = simulated_today - timedelta(days=1)
training_from = training_to - timedelta(days=training_days)
result = await db.execute(
text("""
SELECT date, actual_value
FROM daily_metrics
WHERE metric_code = :metric_code
AND date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
ORDER BY date
"""),
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
)
rows = result.fetchall()
if len(rows) < 30:
logger.warning(f"Insufficient data for historical Prophet: {metric_code} has {len(rows)} records as of {simulated_today}")
return []
df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows])
model = Prophet(
yearly_seasonality=True,
weekly_seasonality=True,
daily_seasonality=False,
interval_width=0.80
)
model.add_country_holidays(country_name='GB')
model.fit(df)
future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D')
future_df = pd.DataFrame({"ds": future_dates})
forecast = model.predict(future_df)
forecasts = []
for _, row in forecast.iterrows():
predicted_value = float(row["yhat"])
lower_bound = float(row["yhat_lower"])
upper_bound = float(row["yhat_upper"])
# Apply physical caps
if metric_code == 'hotel_occupancy_pct':
predicted_value = min(predicted_value, 100)
lower_bound = min(lower_bound, 100)
upper_bound = min(upper_bound, 100)
if metric_code == 'hotel_room_nights':
predicted_value = min(predicted_value, total_rooms)
lower_bound = min(lower_bound, total_rooms)
upper_bound = min(upper_bound, total_rooms)
forecast_record = {
"forecast_date": row["ds"].date(),
"forecast_type": metric_code,
"model_type": "prophet",
"predicted_value": round(predicted_value, 2),
"lower_bound": round(lower_bound, 2),
"upper_bound": round(upper_bound, 2)
}
forecasts.append(forecast_record)
# Store with generated_at = simulated_today to track when this "would have been" generated
await db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type,
predicted_value, lower_bound, upper_bound, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type,
:predicted_value, :lower_bound, :upper_bound, :generated_at
)
"""),
{**forecast_record, "generated_at": simulated_today}
)
logger.info(f"Historical Prophet forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
return forecasts
except ImportError:
logger.error("Prophet not installed")
return []
except Exception as e:
logger.error(f"Historical Prophet failed: {e}")
raise
async def _run_xgboost_historical(
db,
metric_code: str,
simulated_today: date,
forecast_from: date,
forecast_to: date,
training_days: int = 2555
) -> List[dict]:
"""Run XGBoost using only data available before simulated_today."""
try:
import xgboost as xgb
training_to = simulated_today - timedelta(days=1)
training_from = training_to - timedelta(days=training_days + 60)
result = await db.execute(
text("""
SELECT date, actual_value
FROM daily_metrics
WHERE metric_code = :metric_code
AND date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
ORDER BY date
"""),
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
)
rows = result.fetchall()
if len(rows) < 60:
logger.warning(f"Insufficient data for historical XGBoost: {metric_code} has {len(rows)} records")
return []
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
df = df.sort_values('ds').reset_index(drop=True)
df = _create_features(df)
df = df.dropna()
feature_cols = [
'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend',
'dow_sin', 'dow_cos', 'month_sin', 'month_cos',
'lag_7', 'lag_14', 'lag_21', 'lag_28',
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
]
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
feature_cols.append('lag_365')
X = df[feature_cols]
y = df['y']
model = xgb.XGBRegressor(
n_estimators=100,
max_depth=5,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42
)
model.fit(X, y)
forecasts = []
current_df = df.copy()
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
current_df = pd.concat([current_df, new_row], ignore_index=True)
current_df = _create_features(current_df)
X_pred = current_df[feature_cols].iloc[-1:].ffill()
prediction = float(model.predict(X_pred)[0])
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
forecast_record = {
"forecast_date": forecast_date.date(),
"forecast_type": metric_code,
"model_type": "xgboost",
"predicted_value": round(float(prediction), 2)
}
forecasts.append(forecast_record)
await db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
)
"""),
{**forecast_record, "generated_at": simulated_today}
)
logger.info(f"Historical XGBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
return forecasts
except ImportError as e:
logger.error(f"Required package not installed: {e}")
return []
except Exception as e:
logger.error(f"Historical XGBoost failed: {e}")
raise
async def _run_pickup_historical(
db,
metric_code: str,
simulated_today: date,
forecast_from: date,
forecast_to: date
) -> List[dict]:
"""Run Pickup model using only data available before simulated_today."""
forecasts = []
# Get room capacity (sum of available rooms across all categories for a single date)
total_rooms = 25
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 <= :simulated_today
)
"""),
{"simulated_today": simulated_today}
)
rooms_row = rooms_result.fetchone()
if rooms_row and rooms_row.total_rooms:
total_rooms = int(rooms_row.total_rooms)
for days_out in range((forecast_to - forecast_from).days + 1):
forecast_date = forecast_from + timedelta(days=days_out)
lead_time = (forecast_date - simulated_today).days
if lead_time < 1:
continue
# Calculate prior year comparison date
prior_year_date = get_prior_year_daily(forecast_date)
# Get/reconstruct current OTB as of simulated_today
current_otb = await _get_reconstructed_otb(
db, metric_code, forecast_date, simulated_today, total_rooms
)
if current_otb is None:
continue
# Get prior year OTB at same lead time
prior_simulated_today = get_prior_year_daily(simulated_today)
prior_otb = await _get_reconstructed_otb(
db, metric_code, prior_year_date, prior_simulated_today, total_rooms
)
# Get prior year final actual
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 projection using additive method
projected_value = 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
# 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_pickup = prior_final * (1 - estimated_pct)
projected_value = current_otb + implied_pickup
projected_value = max(projected_value, current_otb)
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
forecast_record = {
"forecast_date": forecast_date,
"forecast_type": metric_code,
"model_type": "pickup",
"predicted_value": round(projected_value, 2)
}
forecasts.append(forecast_record)
await db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
)
"""),
{**forecast_record, "generated_at": simulated_today}
)
logger.info(f"Historical Pickup forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
return forecasts
async def _run_catboost_historical(
db,
metric_code: str,
simulated_today: date,
forecast_from: date,
forecast_to: date,
training_days: int = 2555
) -> List[dict]:
"""Run CatBoost using only data available before simulated_today."""
try:
from catboost import CatBoostRegressor
training_to = simulated_today - timedelta(days=1)
training_from = training_to - timedelta(days=training_days + 60)
result = await db.execute(
text("""
SELECT date, actual_value
FROM daily_metrics
WHERE metric_code = :metric_code
AND date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
ORDER BY date
"""),
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
)
rows = result.fetchall()
if len(rows) < 60:
logger.warning(f"Insufficient data for historical CatBoost: {metric_code} has {len(rows)} records")
return []
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
df = df.sort_values('ds').reset_index(drop=True)
df = _create_catboost_features(df)
df = df.dropna()
categorical_features = ['day_of_week', 'month']
numerical_features = [
'day_of_month', 'week_of_year', 'is_weekend',
'lag_7', 'lag_14', 'lag_21', 'lag_28',
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
]
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
numerical_features.append('lag_365')
feature_cols = categorical_features + numerical_features
X = df[feature_cols]
y = df['y']
model = CatBoostRegressor(
iterations=200,
depth=6,
learning_rate=0.1,
loss_function='RMSE',
cat_features=categorical_features,
verbose=False,
random_seed=42
)
model.fit(X, y)
forecasts = []
current_df = df.copy()
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
current_df = pd.concat([current_df, new_row], ignore_index=True)
current_df = _create_catboost_features(current_df)
X_pred = current_df[feature_cols].iloc[-1:].copy()
for col in numerical_features:
if col in X_pred.columns:
X_pred[col] = X_pred[col].ffill()
if X_pred[col].isna().any():
X_pred[col] = X_pred[col].fillna(0)
prediction = float(model.predict(X_pred)[0])
prediction = max(0, prediction)
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
forecast_record = {
"forecast_date": forecast_date.date(),
"forecast_type": metric_code,
"model_type": "catboost",
"predicted_value": round(float(prediction), 2)
}
forecasts.append(forecast_record)
await db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
)
"""),
{**forecast_record, "generated_at": simulated_today}
)
logger.info(f"Historical CatBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
return forecasts
except ImportError as e:
logger.error(f"CatBoost not installed: {e}")
return []
except Exception as e:
logger.error(f"Historical CatBoost failed: {e}")
raise
def _create_catboost_features(df: pd.DataFrame) -> pd.DataFrame:
"""Create features for CatBoost model with native categorical support."""
df = df.copy()
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['day_of_month'] = df['ds'].dt.day
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
for lag in [7, 14, 21, 28]:
df[f'lag_{lag}'] = df['y'].shift(lag)
for window in [7, 14, 28]:
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0)
if len(df) > 365:
df['lag_365'] = df['y'].shift(365)
return df
async def _get_reconstructed_otb(
db,
metric_code: str,
target_date: date,
as_of_date: date,
total_rooms: int = 25
) -> Optional[float]:
"""
Get or reconstruct OTB value for a target date as of a specific date.
First tries pickup_snapshots, then reconstructs from booking data.
"""
# Try snapshots first
snap_result = await db.execute(
text("""
SELECT otb_value
FROM pickup_snapshots
WHERE stay_date = :target_date
AND metric_type = :metric
AND snapshot_date <= :as_of_date
ORDER BY snapshot_date DESC
LIMIT 1
"""),
{"target_date": target_date, "metric": metric_code, "as_of_date": as_of_date}
)
snap_row = snap_result.fetchone()
if snap_row and snap_row.otb_value is not None:
return float(snap_row.otb_value)
# Reconstruct from booking data
# EXCLUDES overflow category (category_id=5) used for chargeable no-shows
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
# Use CAST instead of :: to avoid asyncpg parameter parsing issues
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 CAST(raw_json->>'booking_placed' AS timestamp) <= CAST(:as_of_date AS date) + INTERVAL '1 day'
AND (category_id IS NULL OR category_id != '5')
"""),
{"target_date": target_date, "as_of_date": as_of_date}
)
recon_row = recon_result.fetchone()
if recon_row:
otb_count = recon_row.otb_count or 0
if metric_code == 'hotel_occupancy_pct':
return (otb_count / total_rooms) * 100 if total_rooms > 0 else 0
else:
return otb_count
return None
def _create_features(df: pd.DataFrame) -> pd.DataFrame:
"""Create features for XGBoost model."""
df = df.copy()
df['day_of_week'] = df['ds'].dt.dayofweek
df['month'] = df['ds'].dt.month
df['day_of_month'] = df['ds'].dt.day
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
for lag in [7, 14, 21, 28]:
df[f'lag_{lag}'] = df['y'].shift(lag)
for window in [7, 14, 28]:
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std()
if len(df) > 365:
df['lag_365'] = df['y'].shift(365)
return df

View file

@ -0,0 +1,332 @@
"""
Pickup forecasting model
Hotel industry standard pace/pickup tracking
Compares current on-the-books vs historical patterns
Uses ADDITIVE method for small properties:
- Projected = current_otb + expected_pickup_count
- Where expected_pickup_count = prior_year_final - prior_year_otb
This avoids ratio distortion with small numbers (e.g., 26 = 3x ratio
applied to 5 = 15 rooms, which is unrealistic)
Prior year comparison uses 364 days (52 weeks) for day-of-week alignment:
- Monday compares to Monday
- Saturday compares to Saturday
"""
import logging
from datetime import date, timedelta
from typing import List, Optional
from sqlalchemy import text
from utils.time_alignment import get_prior_year_daily, get_comparison_info
from utils.capacity import get_bookable_cap_sync
logger = logging.getLogger(__name__)
async def run_pickup_forecast(
db,
metric_code: str,
forecast_from: date,
forecast_to: date
) -> List[dict]:
"""
Run Pickup model forecast for a metric
The pickup model works by:
1. Getting current on-the-books (OTB) for each future date
2. Comparing to prior year SAME DAY OF WEEK at same lead time
3. Projecting final using ADDITIVE method (not ratio) for reliability
Additive method: projected = current_otb + (prior_final - prior_otb)
This represents: "what I have now + what typically picks up from here"
Args:
db: Database session
metric_code: Metric to forecast
forecast_from: Start date for forecasts
forecast_to: End date for forecasts
Returns:
List of forecast records
"""
forecasts = []
today = date.today()
for days_out in range((forecast_to - forecast_from).days + 1):
forecast_date = forecast_from + timedelta(days=days_out)
lead_time = (forecast_date - today).days
if lead_time < 1:
continue # Can't do pickup for past dates
# Calculate prior year comparison date (same day of week alignment)
prior_year_date = get_prior_year_daily(forecast_date)
# Get bookable rooms for this date (rooms - maintenance, for capping)
bookable_cap = get_bookable_cap_sync(db, forecast_date, fallback_value=25)
# Get current OTB from snapshots
otb_result = db.execute(
text("""
SELECT otb_value, prior_year_otb, prior_year_final
FROM pickup_snapshots
WHERE stay_date = :forecast_date
AND metric_type = :metric_code
AND snapshot_date = :today
"""),
{"forecast_date": forecast_date, "metric_code": metric_code, "today": today}
)
otb_row = otb_result.fetchone()
if not otb_row:
# No OTB data available, skip
continue
current_otb = float(otb_row.otb_value) if otb_row.otb_value is not None else 0
# Use 'is not None' - 0 is valid data meaning no bookings at that lead time
prior_otb = float(otb_row.prior_year_otb) if otb_row.prior_year_otb is not None else None
prior_final = float(otb_row.prior_year_final) if otb_row.prior_year_final is not None else None
# Get pickup curve for this day of week and season (fallback)
day_of_week = forecast_date.weekday()
day_name = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day_of_week]
month = forecast_date.month
# Determine season
if month in [6, 7, 8]:
season = 'peak'
elif month in [12, 1, 2]:
season = 'low'
else:
season = 'shoulder'
curve_result = db.execute(
text("""
SELECT avg_pct_of_final, std_dev
FROM pickup_curves
WHERE day_of_week = :dow
AND season = :season
AND metric_type = :metric_code
AND days_out = :lead_time
"""),
{"dow": day_of_week, "season": season, "metric_code": metric_code, "lead_time": lead_time}
)
curve_row = curve_result.fetchone()
# Calculate projection
projected_value = current_otb
projection_method = 'current_otb'
pace_vs_prior = None
confidence_note = "Using current on-the-books"
if prior_otb is not None and prior_final is not None:
# Calculate expected pickup count from prior year
prior_pickup_count = prior_final - prior_otb # How many picked up from this lead time
# Calculate pace vs prior year
if prior_otb > 0:
pace_vs_prior = ((current_otb - prior_otb) / prior_otb) * 100
# ADDITIVE METHOD: current + expected pickup
# This is more reliable for small properties than ratio method
# Example: prior had 2 OTB → 6 final = 4 pickup
# current has 5 OTB → project 5 + 4 = 9
projected_value = current_otb + prior_pickup_count
# Ensure projection is at least current OTB (pickup can't be negative in projection)
if projected_value < current_otb:
projected_value = current_otb
projection_method = 'additive_floor'
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}{prior_final:.0f} (negative pickup, using OTB)"
else:
projection_method = 'additive'
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}{prior_final:.0f} (+{prior_pickup_count:.0f} pickup)"
# Apply physical caps
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
projected_value = 100
confidence_note += " (capped at 100%)"
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
projected_value = bookable_cap
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
elif prior_final is not None and prior_final > 0:
# No prior OTB, but have prior final - use as guidance
# Estimate typical OTB percentage at this lead time
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
# Calculate implied pickup from typical percentages
implied_prior_otb = prior_final * estimated_pct
implied_pickup = prior_final - implied_prior_otb
# Apply additive method with implied pickup
projected_value = current_otb + implied_pickup
projected_value = max(projected_value, current_otb)
projection_method = 'implied_additive'
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: final was {prior_final:.0f}, est +{implied_pickup:.0f} pickup at {lead_time}d"
# Apply physical caps
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
projected_value = 100
confidence_note += " (capped at 100%)"
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
projected_value = bookable_cap
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
elif curve_row and curve_row.avg_pct_of_final > 0:
# Curve method: project based on historical pickup curve
projected_value = current_otb / (curve_row.avg_pct_of_final / 100)
projection_method = 'curve'
confidence_note = f"Based on pickup curve ({curve_row.avg_pct_of_final:.1f}% typical at {lead_time} days out)"
# Apply physical caps
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
projected_value = 100
confidence_note += " (capped at 100%)"
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
projected_value = bookable_cap
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
forecast_record = {
"forecast_date": forecast_date,
"forecast_type": metric_code,
"model_type": "pickup",
"predicted_value": round(projected_value, 2)
}
forecasts.append(forecast_record)
# Store in database
db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
)
"""),
forecast_record
)
# Store explanation
try:
db.execute(
text("""
INSERT INTO pickup_explanations (
forecast_date, forecast_type, current_otb, days_out,
comparison_otb, comparison_final,
pickup_curve_pct, pace_vs_prior_pct, projection_method,
projected_value, confidence_note, generated_at
) VALUES (
:date, :metric, :otb, :days_out,
:prior_otb, :prior_final,
:curve_pct, :pace, :method,
:projected, :confidence, NOW()
)
"""),
{
"date": forecast_date,
"metric": metric_code,
"otb": current_otb,
"days_out": lead_time,
"prior_otb": prior_otb,
"prior_final": prior_final,
"curve_pct": curve_row.avg_pct_of_final if curve_row else None,
"pace": pace_vs_prior,
"method": projection_method,
"projected": projected_value,
"confidence": confidence_note
}
)
except Exception:
pass # Skip if conflict, explanations are supplementary
db.commit()
logger.info(f"Pickup forecast generated for {metric_code}: {len(forecasts)} records")
return forecasts
async def update_pickup_curves(db, metric_code: str, lookback_days: int = 2555):
"""
Update historical pickup curves from actuals
Calculates average percentage of final value at each lead time
"""
logger.info(f"Updating pickup curves for {metric_code}")
# For each day of week and season
for dow in range(7):
for season in ['peak', 'shoulder', 'low']:
# Get historical final values and snapshots
result = db.execute(
text("""
WITH final_values AS (
SELECT date, actual_value
FROM daily_metrics
WHERE metric_code = :metric_code
AND date > CURRENT_DATE - :lookback
AND actual_value IS NOT NULL
AND EXTRACT(DOW FROM date) = :dow
),
snapshot_data AS (
SELECT
ps.stay_date,
ps.days_out,
ps.otb_value,
fv.actual_value as final_value
FROM pickup_snapshots ps
JOIN final_values fv ON ps.stay_date = fv.date
WHERE ps.metric_type = :metric_code
)
SELECT
days_out,
AVG(otb_value / NULLIF(final_value, 0) * 100) as avg_pct,
STDDEV(otb_value / NULLIF(final_value, 0) * 100) as std_pct,
COUNT(*) as sample_count
FROM snapshot_data
WHERE final_value > 0
GROUP BY days_out
HAVING COUNT(*) >= 5
"""),
{"metric_code": metric_code, "lookback": lookback_days, "dow": dow}
)
for row in result.fetchall():
db.execute(
text("""
INSERT INTO pickup_curves (
day_of_week, season, metric_type, days_out,
avg_pct_of_final, std_dev, sample_count, updated_at
) VALUES (
:dow, :season, :metric, :days_out,
:avg_pct, :std, :count, NOW()
)
ON CONFLICT (day_of_week, season, metric_type, days_out)
DO UPDATE SET
avg_pct_of_final = :avg_pct,
std_dev = :std,
sample_count = :count,
updated_at = NOW()
"""),
{
"dow": dow,
"season": season,
"metric": metric_code,
"days_out": row.days_out,
"avg_pct": row.avg_pct,
"std": row.std_pct,
"count": row.sample_count
}
)
db.commit()
logger.info(f"Pickup curves updated for {metric_code}")

View file

@ -0,0 +1,177 @@
"""
Pickup Tuned Model Service
This is the production-tuned Pickup model extracted from the preview endpoint.
Uses the exact same logic as the frontend preview to ensure value consistency.
Pickup Formula: Forecast = Current OTB + (Prior Year Final - Prior Year OTB)
This transparent model calculates expected pickup based on prior year booking patterns.
Only works for room-based metrics (occupancy, rooms). Not applicable to revenue metrics.
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
from sqlalchemy import text
from utils.capacity import get_bookable_cap
logger = logging.getLogger(__name__)
def get_lead_time_column(lead_days: int) -> str:
"""Map lead days to the appropriate column in newbook_booking_pace."""
if lead_days <= 0:
return "d0"
elif lead_days <= 30:
return f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
for col in weekly_cols:
if lead_days <= col:
return f"d{col}"
return "d177"
else:
monthly_cols = [210, 240, 270, 300, 330, 365]
for col in monthly_cols:
if lead_days <= col:
return f"d{col}"
return "d365"
async def run_pickup_tuned_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
perception_date: Optional[date] = None
) -> List[Dict]:
"""
Generate Pickup forecast using production-tuned model.
Uses transparent booking pace formula:
Forecast = Current OTB + (Prior Year Final - Prior Year OTB)
This uses the exact same logic as the preview endpoint to ensure
backend snapshots match frontend preview values.
Args:
db: Database session
metric_code: Metric to forecast (only room-based metrics supported)
start_date: Start date for forecast
end_date: End date for forecast
perception_date: Optional date to generate forecast as-of (for backtesting)
Returns:
List of forecast dicts with forecast_date and predicted_value
"""
logger.info(f"Running Pickup tuned forecast for {metric_code}: {start_date} to {end_date}")
# Map metric codes to preview endpoint metric names
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
}
metric = metric_map.get(metric_code, 'rooms')
# Check if metric is room-based (pickup model only works for these)
is_room_based = metric in ('occupancy', 'rooms')
if not is_room_based:
logger.warning(f"Pickup model doesn't apply to non-room metric: {metric_code}")
return []
# Use perception_date if provided, otherwise use actual today
today = perception_date if perception_date else date.today()
# Get default bookable cap
default_bookable_cap = await get_bookable_cap(db)
# Generate forecasts for each date
forecasts = []
current_date = start_date
while current_date <= end_date:
lead_days = (current_date - today).days
if lead_days < 0:
current_date += timedelta(days=1)
continue
lead_col = get_lead_time_column(lead_days)
prior_year_date = current_date - timedelta(days=364) # 52 weeks for DOW alignment
# Get current OTB
current_query = text("""
SELECT booking_count as current_otb
FROM newbook_bookings_stats
WHERE date = :arrival_date
""")
current_result = await db.execute(current_query, {"arrival_date": current_date})
current_row = current_result.fetchone()
# Get prior year OTB from booking_pace (for lead time comparison)
prior_year_for_otb = current_date - timedelta(days=364)
prior_otb_query = text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
""")
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
prior_otb_row = prior_otb_result.fetchone()
# Get prior year FINAL from bookings_stats
prior_final_query = text("""
SELECT booking_count as prior_final
FROM newbook_bookings_stats
WHERE date = :prior_date
""")
prior_final_result = await db.execute(prior_final_query, {"prior_date": prior_year_date})
prior_final_row = prior_final_result.fetchone()
# Extract values
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final is not None else 0
# Get per-date bookable cap
date_bookable_cap = await get_bookable_cap(db, current_date, default_bookable_cap)
# Convert to occupancy % if metric is occupancy
if metric == "occupancy" and date_bookable_cap > 0:
if current_otb is not None:
current_otb = (current_otb / date_bookable_cap) * 100
if prior_otb is not None:
prior_otb = (prior_otb / date_bookable_cap) * 100
if prior_final is not None:
prior_final = (prior_final / date_bookable_cap) * 100
# Calculate forecast using pickup formula
forecast = None
if current_otb is not None:
if prior_final is not None and prior_otb is not None:
expected_pickup = prior_final - prior_otb
forecast = current_otb + expected_pickup
# Floor to current OTB if pickup is negative
if forecast < current_otb:
forecast = current_otb
# Cap at max capacity (uses per-date bookable cap)
if metric == "occupancy" and forecast > 100:
forecast = 100.0
elif metric == "rooms" and forecast > date_bookable_cap:
forecast = float(date_bookable_cap)
else:
# No prior year data - use current OTB as forecast
forecast = current_otb
if forecast is not None:
forecasts.append({
'forecast_date': current_date,
'predicted_value': round(forecast, 1)
})
current_date += timedelta(days=1)
logger.info(f"Pickup tuned generated {len(forecasts)} forecasts for {metric_code}")
return forecasts

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
"""
Prophet forecasting model
Time series forecasting with trend, seasonality, and holiday effects
"""
import logging
from datetime import date, timedelta
from typing import List, Optional
import pandas as pd
import numpy as np
from sqlalchemy import text
logger = logging.getLogger(__name__)
async def run_prophet_forecast(
db,
metric_code: str,
forecast_from: date,
forecast_to: date,
training_days: int = 2555 # ~7 years - use all available history
) -> List[dict]:
"""
Run Prophet forecast for a metric
Args:
db: Database session
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct')
forecast_from: Start date for forecasts
forecast_to: End date for forecasts
training_days: Days of historical data to use for training
Returns:
List of forecast records
"""
try:
from prophet import Prophet
# Get historical data
training_from = forecast_from - timedelta(days=training_days)
# Revenue metrics use earned_revenue_data joined with gl_accounts
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
if metric_code in revenue_metrics:
revenue_departments = {
'net_accom': 'accommodation',
'net_dry': 'dry',
'net_wet': 'wet',
'total_rev': None, # All departments
}
department = revenue_departments.get(metric_code)
if department is None and metric_code != 'total_rev':
logger.warning(f"Unknown revenue metric for Prophet: {metric_code}")
return []
if metric_code == 'total_rev':
# Total revenue across all departments
result = db.execute(
text("""
SELECT date, SUM(amount_net) as actual_value
FROM newbook_earned_revenue_data
WHERE date BETWEEN :from_date AND :to_date
GROUP BY date
HAVING SUM(amount_net) IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
else:
# Revenue by department
result = db.execute(
text("""
SELECT r.date, SUM(r.amount_net) as actual_value
FROM newbook_earned_revenue_data r
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
WHERE r.date BETWEEN :from_date AND :to_date
AND g.department = :department
GROUP BY r.date
HAVING SUM(r.amount_net) IS NOT NULL
ORDER BY r.date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
)
else:
# Hotel metrics use newbook_bookings_stats table
metric_column_map = {
'hotel_occupancy_pct': 'total_occupancy_pct',
'hotel_room_nights': 'booking_count',
'hotel_guests': 'guests_count',
}
column_name = metric_column_map.get(metric_code)
if not column_name:
logger.warning(f"Unknown metric_code for Prophet: {metric_code}")
return []
result = db.execute(
text(f"""
SELECT date, {column_name} as actual_value
FROM newbook_bookings_stats
WHERE date BETWEEN :from_date AND :to_date
AND {column_name} IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
rows = result.fetchall()
if len(rows) < 30:
logger.warning(f"Insufficient data for Prophet forecast: {metric_code} has {len(rows)} records")
return []
# Prepare data for Prophet
df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows])
# Initialize and fit Prophet model
model = Prophet(
yearly_seasonality=True,
weekly_seasonality=True,
daily_seasonality=False,
interval_width=0.80 # 80% confidence interval
)
# Add UK holidays
model.add_country_holidays(country_name='GB')
model.fit(df)
# Generate future dates
future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D')
future_df = pd.DataFrame({"ds": future_dates})
# Make predictions
forecast = model.predict(future_df)
# Store forecasts
forecasts = []
for _, row in forecast.iterrows():
forecast_record = {
"forecast_date": row["ds"].date(),
"forecast_type": metric_code,
"model_type": "prophet",
"predicted_value": round(float(row["yhat"]), 2),
"lower_bound": round(float(row["yhat_lower"]), 2),
"upper_bound": round(float(row["yhat_upper"]), 2)
}
forecasts.append(forecast_record)
# Store in database - simple insert (latest value wins)
db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type,
predicted_value, lower_bound, upper_bound, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type,
:predicted_value, :lower_bound, :upper_bound, NOW()
)
"""),
forecast_record
)
# Store decomposition for explainability
# Simple insert - decomposition stored per generation
try:
db.execute(
text("""
INSERT INTO prophet_decomposition (
forecast_date, forecast_type, trend,
yearly_seasonality, weekly_seasonality, generated_at
) VALUES (
:date, :metric, :trend, :yearly, :weekly, NOW()
)
"""),
{
"date": row["ds"].date(),
"metric": metric_code,
"trend": round(float(row.get("trend", 0)), 4),
"yearly": round(float(row.get("yearly", 0)), 4),
"weekly": round(float(row.get("weekly", 0)), 4)
}
)
except Exception:
pass # Skip if conflict, decomposition is supplementary
db.commit()
logger.info(f"Prophet forecast generated for {metric_code}: {len(forecasts)} records")
return forecasts
except ImportError:
logger.error("Prophet not installed. Install with: pip install prophet")
return []
except Exception as e:
logger.error(f"Prophet forecast failed for {metric_code}: {e}")
return []

View file

@ -0,0 +1,281 @@
"""
Prophet Tuned Model Service
This is the production-tuned Prophet model extracted from the prophet-preview endpoint.
Uses the exact same logic as the frontend preview to ensure value consistency.
Features:
- 2 years of training data
- Logistic growth with floor/cap
- UK holidays + custom special dates
- OTB floor capping
- Per-date bookable cap adjustments
- Metric-specific handling
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
import pandas as pd
import warnings
from prophet import Prophet
from sqlalchemy import text
from utils.capacity import get_bookable_cap
logger = logging.getLogger(__name__)
warnings.filterwarnings('ignore')
# Metric configuration mapping
METRIC_COLUMN_MAP = {
'occupancy': ('s.occupancy_pct', False, True),
'rooms': ('s.booking_count', False, False),
'guests': ('s.guest_count', False, False),
'ave_guest_rate': ('s.arr_net', False, False),
'arr': ('s.arr_net', False, False),
'net_accom': ('r.accommodation', True, False),
'net_dry': ('r.dry', True, False),
'net_wet': ('r.wet', True, False),
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
}
def get_metric_query_parts(metric: str) -> tuple:
"""
Get SQL query parts for a metric.
Returns: (column_expr, from_clause, is_percentage)
"""
if metric not in METRIC_COLUMN_MAP:
# Default to rooms if unknown metric
metric = 'rooms'
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
if needs_revenue:
from_clause = """
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
"""
else:
from_clause = "FROM newbook_bookings_stats s"
return col_expr, from_clause, is_pct
def get_lead_time_column(lead_days: int) -> str:
"""
Map lead days to the appropriate column in newbook_booking_pace.
"""
if lead_days <= 0:
return "d0"
elif lead_days <= 30:
return f"d{lead_days}"
elif lead_days <= 177:
# Weekly intervals - find nearest column
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
for col in weekly_cols:
if lead_days <= col:
return f"d{col}"
return "d177"
else:
# Monthly intervals
monthly_cols = [210, 240, 270, 300, 330, 365]
for col in monthly_cols:
if lead_days <= col:
return f"d{col}"
return "d365"
async def run_prophet_tuned_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
perception_date: Optional[date] = None
) -> List[Dict]:
"""
Generate Prophet forecast using production-tuned model.
This uses the exact same logic as the prophet-preview endpoint to ensure
backend snapshots match frontend preview values.
Args:
db: Database session
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
start_date: Start date for forecast
end_date: End date for forecast
perception_date: Optional date to generate forecast as-of (for backtesting)
Returns:
List of forecast dicts with forecast_date and predicted_value
"""
logger.info(f"Running Prophet tuned forecast for {metric_code}: {start_date} to {end_date}")
# Map metric codes to preview endpoint metric names
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
'hotel_arr': 'arr',
'ave_guest_rate': 'ave_guest_rate',
'net_accom': 'net_accom',
'net_dry': 'net_dry',
'net_wet': 'net_wet',
'total_rev': 'total_rev',
}
metric = metric_map.get(metric_code, 'rooms')
# Use perception_date if provided, otherwise use actual today
today = perception_date if perception_date else date.today()
# Get default bookable cap
default_bookable_cap = await get_bookable_cap(db)
# Get metric column and query parts
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
# Get historical data for Prophet training (past 2 years)
history_start = today - timedelta(days=730)
history_query = f"""
SELECT s.date as ds, {col_expr} as y
{from_clause}
WHERE s.date >= :history_start
AND s.date < :today
AND {col_expr} IS NOT NULL
ORDER BY s.date
"""
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient historical data for Prophet model: {len(history_rows)} rows")
return []
# Build training dataframe
df = pd.DataFrame([{"ds": row.ds, "y": float(row.y) if row.y is not None else 0} for row in history_rows])
# Set floor/cap based on metric type
if is_pct_metric:
# Percentage metrics (occupancy)
training_cap = 100
elif metric == 'rooms':
# Room counts - cap at bookable rooms
training_cap = default_bookable_cap
elif metric == 'guests':
# Guests can exceed rooms (multiple per room) - use historical max * 1.5
training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else default_bookable_cap * 3
else:
# Revenue/rate metrics - use percentile-based cap
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 (respects floor/cap)
model = Prophet(
growth='logistic',
yearly_seasonality=True,
weekly_seasonality=True,
daily_seasonality=False,
interval_width=0.8,
changepoint_prior_scale=0.05
)
# Add UK holidays
model.add_country_holidays(country_name='UK')
# Add custom special dates from settings
try:
from api.special_dates import get_special_dates_for_prophet
# Get special dates for training period + forecast period
min_year = history_start.year
max_year = end_date.year + 1
custom_holidays = await get_special_dates_for_prophet(db, min_year, max_year)
if custom_holidays:
# Create holidays dataframe for Prophet
holidays_df = pd.DataFrame(custom_holidays)
# Group by holiday name and add lower/upper windows
for holiday_name in holidays_df['holiday'].unique():
holiday_dates = holidays_df[holidays_df['holiday'] == holiday_name][['ds', 'holiday']]
holiday_dates = holiday_dates.copy()
holiday_dates['lower_window'] = 0
holiday_dates['upper_window'] = 0
model.holidays = pd.concat([model.holidays, holiday_dates]) if model.holidays is not None else holiday_dates
except Exception as e:
logger.warning(f"Could not load special dates for Prophet: {e}")
model.fit(df)
# Create future dataframe for forecast period
future_dates = []
current_date = start_date
while current_date <= end_date:
if (current_date - today).days >= 0:
future_dates.append({"ds": current_date})
current_date += timedelta(days=1)
if not future_dates:
logger.warning("No future dates to forecast")
return []
future_df = pd.DataFrame(future_dates)
# Add floor/cap for logistic growth predictions (must match training cap)
future_df["floor"] = 0
future_df["cap"] = training_cap
forecast = model.predict(future_df)
# Process forecast results
forecasts = []
is_room_based = metric in ('occupancy', 'rooms')
for _, row in forecast.iterrows():
forecast_date = row["ds"].date()
lead_days = (forecast_date - today).days
lead_col = get_lead_time_column(lead_days)
# Get current OTB (only for room-based metrics)
current_otb = None
if is_room_based:
current_query = text("""
SELECT booking_count as current_otb
FROM newbook_bookings_stats
WHERE date = :arrival_date
""")
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
current_row = current_result.fetchone()
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
# Get per-date bookable cap for room-based metrics
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
# Convert to occupancy if needed
if metric == "occupancy" and date_bookable_cap > 0:
if current_otb is not None:
current_otb = (current_otb / date_bookable_cap) * 100
# Get Prophet forecast values
yhat = row["yhat"]
# Cap at max capacity based on metric type (uses per-date bookable cap)
if is_pct_metric:
yhat = min(yhat, 100.0)
elif metric == 'rooms':
yhat = min(yhat, float(date_bookable_cap))
# Guests and revenue/rate metrics don't have a hard cap
# Floor forecast to current OTB if we have it (room-based metrics only)
# But never exceed the bookable capacity (e.g., closed/maintenance periods)
if is_room_based and current_otb is not None and yhat < current_otb:
yhat = min(current_otb, float(date_bookable_cap))
forecasts.append({
'forecast_date': forecast_date,
'predicted_value': round(yhat, 2)
})
logger.info(f"Prophet tuned generated {len(forecasts)} forecasts for {metric_code}")
return forecasts

View file

@ -0,0 +1,300 @@
"""
XGBoost forecasting model
Gradient boosting with feature engineering and SHAP explainability
"""
import logging
from datetime import date, timedelta
from typing import List, Optional
import pandas as pd
import numpy as np
import json
from sqlalchemy import text
logger = logging.getLogger(__name__)
def create_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Create features for XGBoost model
Features include:
- Day of week (0-6)
- Month (1-12)
- Day of month
- Week of year
- Is weekend
- Is holiday (would need holiday calendar)
- Lag features (7, 14, 28 days)
- Rolling averages (7, 14, 28 days)
"""
df = df.copy()
# Date features
df['day_of_week'] = df['ds'].dt.dayofweek
df['month'] = df['ds'].dt.month
df['day_of_month'] = df['ds'].dt.day
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
# Cyclical encoding for day of week
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
# Cyclical encoding for month
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
# Lag features
for lag in [7, 14, 21, 28]:
df[f'lag_{lag}'] = df['y'].shift(lag)
# Rolling averages
for window in [7, 14, 28]:
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std()
# Year-over-year feature: uses 365 days (same calendar date, not DOW-aligned)
# This is intentional for ML: captures date-specific patterns like holidays
# Combined with day_of_week features, the model learns both patterns
# Note: For direct comparisons (pickup model), use 364 days for DOW alignment
if len(df) > 365:
df['lag_365'] = df['y'].shift(365)
return df
async def run_xgboost_forecast(
db,
metric_code: str,
forecast_from: date,
forecast_to: date,
training_days: int = 2555 # ~7 years - use all available history
) -> List[dict]:
"""
Run XGBoost forecast for a metric
Args:
db: Database session
metric_code: Metric to forecast
forecast_from: Start date for forecasts
forecast_to: End date for forecasts
training_days: Days of historical data to use
Returns:
List of forecast records
"""
try:
import xgboost as xgb
from sklearn.model_selection import train_test_split
# Get historical data
training_from = forecast_from - timedelta(days=training_days + 60) # Extra for lag features
# Revenue metrics use earned_revenue_data joined with gl_accounts
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
if metric_code in revenue_metrics:
revenue_departments = {
'net_accom': 'accommodation',
'net_dry': 'dry',
'net_wet': 'wet',
'total_rev': None, # All departments
}
department = revenue_departments.get(metric_code)
if department is None and metric_code != 'total_rev':
logger.warning(f"Unknown revenue metric for XGBoost: {metric_code}")
return []
if metric_code == 'total_rev':
# Total revenue across all departments
result = db.execute(
text("""
SELECT date, SUM(amount_net) as actual_value
FROM newbook_earned_revenue_data
WHERE date BETWEEN :from_date AND :to_date
GROUP BY date
HAVING SUM(amount_net) IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
else:
# Revenue by department
result = db.execute(
text("""
SELECT r.date, SUM(r.amount_net) as actual_value
FROM newbook_earned_revenue_data r
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
WHERE r.date BETWEEN :from_date AND :to_date
AND g.department = :department
GROUP BY r.date
HAVING SUM(r.amount_net) IS NOT NULL
ORDER BY r.date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
)
else:
# Hotel metrics use newbook_bookings_stats table
metric_column_map = {
'hotel_occupancy_pct': 'total_occupancy_pct',
'hotel_room_nights': 'booking_count',
'hotel_guests': 'guests_count',
}
column_name = metric_column_map.get(metric_code)
if not column_name:
logger.warning(f"Unknown metric_code for XGBoost: {metric_code}")
return []
result = db.execute(
text(f"""
SELECT date, {column_name} as actual_value
FROM newbook_bookings_stats
WHERE date BETWEEN :from_date AND :to_date
AND {column_name} IS NOT NULL
ORDER BY date
"""),
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
)
rows = result.fetchall()
if len(rows) < 60:
logger.warning(f"Insufficient data for XGBoost forecast: {metric_code} has {len(rows)} records")
return []
# Prepare data
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
df = df.sort_values('ds').reset_index(drop=True)
# Create features
df = create_features(df)
# Remove rows with NaN from lag features
df = df.dropna()
# Define feature columns
feature_cols = [
'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend',
'dow_sin', 'dow_cos', 'month_sin', 'month_cos',
'lag_7', 'lag_14', 'lag_21', 'lag_28',
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
]
# Add lag_365 if available
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
feature_cols.append('lag_365')
X = df[feature_cols]
y = df['y']
# Train model
model = xgb.XGBRegressor(
n_estimators=100,
max_depth=5,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42
)
model.fit(X, y)
# Generate forecasts
forecasts = []
current_df = df.copy()
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
# Create row for forecast date
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
current_df = pd.concat([current_df, new_row], ignore_index=True)
current_df = create_features(current_df)
# Get features for prediction
X_pred = current_df[feature_cols].iloc[-1:].ffill()
# Make prediction
prediction = float(model.predict(X_pred)[0])
# Update y value for lag features
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
forecast_record = {
"forecast_date": forecast_date.date(),
"forecast_type": metric_code,
"model_type": "xgboost",
"predicted_value": round(float(prediction), 2)
}
forecasts.append(forecast_record)
# Store in database
db.execute(
text("""
INSERT INTO forecasts (
forecast_date, forecast_type, model_type, predicted_value, generated_at
) VALUES (
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
)
"""),
forecast_record
)
# Commit forecasts before SHAP calculations
db.commit()
# Calculate SHAP values for explainability
try:
import shap
explainer = shap.TreeExplainer(model)
# Get SHAP values for last few predictions
for i, forecast_date in enumerate(pd.date_range(start=forecast_from, end=min(forecast_from + timedelta(days=7), forecast_to), freq='D')):
idx = len(df) + i
X_explain = current_df[feature_cols].iloc[idx:idx+1].ffill()
shap_values = explainer.shap_values(X_explain)
# Store SHAP explanation
feature_contributions = dict(zip(feature_cols, shap_values[0].tolist()))
top_positive = sorted(
[{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v > 0],
key=lambda x: x["contribution"], reverse=True
)[:5]
top_negative = sorted(
[{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v < 0],
key=lambda x: x["contribution"]
)[:3]
try:
db.execute(
text("""
INSERT INTO xgboost_explanations (
forecast_date, forecast_type, base_value,
feature_values, shap_values, top_positive, top_negative, generated_at
) VALUES (
:date, :metric, :base_value, :feature_values,
:shap_values, :top_positive, :top_negative, NOW()
)
"""),
{
"date": forecast_date.date(),
"metric": metric_code,
"base_value": float(explainer.expected_value),
"feature_values": json.dumps(X_explain.iloc[0].to_dict()),
"shap_values": json.dumps(feature_contributions),
"top_positive": json.dumps(top_positive),
"top_negative": json.dumps(top_negative)
}
)
except Exception:
pass # Skip if conflict, explanations are supplementary
except Exception as e:
logger.warning(f"SHAP calculation failed: {e}")
db.commit()
logger.info(f"XGBoost forecast generated for {metric_code}: {len(forecasts)} records")
return forecasts
except ImportError as e:
logger.error(f"Required package not installed: {e}")
return []
except Exception as e:
logger.error(f"XGBoost forecast failed for {metric_code}: {e}")
return []

View file

@ -0,0 +1,446 @@
"""
XGBoost Tuned Model Service
This is the production-tuned XGBoost model extracted from the xgboost-preview endpoint.
Uses the exact same logic as the frontend preview to ensure value consistency.
Features:
- 2 years of training data
- Pace features (OTB at different lead times) for room-based metrics
- Time-based features (day of week, month, week, weekend, special dates)
- Lag features from prior year same DOW
- OTB floor capping
- Per-date bookable cap adjustments
"""
import logging
from datetime import date, timedelta
from typing import List, Dict, Optional
import pandas as pd
import numpy as np
from xgboost import XGBRegressor
import warnings
from sqlalchemy import text
from utils.capacity import get_bookable_cap
from api.special_dates import resolve_special_date
logger = logging.getLogger(__name__)
warnings.filterwarnings('ignore')
# Metric configuration mapping
METRIC_COLUMN_MAP = {
'occupancy': ('s.occupancy_pct', False, True),
'rooms': ('s.booking_count', False, False),
'guests': ('s.guest_count', False, False),
'ave_guest_rate': ('s.arr_net', False, False),
'arr': ('s.arr_net', False, False),
'net_accom': ('r.accommodation', True, False),
'net_dry': ('r.dry', True, False),
'net_wet': ('r.wet', True, False),
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
}
def get_metric_query_parts(metric: str) -> tuple:
"""Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)"""
if metric not in METRIC_COLUMN_MAP:
metric = 'rooms'
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
if needs_revenue:
from_clause = """
FROM newbook_bookings_stats s
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
"""
else:
from_clause = "FROM newbook_bookings_stats s"
return col_expr, from_clause, is_pct
def get_lead_time_column(lead_days: int) -> str:
"""Map lead days to the appropriate column in newbook_booking_pace."""
if lead_days <= 0:
return "d0"
elif lead_days <= 30:
return f"d{lead_days}"
elif lead_days <= 177:
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
for col in weekly_cols:
if lead_days <= col:
return f"d{col}"
return "d177"
else:
monthly_cols = [210, 240, 270, 300, 330, 365]
for col in monthly_cols:
if lead_days <= col:
return f"d{col}"
return "d365"
def round_towards_reference(value: float, reference: Optional[float]) -> int:
"""Round a forecast value towards a reference value (prior year actual)."""
if reference is None:
return round(value)
if value < reference:
return int(np.ceil(value))
else:
return int(np.floor(value))
async def run_xgboost_tuned_forecast(
db,
metric_code: str,
start_date: date,
end_date: date,
perception_date: Optional[date] = None
) -> List[Dict]:
"""
Generate XGBoost forecast using production-tuned model.
This uses the exact same logic as the xgboost-preview endpoint to ensure
backend snapshots match frontend preview values.
Args:
db: Database session
metric_code: Metric to forecast
start_date: Start date for forecast
end_date: End date for forecast
perception_date: Optional date to generate forecast as-of (for backtesting)
Returns:
List of forecast dicts with forecast_date and predicted_value
"""
logger.info(f"Running XGBoost tuned forecast for {metric_code}: {start_date} to {end_date}")
# Map metric codes to preview endpoint metric names
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
'hotel_arr': 'arr',
'ave_guest_rate': 'ave_guest_rate',
'net_accom': 'net_accom',
'net_dry': 'net_dry',
'net_wet': 'net_wet',
'total_rev': 'total_rev',
}
metric = metric_map.get(metric_code, 'rooms')
# Use perception_date if provided, otherwise use actual today
today = perception_date if perception_date else date.today()
# Get default bookable cap
default_bookable_cap = await get_bookable_cap(db)
# Get metric column and query parts
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
is_room_based = metric in ('occupancy', 'rooms')
# Get historical data for XGBoost training (past 2 years)
history_start = today - timedelta(days=730)
# Lead times to train on (key intervals) - only used for room-based metrics
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
# Get final values (and pace data for room-based metrics)
if is_room_based:
history_result = await 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})
else:
# Non-room metrics: get values without pace join
history_query = f"""
SELECT s.date as ds, {col_expr} as final
{from_clause}
WHERE s.date >= :history_start
AND s.date < :today
AND {col_expr} IS NOT NULL
ORDER BY s.date
"""
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
history_rows = history_result.fetchall()
if len(history_rows) < 30:
logger.warning(f"Insufficient historical data for XGBoost model: {len(history_rows)} rows")
return []
# Load special dates for feature
special_date_set = set()
try:
special_dates_result = await 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 as e:
logger.warning(f"Could not load special dates: {e}")
# Build lookup dicts
final_by_date = {}
pace_by_date = {}
for row in history_rows:
final_by_date[row.ds] = row.final
if is_room_based and hasattr(row, 'd0'):
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
training_rows = []
if is_room_based:
# Room-based metrics: use pace features (one per date,lead_time combo)
for row in history_rows:
ds = row.ds
final = float(row.final) if row.final else 0
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
})
else:
# Non-room metrics: use time features only (one per date)
for row in history_rows:
ds = row.ds
final = float(row.final) if row.final else 0
prior_ds = ds - timedelta(days=364)
prior_final = final_by_date.get(prior_ds)
if prior_final is None:
prior_final = 0 # Allow training even without prior year for revenue metrics
training_rows.append({
'ds': ds,
'y': final,
'lag_364': float(prior_final) if prior_final else 0
})
if len(training_rows) < 30:
logger.warning(f"Insufficient data for XGBoost training: {len(training_rows)} rows")
return []
df = pd.DataFrame(training_rows)
df['ds'] = pd.to_datetime(df['ds'])
# Convert to occupancy if needed
if metric == "occupancy" and default_bookable_cap > 0:
df["y"] = (df["y"] / default_bookable_cap) * 100
if "current_otb" in df.columns:
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
if "prior_otb_same_lead" in df.columns:
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
# Create time-based features
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:
logger.warning(f"Insufficient data after creating features: {len(df_train)} rows")
return []
# Define features based on metric type
if is_room_based:
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:
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
X_train = df_train[feature_cols]
y_train = df_train['y']
# Train XGBoost model
model = XGBRegressor(
n_estimators=100,
max_depth=6,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42,
n_jobs=-1
)
model.fit(X_train, y_train)
# Create future dataframe for forecast period
future_dates = []
current_date = start_date
while current_date <= end_date:
if (current_date - today).days >= 0:
future_dates.append(current_date)
current_date += timedelta(days=1)
if not future_dates:
logger.warning("No future dates to forecast")
return []
# Generate forecasts for each date
forecasts = []
for forecast_date in future_dates:
lead_days = (forecast_date - today).days
lead_col = get_lead_time_column(lead_days)
prior_year_date = forecast_date - timedelta(days=364)
# Get OTB data only for room-based metrics
current_otb = None
if is_room_based:
current_query = text("""
SELECT booking_count as current_otb
FROM newbook_bookings_stats
WHERE date = :arrival_date
""")
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
current_row = current_result.fetchone()
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
# Get prior year final using metric mapping
prior_query = f"""
SELECT {col_expr} as prior_final
{from_clause}
WHERE s.date = :prior_date
"""
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
prior_row = prior_result.fetchone()
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
# Get per-date bookable cap for this forecast date
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
# Build features for this date
forecast_dt = pd.Timestamp(forecast_date)
lag_364_val = prior_final if prior_final else 0
# Convert to occupancy if needed
if metric == "occupancy" and date_bookable_cap > 0:
if current_otb is not None:
current_otb = (current_otb / date_bookable_cap) * 100
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
# Build features based on metric type
if is_room_based:
# Get prior OTB at same lead time
prior_year_for_otb = forecast_date - timedelta(days=364)
prior_otb_query = text(f"""
SELECT {lead_col} as prior_otb
FROM newbook_booking_pace
WHERE arrival_date = :prior_date
""")
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
prior_otb_row = prior_otb_result.fetchone()
prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0
if metric == "occupancy" and date_bookable_cap > 0:
prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0
current_otb_val = current_otb if current_otb is not None else 0
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
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 forecast_date in special_date_set else 0,
'days_out': lead_days,
'current_otb': current_otb_val,
'prior_otb_same_lead': prior_otb_same_lead,
'lag_364': lag_364_val,
'otb_pct_of_prior_final': otb_pct_of_prior_final,
}])
else:
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 forecast_date in special_date_set else 0,
'lag_364': lag_364_val,
}])
# Predict
yhat = float(model.predict(features)[0])
# Cap at max capacity based on metric type (uses per-date bookable cap)
if is_pct_metric:
yhat = min(max(yhat, 0), 100.0)
elif metric == 'rooms':
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
elif metric == 'guests':
yhat = round(max(yhat, 0))
else:
# Revenue/rate metrics: just ensure non-negative
yhat = max(yhat, 0)
# Floor forecast to current OTB (room-based only)
if is_room_based and current_otb is not None and yhat < current_otb:
yhat = current_otb
# Round based on metric type
if metric == "occupancy":
yhat = round(yhat, 1)
else:
yhat = round_towards_reference(yhat, prior_final)
forecasts.append({
'forecast_date': forecast_date,
'predicted_value': yhat
})
logger.info(f"XGBoost tuned generated {len(forecasts)} forecasts for {metric_code}")
return forecasts

View file

@ -0,0 +1,443 @@
"""
Newbook API Client
CRITICAL: This client is READ-ONLY.
Newbook API uses POST for all requests - the "action" parameter determines the operation.
This client ONLY uses read actions (bookings_list, site_list, report_*).
NO write actions (booking_create, booking_update, booking_cancel, etc.) are used.
Data flows ONE WAY: Newbook Local Database
"""
import os
import httpx
import asyncio
import logging
from datetime import date, timedelta
from typing import Optional, List
logger = logging.getLogger(__name__)
class NewbookAPIError(Exception):
"""Custom exception for Newbook API errors"""
pass
class NewbookClient:
"""
Async client for Newbook REST API
Rate limiting: ~100 requests/min, using 0.75s delay between requests
Pagination: Uses data_offset/data_limit, max 1000 per request
"""
BASE_URL = "https://api.newbook.cloud/rest"
def __init__(self, api_key: str = None, username: str = None, password: str = None, region: str = None):
# Use provided credentials or fall back to environment variables
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
self.username = username or os.getenv("NEWBOOK_USERNAME")
self.password = password or os.getenv("NEWBOOK_PASSWORD")
self.region = region or os.getenv("NEWBOOK_REGION")
if not all([self.api_key, self.username, self.password, self.region]):
logger.warning("Newbook credentials not fully configured")
def _get_url(self, endpoint: str) -> str:
"""Get full URL for an endpoint"""
return f"{self.BASE_URL}/{endpoint}"
@classmethod
async def from_db(cls, db):
"""Create client with credentials from database"""
from api.config import _get_config_value
api_key = await _get_config_value(db, "newbook_api_key")
username = await _get_config_value(db, "newbook_username")
password = await _get_config_value(db, "newbook_password")
region = await _get_config_value(db, "newbook_region")
return cls(api_key=api_key, username=username, password=password, region=region)
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=300.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
def _get_auth_payload(self) -> dict:
"""Get base authentication payload (api_key and region only - username/password go in Basic Auth)"""
return {
"api_key": self.api_key,
"region": self.region
}
async def test_connection(self) -> bool:
"""Test API connection"""
try:
payload = self._get_auth_payload()
response = await self.client.post(
self._get_url("site_list"),
json=payload,
auth=(self.username, self.password)
)
return response.status_code == 200
except Exception as e:
logger.error(f"Newbook connection test failed: {e}")
return False
async def get_bookings(
self,
modified_since: Optional[str] = None,
modified_until: Optional[str] = None,
batch_size: int = 1000
) -> List[dict]:
"""
Fetch all bookings with pagination and rate limiting.
Uses list_type="all" which returns all bookings (including cancelled).
period_from/period_to filter by created/modified timestamp, not stay dates.
Args:
modified_since: ISO timestamp - only bookings created/modified after this
modified_until: ISO timestamp - only bookings created/modified before this
batch_size: Records per request (max 1000)
Returns:
List of booking objects (all statuses including cancelled)
"""
all_bookings = []
offset = 0
while True:
logger.info(f"Fetching Newbook bookings (all): modified_since={modified_since} (offset: {offset})")
payload = self._get_auth_payload()
payload.update({
"list_type": "all",
"data_offset": offset,
"data_limit": batch_size
})
# Add optional timestamp filters
if modified_since:
payload["period_from"] = modified_since
if modified_until:
payload["period_to"] = modified_until
response = await self.client.post(
self._get_url("bookings_list"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
logger.error(f"Newbook API error {response.status_code}: {response.text}")
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
data = response.json()
if not data.get("success"):
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
bookings = data.get("data", [])
if not bookings:
break
all_bookings.extend(bookings)
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
# Check if we've got all records
total = data.get("data_total", 0)
if offset + len(bookings) >= total:
break
offset += batch_size
# Rate limiting: 0.75s delay
await asyncio.sleep(0.75)
logger.info(f"Total bookings fetched: {len(all_bookings)}")
return all_bookings
async def get_bookings_by_stay_dates(
self,
from_date: date,
to_date: date,
list_type: str = "staying",
batch_size: int = 1000
) -> List[dict]:
"""
Fetch bookings by stay dates (arrival/departure/staying period).
Args:
from_date: Start date for stay period
to_date: End date for stay period
list_type: Type of booking list:
"staying" - bookings staying during dates (excludes cancelled)
"arrived" - arrived during dates (add mode="projected" for expected)
"arriving" - expected to arrive before period_to
"departed" - departed during dates
"departing" - expected to depart during dates
"cancelled" - cancelled during dates
"placed" - created during dates
"no_show" - no shows for dates
batch_size: Records per request (max 1000)
Returns:
List of booking objects
"""
all_bookings = []
offset = 0
while True:
logger.info(f"Fetching Newbook bookings ({list_type}): {from_date} to {to_date} (offset: {offset})")
payload = self._get_auth_payload()
payload.update({
"list_type": list_type,
"period_from": from_date.isoformat(),
"period_to": to_date.isoformat(),
"data_offset": offset,
"data_limit": batch_size
})
response = await self.client.post(
self._get_url("bookings_list"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
logger.error(f"Newbook API error {response.status_code}: {response.text}")
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
data = response.json()
if not data.get("success"):
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
bookings = data.get("data", [])
if not bookings:
break
all_bookings.extend(bookings)
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
# Check if we've got all records
total = data.get("data_total", 0)
if offset + len(bookings) >= total:
break
offset += batch_size
# Rate limiting: 0.75s delay
await asyncio.sleep(0.75)
logger.info(f"Total bookings fetched: {len(all_bookings)}")
return all_bookings
async def get_occupancy_report(
self,
from_date: date,
to_date: date
) -> List[dict]:
"""
Fetch occupancy report for date range.
Uses reports_occupancy endpoint which returns data by room category.
Returns all categories with nested occupancy data for each date in range.
No pagination needed - API returns full dataset in single response.
Response format:
[
{
"category_id": "1",
"category_name": "Single Room",
"occupancy": {
"2024-08-01": {
"date": "2024-08-01",
"available": 5,
"occupied": 3,
"maintenance": 1,
"allotted": 0,
"revenue_gross": 450.00,
"revenue_net": 375.00
},
...
}
},
...
]
Returns list of category objects with nested occupancy by date
"""
logger.info(f"Fetching occupancy report: {from_date} to {to_date}")
payload = self._get_auth_payload()
payload.update({
"period_from": f"{from_date.isoformat()} 00:00:00",
"period_to": f"{to_date.isoformat()} 23:59:59"
})
response = await self.client.post(
self._get_url("reports_occupancy"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
raise NewbookAPIError(f"Failed to fetch occupancy: {response.status_code}")
data = response.json()
if not data.get("success"):
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
records = data.get("data", [])
logger.info(f"Fetched occupancy report: {len(records)} categories")
return records
async def get_site_list(self) -> List[dict]:
"""Fetch list of rooms/sites with categories"""
payload = self._get_auth_payload()
response = await self.client.post(
self._get_url("site_list"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
raise NewbookAPIError(f"Failed to fetch site list: {response.status_code}")
data = response.json()
return data.get("data", [])
async def get_earned_revenue(
self,
from_date: date,
to_date: date
) -> dict:
"""
Fetch earned revenue report day by day
Returns dict keyed by date with revenue breakdown by GL code
"""
all_revenue = {}
current_date = from_date
while current_date <= to_date:
logger.info(f"Fetching earned revenue for {current_date}")
payload = self._get_auth_payload()
payload.update({
"period_from": current_date.isoformat(),
"period_to": current_date.isoformat()
})
response = await self.client.post(
self._get_url("reports_earned_revenue"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code == 200:
data = response.json()
if data.get("success"):
day_data = data.get("data", {})
# Debug: log first day's response structure
if current_date == from_date:
import json
logger.info(f"Sample earned revenue response: {json.dumps(day_data)[:500]}")
all_revenue[current_date.isoformat()] = day_data
current_date += timedelta(days=1)
# Rate limiting
await asyncio.sleep(0.75)
return all_revenue
async def get_transaction_flow(
self,
from_date: date,
to_date: date,
batch_size: int = 5000
) -> List[dict]:
"""
Fetch transaction flow report for date range.
Used by reconciliation module for payment categorization.
Returns raw transaction records (payments, refunds, voided items).
Excludes balance_transfer items.
Handles pagination via data_offset/data_limit.
"""
logger.info(f"Fetching transaction flow: {from_date} to {to_date}")
all_transactions = []
offset = 0
while True:
payload = self._get_auth_payload()
payload.update({
"period_from": f"{from_date.isoformat()} 00:00:00",
"period_to": f"{to_date.isoformat()} 23:59:59",
"data_offset": offset,
"data_limit": batch_size
})
response = await self.client.post(
self._get_url("reports_transaction_flow"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
raise NewbookAPIError(f"Failed to fetch transaction flow: {response.status_code}")
data = response.json()
if not data.get("success"):
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
records = data.get("data", [])
all_transactions.extend(records)
# If we got fewer records than the limit, we're done
if len(records) < batch_size:
break
offset += batch_size
await asyncio.sleep(0.75)
logger.info(f"Fetched transaction flow: {len(all_transactions)} transactions")
return all_transactions
async def get_gl_account_list(self) -> List[dict]:
"""
Fetch GL account list from Newbook.
Used for reconciliation sales breakdown column configuration.
"""
logger.info("Fetching GL account list from Newbook")
payload = self._get_auth_payload()
response = await self.client.post(
self._get_url("gl_account_list"),
json=payload,
auth=(self.username, self.password)
)
if response.status_code != 200:
raise NewbookAPIError(f"Failed to fetch GL accounts: {response.status_code}")
data = response.json()
if not data.get("success"):
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
records = data.get("data", [])
logger.info(f"Fetched {len(records)} GL accounts")
return records

View file

@ -0,0 +1,855 @@
"""
Newbook Rates Client
Fetches current rack rates from Newbook API for revenue forecasting.
Uses the bookings_availability_pricing endpoint to simulate booking requests.
This client is READ-ONLY - it only queries available rates, never creates bookings.
"""
import os
import httpx
import asyncio
import logging
from datetime import date, timedelta
from decimal import Decimal
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
class NewbookRatesError(Exception):
"""Custom exception for Newbook rates API errors"""
pass
class NewbookRatesClient:
"""
Async client for fetching current rates from Newbook API.
Uses bookings_availability_pricing endpoint which simulates a booking request.
Handles minimum stay restrictions by extending the stay period when needed.
Rate limiting: ~100 requests/min, using 0.75s delay between requests
"""
BASE_URL = "https://api.newbook.cloud/rest"
def __init__(self, api_key: str = None, username: str = None, password: str = None,
region: str = None, vat_rate: Decimal = Decimal('0.20')):
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
self.username = username or os.getenv("NEWBOOK_USERNAME")
self.password = password or os.getenv("NEWBOOK_PASSWORD")
self.region = region or os.getenv("NEWBOOK_REGION")
self.vat_rate = vat_rate
if not all([self.api_key, self.username, self.password, self.region]):
logger.warning("Newbook credentials not fully configured")
def _get_url(self, endpoint: str) -> str:
"""Get full URL for an endpoint"""
return f"{self.BASE_URL}/{endpoint}"
@classmethod
async def from_db(cls, db):
"""Create client with credentials and VAT rate from database"""
from sqlalchemy import text
# Get credentials from config
result = await db.execute(
text("SELECT config_key, config_value FROM system_config WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')")
)
rows = result.fetchall()
config = {row.config_key: row.config_value for row in rows}
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
return cls(
api_key=config.get('newbook_api_key'),
username=config.get('newbook_username'),
password=config.get('newbook_password'),
region=config.get('newbook_region'),
vat_rate=vat_rate
)
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=60.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
def _get_auth_payload(self) -> dict:
"""Get base authentication payload"""
return {
"api_key": self.api_key,
"region": self.region
}
async def get_category_rates(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> List[Dict]:
"""
Fetch current rates for a category over a date range.
Uses daily=true to get per-night rates. Handles minimum stay
restrictions by extending the period when needed.
Args:
category_id: Newbook category ID
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
List of dicts with {date, gross_rate, net_rate}
"""
rates = []
current_date = from_date
while current_date <= to_date:
try:
# Fetch rates for up to 7 days at a time to optimize API calls
batch_end = min(current_date + timedelta(days=6), to_date)
batch_rates = await self._fetch_rates_batch(
category_id, current_date, batch_end, guests_adults, guests_children
)
rates.extend(batch_rates)
# Move to next batch
current_date = batch_end + timedelta(days=1)
except Exception as e:
logger.error(f"Failed to fetch rates for category {category_id} starting {current_date}: {e}")
# Skip this batch and continue
current_date = current_date + timedelta(days=7)
# Rate limiting - ALWAYS wait 1.5s between requests, even after errors
await asyncio.sleep(1.5)
return rates
async def get_single_night_rates(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> List[Dict]:
"""
Fetch rates with single-night queries for accurate per-day tariff availability.
Unlike get_category_rates which batches, this queries each date individually
as a 1-night stay. This gives accurate tariff_success per night, catching
issues like Valentine's Day blocking only that night, not a whole week.
Much slower but necessary for accurate bookability data.
Args:
category_id: Newbook category ID
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
List of dicts with {date, gross_rate, net_rate, tariffs_data}
"""
rates = []
current_date = from_date
while current_date <= to_date:
try:
# Single-night query for accurate tariff availability
batch_rates = await self._fetch_rates_batch(
category_id, current_date, current_date, guests_adults, guests_children
)
rates.extend(batch_rates)
except Exception as e:
logger.warning(f"Failed to fetch single-night rate for {category_id} on {current_date}: {e}")
# Continue with next date
current_date += timedelta(days=1)
# Rate limiting - wait between each single-night query
await asyncio.sleep(1.0)
return rates
async def fetch_single_date_all_categories(
self,
for_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch single-night rates for ALL categories for one date.
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
return await self._fetch_all_categories_batch(
for_date, guests_adults, guests_children
)
async def fetch_multi_night_for_date(
self,
for_date: date,
nights: int,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, Dict[str, bool]]:
"""
Fetch multi-night availability for ALL categories for one date.
Returns:
Dict of {category_id: {tariff_name: available}}
"""
return await self._fetch_all_categories_multi_night(
for_date, nights, guests_adults, guests_children
)
async def get_all_categories_single_night_rates(
self,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch rates for ALL categories with single-night queries.
More efficient than get_single_night_rates - omits category_id to get
all categories in a single API call per date. This reduces API calls
from (categories × days) to just (days).
Args:
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}, ...]}
"""
all_rates: Dict[str, List[Dict]] = {}
current_date = from_date
total_days = (to_date - from_date).days + 1
day_count = 0
while current_date <= to_date:
day_count += 1
try:
# Single-night query WITHOUT category_id - returns ALL categories
category_rates = await self._fetch_all_categories_batch(
current_date, guests_adults, guests_children
)
# Merge into all_rates dict
for cat_id, rates in category_rates.items():
if cat_id not in all_rates:
all_rates[cat_id] = []
all_rates[cat_id].extend(rates)
logger.info(f"Fetched {current_date} ({day_count}/{total_days}) - {len(category_rates)} categories")
except Exception as e:
logger.warning(f"Failed to fetch rates for {current_date}: {e}")
# Continue with next date
current_date += timedelta(days=1)
# Rate limiting - wait between each query
await asyncio.sleep(1.0)
return all_rates
async def _fetch_all_categories_batch(
self,
for_date: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch rates for ALL categories for a single date.
Omits category_id from request - Newbook returns all available categories.
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
# Single-night query
period_from = f"{for_date.isoformat()} 14:00:00"
period_to = f"{(for_date + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"daily_mode": "true"
# NO category_id - returns all categories
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
await asyncio.sleep(wait_time)
return await self._fetch_all_categories_batch(
for_date, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse all categories from response
return self._parse_all_categories_tariffs(data, for_date)
async def _fetch_all_categories_multi_night(
self,
for_date: date,
nights: int,
guests_adults: int = 2,
guests_children: int = 0,
retry_count: int = 0
) -> Dict[str, Dict[str, bool]]:
"""
Fetch multi-night availability for ALL categories for a specific date.
Used to verify that rates with min_stay requirements are actually bookable.
Args:
for_date: Check-in date
nights: Number of nights to query (e.g., 2 for min_stay=2)
guests_adults: Number of adult guests
guests_children: Number of child guests
Returns:
Dict of {category_id: {tariff_name: available}}
"""
# Multi-night query
period_from = f"{for_date.isoformat()} 14:00:00"
period_to = f"{(for_date + timedelta(days=nights)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"daily_mode": "true"
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited (multi-night), waiting {wait_time}s")
await asyncio.sleep(wait_time)
return await self._fetch_all_categories_multi_night(
for_date, nights, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse availability by tariff name for each category
results: Dict[str, Dict[str, bool]] = {}
if not isinstance(data.get("data"), dict):
return results
for key, cat_data in data["data"].items():
if not (key.isdigit() or str(key).isnumeric()):
continue
if not isinstance(cat_data, dict):
continue
category_id = str(key)
tariffs_available = cat_data.get("tariffs_available", [])
results[category_id] = {}
for tariff in tariffs_available:
tariff_name = tariff.get("tariff_name", "")
tariff_label = tariff.get("tariff_label", "")
# Check tariff_success (API returns string "true"/"false")
tariff_success = str(tariff.get("tariff_success", False)).lower() in ("true", "1")
# Available if API says success, OR if rates are quoted and no restriction message
is_available = tariff_success or (
bool(tariff.get("tariffs_quoted")) and not tariff.get("tariff_message")
)
# Store under both tariff_name and tariff_label for flexible matching
results[category_id][tariff_name] = is_available
if tariff_label and tariff_label != tariff_name:
results[category_id][tariff_label] = is_available
return results
async def get_multi_night_availability(
self,
dates_by_nights: Dict[int, List[date]],
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[date, Dict[str, Dict[str, bool]]]:
"""
Fetch multi-night availability for specific dates grouped by stay length.
Checks if a tariff is available when booking N nights starting from each date.
Args:
dates_by_nights: Dict of {nights: [dates]} e.g., {2: [date1, date2], 3: [date3]}
guests_adults: Number of adult guests
guests_children: Number of child guests
Returns:
Dict of {date: {category_id: {tariff_name: available}}}
"""
results: Dict[date, Dict[str, Dict[str, bool]]] = {}
total_queries = sum(len(dates) for dates in dates_by_nights.values())
query_count = 0
for nights, dates in dates_by_nights.items():
for query_date in dates:
query_count += 1
try:
result = await self._fetch_all_categories_multi_night(
query_date, nights, guests_adults, guests_children
)
results[query_date] = result
logger.info(f"Multi-night check {query_count}/{total_queries}: {query_date} ({nights} nights)")
except Exception as e:
logger.warning(f"Failed multi-night check for {query_date}: {e}")
# Rate limiting
await asyncio.sleep(1.0)
return results
async def _fetch_rates_batch(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> List[Dict]:
"""
Fetch rates for a batch of dates (up to 7 days).
Handles minimum stay restrictions by extending the period and
extracting only the dates we need.
Returns:
List of dicts with {date, gross_rate, net_rate}
"""
# Format dates with times (check-in 14:00, check-out 10:00)
period_from = f"{from_date.isoformat()} 14:00:00"
period_to = f"{(to_date + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"category_id": category_id,
"daily_mode": "true" # Get per-night breakdown
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1) # 60s, 120s, 180s
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
await asyncio.sleep(wait_time)
return await self._fetch_rates_batch(
category_id, from_date, to_date, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
# Check if minimum stay restriction
categories = data.get("data", {}).get("categories", [])
if categories:
cat = categories[0] if isinstance(categories, list) else categories.get(category_id, {})
min_periods = cat.get("minimum_periods", 1)
if min_periods > 1:
# Extend the stay to meet minimum and retry
extended_to = from_date + timedelta(days=min_periods)
logger.info(f"Minimum stay {min_periods} nights for category {category_id}, extending to {extended_to}")
return await self._fetch_rates_with_min_stay(
category_id, from_date, to_date, extended_to,
guests_adults, guests_children
)
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse tariffs_quoted from response
return self._parse_tariffs(data, from_date, to_date)
async def _fetch_rates_with_min_stay(
self,
category_id: str,
from_date: date,
to_date: date,
extended_to: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> List[Dict]:
"""
Fetch rates with extended period for minimum stay requirement.
Args:
category_id: Newbook category ID
from_date: Original start date
to_date: Original end date (dates we want)
extended_to: Extended end date to meet minimum stay
guests_adults: Number of adults
guests_children: Number of children
Returns:
List of rates for the original date range only
"""
period_from = f"{from_date.isoformat()} 14:00:00"
period_to = f"{(extended_to + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"category_id": category_id,
"daily_mode": "true"
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry")
await asyncio.sleep(wait_time)
return await self._fetch_rates_with_min_stay(
category_id, from_date, to_date, extended_to, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure even with extended stay: {data.get('message')}")
# Parse tariffs but only return dates in our original range
return self._parse_tariffs(data, from_date, to_date)
def _parse_tariffs(self, data: dict, from_date: date, to_date: date) -> List[Dict]:
"""
Parse tariffs from API response.
With daily_mode=true, the API returns tariffs_quoted as a dict keyed by date.
Falls back to tariffs_available average if tariffs_quoted not available.
Args:
data: Full API response
from_date: Start date to include
to_date: End date to include
Returns:
List of dicts with {date, gross_rate, net_rate, tariffs_data}
tariffs_data contains all available tariff options for rate report
"""
rates = []
tariffs_quoted = {}
fallback_rate = None
inventory_items = []
all_tariffs_available = [] # Store all tariff options for reporting
# Find tariffs data in the response
if isinstance(data.get("data"), dict):
for key in data["data"].keys():
# Category IDs are numeric strings
if key.isdigit() or key.isnumeric():
cat_data = data["data"][key]
if isinstance(cat_data, dict):
tariffs_available = cat_data.get("tariffs_available", [])
all_tariffs_available = tariffs_available # Capture all options
if tariffs_available:
first_tariff = tariffs_available[0]
# tariffs_quoted is a dict keyed by date string
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
# inventory_items are at tariff level (total for whole stay)
inventory_items = first_tariff.get("inventory_items", [])
# Fallback average rate
fallback_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
break
# If we have per-night tariffs_quoted dict, parse it
if isinstance(tariffs_quoted, dict) and tariffs_quoted:
num_nights = len(tariffs_quoted)
# Calculate per-night inventory item amount for items already included in tariff
included_inventory_per_night = Decimal('0')
for item in inventory_items:
already_included = item.get('amount_already_included_in_tariff_total', '')
if str(already_included).lower() == 'true':
total_amount = Decimal(str(item.get('amount', 0) or 0))
included_inventory_per_night += total_amount / num_nights
for date_str, tariff in tariffs_quoted.items():
try:
stay_date = date.fromisoformat(date_str)
except ValueError:
continue
# Only include dates in our range
if stay_date < from_date or stay_date > to_date:
continue
gross_rate = Decimal(str(tariff.get('amount', 0) or 0))
# Net = (gross - included_inventory_per_night) / (1 + VAT)
gross_after_inventory = gross_rate - included_inventory_per_night
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
# Build tariffs_data with day-specific rates
tariffs_data = self._build_tariffs_summary(all_tariffs_available, stay_date)
rates.append({
'date': stay_date,
'gross_rate': float(gross_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
})
return rates
# Fallback: use average_nightly_tariff and apply to all dates
if fallback_rate and fallback_rate > 0:
net_rate = (fallback_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
current_date = from_date
while current_date <= to_date:
# Build tariffs_data (no day-specific rates in fallback)
tariffs_data = self._build_tariffs_summary(all_tariffs_available, current_date)
rates.append({
'date': current_date,
'gross_rate': float(fallback_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
})
current_date += timedelta(days=1)
return rates
logger.warning(f"No rate found in response for {from_date} to {to_date}")
return rates
def _build_tariffs_summary(self, tariffs_available: list, for_date: date = None) -> dict:
"""
Build a summary of all available tariff options for rate reporting.
Args:
tariffs_available: List of tariff dicts from API response
for_date: Optional specific date to extract day-specific rates
Returns:
Dict with tariff summaries - tariff_count and list of tariff details
"""
if not tariffs_available:
return {}
summary = {
'tariff_count': len(tariffs_available),
'tariffs': []
}
date_key = for_date.isoformat() if for_date else None
for idx, tariff in enumerate(tariffs_available):
# Get day-specific rate from tariffs_quoted if available
day_rate = None
if date_key:
tariffs_quoted = tariff.get('tariffs_quoted', {})
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
day_quote = tariffs_quoted[date_key]
if isinstance(day_quote, dict):
day_rate = float(day_quote.get('amount', 0) or 0)
else:
day_rate = float(day_quote or 0)
# API uses tariff_label for the name
message = tariff.get('tariff_message', '')
# Extract minimum stay from message or dedicated field
min_stay = tariff.get('minimum_nights', None)
if min_stay is None and message:
# Try to parse from message like "Minimum 2 nights" or "2 Night Minimum"
import re
match = re.search(r'(\d+)\s*[Nn]ight\s*[Mm]inimum', message)
if not match:
match = re.search(r'[Mm]inimum\s+(\d+)\s*(?:night|period)', message)
if match:
min_stay = int(match.group(1))
# Extract advance booking requirement from message
min_advance_days = None
if message:
import re
advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE)
if advance_match:
min_advance_days = int(advance_match.group(1))
tariff_info = {
'name': tariff.get('tariff_label', 'Unknown'),
'description': tariff.get('tariff_short_description', ''),
'rate': day_rate, # Day-specific rate (None if not available)
'average_nightly': float(tariff.get('average_nightly_tariff', 0) or 0),
'success': str(tariff.get('tariff_success', False)).lower() in ('true', '1'),
'message': message,
'sort_order': idx, # Preserve Newbook ordering
'min_stay': min_stay, # Minimum nights required (if any)
'min_advance_days': min_advance_days, # Advance booking requirement (if any)
}
summary['tariffs'].append(tariff_info)
return summary
def _parse_all_categories_tariffs(self, data: dict, for_date: date) -> Dict[str, List[Dict]]:
"""
Parse tariffs from API response for ALL categories.
When category_id is omitted, data.data contains category IDs as keys,
each with their own tariffs_available.
Args:
data: Full API response
for_date: The date we queried
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
results: Dict[str, List[Dict]] = {}
if not isinstance(data.get("data"), dict):
return results
for key, cat_data in data["data"].items():
# Category IDs are numeric strings like "1", "8", etc.
if not (key.isdigit() or str(key).isnumeric()):
continue
if not isinstance(cat_data, dict):
continue
category_id = str(key)
tariffs_available = cat_data.get("tariffs_available", [])
if not tariffs_available:
continue
# Get the first (best) tariff for gross/net calculation
first_tariff = tariffs_available[0]
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
inventory_items = first_tariff.get("inventory_items", [])
# Get rate for this date
date_key = for_date.isoformat()
gross_rate = Decimal('0')
net_rate = Decimal('0')
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
day_tariff = tariffs_quoted[date_key]
gross_rate = Decimal(str(day_tariff.get('amount', 0) or 0))
# Calculate included inventory per night
included_inventory = Decimal('0')
for item in inventory_items:
already_included = item.get('amount_already_included_in_tariff_total', '')
if str(already_included).lower() == 'true':
included_inventory += Decimal(str(item.get('amount', 0) or 0))
gross_after_inventory = gross_rate - included_inventory
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
else:
# Fallback to average
gross_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
net_rate = (gross_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
# Build tariffs summary for all options
tariffs_data = self._build_tariffs_summary(tariffs_available, for_date)
results[category_id] = [{
'date': for_date,
'gross_rate': float(gross_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
}]
return results

View file

@ -0,0 +1,531 @@
"""
Reconciliation Business Logic Service
Ported from the WordPress plugin hotel-cashup-reconciliation.
Handles payment categorization, variance calculation, and report aggregation.
"""
import re
import logging
from datetime import date, datetime
from typing import List, Dict, Optional, Any
from decimal import Decimal
logger = logging.getLogger(__name__)
# ============================================
# PAYMENT CATEGORIZATION
# ============================================
def identify_card_type(transaction: dict) -> str:
"""
Categorize a Newbook transaction into a card type.
Ported from PHP: HCR_Newbook_API::identify_card_type()
Returns: 'cash', 'visa_mc', 'amex', 'bacs', or 'other'
"""
# Handle both old 'type' field and new 'payment_type' field
ptype = (transaction.get('payment_type') or transaction.get('type') or '').lower()
method = (transaction.get('method') or '').lower()
transaction_method = (transaction.get('payment_transaction_method') or '').lower()
combined = f"{ptype} {method}"
# Cash must be identified first
if 'cash' in combined:
return 'cash'
# BACS/Bank transfers
if any(kw in combined for kw in ['eft', 'bacs', 'bank transfer', 'banktransfer', 'direct debit']):
return 'bacs'
# Amex - must be explicitly identified
if 'amex' in combined or 'american express' in combined:
return 'amex'
# Visa/Mastercard - must be explicitly identified
if any(kw in combined for kw in ['visa', 'mastercard', 'master card', 'mc']):
return 'visa_mc'
# For gateway/automated transactions, default to visa_mc (most common card type)
if transaction_method in ('automated', 'gateway', 'cc_gateway'):
if any(kw in combined for kw in ['card', 'credit', 'debit']):
return 'visa_mc'
# Gateway transactions are almost always card payments
return 'visa_mc'
if ptype:
logger.warning(f"Unidentified payment type: '{ptype}' (method: '{method}', transaction_method: '{transaction_method}')")
return 'other'
def convert_newbook_amount(amount: float) -> float:
"""
Convert Newbook amount from accounting perspective to revenue perspective.
In Newbook: payments are negative, refunds are positive.
For reconciliation: payments should be positive, refunds negative.
"""
return -float(amount)
def process_transaction(transaction: dict) -> Optional[dict]:
"""
Process a single Newbook transaction into a payment record.
Returns None if the transaction should be skipped.
"""
item_type = transaction.get('item_type', '')
# Only process payments, refunds, and voided transactions
if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'):
return None
# Skip balance transfers (system-generated, always net to zero)
payment_type = transaction.get('payment_type', '')
if payment_type == 'balance_transfer':
return None
amount = convert_newbook_amount(float(transaction.get('item_amount', 0)))
return {
'payment_id': transaction.get('item_id', ''),
'booking_id': str(transaction.get('booking_id', '')),
'guest_name': transaction.get('account_for_name', ''),
'payment_date': transaction.get('item_date', ''),
'payment_type': payment_type,
'payment_method': '',
'transaction_method': transaction.get('payment_transaction_method', 'manual'),
'card_type': identify_card_type(transaction),
'amount': amount,
'tendered': 0,
'processed_by': '',
'item_type': item_type,
'description': transaction.get('item_description', ''),
}
def categorize_payments(raw_transactions: List[dict]) -> List[dict]:
"""
Process raw Newbook API transactions into categorized payment records.
Filters out non-payment items and balance transfers, converts amounts,
and identifies card types.
"""
payments = []
for transaction in raw_transactions:
payment = process_transaction(transaction)
if payment is not None:
payments.append(payment)
return payments
def calculate_payment_totals(payments: List[dict]) -> dict:
"""
Calculate payment totals by reconciliation category.
Ported from PHP: HCR_Newbook_API::calculate_payment_totals()
Categories:
- cash: Physical cash payments
- manual_visa_mc: Card machine (PDQ) Visa/MC payments
- manual_amex: Card machine (PDQ) Amex payments
- gateway_visa_mc: Online/gateway Visa/MC payments
- gateway_amex: Online/gateway Amex payments
- bacs: Bank transfers
"""
totals = {
'cash': 0.0,
'manual_visa_mc': 0.0,
'manual_amex': 0.0,
'gateway_visa_mc': 0.0,
'gateway_amex': 0.0,
'bacs': 0.0
}
for payment in payments:
amount = float(payment.get('amount', 0))
transaction_method = (payment.get('transaction_method') or '').lower()
card_type = payment.get('card_type', '')
if card_type == 'cash':
totals['cash'] += amount
elif card_type == 'bacs':
totals['bacs'] += amount
elif transaction_method == 'manual':
if card_type == 'amex':
totals['manual_amex'] += amount
elif card_type == 'visa_mc':
totals['manual_visa_mc'] += amount
elif transaction_method in ('automated', 'gateway', 'cc_gateway'):
if card_type == 'amex':
totals['gateway_amex'] += amount
elif card_type == 'visa_mc':
totals['gateway_visa_mc'] += amount
# Round all totals to 2 decimal places
return {k: round(v, 2) for k, v in totals.items()}
# ============================================
# TILL SYSTEM TRANSACTIONS
# ============================================
def parse_till_transactions(raw_transactions: List[dict]) -> dict:
"""
Parse till system transactions from Newbook transaction data.
Extracts transactions where method is "manual" and item_description follows:
"Ticket: {number} - {payment_type}"
Returns dict grouped by payment type with count and total.
"""
till_payments = {}
ticket_pattern = re.compile(r'^Ticket:\s*(\d+)\s*-\s*(.+)$', re.IGNORECASE)
for transaction in raw_transactions:
item_type = transaction.get('item_type', '')
if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'):
continue
method = transaction.get('payment_transaction_method', '')
if method != 'manual':
continue
description = transaction.get('item_description', '')
match = ticket_pattern.match(description)
if not match:
continue
payment_type = match.group(2).strip()
# Skip balance transfers
if payment_type == 'balance_transfer':
continue
amount = convert_newbook_amount(float(transaction.get('item_amount', 0)))
if amount == 0:
continue
if payment_type not in till_payments:
till_payments[payment_type] = {
'payment_type': payment_type,
'quantity': 0,
'total': 0.0,
'transactions': []
}
till_payments[payment_type]['quantity'] += 1
till_payments[payment_type]['total'] += amount
till_payments[payment_type]['transactions'].append({
'ticket': match.group(1),
'amount': amount,
'item_type': item_type
})
# Round totals
for key in till_payments:
till_payments[key]['total'] = round(till_payments[key]['total'], 2)
return till_payments
# ============================================
# TRANSACTION BREAKDOWN
# ============================================
def build_transaction_breakdown(payments: List[dict]) -> dict:
"""
Group processed payments into a transaction breakdown for display.
Groups:
- reception_manual: Manual payments at reception (PDQ entered by staff)
- reception_gateway: Automated/gateway payments at reception
- restaurant_bar: Payments from till system (description contains "Ticket:")
Each group is further sub-grouped by payment type label.
Returns dict of groups, each containing sub-groups with transaction lists.
"""
ticket_pattern = re.compile(r'Ticket:\s*(\d+)\s*-\s*(.+)', re.IGNORECASE)
reception_manual: Dict[str, list] = {}
reception_gateway: Dict[str, list] = {}
restaurant_bar: Dict[str, list] = {}
for p in payments:
transaction_method = (p.get('transaction_method') or '').lower()
card_type = p.get('card_type', 'other')
payment_type = p.get('payment_type', '')
item_type = p.get('item_type', '')
amount = float(p.get('amount', 0))
guest_name = p.get('guest_name', '')
payment_date = p.get('payment_date', '')
description = p.get('description', '')
is_voided = item_type in ('payments_voided', 'refunds_voided')
# Extract time from date string
time_str = ''
if payment_date and ' ' in str(payment_date):
time_str = str(payment_date).split(' ')[1][:5] # HH:MM
# Determine display type label
type_label = payment_type.title() if payment_type else 'Other'
if card_type == 'cash':
type_label = 'Cash'
elif card_type == 'bacs':
type_label = 'BACS'
elif card_type == 'amex':
type_label = 'Amex'
elif card_type == 'visa_mc':
type_label = 'Card'
# Check for restaurant/bar till ticket pattern in description
ticket_match = ticket_pattern.search(description) if description else None
details = guest_name
if ticket_match:
ticket_num = ticket_match.group(1)
ticket_type = ticket_match.group(2).strip()
details = f"Ticket #{ticket_num} - {ticket_type}"
type_label = ticket_type.title() if ticket_type else type_label
entry = {
'time': time_str,
'type': type_label,
'details': details,
'amount': round(amount, 2),
'is_voided': is_voided,
'is_refund': item_type in ('refunds_raised', 'refunds_voided'),
'item_type': item_type,
'payment_id': p.get('payment_id', ''),
'booking_id': p.get('booking_id', ''),
}
# Route to appropriate group
if ticket_match:
if type_label not in restaurant_bar:
restaurant_bar[type_label] = []
restaurant_bar[type_label].append(entry)
elif transaction_method in ('automated', 'gateway', 'cc_gateway'):
if type_label not in reception_gateway:
reception_gateway[type_label] = []
reception_gateway[type_label].append(entry)
else:
# Manual and default go to reception_manual
if type_label not in reception_manual:
reception_manual[type_label] = []
reception_manual[type_label].append(entry)
# Calculate subtotals for each group
def with_subtotals(group: Dict[str, list]) -> dict:
result = {}
group_total = 0.0
group_count = 0
for key, transactions in group.items():
subtotal = round(sum(t['amount'] for t in transactions), 2)
result[key] = {
'transactions': transactions,
'subtotal': subtotal,
'count': len(transactions),
}
group_total += subtotal
group_count += len(transactions)
return {'groups': result, 'total': round(group_total, 2), 'count': group_count}
return {
'reception_manual': with_subtotals(reception_manual),
'reception_gateway': with_subtotals(reception_gateway),
'restaurant_bar': with_subtotals(restaurant_bar),
}
# ============================================
# VARIANCE CALCULATION
# ============================================
def calculate_variance(banked: float, reported: float) -> float:
"""
Calculate variance between banked (manual count) and reported (Newbook).
Positive = over (extra cash/payments found)
Negative = short (missing cash/payments)
"""
return round(banked - reported, 2)
def get_variance_status(variance: float, threshold: float = 10.0) -> str:
"""
Determine variance status for display.
Returns: 'balanced', 'over', or 'short'
"""
if abs(variance) <= threshold:
return 'balanced'
elif variance > 0:
return 'over'
else:
return 'short'
def build_reconciliation_rows(
banked_totals: dict,
reported_totals: dict
) -> List[dict]:
"""
Build reconciliation comparison rows for each category.
banked_totals: From manual entry (cash count + card machines)
reported_totals: From Newbook payments
Returns list of rows with category, banked, reported, variance.
"""
categories = [
('Cash', 'cash'),
('PDQ Visa/MC', 'manual_visa_mc'),
('PDQ Amex', 'manual_amex'),
('Gateway Visa/MC', 'gateway_visa_mc'),
('Gateway Amex', 'gateway_amex'),
('BACS', 'bacs'),
]
rows = []
for label, key in categories:
banked = banked_totals.get(key, 0.0)
reported = reported_totals.get(key, 0.0)
variance = calculate_variance(banked, reported)
rows.append({
'category': label,
'key': key,
'banked_amount': round(banked, 2),
'reported_amount': round(reported, 2),
'variance': variance,
'status': get_variance_status(variance)
})
return rows
# ============================================
# MULTI-DAY REPORT AGGREGATION
# ============================================
def build_multi_day_report(
cash_ups: List[dict],
payment_totals_by_date: Dict[str, dict],
daily_stats: List[dict],
sales_breakdown: List[dict],
) -> dict:
"""
Build multi-day report with 3 tables:
1. Daily Reconciliation Summary (banked vs reported by category per day)
2. Sales Breakdown (GL categories vs days)
3. Occupancy Stats (rooms, people, rates per day)
Returns dict with three table datasets.
"""
# Table 1: Daily Reconciliation Summary
recon_summary = []
total_banked = {
'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0,
'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0
}
total_reported = {
'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0,
'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0
}
for cash_up in cash_ups:
date_str = cash_up['session_date']
reported = payment_totals_by_date.get(date_str, {})
# Build banked totals from cash_up data
banked = {
'cash': float(cash_up.get('total_cash_counted', 0)),
'manual_visa_mc': 0.0,
'manual_amex': 0.0,
'gateway_visa_mc': 0.0,
'gateway_amex': 0.0,
'bacs': 0.0
}
# Card machine totals from cash_up
for card in cash_up.get('card_machines', []):
machine_name = card.get('machine_name', '').lower()
banked['manual_visa_mc'] += float(card.get('visa_mc_amount', 0))
banked['manual_amex'] += float(card.get('amex_amount', 0))
# Reported amounts from Newbook
reported_amounts = {
'cash': float(reported.get('cash', 0)),
'manual_visa_mc': float(reported.get('manual_visa_mc', 0)),
'manual_amex': float(reported.get('manual_amex', 0)),
'gateway_visa_mc': float(reported.get('gateway_visa_mc', 0)),
'gateway_amex': float(reported.get('gateway_amex', 0)),
'bacs': float(reported.get('bacs', 0)),
}
# Calculate row variances
row_variance = {}
for key in banked:
row_variance[key] = round(banked[key] - reported_amounts[key], 2)
total_banked[key] += banked[key]
total_reported[key] += reported_amounts[key]
recon_summary.append({
'date': date_str,
'status': cash_up.get('status', ''),
'banked': {k: round(v, 2) for k, v in banked.items()},
'reported': {k: round(v, 2) for k, v in reported_amounts.items()},
'variance': row_variance,
'banked_total': round(sum(banked.values()), 2),
'reported_total': round(sum(reported_amounts.values()), 2),
})
# Totals row
total_variance = {}
for key in total_banked:
total_variance[key] = round(total_banked[key] - total_reported[key], 2)
recon_totals = {
'banked': {k: round(v, 2) for k, v in total_banked.items()},
'reported': {k: round(v, 2) for k, v in total_reported.items()},
'variance': total_variance,
'banked_total': round(sum(total_banked.values()), 2),
'reported_total': round(sum(total_reported.values()), 2),
}
# Table 2: Sales Breakdown
sales_by_date = {}
all_categories = set()
for row in sales_breakdown:
d = row['business_date']
cat = row['category']
amt = float(row['net_amount'])
all_categories.add(cat)
if d not in sales_by_date:
sales_by_date[d] = {}
sales_by_date[d][cat] = amt
# Table 3: Occupancy Stats
occupancy_data = []
for stat in daily_stats:
occupancy_data.append({
'date': stat['business_date'],
'gross_sales': float(stat.get('gross_sales', 0)),
'rooms_sold': int(stat.get('rooms_sold', 0)),
'total_people': int(stat.get('total_people', 0)),
'debtors_creditors': float(stat.get('debtors_creditors_balance', 0)),
})
return {
'reconciliation_summary': {
'rows': recon_summary,
'totals': recon_totals,
},
'sales_breakdown': {
'categories': sorted(list(all_categories)),
'by_date': sales_by_date,
},
'occupancy': {
'rows': occupancy_data,
}
}

View file

@ -0,0 +1,177 @@
"""
Resos API Client
CRITICAL: This client is READ-ONLY. All methods use GET requests only.
NO data is written, modified, or deleted in Resos.
Data flows ONE WAY: Resos Local Database
"""
import os
import httpx
import base64
import asyncio
import logging
from datetime import date, timedelta
from typing import List, Optional
logger = logging.getLogger(__name__)
class ResosAPIError(Exception):
"""Custom exception for Resos API errors"""
pass
class ResosClient:
"""
Async client for Resos API
Rate limiting: ~60 requests/min, using 1s delay between requests
Pagination: Uses skip/limit, max 100 per request
Date filtering: Uses fromDateTime/toDateTime
"""
BASE_URL = "https://api.resos.com/v1"
def __init__(self, api_key: str = None):
# Use provided credentials or fall back to environment variables
self.api_key = api_key or os.getenv("RESOS_API_KEY")
if self.api_key:
# HTTP Basic Auth: base64_encode(api_key + ':')
self.auth_header = f"Basic {base64.b64encode(f'{self.api_key}:'.encode()).decode()}"
else:
self.auth_header = None
logger.warning("Resos API key not configured")
@classmethod
async def from_db(cls, db):
"""Create client with credentials from database"""
from api.config import _get_config_value
api_key = await _get_config_value(db, "resos_api_key")
return cls(api_key=api_key)
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=30.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
async def test_connection(self) -> bool:
"""Test API connection by fetching opening hours"""
try:
response = await self.client.get(
f"{self.BASE_URL}/openingHours",
headers={"Authorization": self.auth_header}
)
return response.status_code == 200
except Exception as e:
logger.error(f"Resos connection test failed: {e}")
return False
async def get_bookings(
self,
from_date: date,
to_date: date
) -> List[dict]:
"""
Fetch bookings for date range with pagination and rate limiting.
Returns list of booking objects with structure:
{
'_id': 'booking_id',
'date': '2026-01-20',
'time': '19:00',
'people': 2,
'status': 'confirmed',
'source': 'website',
'guest': {...},
'customFields': [...],
'restaurantNotes': [...]
}
"""
all_bookings = []
offset = 0
from_datetime = f"{from_date}T00:00:00"
to_datetime = f"{to_date}T23:59:59"
while True:
logger.info(f"Fetching Resos bookings: {from_date} to {to_date} (offset: {offset})")
response = await self.client.get(
f"{self.BASE_URL}/bookings",
headers={"Authorization": self.auth_header},
params={
"fromDateTime": from_datetime,
"toDateTime": to_datetime,
"limit": 100,
"skip": offset
}
)
if response.status_code != 200:
error_body = response.text
logger.error(f"Resos API error {response.status_code}: {error_body}")
raise ResosAPIError(f"Failed to fetch bookings: {response.status_code} - {error_body}")
data = response.json()
page_bookings = data if isinstance(data, list) else []
if not page_bookings:
break
all_bookings.extend(page_bookings)
logger.info(f"Fetched {len(page_bookings)} bookings (offset {offset})")
# If we got fewer than the limit, we've reached the end
if len(page_bookings) < 100:
break
offset += 100
# Rate limiting: 1 request per second
await asyncio.sleep(1)
logger.info(f"Total bookings fetched: {len(all_bookings)}")
return all_bookings
async def get_opening_hours(self) -> List[dict]:
"""
Fetch opening hours/service periods
Returns list of opening hour objects:
{
'_id': 'opening_hour_id',
'name': 'Dinner',
'startTime': '18:00',
'endTime': '22:00',
'days': ['monday', 'tuesday', 'wednesday', ...]
}
"""
response = await self.client.get(
f"{self.BASE_URL}/openingHours",
headers={"Authorization": self.auth_header},
params={"showDeleted": "false", "onlySpecial": "false"}
)
if response.status_code != 200:
raise ResosAPIError(f"Failed to fetch opening hours: {response.status_code}")
return response.json()
async def get_custom_field_definitions(self) -> List[dict]:
"""
Fetch custom field definitions
Returns field definitions with choice options for dropdowns/radios
"""
response = await self.client.get(
f"{self.BASE_URL}/customFields",
headers={"Authorization": self.auth_header}
)
if response.status_code != 200:
raise ResosAPIError(f"Failed to fetch custom fields: {response.status_code}")
return response.json()

View file

@ -0,0 +1,20 @@
"""
Scraper backends for booking.com rate scraping.
Provides pluggable backends to allow switching between:
- playwright_local: Direct Playwright (default)
- playwright_proxy: Playwright with rotating proxies (future)
- apify_backend: Apify scraping service (future)
"""
from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus
from .playwright_local import PlaywrightLocalBackend
__all__ = [
'ScraperBackend',
'ScraperResult',
'HotelData',
'RateData',
'AvailabilityStatus',
'PlaywrightLocalBackend',
]

View file

@ -0,0 +1,152 @@
"""
Abstract base class for booking.com scraper backends.
Defines the interface that all scraper backends must implement,
allowing easy switching between local Playwright, proxied Playwright,
or external services like Apify.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import List, Optional, Dict, Any
from enum import Enum
class AvailabilityStatus(str, Enum):
"""Availability status for a hotel rate."""
AVAILABLE = 'available' # Rate found, bookable
SOLD_OUT = 'sold_out' # Hotel shows no availability
NO_DATA = 'no_data' # Couldn't determine (scraper issue)
@dataclass
class RateData:
"""Rate data for a single hotel on a single date."""
hotel_id: Optional[str] = None # Our internal hotel_id (filled after DB lookup)
booking_com_id: str = '' # Hotel ID from booking.com
rate_date: date = None
availability_status: AvailabilityStatus = AvailabilityStatus.NO_DATA
rate_gross: Optional[Decimal] = None
currency: str = 'GBP'
room_type: Optional[str] = None
breakfast_included: Optional[bool] = None
free_cancellation: Optional[bool] = None
no_prepayment: Optional[bool] = None
rooms_left: Optional[int] = None # "Only X rooms left"
available_qty: Optional[int] = None # Future: from hotel page dropdown
@dataclass
class HotelData:
"""Hotel data discovered from search results."""
booking_com_id: str
name: str
booking_com_url: Optional[str] = None
star_rating: Optional[Decimal] = None
review_score: Optional[Decimal] = None
review_count: Optional[int] = None
@dataclass
class ScraperResult:
"""Result from a scraping operation."""
success: bool
blocked: bool = False # True if anti-scrape blocking detected
block_reason: Optional[str] = None # CAPTCHA, rate limit, etc.
hotels: List[HotelData] = field(default_factory=list)
rates: List[RateData] = field(default_factory=list)
error_message: Optional[str] = None
page_content_sample: Optional[str] = None # For debugging
class ScraperBackend(ABC):
"""
Abstract base class for scraper backends.
All backends must implement these methods to provide a consistent
interface for the main booking_scraper.py service.
"""
# Common block detection signals
BLOCK_SIGNALS = [
'captcha',
'unusual traffic',
'access denied',
'please verify',
'too many requests',
'are you a robot',
'verify you are human',
'security check',
]
@abstractmethod
async def scrape_location_search(
self,
location: str,
check_in: date,
check_out: date,
adults: int = 2,
pages: int = 2
) -> ScraperResult:
"""
Scrape booking.com location search results.
Args:
location: Location name (e.g., "Bowness-on-Windermere")
check_in: Check-in date
check_out: Check-out date (typically check_in + 1 for single night)
adults: Number of adults for search
pages: Number of search result pages to scrape
Returns:
ScraperResult with hotels and rates found
"""
pass
@abstractmethod
async def scrape_hotel_page(
self,
hotel_url: str,
check_in: date,
check_out: date,
adults: int = 2
) -> ScraperResult:
"""
Scrape an individual hotel page for detailed rates.
Future expansion - not used in initial implementation.
Will provide available_qty from room dropdowns.
Args:
hotel_url: Full booking.com URL for the hotel
check_in: Check-in date
check_out: Check-out date
adults: Number of adults
Returns:
ScraperResult with detailed rate information
"""
pass
@abstractmethod
async def close(self):
"""Clean up any resources (browser instances, etc.)."""
pass
def detect_blocking(self, page_content: str) -> tuple[bool, Optional[str]]:
"""
Check if page content shows anti-scrape response.
Args:
page_content: HTML content of the page
Returns:
Tuple of (is_blocked, reason)
"""
content_lower = page_content.lower()
for signal in self.BLOCK_SIGNALS:
if signal in content_lower:
return True, signal
return False, None

View file

@ -0,0 +1,401 @@
"""
Local Playwright backend for booking.com scraping.
Uses Playwright with Chromium to scrape search results.
No proxy - direct connection. Suitable for low-volume scraping.
"""
import asyncio
import logging
import random
import re
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import List, Optional
from urllib.parse import urlencode
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
from .base import (
ScraperBackend,
ScraperResult,
HotelData,
RateData,
AvailabilityStatus
)
logger = logging.getLogger(__name__)
class PlaywrightLocalBackend(ScraperBackend):
"""
Local Playwright backend using Chromium.
Features:
- Rotates user agents
- Random delays between requests
- Mimics human scroll behavior
- Uses data-testid selectors for stability
"""
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
]
def __init__(self, proxy_config: dict = None):
"""
Initialize the backend.
Args:
proxy_config: Optional proxy configuration (for future use)
"""
self.proxy_config = proxy_config
self._playwright = None
self._browser: Optional[Browser] = None
async def _ensure_browser(self) -> Browser:
"""Ensure browser is running, start if needed."""
if self._browser is None or not self._browser.is_connected():
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage',
]
)
return self._browser
async def _create_context(self) -> BrowserContext:
"""Create a new browser context with random user agent."""
browser = await self._ensure_browser()
context = await browser.new_context(
user_agent=random.choice(self.USER_AGENTS),
viewport={'width': 1920, 'height': 1080},
locale='en-GB',
timezone_id='Europe/London',
)
return context
def _build_search_url(
self,
location: str,
check_in: date,
check_out: date,
adults: int,
offset: int = 0
) -> str:
"""Build booking.com search URL with parameters."""
params = {
'ss': location,
'checkin': check_in.isoformat(),
'checkout': check_out.isoformat(),
'group_adults': adults,
'no_rooms': 1,
'group_children': 0,
}
if offset > 0:
params['offset'] = offset
return f"https://www.booking.com/searchresults.en-gb.html?{urlencode(params)}"
def _parse_price(self, price_text: str) -> Optional[Decimal]:
"""Parse price from text like '£150' or 'GBP 150'."""
if not price_text:
return None
# Remove currency symbols and extract number
cleaned = re.sub(r'[£$€,\s]', '', price_text)
# Find first number (including decimals)
match = re.search(r'[\d,]+(?:\.\d{2})?', cleaned)
if match:
try:
return Decimal(match.group().replace(',', ''))
except InvalidOperation:
return None
return None
def _extract_hotel_id(self, url: str) -> Optional[str]:
"""Extract hotel ID from booking.com URL."""
if not url:
return None
# URL format: /hotel/gb/hotel-name.en-gb.html or ?dest_id=123
# Try to extract from URL path
match = re.search(r'/hotel/[a-z]{2}/([^/]+)\.', url)
if match:
return match.group(1)
# Try dest_id parameter
match = re.search(r'dest_id=(-?\d+)', url)
if match:
return match.group(1)
return None
async def _human_like_scroll(self, page: Page):
"""Simulate human-like scrolling behavior."""
# Scroll down in increments
for _ in range(3):
await page.mouse.wheel(0, random.randint(300, 600))
await asyncio.sleep(random.uniform(0.3, 0.8))
async def _extract_search_results(self, page: Page, rate_date: date) -> tuple[List[HotelData], List[RateData]]:
"""Extract hotel and rate data from search results page."""
hotels = []
rates = []
# Wait for property cards - booking.com uses data-testid
try:
await page.wait_for_selector('[data-testid="property-card"]', timeout=15000)
except Exception as e:
logger.warning(f"No property cards found: {e}")
return hotels, rates
# Get all property cards
cards = await page.query_selector_all('[data-testid="property-card"]')
logger.info(f"Found {len(cards)} property cards")
for card in cards:
try:
hotel = HotelData(booking_com_id='', name='')
rate = RateData(rate_date=rate_date)
# Hotel name
name_el = await card.query_selector('[data-testid="title"]')
if name_el:
hotel.name = (await name_el.inner_text()).strip()
if not hotel.name:
continue # Skip if no name found
# Hotel URL and ID
link_el = await card.query_selector('[data-testid="title-link"]')
if link_el:
hotel.booking_com_url = await link_el.get_attribute('href')
hotel.booking_com_id = self._extract_hotel_id(hotel.booking_com_url) or ''
rate.booking_com_id = hotel.booking_com_id
# Star rating - look for star icons or rating text
stars_el = await card.query_selector('[data-testid="rating-stars"]')
if stars_el:
stars_text = await stars_el.get_attribute('aria-label') or ''
match = re.search(r'(\d+)', stars_text)
if match:
hotel.star_rating = Decimal(match.group(1))
# Review score
score_el = await card.query_selector('[data-testid="review-score"]')
if score_el:
score_text = await score_el.inner_text()
match = re.search(r'([\d.]+)', score_text)
if match:
try:
hotel.review_score = Decimal(match.group(1))
except InvalidOperation:
pass
# Check for no availability message FIRST
no_avail_el = await card.query_selector('[data-testid="availability-message"]')
if no_avail_el:
avail_text = (await no_avail_el.inner_text()).lower()
if 'no availability' in avail_text or 'sold out' in avail_text:
rate.availability_status = AvailabilityStatus.SOLD_OUT
hotels.append(hotel)
rates.append(rate)
continue
# Price
price_el = await card.query_selector('[data-testid="price-and-discounted-price"]')
if not price_el:
# Try alternative selector
price_el = await card.query_selector('[data-testid="price"]')
if price_el:
price_text = await price_el.inner_text()
rate.rate_gross = self._parse_price(price_text)
if rate.rate_gross:
rate.availability_status = AvailabilityStatus.AVAILABLE
# Room type
room_el = await card.query_selector('[data-testid="recommended-units"]')
if room_el:
rate.room_type = (await room_el.inner_text()).strip()
# Rate option badges - try multiple selectors
# Breakfast included
breakfast_el = await card.query_selector('[data-testid="breakfast-included"]')
if not breakfast_el:
# Check text content for breakfast mentions
card_text = (await card.inner_text()).lower()
rate.breakfast_included = 'breakfast included' in card_text
else:
rate.breakfast_included = True
# Free cancellation
cancel_el = await card.query_selector('[data-testid="cancellation-policy"]')
if cancel_el:
cancel_text = (await cancel_el.inner_text()).lower()
rate.free_cancellation = 'free cancellation' in cancel_text
else:
card_text = (await card.inner_text()).lower()
rate.free_cancellation = 'free cancellation' in card_text
# No prepayment
prepay_el = await card.query_selector('[data-testid="no-prepayment"]')
if prepay_el:
rate.no_prepayment = True
else:
card_text = (await card.inner_text()).lower()
rate.no_prepayment = 'no prepayment' in card_text
# Rooms left / scarcity indicator
scarcity_el = await card.query_selector('[data-testid="availability-rate"]')
if scarcity_el:
scarcity_text = await scarcity_el.inner_text()
match = re.search(r'(\d+)\s*room', scarcity_text.lower())
if match:
rate.rooms_left = int(match.group(1))
hotels.append(hotel)
rates.append(rate)
except Exception as e:
logger.warning(f"Error extracting hotel data: {e}")
continue
return hotels, rates
async def scrape_location_search(
self,
location: str,
check_in: date,
check_out: date,
adults: int = 2,
pages: int = 2
) -> ScraperResult:
"""
Scrape booking.com location search results.
Args:
location: Location name
check_in: Check-in date
check_out: Check-out date (check_in + 1 for single night rate)
adults: Number of adults
pages: Number of result pages to scrape
Returns:
ScraperResult with hotels and rates found
"""
all_hotels = []
all_rates = []
seen_hotel_ids = set()
context = None
page = None
try:
context = await self._create_context()
page = await context.new_page()
for page_num in range(pages):
# Random delay between pages (3-7 seconds)
if page_num > 0:
delay = random.uniform(3, 7)
logger.info(f"Waiting {delay:.1f}s before page {page_num + 1}")
await asyncio.sleep(delay)
# Build URL with offset for pagination (25 results per page)
url = self._build_search_url(
location, check_in, check_out, adults,
offset=page_num * 25
)
logger.info(f"Scraping page {page_num + 1}: {url}")
try:
await page.goto(url, wait_until='networkidle', timeout=30000)
except Exception as e:
logger.warning(f"Page load timeout, continuing: {e}")
# Check for blocking
content = await page.content()
is_blocked, reason = self.detect_blocking(content)
if is_blocked:
logger.warning(f"Blocking detected: {reason}")
return ScraperResult(
success=False,
blocked=True,
block_reason=reason,
hotels=all_hotels,
rates=all_rates,
page_content_sample=content[:1000]
)
# Human-like scrolling
await self._human_like_scroll(page)
# Extract data
hotels, rates = await self._extract_search_results(page, check_in)
# Deduplicate by booking_com_id
for hotel, rate in zip(hotels, rates):
if hotel.booking_com_id and hotel.booking_com_id not in seen_hotel_ids:
seen_hotel_ids.add(hotel.booking_com_id)
all_hotels.append(hotel)
all_rates.append(rate)
logger.info(f"Page {page_num + 1}: found {len(hotels)} hotels, {len(all_hotels)} total unique")
return ScraperResult(
success=True,
blocked=False,
hotels=all_hotels,
rates=all_rates
)
except Exception as e:
logger.error(f"Scrape error: {e}")
return ScraperResult(
success=False,
blocked=False,
error_message=str(e),
hotels=all_hotels,
rates=all_rates
)
finally:
if page:
await page.close()
if context:
await context.close()
async def scrape_hotel_page(
self,
hotel_url: str,
check_in: date,
check_out: date,
adults: int = 2
) -> ScraperResult:
"""
Scrape individual hotel page for detailed rates.
Future expansion - placeholder for now.
Will extract available_qty from room dropdowns.
"""
# Not implemented in Phase 2a
logger.warning("scrape_hotel_page not yet implemented")
return ScraperResult(
success=False,
error_message="Hotel page scraping not yet implemented"
)
async def close(self):
"""Clean up browser resources."""
if self._browser:
await self._browser.close()
self._browser = None
if self._playwright:
await self._playwright.stop()
self._playwright = None