Parity: best-available comparison as default, term-matching optional

parity_match_mode config ('best_available' default | 'match_terms').
Best available = cheapest bookable non-dinner direct tariff vs the BC
lead-in (already BC's best available) — simplest like-for-like. Term
matching kept as an option for days where BC's cheapest basis differs
from direct's. Settings gains a comparison-basis select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 08:04:15 +00:00
parent 3b501f95c5
commit ad0e82465c
2 changed files with 42 additions and 11 deletions

View file

@ -24,6 +24,10 @@ 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)
parity_match_mode 'best_available' (default: cheapest bookable
non-dinner tariff vs the BC lead-in, which is
already BC's best available) or 'match_terms'
(flex vs flex, prepaid vs prepaid)
parity_markup_value expected Booking.com premium over Newbook (default 0) parity_markup_value expected Booking.com premium over Newbook (default 0)
parity_markup_unit 'pct' or 'gbp' (default pct) parity_markup_unit 'pct' or 'gbp' (default pct)
parity_tolerance_value allowed deviation from expected before alerting (default 2) parity_tolerance_value allowed deviation from expected before alerting (default 2)
@ -74,6 +78,7 @@ def get_parity_config(db) -> dict:
enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower() enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower()
return { return {
"enabled": enabled_raw in ('true', '1', 'yes', 'enabled'), "enabled": enabled_raw in ('true', '1', 'yes', 'enabled'),
"match_mode": 'match_terms' if (cfg.get('parity_match_mode') or '').lower() == 'match_terms' else 'best_available',
"markup_value": num('parity_markup_value', 0.0, 'parity_expected_markup_pct'), "markup_value": num('parity_markup_value', 0.0, 'parity_expected_markup_pct'),
"markup_unit": unit('parity_markup_unit'), "markup_unit": unit('parity_markup_unit'),
"tolerance_value": num('parity_tolerance_value', 2.0, 'parity_tolerance_pct'), "tolerance_value": num('parity_tolerance_value', 2.0, 'parity_tolerance_pct'),
@ -133,19 +138,24 @@ def _candidate_tariffs(tariffs_data, days_ahead: int) -> list:
return out return out
def _pick_comparable(candidates: list, booking_flex: bool, booking_breakfast: bool): def _pick_comparable(candidates: list, booking_flex: bool, booking_breakfast: bool,
"""Cheapest tariff matching the Booking.com rate's basis, tiered fallback. match_mode: str = 'best_available'):
All our tariffs include breakfast, so board matching reduces to excluding """Newbook tariff to compare against the Booking.com lead-in.
dinner-inclusive tariffs unless nothing else exists. best_available: cheapest bookable non-dinner tariff (the BC lead-in is
already BC's best available, so this is BAR vs BAR).
match_terms: cheapest tariff matching the BC rate's flex/prepaid basis,
falling back to best available. 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).""" Returns (tariff, match_quality) or (None, None)."""
if not candidates: if not candidates:
return None, None return None, None
want_prepaid = not booking_flex tiers = []
tiers = [ if match_mode == 'match_terms':
([c for c in candidates if c["prepaid"] == want_prepaid and not c["dinner"]], 'matched'), want_prepaid = not booking_flex
([c for c in candidates if not c["dinner"]], 'any'), tiers.append(([c for c in candidates if c["prepaid"] == want_prepaid and not c["dinner"]], 'matched'))
(candidates, 'any'), tiers.append(([c for c in candidates if not c["dinner"]], 'best available'))
] tiers.append((candidates, 'best available'))
for pool, quality in tiers: for pool, quality in tiers:
if pool: if pool:
return min(pool, key=lambda c: c["rate"]), quality return min(pool, key=lambda c: c["rate"]), quality
@ -197,7 +207,7 @@ def gather_comparisons(db, start: date, end: date, cfg: dict) -> list:
for cat in cat_rows: for cat in cat_rows:
candidates.extend(_candidate_tariffs(cat["tariffs_data"], days_ahead)) candidates.extend(_candidate_tariffs(cat["tariffs_data"], days_ahead))
chosen, quality = _pick_comparable(candidates, booking_flex, booking_breakfast) chosen, quality = _pick_comparable(candidates, booking_flex, booking_breakfast, cfg["match_mode"])
if chosen: if chosen:
newbook_rate = chosen["rate"] newbook_rate = chosen["rate"]
tariff_name = chosen["name"] tariff_name = chosen["name"]
@ -305,6 +315,7 @@ def run_parity_check() -> dict:
db.commit() db.commit()
summary = { summary = {
"status": "ok", "status": "ok",
"match_mode": cfg["match_mode"],
"dates_compared": len(comparisons), "dates_compared": len(comparisons),
"created": created, "created": created,
"updated": updated, "updated": updated,

View file

@ -167,6 +167,26 @@ function ParityTab({ config, isLoading, onSave, saving }: {
Run daily parity check Run daily parity check
</label> </label>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
Comparison basis
</label>
<select
style={{ width: 330 }}
value={(config?.['parity_match_mode'] ?? 'best_available')}
onChange={e => onSave('parity_match_mode', e.target.value)}
>
<option value="best_available">Best available (cheapest bookable tariff on both sides)</option>
<option value="match_terms">Match terms (flex vs flex, prepaid vs prepaid)</option>
</select>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4, maxWidth: 480 }}>
Best available compares our cheapest bookable direct tariff against the Booking.com
lead-in rate (Booking.com's best available). Match terms instead picks the direct tariff
with the same conditions as the scraped Booking.com rate only differs on days where
Booking.com's cheapest is flexible while a cheaper prepaid exists direct.
</div>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div> <div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}> <label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>