- 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>
205 lines
8.5 KiB
Python
205 lines
8.5 KiB
Python
"""
|
||
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()
|