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:
parent
029e3b379f
commit
493e87b2dc
5 changed files with 595 additions and 12 deletions
|
|
@ -551,6 +551,39 @@ async def update_hotel_notes(
|
|||
return {"status": "success"}
|
||||
|
||||
|
||||
class DirectLinkUpdate(BaseModel):
|
||||
direct_hotel_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.put("/hotels/{hotel_id}/direct-link")
|
||||
async def update_hotel_direct_link(
|
||||
hotel_id: int,
|
||||
payload: DirectLinkUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Link (or unlink with null) a Booking.com hotel to a direct booking
|
||||
engine competitor, enabling the direct-rates sub-row in the rate matrix."""
|
||||
if payload.direct_hotel_id is not None:
|
||||
exists = await db.execute(
|
||||
text("SELECT 1 FROM direct_competitor_hotels WHERE id = :id"),
|
||||
{'id': payload.direct_hotel_id}
|
||||
)
|
||||
if not exists.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Direct hotel not found")
|
||||
|
||||
result = await db.execute(
|
||||
text("UPDATE booking_com_hotels SET direct_hotel_id = :did WHERE id = :hotel_id RETURNING id"),
|
||||
{'hotel_id': hotel_id, 'did': payload.direct_hotel_id}
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# COMPETITOR RATES MATRIX
|
||||
# ============================================
|
||||
|
|
@ -711,21 +744,32 @@ async def get_rate_parity(
|
|||
)
|
||||
booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()}
|
||||
|
||||
# Get Newbook rates (best rate per date across categories)
|
||||
# Get Newbook rates (cheapest per date across latest category snapshots)
|
||||
newbook_rates_result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (rate_date)
|
||||
rate_date,
|
||||
rate_gross as newbook_rate,
|
||||
category_id
|
||||
FROM newbook_current_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
ORDER BY rate_date, valid_from DESC
|
||||
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 >= :from_date AND rate_date <= :to_date
|
||||
AND rate_gross IS NOT NULL AND rate_gross > 0
|
||||
ORDER BY rate_date, category_id, valid_from DESC
|
||||
) t GROUP BY rate_date
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end}
|
||||
)
|
||||
newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()}
|
||||
|
||||
# Expected-markup config: we deliberately price Booking.com higher to
|
||||
# cover commission, so parity is measured against newbook × (1 + markup%)
|
||||
from jobs.check_rate_parity import get_parity_config, deviation_from_expected
|
||||
from database import SyncSessionLocal
|
||||
cfg_db = SyncSessionLocal()
|
||||
try:
|
||||
cfg = get_parity_config(cfg_db)
|
||||
finally:
|
||||
cfg_db.close()
|
||||
|
||||
# Compare rates
|
||||
parity_issues = []
|
||||
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
|
||||
|
|
@ -743,13 +787,17 @@ async def get_rate_parity(
|
|||
if not booking_rate or not newbook_rate:
|
||||
continue
|
||||
|
||||
diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100
|
||||
expected_rate = float(newbook_rate) * (1 + cfg["markup_pct"] / 100)
|
||||
diff_pct = deviation_from_expected(float(booking_rate), float(newbook_rate), cfg["markup_pct"])
|
||||
if diff_pct is None:
|
||||
continue
|
||||
|
||||
if abs(diff_pct) > 1: # More than 1% difference
|
||||
if abs(diff_pct) > cfg["tolerance_pct"]:
|
||||
parity_issues.append({
|
||||
'rate_date': rate_date.isoformat(),
|
||||
'booking_rate': float(booking_rate),
|
||||
'newbook_rate': float(newbook_rate),
|
||||
'expected_rate': round(expected_rate, 2),
|
||||
'difference_pct': round(diff_pct, 2),
|
||||
'alert_type': 'higher' if diff_pct > 0 else 'lower',
|
||||
'booking_room_type': booking.get('booking_room_type'),
|
||||
|
|
@ -759,11 +807,24 @@ async def get_rate_parity(
|
|||
return {
|
||||
'from_date': start.isoformat(),
|
||||
'to_date': end.isoformat(),
|
||||
'expected_markup_pct': cfg["markup_pct"],
|
||||
'tolerance_pct': cfg["tolerance_pct"],
|
||||
'issues_count': len(parity_issues),
|
||||
'issues': parity_issues
|
||||
}
|
||||
|
||||
|
||||
@router.post("/parity/check")
|
||||
async def trigger_parity_check(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Run the parity check now (same logic as the daily 06:45 job)."""
|
||||
import asyncio
|
||||
from jobs.check_rate_parity import run_parity_check
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, run_parity_check)
|
||||
|
||||
|
||||
# ============================================
|
||||
# PARITY ALERTS
|
||||
# ============================================
|
||||
|
|
|
|||
176
backend/jobs/check_rate_parity.py
Normal file
176
backend/jobs/check_rate_parity.py
Normal 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()
|
||||
|
|
@ -61,6 +61,13 @@ async def run_scheduled_direct_scrape():
|
|||
await loop.run_in_executor(None, run_scrape_all_direct)
|
||||
|
||||
|
||||
async def run_scheduled_parity_check():
|
||||
from jobs.check_rate_parity import run_parity_check
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, run_parity_check)
|
||||
|
||||
|
||||
async def run_scheduled_booking_scrape_async():
|
||||
from jobs.scrape_booking_rates import run_scheduled_booking_scrape
|
||||
import asyncio
|
||||
|
|
@ -116,8 +123,16 @@ def start_scheduler():
|
|||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Rate parity check — daily at 06:45, after Newbook fetch + Booking scrape
|
||||
scheduler.add_job(
|
||||
run_scheduled_parity_check,
|
||||
CronTrigger(hour=6, minute=45),
|
||||
id='parity_check',
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00")
|
||||
logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00, parity check at 06:45")
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue