diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 81a4831..9bbc632 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -761,8 +761,8 @@ async def get_rate_parity( 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 × (1 + markup%) - from jobs.check_rate_parity import get_parity_config, deviation_from_expected + # 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: @@ -787,18 +787,19 @@ async def get_rate_parity( if not booking_rate or not newbook_rate: continue - expected_rate = float(newbook_rate) * (1 + cfg["markup_pct"] / 100) - diff_pct = deviation_from_expected(float(booking_rate), float(newbook_rate), cfg["markup_pct"]) - if diff_pct is None: + result = evaluate_parity(float(booking_rate), float(newbook_rate), cfg) + if result is None: continue + diff_pct, diff_gbp, breach = result - if abs(diff_pct) > cfg["tolerance_pct"]: + if breach: parity_issues.append({ 'rate_date': rate_date.isoformat(), 'booking_rate': float(booking_rate), 'newbook_rate': float(newbook_rate), - 'expected_rate': round(expected_rate, 2), + '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'), @@ -807,8 +808,10 @@ async def get_rate_parity( return { 'from_date': start.isoformat(), 'to_date': end.isoformat(), - 'expected_markup_pct': cfg["markup_pct"], - 'tolerance_pct': cfg["tolerance_pct"], + 'markup_value': cfg["markup_value"], + 'markup_unit': cfg["markup_unit"], + 'tolerance_value': cfg["tolerance_value"], + 'tolerance_unit': cfg["tolerance_unit"], 'issues_count': len(parity_issues), 'issues': parity_issues } diff --git a/backend/jobs/check_rate_parity.py b/backend/jobs/check_rate_parity.py index 7c06c65..9b16fd6 100644 --- a/backend/jobs/check_rate_parity.py +++ b/backend/jobs/check_rate_parity.py @@ -12,9 +12,12 @@ re-alerting for it until the alert is resolved by the rates coming back in line. Config (system_config): - parity_check_enabled true/false (default true) - parity_expected_markup_pct expected Booking.com premium over Newbook (default 0) - parity_tolerance_pct allowed deviation from expected before alerting (default 2) + parity_check_enabled true/false (default true) + parity_markup_value expected Booking.com premium over Newbook (default 0) + parity_markup_unit 'pct' or 'gbp' (default pct) + parity_tolerance_value allowed deviation from expected before alerting (default 2) + parity_tolerance_unit 'pct' or 'gbp' (default pct) + (legacy fallbacks: parity_expected_markup_pct, parity_tolerance_pct) Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape. """ @@ -34,32 +37,55 @@ def get_parity_config(db) -> dict: rows = db.execute( text("""SELECT config_key, config_value FROM system_config WHERE config_key IN ('parity_check_enabled', - 'parity_expected_markup_pct', - 'parity_tolerance_pct')""") + 'parity_markup_value', 'parity_markup_unit', + 'parity_tolerance_value', 'parity_tolerance_unit', + 'parity_expected_markup_pct', 'parity_tolerance_pct')""") ).fetchall() cfg = {r[0]: r[1] for r in rows} - def num(key: str, default: float) -> float: + def num(key: str, default: float, legacy_key: str = None) -> float: + raw = cfg.get(key) + if raw in (None, '') and legacy_key: + raw = cfg.get(legacy_key) try: - return float(cfg.get(key) or default) + return float(raw if raw not in (None, '') else default) except (TypeError, ValueError): return default + def unit(key: str) -> str: + return 'gbp' if (cfg.get(key) or 'pct').lower() in ('gbp', '£', 'abs') else 'pct' + enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower() return { "enabled": enabled_raw in ('true', '1', 'yes', 'enabled'), - "markup_pct": num('parity_expected_markup_pct', 0.0), - "tolerance_pct": num('parity_tolerance_pct', 2.0), + "markup_value": num('parity_markup_value', 0.0, 'parity_expected_markup_pct'), + "markup_unit": unit('parity_markup_unit'), + "tolerance_value": num('parity_tolerance_value', 2.0, 'parity_tolerance_pct'), + "tolerance_unit": unit('parity_tolerance_unit'), } -def deviation_from_expected(booking_rate: float, newbook_rate: float, markup_pct: float) -> float | None: - """% deviation of the actual Booking.com rate from the expected - (Newbook × (1 + markup%)) rate. None if expected is not positive.""" - expected = newbook_rate * (1 + markup_pct / 100) +def expected_booking_rate(newbook_rate: float, cfg: dict) -> float: + """Expected Booking.com rate: Newbook + markup (% or flat £).""" + if cfg["markup_unit"] == 'gbp': + return newbook_rate + cfg["markup_value"] + return newbook_rate * (1 + cfg["markup_value"] / 100) + + +def evaluate_parity(booking_rate: float, newbook_rate: float, cfg: dict): + """Compare actual Booking.com rate against expected. + Returns (deviation_pct, deviation_gbp, breach) or None if expected invalid. + The breach test uses the tolerance in its own unit (% or £).""" + expected = expected_booking_rate(newbook_rate, cfg) if expected <= 0: return None - return (booking_rate - expected) / expected * 100 + dev_gbp = booking_rate - expected + dev_pct = dev_gbp / expected * 100 + if cfg["tolerance_unit"] == 'gbp': + breach = abs(dev_gbp) > cfg["tolerance_value"] + else: + breach = abs(dev_pct) > cfg["tolerance_value"] + return dev_pct, dev_gbp, breach def run_parity_check() -> dict: @@ -113,12 +139,13 @@ def run_parity_check() -> dict: created = updated = resolved = 0 for d in common_dates: - dev = deviation_from_expected(booking[d]["rate"], newbook[d], cfg["markup_pct"]) - if dev is None: + 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 abs(dev) > cfg["tolerance_pct"]: + if breach: if alert and alert["status"] == "active": if round(dev, 2) != round(alert["diff"], 2): db.execute(text(""" @@ -164,8 +191,10 @@ def run_parity_check() -> dict: "created": created, "updated": updated, "resolved": resolved, - "markup_pct": cfg["markup_pct"], - "tolerance_pct": cfg["tolerance_pct"], + "markup_value": cfg["markup_value"], + "markup_unit": cfg["markup_unit"], + "tolerance_value": cfg["tolerance_value"], + "tolerance_unit": cfg["tolerance_unit"], } logger.info(f"Parity check: {summary}") return summary diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index a504590..69903a6 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -777,8 +777,12 @@ const ParityAlertsTab: React.FC = () => { }, }) - const markup = parityConfig?.['parity_expected_markup_pct'] ?? '0' - const tolerance = parityConfig?.['parity_tolerance_pct'] ?? '2' + const markupUnit = parityConfig?.['parity_markup_unit'] ?? 'pct' + const toleranceUnit = parityConfig?.['parity_tolerance_unit'] ?? 'pct' + const markupVal = parityConfig?.['parity_markup_value'] ?? parityConfig?.['parity_expected_markup_pct'] ?? '0' + const toleranceVal = parityConfig?.['parity_tolerance_value'] ?? parityConfig?.['parity_tolerance_pct'] ?? '2' + const markupLabel = markupUnit === 'gbp' ? `£${markupVal}` : `${markupVal}%` + const toleranceLabel = toleranceUnit === 'gbp' ? `£${toleranceVal}` : `${toleranceVal}%` const thStyle: React.CSSProperties = { textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600, @@ -793,7 +797,7 @@ const ParityAlertsTab: React.FC = () => {