rates/backend/api/analysis.py
jtricerolph e05054172f Add Rate Monitor app — Booking.com + direct booking engine competitor rates
Combines Booking.com Playwright scraper (from forecasting), direct booking
engine scraper (ported from laptop-archive/guestline-monitor), and Newbook
own-hotel rates into one focused tool. Four views: Bookability, Market View
(with price index badges + direct rate sub-rows), Direct Rates (per-competitor
room breakdown, min-stay flags, hotel config/discovery), Rate Analysis
(advance purchase curve, DOW chart, rate timeline, comparison table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:06:30 +00:00

317 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Rate Analysis API — advance purchase curves, DOW analysis, rate timelines, strategy summary
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from typing import Optional
from datetime import date, timedelta
from database import AsyncSessionLocal
from auth import get_current_user, require_cap
router = APIRouter()
async def get_db():
async with AsyncSessionLocal() as db:
yield db
# ─── Hotels available for analysis ───────────────────────────────────────────
@router.get("/hotels")
async def list_analysis_hotels(
tier: Optional[str] = Query(None, description="Filter by tier: own|competitor|market"),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
where = "is_active = true"
params = {}
if tier:
where += " AND tier = :tier"
params["tier"] = tier
result = await db.execute(
text(f"""
SELECT h.id, h.name, h.tier, h.star_rating, h.review_score,
h.booking_com_url,
COUNT(DISTINCT r.rate_date) AS scraped_dates,
MAX(r.scraped_at) AS last_scraped
FROM booking_com_hotels h
LEFT JOIN booking_com_rates r ON r.hotel_id = h.id
WHERE {where}
GROUP BY h.id, h.name, h.tier, h.star_rating, h.review_score, h.booking_com_url
ORDER BY h.tier, h.display_order, h.name
"""),
params
)
rows = result.mappings().all()
return [dict(r) for r in rows]
# ─── Full analysis for one hotel ─────────────────────────────────────────────
@router.get("/hotel/{hotel_id}")
async def analyse_hotel(
hotel_id: int,
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=89)),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
# Hotel info
hotel_row = await db.execute(
text("SELECT id, name, tier, star_rating, booking_com_url FROM booking_com_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = hotel_row.mappings().fetchone()
if not hotel:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Hotel not found")
# Advance purchase curve
apc_result = await db.execute(
text("""
SELECT
CASE
WHEN (rate_date - scraped_at::date) <= 7 THEN '0-7d'
WHEN (rate_date - scraped_at::date) <= 29 THEN '8-29d'
WHEN (rate_date - scraped_at::date) <= 89 THEN '30-89d'
ELSE '90+d'
END AS lead_bucket,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
COUNT(*) AS sample_count
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
AND availability_status = 'available'
AND rate_gross IS NOT NULL
GROUP BY lead_bucket
ORDER BY
CASE lead_bucket
WHEN '0-7d' THEN 1
WHEN '8-29d' THEN 2
WHEN '30-89d' THEN 3
ELSE 4
END
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
advance_purchase_curve = [dict(r) for r in apc_result.mappings().all()]
# Day-of-week averages (latest scrape per date)
dow_result = await db.execute(
text("""
SELECT
EXTRACT(DOW FROM rate_date)::int AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_label,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
COUNT(DISTINCT rate_date) AS date_count
FROM (
SELECT DISTINCT ON (rate_date)
rate_date, rate_gross
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
AND availability_status = 'available'
AND rate_gross IS NOT NULL
ORDER BY rate_date, scraped_at DESC
) latest
GROUP BY dow, dow_label
ORDER BY dow
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
dow_analysis = [dict(r) for r in dow_result.mappings().all()]
# Sold-out pattern by day-of-week
sold_out_result = await db.execute(
text("""
SELECT
EXTRACT(DOW FROM rate_date)::int AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_label,
COUNT(DISTINCT rate_date) AS total_dates,
COUNT(DISTINCT rate_date) FILTER (
WHERE availability_status = 'sold_out'
) AS sold_out_dates
FROM (
SELECT DISTINCT ON (rate_date)
rate_date, availability_status
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
ORDER BY rate_date, scraped_at DESC
) latest
GROUP BY dow, dow_label
ORDER BY dow
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
sold_out_pattern = [dict(r) for r in sold_out_result.mappings().all()]
# Strategy summary
strategy = _compute_strategy(advance_purchase_curve, dow_analysis, sold_out_pattern)
return {
"hotel": dict(hotel),
"date_range": {"from": str(from_date), "to": str(to_date)},
"advance_purchase_curve": advance_purchase_curve,
"dow_analysis": dow_analysis,
"sold_out_pattern": sold_out_pattern,
"strategy_summary": strategy,
}
# ─── Rate timeline for a single date ─────────────────────────────────────────
@router.get("/hotel/{hotel_id}/timeline")
async def rate_timeline(
hotel_id: int,
rate_date: date = Query(...),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""
SELECT
scraped_at,
rate_gross,
availability_status,
rooms_left,
room_type,
(rate_date - scraped_at::date) AS days_out
FROM booking_com_rates
WHERE hotel_id = :hid AND rate_date = :rd
ORDER BY scraped_at ASC
"""),
{"hid": hotel_id, "rd": rate_date}
)
rows = result.mappings().all()
return {
"hotel_id": hotel_id,
"rate_date": str(rate_date),
"timeline": [dict(r) for r in rows],
}
# ─── Own vs Competitor comparison ────────────────────────────────────────────
@router.get("/comparison")
async def rate_comparison(
competitor_ids: str = Query(..., description="Comma-separated hotel IDs to compare"),
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
try:
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
except ValueError:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
async with AsyncSessionLocal() as db:
# Own hotel latest rates
own_result = await db.execute(
text("""
SELECT
ncr.rate_date,
ncr.gross_rate AS own_rate
FROM newbook_current_rates ncr
WHERE ncr.rate_date BETWEEN :from_date AND :to_date
ORDER BY ncr.rate_date
"""),
{"from_date": from_date, "to_date": to_date}
)
own_rates = {str(r.rate_date): float(r.own_rate) for r in own_result if r.own_rate}
# Competitor latest rates per date
comp_result = await db.execute(
text("""
SELECT
r.hotel_id,
h.name AS hotel_name,
r.rate_date,
r.rate_gross,
r.availability_status
FROM (
SELECT DISTINCT ON (hotel_id, rate_date)
hotel_id, rate_date, rate_gross, availability_status
FROM booking_com_rates
WHERE hotel_id = ANY(:comp_ids)
AND rate_date BETWEEN :from_date AND :to_date
ORDER BY hotel_id, rate_date, scraped_at DESC
) r
JOIN booking_com_hotels h ON h.id = r.hotel_id
ORDER BY r.rate_date, h.display_order
"""),
{"comp_ids": comp_ids, "from_date": from_date, "to_date": to_date}
)
comp_rows = comp_result.mappings().all()
# Build per-date rows
date_map: dict = {}
hotel_names: dict = {}
for row in comp_rows:
d = str(row["rate_date"])
if d not in date_map:
date_map[d] = {"date": d, "own_rate": own_rates.get(d)}
date_map[d][f"h{row['hotel_id']}"] = float(row["rate_gross"]) if row["rate_gross"] else None
date_map[d][f"h{row['hotel_id']}_status"] = row["availability_status"]
hotel_names[row["hotel_id"]] = row["hotel_name"]
return {
"hotel_names": hotel_names,
"rows": sorted(date_map.values(), key=lambda x: x["date"]),
}
# ─── Strategy computation helper ─────────────────────────────────────────────
def _compute_strategy(apc: list, dow: list, sold_out: list) -> dict:
# Advance discount — compare 0-7d vs 30-89d
rates_by_bucket = {r["lead_bucket"]: float(r["avg_rate"]) for r in apc if r.get("avg_rate")}
advance_discount_pct = None
if "0-7d" in rates_by_bucket and "30-89d" in rates_by_bucket:
close_in = rates_by_bucket["0-7d"]
far_out = rates_by_bucket["30-89d"]
if far_out > 0:
# Positive = closes in higher (scarcity premium); negative = discount for advance
advance_discount_pct = round((close_in - far_out) / far_out * 100, 1)
# Weekend premium — Fri(5)+Sat(6) vs Mon(1)Thu(4)
rates_by_dow = {r["dow"]: float(r["avg_rate"]) for r in dow if r.get("avg_rate")}
weekend_premium_pct = None
weekend_rates = [rates_by_dow[d] for d in [5, 6] if d in rates_by_dow]
weekday_rates = [rates_by_dow[d] for d in [1, 2, 3, 4] if d in rates_by_dow]
if weekend_rates and weekday_rates:
avg_wk = sum(weekend_rates) / len(weekend_rates)
avg_wd = sum(weekday_rates) / len(weekday_rates)
if avg_wd > 0:
weekend_premium_pct = round((avg_wk - avg_wd) / avg_wd * 100, 1)
# Sold-out rate
total_dates = sum(r["total_dates"] for r in sold_out)
sold_out_dates = sum(r["sold_out_dates"] for r in sold_out)
sold_out_rate_pct = round(sold_out_dates / total_dates * 100, 1) if total_dates > 0 else None
# Strategy label
label = "Mixed / insufficient data"
if advance_discount_pct is not None:
if advance_discount_pct <= -5:
label = "Advance-booking discounter"
elif advance_discount_pct >= 8 and (sold_out_rate_pct or 0) >= 10:
label = "Yield manager (scarcity-driven)"
elif advance_discount_pct >= 3:
label = "Flat-rate / hold-firm strategy"
else:
label = "Stable pricing"
return {
"advance_discount_pct": advance_discount_pct,
"weekend_premium_pct": weekend_premium_pct,
"avg_sold_out_rate_pct": sold_out_rate_pct,
"strategy_label": label,
}