Parity: like-for-like tariff matching instead of headline vs lead-in

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 07:57:17 +00:00
parent 9545fd651d
commit 3b501f95c5
2 changed files with 204 additions and 133 deletions

View file

@ -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,