rates/backend/jobs/check_rate_parity.py
jtricerolph 9545fd651d 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>
2026-07-06 07:41:55 +00:00

205 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.
Config (system_config):
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.
"""
import logging
from datetime import date, timedelta
from sqlalchemy import text
from database import SyncSessionLocal
logger = logging.getLogger(__name__)
HORIZON_DAYS = 90
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_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, legacy_key: str = None) -> float:
raw = cfg.get(key)
if raw in (None, '') and legacy_key:
raw = cfg.get(legacy_key)
try:
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_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 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
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:
db = SyncSessionLocal()
try:
cfg = get_parity_config(db)
if not cfg["enabled"]:
logger.info("Parity check skipped (disabled)")
return {"status": "disabled"}
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}
# Latest alert per date in the horizon
alert_rows = db.execute(text("""
SELECT DISTINCT ON (rate_date)
id, rate_date, alert_status, difference_pct
FROM rate_parity_alerts
WHERE rate_date BETWEEN :fd AND :td
ORDER BY rate_date, created_at DESC
"""), {"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 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 alert and alert["status"] == "active":
if round(dev, 2) != round(alert["diff"], 2):
db.execute(text("""
UPDATE rate_parity_alerts
SET newbook_rate = :nb, booking_com_rate = :bc,
difference_pct = :diff, alert_type = :atype,
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"],
})
updated += 1
elif alert and alert["status"] == "acknowledged":
pass # user has dealt with this date — don't nag
else:
db.execute(text("""
INSERT INTO rate_parity_alerts
(rate_date, room_category, newbook_rate, booking_com_rate,
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",
})
created += 1
else:
if alert and alert["status"] in ("active", "acknowledged"):
db.execute(
text("UPDATE rate_parity_alerts SET alert_status = 'resolved' WHERE id = :id"),
{"id": alert["id"]}
)
resolved += 1
db.commit()
summary = {
"status": "ok",
"dates_compared": len(common_dates),
"created": created,
"updated": updated,
"resolved": resolved,
"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
except Exception:
db.rollback()
raise
finally:
db.close()