rates/backend/api/analysis.py
jtricerolph 349236cd7e Rate Analysis: fix hotel detail 500 + timeline 422, match frontend shapes; signed % delta badge
- /analysis/hotel/{id}: lead_bucket alias in ORDER BY CASE broke Postgres; replaced
  bucketed curve with per-days_ahead curve and reshaped response to the frontend
  HotelAnalysis interface (strategy/advance_curve/dow_breakdown/sold_out_pattern)
- /analysis/hotel/{id}/timeline: accept ?date= (was rate_date, 422) and return
  flat TimelineEntry array
- /analysis/hotels: alias to hotel_id/hotel_name/date_count for the selector
- strategy pcts default 0 (frontend calls .toFixed), added peak_months
- Market View badge now shows +/-% vs our rate instead of 100-index

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:01:57 +00:00

339 lines
14 KiB
Python

"""
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 AS hotel_id, h.name AS hotel_name, h.tier,
h.star_rating, h.review_score, h.booking_com_url,
COUNT(DISTINCT r.rate_date) AS date_count,
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 must exist
hotel_row = await db.execute(
text("SELECT id FROM booking_com_hotels WHERE id = :id"),
{"id": hotel_id}
)
if not hotel_row.fetchone():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Hotel not found")
params = {"hid": hotel_id, "from_date": from_date, "to_date": to_date}
# Advance purchase curve — avg price per days-ahead-of-stay
apc_result = await db.execute(
text("""
SELECT
(rate_date - scraped_at::date) AS days_ahead,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
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
AND (rate_date - scraped_at::date) >= 0
GROUP BY days_ahead
ORDER BY days_ahead
"""),
params
)
advance_curve = [dict(r) for r in apc_result.mappings().all()]
# Day-of-week averages (latest scrape per date), Mon=0 … Sun=6
dow_result = await db.execute(
text("""
SELECT
(EXTRACT(ISODOW FROM rate_date)::int - 1) AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_name,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
COUNT(DISTINCT rate_date) AS 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_name
ORDER BY dow
"""),
params
)
dow_breakdown = [dict(r) for r in dow_result.mappings().all()]
# Latest status + rate per stay date (sold-out pattern, peak months)
latest_result = await db.execute(
text("""
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
"""),
params
)
latest_rows = latest_result.mappings().all()
sold_out_pattern = [
{
"stay_date": str(r["rate_date"]),
"sold_out_pct": 100.0 if r["availability_status"] == "sold_out" else 0.0,
}
for r in latest_rows
]
strategy = _compute_strategy(advance_curve, dow_breakdown, latest_rows)
return {
"strategy": strategy,
"advance_curve": advance_curve,
"dow_breakdown": dow_breakdown,
"sold_out_pattern": sold_out_pattern,
}
# ─── Rate timeline for a single date ─────────────────────────────────────────
@router.get("/hotel/{hotel_id}/timeline")
async def rate_timeline(
hotel_id: int,
rate_date: date = Query(..., alias="date"),
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
FROM booking_com_rates
WHERE hotel_id = :hid AND rate_date = :rd
ORDER BY scraped_at ASC
"""),
{"hid": hotel_id, "rd": rate_date}
)
return [
{
"scraped_at": r["scraped_at"].isoformat() if r["scraped_at"] else None,
"room_id": r["room_type"] or "cheapest",
"rate_id": "",
"room_label": r["room_type"] or "Cheapest rate",
"rate_label": r["availability_status"],
"price_incl": float(r["rate_gross"]) if r["rate_gross"] is not None else None,
"availability": r["rooms_left"] or 0,
}
for r in result.mappings().all()
]
# ─── 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(advance_curve: list, dow_breakdown: list, latest_rows: list) -> dict:
"""Shape matches the frontend StrategyLabel interface — pcts must never be null."""
def _weighted_avg(points):
total = sum(p["sample_count"] for p in points)
if not total:
return None
return sum(float(p["avg_price"]) * p["sample_count"] for p in points) / total
# Advance discount — close-in (0-7d) vs far-out (30d+)
close_in = _weighted_avg([p for p in advance_curve if p["days_ahead"] <= 7])
far_out = _weighted_avg([p for p in advance_curve if p["days_ahead"] >= 30])
advance_discount_pct = 0.0
has_advance_data = close_in is not None and far_out is not None and far_out > 0
if has_advance_data:
# 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(4)-Sun(6) vs Mon(0)-Thu(3), dow is Mon=0 … Sun=6
rates_by_dow = {r["dow"]: float(r["avg_price"]) for r in dow_breakdown if r.get("avg_price")}
weekend_premium_pct = 0.0
weekend_rates = [rates_by_dow[d] for d in [4, 5, 6] if d in rates_by_dow]
weekday_rates = [rates_by_dow[d] for d in [0, 1, 2, 3] 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 — % of stay dates whose latest status is sold_out
sold_out_rate_pct = 0.0
if latest_rows:
sold_out_dates = sum(1 for r in latest_rows if r["availability_status"] == "sold_out")
sold_out_rate_pct = round(sold_out_dates / len(latest_rows) * 100, 1)
# Peak months — months averaging >10% above the overall average
monthly: dict = {}
for r in latest_rows:
if r["availability_status"] == "available" and r["rate_gross"] is not None:
key = (r["rate_date"].month, r["rate_date"].strftime("%b"))
monthly.setdefault(key, []).append(float(r["rate_gross"]))
peak_months = []
if monthly:
overall = sum(sum(v) for v in monthly.values()) / sum(len(v) for v in monthly.values())
peak_months = [
label for (num, label), v in sorted(monthly.items())
if sum(v) / len(v) > overall * 1.10
]
label = "Mixed / insufficient data"
if has_advance_data:
if advance_discount_pct <= -5:
label = "Advance-booking discounter"
elif advance_discount_pct >= 8 and sold_out_rate_pct >= 10:
label = "Yield manager (scarcity-driven)"
elif advance_discount_pct >= 3:
label = "Flat-rate / hold-firm strategy"
else:
label = "Stable pricing"
return {
"label": label,
"advance_discount_pct": advance_discount_pct,
"weekend_premium_pct": weekend_premium_pct,
"avg_sold_out_rate_pct": sold_out_rate_pct,
"peak_months": peak_months,
}