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()}
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
}