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

@ -717,93 +717,45 @@ async def get_rate_parity(
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
""" """
Get rate parity comparison between booking.com and Newbook rates. Like-for-like parity comparison between our Booking.com rate and the
comparable Newbook tariff (matched on flex/prepaid + board via the
Compares scraped booking.com rates for own hotel against Newbook current rates. 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() today = date.today()
start = date.fromisoformat(from_date) if from_date else today start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's booking.com rates def _run():
booking_rates_result = await db.execute( sdb = SyncSessionLocal()
text(""" try:
SELECT DISTINCT ON (r.rate_date) cfg = get_parity_config(sdb)
r.rate_date, return cfg, gather_comparisons(sdb, start, end, cfg)
r.rate_gross as booking_rate, finally:
r.availability_status, sdb.close()
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()}
# Get Newbook rates (cheapest per date across latest category snapshots) loop = asyncio.get_event_loop()
newbook_rates_result = await db.execute( cfg, comparisons = await loop.run_in_executor(None, _run)
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()}
# Expected-markup config: we deliberately price Booking.com higher to parity_issues = [
# cover commission, so parity is measured against Newbook + markup (% or £) {
from jobs.check_rate_parity import get_parity_config, expected_booking_rate, evaluate_parity 'rate_date': c["date"].isoformat(),
from database import SyncSessionLocal 'booking_rate': c["booking_rate"],
cfg_db = SyncSessionLocal() 'booking_basis': c["booking_basis"],
try: 'booking_room_type': c["booking_room"],
cfg = get_parity_config(cfg_db) 'newbook_rate': c["newbook_rate"],
finally: 'newbook_tariff': c["newbook_tariff"],
cfg_db.close() 'match_quality': c["match_quality"],
'expected_rate': round(expected_booking_rate(c["newbook_rate"], cfg), 2),
# Compare rates 'difference_pct': round(c["dev_pct"], 2),
parity_issues = [] 'difference_gbp': round(c["dev_gbp"], 2),
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys()) 'alert_type': 'higher' if c["dev_pct"] > 0 else 'lower',
}
for rate_date in sorted(all_dates): for c in comparisons if c["breach"]
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'),
})
return { return {
'from_date': start.isoformat(), 'from_date': start.isoformat(),
@ -812,6 +764,7 @@ async def get_rate_parity(
'markup_unit': cfg["markup_unit"], 'markup_unit': cfg["markup_unit"],
'tolerance_value': cfg["tolerance_value"], 'tolerance_value': cfg["tolerance_value"],
'tolerance_unit': cfg["tolerance_unit"], 'tolerance_unit': cfg["tolerance_unit"],
'dates_compared': len(comparisons),
'issues_count': len(parity_issues), 'issues_count': len(parity_issues),
'issues': parity_issues 'issues': parity_issues
} }

View file

@ -1,15 +1,26 @@
""" """
Rate Parity Check Job Rate Parity Check Job
Compares our own hotel's scraped Booking.com rate against our Newbook rate Compares our own hotel's scraped Booking.com rate against the COMPARABLE
for each date, allowing for a configured expected markup we deliberately Newbook tariff for each date like-for-like, not headline vs headline.
price Booking.com higher to cover commission, so parity is measured against
expected = newbook × (1 + markup%), not raw equality. Deviations beyond the The scraped Booking.com lead-in rate carries condition flags (breakfast
tolerance are persisted as rate_parity_alerts rows (feeding the Market View included, free cancellation, no prepayment); Newbook's tariffs_data holds
badge); dates back within tolerance auto-resolve their active alert. every tariff by name with per-day rates (e.g. DIRECT B&B FLEX / PPAY / ADV,
Acknowledged alerts are left alone acknowledging a date suppresses DIRECT DBB ...). We classify tariffs from their names and pick the cheapest
re-alerting for it until the alert is resolved by the rates coming back one matching the Booking.com rate's basis: a prepaid B&B rate on Booking.com
in line. 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): Config (system_config):
parity_check_enabled true/false (default true) 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. Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape.
""" """
import json
import logging import logging
import re
from datetime import date, timedelta from datetime import date, timedelta
from sqlalchemy import text from sqlalchemy import text
@ -32,6 +45,9 @@ logger = logging.getLogger(__name__)
HORIZON_DAYS = 90 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: def get_parity_config(db) -> dict:
rows = db.execute( rows = db.execute(
@ -88,6 +104,136 @@ def evaluate_parity(booking_rate: float, newbook_rate: float, cfg: dict):
return dev_pct, dev_gbp, breach 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: def run_parity_check() -> dict:
db = SyncSessionLocal() db = SyncSessionLocal()
try: try:
@ -99,31 +245,7 @@ def run_parity_check() -> dict:
start = date.today() start = date.today()
end = start + timedelta(days=HORIZON_DAYS) end = start + timedelta(days=HORIZON_DAYS)
# Own hotel's latest Booking.com lead-in rate per date comparisons = gather_comparisons(db, start, end, cfg)
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}
# Latest alert per date in the horizon # Latest alert per date in the horizon
alert_rows = db.execute(text(""" alert_rows = db.execute(text("""
@ -135,19 +257,15 @@ def run_parity_check() -> dict:
"""), {"fd": start, "td": end}).fetchall() """), {"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} 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 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: if c["breach"]:
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 alert and alert["status"] == "active": 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(""" db.execute(text("""
UPDATE rate_parity_alerts UPDATE rate_parity_alerts
SET newbook_rate = :nb, booking_com_rate = :bc, SET newbook_rate = :nb, booking_com_rate = :bc,
@ -155,10 +273,10 @@ def run_parity_check() -> dict:
room_category = :room room_category = :room
WHERE id = :id WHERE id = :id
"""), { """), {
"id": alert["id"], "nb": newbook[d], "bc": booking[d]["rate"], "id": alert["id"], "nb": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(dev, 2), "diff": round(c["dev_pct"], 2),
"atype": "higher" if dev > 0 else "lower", "atype": "higher" if c["dev_pct"] > 0 else "lower",
"room": booking[d]["room"], "room": category_label,
}) })
updated += 1 updated += 1
elif alert and alert["status"] == "acknowledged": elif alert and alert["status"] == "acknowledged":
@ -170,10 +288,10 @@ def run_parity_check() -> dict:
difference_pct, alert_type, alert_status) difference_pct, alert_type, alert_status)
VALUES (:date, :room, :nb, :bc, :diff, :atype, 'active') VALUES (:date, :room, :nb, :bc, :diff, :atype, 'active')
"""), { """), {
"date": d, "room": booking[d]["room"], "date": c["date"], "room": category_label,
"nb": newbook[d], "bc": booking[d]["rate"], "nb": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(dev, 2), "diff": round(c["dev_pct"], 2),
"atype": "higher" if dev > 0 else "lower", "atype": "higher" if c["dev_pct"] > 0 else "lower",
}) })
created += 1 created += 1
else: else:
@ -187,7 +305,7 @@ def run_parity_check() -> dict:
db.commit() db.commit()
summary = { summary = {
"status": "ok", "status": "ok",
"dates_compared": len(common_dates), "dates_compared": len(comparisons),
"created": created, "created": created,
"updated": updated, "updated": updated,
"resolved": resolved, "resolved": resolved,