From 3b501f95c582c7370fceb795597be1a5f46de535 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 6 Jul 2026 07:57:17 +0000 Subject: [PATCH] Parity: like-for-like tariff matching instead of headline vs lead-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check compared Newbook's FIRST tariff (typically flexi B&B) against Booking.com's lead-in card (often prepaid) — apples vs oranges, e.g. 21 Jul flagged B&B FLEX £289 vs a prepaid BC rate whose true comparable was B&B PPAY £279. - classify Newbook tariffs from names/descriptions (PPAY/prepay/advance/ ADV/NRF/saver = prepaid; DBB/dinner/half board = dinner-inclusive) - use the scraped BC rate's flags (free_cancellation/no_prepayment/ breakfast_included) to pick the cheapest COMPARABLE tariff per date, excluding tariffs not bookable for that date (success=false, min-stay >1, unmet advance-purchase windows) - tiered fallback (matched -> any non-dinner -> any -> legacy headline), recorded per alert in room_category as 'TARIFF vs BC basis' - shared gather_comparisons() now drives both the daily job and GET /competitors/parity (issues gain newbook_tariff, booking_basis, match_quality, expected_rate) Co-Authored-By: Claude Fable 5 --- backend/api/competitors.py | 113 +++++---------- backend/jobs/check_rate_parity.py | 224 +++++++++++++++++++++++------- 2 files changed, 204 insertions(+), 133 deletions(-) diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 9bbc632..ac90d75 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -717,93 +717,45 @@ async def get_rate_parity( current_user: dict = Depends(get_current_user) ): """ - Get rate parity comparison between booking.com and Newbook rates. - - Compares scraped booking.com rates for own hotel against Newbook current rates. + Like-for-like parity comparison between our Booking.com rate and the + comparable Newbook tariff (matched on flex/prepaid + board via the + scraped rate's condition flags). Same logic as the daily alert job. """ + import asyncio + from jobs.check_rate_parity import get_parity_config, gather_comparisons, expected_booking_rate + from database import SyncSessionLocal + today = date.today() start = date.fromisoformat(from_date) if from_date else today end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - # Get own hotel's booking.com rates - booking_rates_result = await db.execute( - text(""" - SELECT DISTINCT ON (r.rate_date) - r.rate_date, - r.rate_gross as booking_rate, - r.availability_status, - r.room_type as booking_room_type, - r.scraped_at - FROM booking_com_rates r - JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE h.tier = 'own' - AND r.rate_date >= :from_date AND r.rate_date <= :to_date - ORDER BY r.rate_date, r.scraped_at DESC - """), - {'from_date': start, 'to_date': end} - ) - booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()} + def _run(): + sdb = SyncSessionLocal() + try: + cfg = get_parity_config(sdb) + return cfg, gather_comparisons(sdb, start, end, cfg) + finally: + sdb.close() - # Get Newbook rates (cheapest per date across latest category snapshots) - newbook_rates_result = await db.execute( - text(""" - SELECT rate_date, MIN(rate_gross) AS newbook_rate FROM ( - SELECT DISTINCT ON (rate_date, category_id) - rate_date, rate_gross - FROM newbook_current_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - AND rate_gross IS NOT NULL AND rate_gross > 0 - ORDER BY rate_date, category_id, valid_from DESC - ) t GROUP BY rate_date - """), - {'from_date': start, 'to_date': end} - ) - newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()} + loop = asyncio.get_event_loop() + cfg, comparisons = await loop.run_in_executor(None, _run) - # Expected-markup config: we deliberately price Booking.com higher to - # cover commission, so parity is measured against Newbook + markup (% or £) - from jobs.check_rate_parity import get_parity_config, expected_booking_rate, evaluate_parity - from database import SyncSessionLocal - cfg_db = SyncSessionLocal() - try: - cfg = get_parity_config(cfg_db) - finally: - cfg_db.close() - - # Compare rates - parity_issues = [] - all_dates = set(booking_rates.keys()) | set(newbook_rates.keys()) - - for rate_date in sorted(all_dates): - booking = booking_rates.get(rate_date) - newbook = newbook_rates.get(rate_date) - - if not booking or not newbook: - continue - - booking_rate = booking.get('booking_rate') - newbook_rate = newbook.get('newbook_rate') - - if not booking_rate or not newbook_rate: - continue - - result = evaluate_parity(float(booking_rate), float(newbook_rate), cfg) - if result is None: - continue - diff_pct, diff_gbp, breach = result - - if breach: - parity_issues.append({ - 'rate_date': rate_date.isoformat(), - 'booking_rate': float(booking_rate), - 'newbook_rate': float(newbook_rate), - 'expected_rate': round(expected_booking_rate(float(newbook_rate), cfg), 2), - 'difference_pct': round(diff_pct, 2), - 'difference_gbp': round(diff_gbp, 2), - 'alert_type': 'higher' if diff_pct > 0 else 'lower', - 'booking_room_type': booking.get('booking_room_type'), - 'availability_status': booking.get('availability_status'), - }) + parity_issues = [ + { + 'rate_date': c["date"].isoformat(), + 'booking_rate': c["booking_rate"], + 'booking_basis': c["booking_basis"], + 'booking_room_type': c["booking_room"], + 'newbook_rate': c["newbook_rate"], + 'newbook_tariff': c["newbook_tariff"], + 'match_quality': c["match_quality"], + 'expected_rate': round(expected_booking_rate(c["newbook_rate"], cfg), 2), + 'difference_pct': round(c["dev_pct"], 2), + 'difference_gbp': round(c["dev_gbp"], 2), + 'alert_type': 'higher' if c["dev_pct"] > 0 else 'lower', + } + for c in comparisons if c["breach"] + ] return { 'from_date': start.isoformat(), @@ -812,6 +764,7 @@ async def get_rate_parity( 'markup_unit': cfg["markup_unit"], 'tolerance_value': cfg["tolerance_value"], 'tolerance_unit': cfg["tolerance_unit"], + 'dates_compared': len(comparisons), 'issues_count': len(parity_issues), 'issues': parity_issues } diff --git a/backend/jobs/check_rate_parity.py b/backend/jobs/check_rate_parity.py index 9b16fd6..43c17cb 100644 --- a/backend/jobs/check_rate_parity.py +++ b/backend/jobs/check_rate_parity.py @@ -1,15 +1,26 @@ """ Rate Parity Check Job -Compares our own hotel's scraped Booking.com rate against our Newbook rate -for each date, allowing for a configured expected markup — we deliberately -price Booking.com higher to cover commission, so parity is measured against -expected = newbook × (1 + markup%), not raw equality. Deviations beyond the -tolerance are persisted as rate_parity_alerts rows (feeding the Market View -badge); dates back within tolerance auto-resolve their active alert. -Acknowledged alerts are left alone — acknowledging a date suppresses -re-alerting for it until the alert is resolved by the rates coming back -in line. +Compares our own hotel's scraped Booking.com rate against the COMPARABLE +Newbook tariff for each date — like-for-like, not headline vs headline. + +The scraped Booking.com lead-in rate carries condition flags (breakfast +included, free cancellation, no prepayment); Newbook's tariffs_data holds +every tariff by name with per-day rates (e.g. DIRECT B&B FLEX / PPAY / ADV, +DIRECT DBB ...). We classify tariffs from their names and pick the cheapest +one matching the Booking.com rate's basis: a prepaid B&B rate on Booking.com +is compared against the cheapest prepaid B&B tariff, not the flexi rate. + +Fallback tiers when no exact match exists (each alert records which was used): + matched — terms (flex/prepaid) and board match + terms — terms match, any board except dinner-inclusive + any — any non-dinner tariff + headline — legacy rate_gross (first tariff) when tariffs_data is absent + +Parity is then measured against an expected markup — we deliberately price +Booking.com higher to cover commission: expected = comparable + markup +(% or flat £), alert when the deviation exceeds the tolerance (% or £). +Dates back within tolerance auto-resolve; acknowledged dates stay quiet. Config (system_config): parity_check_enabled true/false (default true) @@ -21,7 +32,9 @@ Config (system_config): Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape. """ +import json import logging +import re from datetime import date, timedelta from sqlalchemy import text @@ -32,6 +45,9 @@ logger = logging.getLogger(__name__) HORIZON_DAYS = 90 +PREPAID_PATTERNS = ('ppay', 'prepay', 'pre-pay', 'advance', 'adv', 'non-ref', 'nonref', 'no refund', 'nrf', 'saver') +DINNER_PATTERNS = ('dbb', 'dinner', 'half board', 'half-board') + def get_parity_config(db) -> dict: rows = db.execute( @@ -88,6 +104,136 @@ def evaluate_parity(booking_rate: float, newbook_rate: float, cfg: dict): return dev_pct, dev_gbp, breach +def _classify_tariff(name: str, description: str = '') -> dict: + s = f"{name} {description}".lower() + is_dinner = any(p in s for p in DINNER_PATTERNS) + is_prepaid = any(re.search(rf'\b{re.escape(p)}\b', s) if p == 'adv' else (p in s) + for p in PREPAID_PATTERNS) + return {"prepaid": is_prepaid, "dinner": is_dinner} + + +def _candidate_tariffs(tariffs_data, days_ahead: int) -> list: + """Bookable-tonight tariff options from one category's tariffs_data.""" + if isinstance(tariffs_data, str): + try: + tariffs_data = json.loads(tariffs_data) + except (ValueError, TypeError): + return [] + out = [] + for t in (tariffs_data or {}).get('tariffs', []): + rate = t.get('rate') + if not t.get('success') or not rate or rate <= 0: + continue + if (t.get('min_stay') or 1) > 1: + continue + if t.get('min_advance_days') and days_ahead < t['min_advance_days']: + continue + cls = _classify_tariff(t.get('name', ''), t.get('description', '')) + out.append({"rate": float(rate), "name": t.get('name', 'Unknown'), **cls}) + return out + + +def _pick_comparable(candidates: list, booking_flex: bool, booking_breakfast: bool): + """Cheapest tariff matching the Booking.com rate's basis, tiered fallback. + All our tariffs include breakfast, so board matching reduces to excluding + dinner-inclusive tariffs unless nothing else exists. + Returns (tariff, match_quality) or (None, None).""" + if not candidates: + return None, None + want_prepaid = not booking_flex + tiers = [ + ([c for c in candidates if c["prepaid"] == want_prepaid and not c["dinner"]], 'matched'), + ([c for c in candidates if not c["dinner"]], 'any'), + (candidates, 'any'), + ] + for pool, quality in tiers: + if pool: + return min(pool, key=lambda c: c["rate"]), quality + return None, None + + +def gather_comparisons(db, start: date, end: date, cfg: dict) -> list: + """Per-date like-for-like comparison rows for the horizon. + Each row: {date, booking_rate, booking_room, booking_basis, newbook_rate, + newbook_tariff, match_quality, dev_pct, dev_gbp, breach}""" + booking_rows = db.execute(text(""" + SELECT DISTINCT ON (r.rate_date) + r.rate_date, r.rate_gross, r.room_type, + r.breakfast_included, r.free_cancellation, r.no_prepayment + FROM booking_com_rates r + JOIN booking_com_hotels h ON r.hotel_id = h.id + WHERE h.tier = 'own' + AND r.rate_date BETWEEN :fd AND :td + AND r.rate_gross IS NOT NULL + ORDER BY r.rate_date, r.scraped_at DESC + """), {"fd": start, "td": end}).mappings().all() + + newbook_rows = db.execute(text(""" + SELECT DISTINCT ON (rate_date, category_id) + rate_date, category_id, rate_gross, tariffs_data + FROM newbook_current_rates + WHERE rate_date BETWEEN :fd AND :td + ORDER BY rate_date, category_id, valid_from DESC + """), {"fd": start, "td": end}).mappings().all() + + newbook_by_date: dict = {} + for r in newbook_rows: + newbook_by_date.setdefault(r["rate_date"], []).append(r) + + today = date.today() + out = [] + for b in booking_rows: + d = b["rate_date"] + cat_rows = newbook_by_date.get(d) + if not cat_rows: + continue + + booking_rate = float(b["rate_gross"]) + booking_flex = bool(b["free_cancellation"] or b["no_prepayment"]) + booking_breakfast = bool(b["breakfast_included"]) + days_ahead = (d - today).days + + candidates = [] + for cat in cat_rows: + candidates.extend(_candidate_tariffs(cat["tariffs_data"], days_ahead)) + + chosen, quality = _pick_comparable(candidates, booking_flex, booking_breakfast) + if chosen: + newbook_rate = chosen["rate"] + tariff_name = chosen["name"] + else: + # No tariff detail — legacy fallback to cheapest headline rate + headline = [float(c["rate_gross"]) for c in cat_rows if c["rate_gross"] and float(c["rate_gross"]) > 0] + if not headline: + continue + newbook_rate = min(headline) + tariff_name = 'headline rate' + quality = 'headline' + + result = evaluate_parity(booking_rate, newbook_rate, cfg) + if result is None: + continue + dev_pct, dev_gbp, breach = result + + basis_bits = [ + 'flex' if booking_flex else 'prepaid', + 'B&B' if booking_breakfast else 'room only', + ] + out.append({ + "date": d, + "booking_rate": booking_rate, + "booking_room": (b["room_type"] or '').split('\n')[0].strip() or None, + "booking_basis": '/'.join(basis_bits), + "newbook_rate": newbook_rate, + "newbook_tariff": tariff_name, + "match_quality": quality, + "dev_pct": dev_pct, + "dev_gbp": dev_gbp, + "breach": breach, + }) + return out + + def run_parity_check() -> dict: db = SyncSessionLocal() try: @@ -99,31 +245,7 @@ def run_parity_check() -> dict: start = date.today() end = start + timedelta(days=HORIZON_DAYS) - # Own hotel's latest Booking.com lead-in rate per date - booking_rows = db.execute(text(""" - SELECT DISTINCT ON (r.rate_date) - r.rate_date, r.rate_gross, r.room_type - FROM booking_com_rates r - JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE h.tier = 'own' - AND r.rate_date BETWEEN :fd AND :td - AND r.rate_gross IS NOT NULL - ORDER BY r.rate_date, r.scraped_at DESC - """), {"fd": start, "td": end}).fetchall() - booking = {r[0]: {"rate": float(r[1]), "room": r[2]} for r in booking_rows} - - # Cheapest current Newbook rate per date (latest snapshot per category) - newbook_rows = db.execute(text(""" - SELECT rate_date, MIN(rate_gross) AS newbook_rate FROM ( - SELECT DISTINCT ON (rate_date, category_id) - rate_date, rate_gross - FROM newbook_current_rates - WHERE rate_date BETWEEN :fd AND :td - AND rate_gross IS NOT NULL AND rate_gross > 0 - ORDER BY rate_date, category_id, valid_from DESC - ) t GROUP BY rate_date - """), {"fd": start, "td": end}).fetchall() - newbook = {r[0]: float(r[1]) for r in newbook_rows} + comparisons = gather_comparisons(db, start, end, cfg) # Latest alert per date in the horizon alert_rows = db.execute(text(""" @@ -135,19 +257,15 @@ def run_parity_check() -> dict: """), {"fd": start, "td": end}).fetchall() latest_alert = {r[1]: {"id": r[0], "status": r[2], "diff": float(r[3] or 0)} for r in alert_rows} - common_dates = sorted(set(booking) & set(newbook)) created = updated = resolved = 0 + for c in comparisons: + alert = latest_alert.get(c["date"]) + # e.g. "DIRECT B&B PPAY vs BC prepaid/B&B" + category_label = f"{c['newbook_tariff']} vs BC {c['booking_basis']}"[:255] - for d in common_dates: - result = evaluate_parity(booking[d]["rate"], newbook[d], cfg) - if result is None: - continue - dev, _dev_gbp, breach = result - alert = latest_alert.get(d) - - if breach: + if c["breach"]: if alert and alert["status"] == "active": - if round(dev, 2) != round(alert["diff"], 2): + if round(c["dev_pct"], 2) != round(alert["diff"], 2): db.execute(text(""" UPDATE rate_parity_alerts SET newbook_rate = :nb, booking_com_rate = :bc, @@ -155,10 +273,10 @@ def run_parity_check() -> dict: room_category = :room WHERE id = :id """), { - "id": alert["id"], "nb": newbook[d], "bc": booking[d]["rate"], - "diff": round(dev, 2), - "atype": "higher" if dev > 0 else "lower", - "room": booking[d]["room"], + "id": alert["id"], "nb": c["newbook_rate"], "bc": c["booking_rate"], + "diff": round(c["dev_pct"], 2), + "atype": "higher" if c["dev_pct"] > 0 else "lower", + "room": category_label, }) updated += 1 elif alert and alert["status"] == "acknowledged": @@ -170,10 +288,10 @@ def run_parity_check() -> dict: difference_pct, alert_type, alert_status) VALUES (:date, :room, :nb, :bc, :diff, :atype, 'active') """), { - "date": d, "room": booking[d]["room"], - "nb": newbook[d], "bc": booking[d]["rate"], - "diff": round(dev, 2), - "atype": "higher" if dev > 0 else "lower", + "date": c["date"], "room": category_label, + "nb": c["newbook_rate"], "bc": c["booking_rate"], + "diff": round(c["dev_pct"], 2), + "atype": "higher" if c["dev_pct"] > 0 else "lower", }) created += 1 else: @@ -187,7 +305,7 @@ def run_parity_check() -> dict: db.commit() summary = { "status": "ok", - "dates_compared": len(common_dates), + "dates_compared": len(comparisons), "created": created, "updated": updated, "resolved": resolved,