Parity markup and tolerance configurable as % or flat £

- config: parity_markup_value/_unit + parity_tolerance_value/_unit
  (pct|gbp), legacy *_pct keys still read as fallback
- expected rate = newbook + £X or newbook × (1 + X%); breach test uses
  the tolerance in its own unit
- Settings parity tab: unit selects + live worked example line
- Parity Alerts tab: unit-aware description, badge now shows £ deviation
  alongside %

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 07:41:55 +00:00
parent 493e87b2dc
commit 9545fd651d
4 changed files with 131 additions and 53 deletions

View file

@ -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