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)
):
"""
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()}
# 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()}
# 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()
def _run():
sdb = SyncSessionLocal()
try:
cfg = get_parity_config(cfg_db)
cfg = get_parity_config(sdb)
return cfg, gather_comparisons(sdb, start, end, cfg)
finally:
cfg_db.close()
sdb.close()
# Compare rates
parity_issues = []
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
loop = asyncio.get_event_loop()
cfg, comparisons = await loop.run_in_executor(None, _run)
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
}

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,