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:
parent
9545fd651d
commit
3b501f95c5
2 changed files with 204 additions and 133 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue