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>
This commit is contained in:
jtricerolph 2026-07-06 07:30:41 +00:00
parent 029e3b379f
commit 493e87b2dc
5 changed files with 595 additions and 12 deletions

View file

@ -0,0 +1,176 @@
"""
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()