rates/backend/jobs/check_rate_parity.py
jtricerolph 493e87b2dc Rate parity: expected-markup config, alert writer job, alerts UI + direct-link UI
Parity was read-only — the alerts table had no producer, so the Market
View badge could never fire. Now:
- jobs/check_rate_parity.py: daily 06:45 job comparing own Booking.com
  lead-in rate vs cheapest Newbook rate per date, measured against an
  EXPECTED markup (we deliberately price Booking.com higher to cover
  commission): alert when deviation from newbook*(1+markup%) exceeds the
  tolerance. Creates/updates active alerts, auto-resolves dates back in
  line, leaves acknowledged dates alone.
- config keys: parity_check_enabled, parity_expected_markup_pct,
  parity_tolerance_pct (system_config)
- POST /competitors/parity/check manual trigger; GET /parity now uses the
  same markup/tolerance and cheapest-across-categories Newbook rate
- Settings -> Rate Parity tab: markup %, tolerance %, enable toggle,
  run-now with result summary
- Market View -> Parity Alerts tab: status-filtered list w/ acknowledge
- Market View -> Hotels: direct-link dropdown per competitor (new PUT
  /competitors/hotels/{id}/direct-link) — closes the never-written
  direct_hotel_id gap so the matrix direct-rates sub-row can populate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:30:41 +00:00

176 lines
7.1 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_expected_markup_pct expected Booking.com premium over Newbook (default 0)
parity_tolerance_pct allowed deviation from expected before alerting (default 2)
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_expected_markup_pct',
'parity_tolerance_pct')""")
).fetchall()
cfg = {r[0]: r[1] for r in rows}
def num(key: str, default: float) -> float:
try:
return float(cfg.get(key) or default)
except (TypeError, ValueError):
return default
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),
}
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)
if expected <= 0:
return None
return (booking_rate - expected) / expected * 100
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:
dev = deviation_from_expected(booking[d]["rate"], newbook[d], cfg["markup_pct"])
if dev is None:
continue
alert = latest_alert.get(d)
if abs(dev) > cfg["tolerance_pct"]:
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_pct": cfg["markup_pct"],
"tolerance_pct": cfg["tolerance_pct"],
}
logger.info(f"Parity check: {summary}")
return summary
except Exception:
db.rollback()
raise
finally:
db.close()