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 = () => {
Dates where our Booking.com rate deviates from the expected level - (Newbook rate + {markup}% markup, ±{tolerance}% tolerance). Checked daily at 06:45 — + (Newbook rate + {markupLabel} markup, ±{toleranceLabel} tolerance). Checked daily at 06:45 — adjust the markup and tolerance in Settings → Rate Parity. Acknowledge a date once dealt with; alerts auto-resolve when the rates come back in line.
@@ -850,11 +854,21 @@ const ParityAlertsTab: React.FC = () => { {a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'} {a.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'} - {a.difference_pct != null && ( - - {a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% vs expected - - )} + {a.difference_pct != null && (() => { + // expected = booking / (1 + dev%); £ deviation derived from that + const expected = a.booking_com_rate != null + ? a.booking_com_rate / (1 + a.difference_pct / 100) + : null + const devGbp = expected != null && a.booking_com_rate != null + ? a.booking_com_rate - expected + : null + return ( + + {a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% + {devGbp != null ? ` (${devGbp > 0 ? '+' : '−'}£${Math.abs(devGbp).toFixed(2)})` : ''} vs expected + + ) + })()} {a.room_category || '—'} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3f6809e..eee0a52 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -115,14 +115,18 @@ function ParityTab({ config, isLoading, onSave, saving }: { saving: boolean }) { const [markup, setMarkup] = useState('') + const [markupUnit, setMarkupUnit] = useState('pct') const [tolerance, setTolerance] = useState('') + const [toleranceUnit, setToleranceUnit] = useState('pct') const [loaded, setLoaded] = useState(false) const [checkResult, setCheckResult] = useState(null) useEffect(() => { if (config && !loaded) { - setMarkup(config['parity_expected_markup_pct'] ?? '0') - setTolerance(config['parity_tolerance_pct'] ?? '2') + setMarkup(config['parity_markup_value'] ?? config['parity_expected_markup_pct'] ?? '0') + setMarkupUnit(config['parity_markup_unit'] ?? 'pct') + setTolerance(config['parity_tolerance_value'] ?? config['parity_tolerance_pct'] ?? '2') + setToleranceUnit(config['parity_tolerance_unit'] ?? 'pct') setLoaded(true) } }, [config, loaded]) @@ -166,39 +170,67 @@ function ParityTab({ config, isLoading, onSave, saving }: {
- setMarkup(e.target.value)} - placeholder="e.g. 15" - /> +
+ setMarkup(e.target.value)} + placeholder={markupUnit === 'gbp' ? 'e.g. 20' : 'e.g. 15'} + /> + +
- How much higher Booking.com should be than Newbook. + How much higher Booking.com should be than Newbook + {markupUnit === 'gbp' ? ' (flat £ per night)' : ' (percentage)'}.
- setTolerance(e.target.value)} - placeholder="e.g. 2" - /> +
+ setTolerance(e.target.value)} + placeholder={toleranceUnit === 'gbp' ? 'e.g. 5' : 'e.g. 2'} + /> + +
Allowed deviation from expected before alerting.
+
+ Rule: alert when Booking.com ≠ Newbook {markupUnit === 'gbp' ? `+ £${markup || '0'}` : `+ ${markup || '0'}%`} + {' '}beyond ±{toleranceUnit === 'gbp' ? `£${tolerance || '0'}` : `${tolerance || '0'}%`}. + {' '}Example: Newbook £100 → expect £{(markupUnit === 'gbp' + ? 100 + (parseFloat(markup) || 0) + : 100 * (1 + (parseFloat(markup) || 0) / 100)).toFixed(0)}, + {' '}alert outside £{(() => { + const exp = markupUnit === 'gbp' ? 100 + (parseFloat(markup) || 0) : 100 * (1 + (parseFloat(markup) || 0) / 100) + const tol = toleranceUnit === 'gbp' ? (parseFloat(tolerance) || 0) : exp * (parseFloat(tolerance) || 0) / 100 + return `${(exp - tol).toFixed(0)}–£${(exp + tol).toFixed(0)}` + })()}. +
+