rates/backend/api/analysis.py
jtricerolph ba6c000903 Flag hotels absent from successful scrapes as 'not_listed' + widen booking_com_id
Location-search results aren't a fixed hotel set — a sold-out hotel drops
out and its last 'available' rate would remain the latest row for that
date, reading as a live price and skewing market averages. On each
successful per-date scrape, insert a NULL-rate 'not_listed' row for every
active hotel missing from the results (skipped if the parse found nothing,
which indicates scraper fault not absence). Failed/blocked scrapes write
nothing, so genuinely-stale data remains distinguishable by scraped_at.

Also: fix the DOW analysis to pick latest-then-filter so a not_listed
latest row drops the date instead of resurfacing an older rate, and widen
booking_com_id to VARCHAR(255) (some Booking slugs exceed 50 chars).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:44:02 +00:00

328 lines
14 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 (
-- Latest row per date first, THEN filter: a 'not_listed'
-- latest row must drop the date, not resurface an older rate
SELECT DISTINCT ON (rate_date)
rate_date, rate_gross, 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
WHERE availability_status = 'available'
AND rate_gross IS NOT NULL
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(
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
competitor_ids: Optional[str] = Query(None, description="Comma-separated hotel IDs; defaults to all active competitors"),
user=Depends(get_current_user)
):
"""Per-hotel market comparison: avg own vs competitor rate over the range."""
require_cap(user, "rate_analysis")
comp_ids = None
if competitor_ids:
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 avg rate per date (across included categories)
own_result = await db.execute(
text("""
SELECT rate_date, AVG(rate_gross) AS own_rate
FROM newbook_current_rates
WHERE rate_date BETWEEN :from_date AND :to_date
GROUP BY rate_date
"""),
{"from_date": from_date, "to_date": to_date}
)
own_rates = {r.rate_date: float(r.own_rate) for r in own_result if r.own_rate}
# Competitor latest rate per date
hotel_filter = "h.id = ANY(:comp_ids)" if comp_ids else "h.tier = 'competitor'"
comp_result = await db.execute(
text(f"""
SELECT r.hotel_id, h.name AS hotel_name, r.rate_date, r.rate_gross
FROM (
SELECT DISTINCT ON (hotel_id, rate_date)
hotel_id, rate_date, rate_gross
FROM booking_com_rates
WHERE 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
WHERE h.is_active = true AND {hotel_filter}
ORDER BY h.display_order, h.name
"""),
{"from_date": from_date, "to_date": to_date,
**({"comp_ids": comp_ids} if comp_ids else {})}
)
# Aggregate per hotel, comparing own rates over the same dates
by_hotel: dict = {}
for row in comp_result.mappings().all():
entry = by_hotel.setdefault(row["hotel_id"], {
"hotel_id": row["hotel_id"],
"hotel_name": row["hotel_name"],
"their": [], "ours": [],
})
if row["rate_gross"]:
entry["their"].append(float(row["rate_gross"]))
if row["rate_date"] in own_rates:
entry["ours"].append(own_rates[row["rate_date"]])
rows = []
for entry in by_hotel.values():
their_rate = round(sum(entry["their"]) / len(entry["their"]), 2) if entry["their"] else None
our_rate = round(sum(entry["ours"]) / len(entry["ours"]), 2) if entry["ours"] else None
price_index = round(their_rate / our_rate * 100, 1) if their_rate and our_rate else None
rows.append({
"hotel_id": entry["hotel_id"],
"hotel_name": entry["hotel_name"],
"our_rate": our_rate,
"their_rate": their_rate,
"price_index": price_index,
"days_checked": len(entry["their"]),
})
return rows
# ─── 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,
}