The Hotel Revenue Manager's Daily Rate Check Against the Comp Set
If you run revenue for an independent hotel — a single boutique property, a small group, or a managed listing — your job is to set a nightly rate that captures demand without leaving money on the table or pricing yourself out of the consideration set. The number you need to make that call is not your own historical rate; it is where your rate sits today relative to the handful of properties a guest actually compares you against, for the dates they are actually shopping. Booking.com is the best public source for that, because it shows the live retail rate for any property on any date, to anyone, with no login.
This guide is the full daily-rate-check pipeline. We will cover why a hotel rate is meaningless without pinned dates and occupancy, how to define a comp set as a list of property URLs and a rolling set of date windows, how to pull your own rate and every comp's rate for the same window, how to submit and poll those jobs, compute where you land against the comp median and minimum, flag the windows where you are priced out of range, and write a rate-grid CSV you can open every morning. The example is a boutique property in Savannah tracking four comps across the next few weekends, but the same code covers any property and any comp set by swapping URLs.
Why a Rate Without Dates and Occupancy Is Noise
Every Booking.com rate is a function of three things baked into the URL: the arrival date, the departure date, and the party size. A search URL carries them as query parameters:
https://www.booking.com/searchresults.html?ss=Savannah&checkin=2026-07-10&checkout=2026-07-12&group_adults=2&no_rooms=1
The ss is the destination string, checkin and checkout are ISO dates, group_adults is the occupancy, and no_rooms is how many rooms. Change checkin by one day and the price can move by 40% because you crossed from a weekday into a weekend or into a minimum-stay window. Change group_adults from 2 to 4 and you may land on an entirely different room type.
The consequence for tracking is strict: a scraped rate is only comparable to another rate pulled for the identical checkin, checkout, and group_adults. If you pull your own property for the July 10–12 weekend and a competitor for July 11–13, you are comparing two different products and the gap is fiction. So the unit of work in this pipeline is not "a price" — it is a (property, window) pair, where the window is a fixed date-and-occupancy tuple applied identically to your hotel and every comp.
The second thing to internalize: one snapshot is not a signal. Rates drift as the arrival date approaches and demand firms up. What you want is a daily pull over a rolling window of future dates — the next several arrival dates that matter for your property — so you see the gap move and can react before the high-intent booking days, not after.
Step 1: Define the Comp Set and the Date Windows
Two pieces of static config drive everything: the list of property URLs in your comp set (your own hotel first, then each named competitor), and the rolling set of date windows you care about. Keep them as plain data so the daily run is just a loop.
A comp-set entry is a Booking.com property URL — the page for one specific hotel. Get each one by opening the hotel on Booking.com and copying the URL; you will strip and re-add the date parameters per window in code, so the base path is all you need to store.
# Your own property first, then each named comp. Use the base property URL;
# date/occupancy params get attached per window below.
COMP_SET = {
"own": "https://www.booking.com/hotel/us/your-boutique-savannah.html",
"the_marshall": "https://www.booking.com/hotel/us/the-marshall-house.html",
"perry_lane": "https://www.booking.com/hotel/us/perry-lane.html",
"kessler_grand": "https://www.booking.com/hotel/us/grand-bohemian.html",
"river_street": "https://www.booking.com/hotel/us/river-street-inn.html",
}
# Occupancy you sell against most. Track a second profile separately if needed.
GROUP_ADULTS = 2
NO_ROOMS = 1
def date_windows(start_offset=2, count=8, nights=2):
"""Rolling list of (checkin, checkout) for the next `count` arrival dates.
start_offset skips today/tomorrow (usually already committed);
nights is the length of stay you price against.
"""
from datetime import date, timedelta
today = date.today()
windows = []
for i in range(start_offset, start_offset + count):
ci = today + timedelta(days=i)
co = ci + timedelta(days=nights)
windows.append((ci.isoformat(), co.isoformat()))
return windows
WINDOWS = date_windows(start_offset=2, count=8, nights=2)
print(f"{len(WINDOWS)} windows x {len(COMP_SET)} properties = "
f"{len(WINDOWS) * len(COMP_SET)} pulls/day")
A short rolling window — the next 8 arrival dates for a 2-night stay — is plenty to start. You can widen count for a longer booking curve or add a second occupancy profile (a 1-adult and a 4-adult run) later, but every property you add and every window you add multiplies the daily pull count, so begin tight and expand where the gap actually moves.
Step 2: Attach the Window and Pull One Rate
To price a property for a specific window, attach the window's checkin, checkout, group_adults, and no_rooms to its URL, then call the Booking property endpoint. Every call is asynchronous: you submit, get the job id back, then poll — because the property page render plus rate lookup can run long, and api.logposervices.com sits behind Cloudflare, which kills any single connection at roughly 90 seconds.
Confirm one pull works with curl before you loop:
# 1) Submit one property + window — returns a job id immediately
curl -G "https://api.logposervices.com/api/v1/travel/booking/property" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.booking.com/hotel/us/the-marshall-house.html?checkin=2026-07-10&checkout=2026-07-12&group_adults=2&no_rooms=1"
# → {"job_id": "bk_4c1d...", "status": "pending"}
# 2) Poll the job until status == "completed"
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/bk_4c1d
# 3) Fetch the rate result
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/bk_4c1d/result
The property endpoint returns the lowest available nightly rate (and the room types) for the dates carried in the URL. The whole game is making sure the dates in the URL match the window you are pricing — which is why you build the URL in code rather than by hand.
Here is the URL builder and a single-pull helper:
import os, time, requests
from urllib.parse import urlparse, urlunparse, urlencode
API_KEY = os.environ["LOGPOSE_API_KEY"]
BASE = "https://api.logposervices.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
def with_window(base_url, checkin, checkout):
"""Attach this window's date + occupancy params to a property URL."""
parts = urlparse(base_url)
q = urlencode({
"checkin": checkin,
"checkout": checkout,
"group_adults": GROUP_ADULTS,
"no_rooms": NO_ROOMS,
})
return urlunparse(parts._replace(query=q))
def submit_property(url):
r = requests.get(f"{BASE}/travel/booking/property",
params={"url": url}, headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()["job_id"]
Step 3: Submit the Whole Grid, Then Poll It
A daily run is every property crossed with every window — dozens of pulls. The right pattern is fire-all-then-poll: submit every (property, window) job up front (each returns instantly with a job id), tag each job id with which property and window it belongs to, then poll the outstanding ids until they all finish. This runs the whole grid concurrently server-side instead of waiting on each pull in sequence.
def submit_grid():
"""Submit one job per (property, window). Returns id -> (key, ci, co)."""
jobs = {}
for key, base_url in COMP_SET.items():
for ci, co in WINDOWS:
url = with_window(base_url, ci, co)
jid = submit_property(url)
jobs[jid] = (key, ci, co)
return jobs
def collect(jobs, poll_every=5, timeout_s=1200):
"""Poll job ids; return rows of (property, checkin, checkout, rate)."""
pending = set(jobs)
rows, deadline = [], time.time() + timeout_s
while pending and time.time() < deadline:
for jid in list(pending):
s = requests.get(f"{BASE}/jobs/{jid}", headers=HEADERS, timeout=15).json()
status = s.get("status")
if status == "completed":
res = requests.get(f"{BASE}/jobs/{jid}/result",
headers=HEADERS, timeout=30).json()
key, ci, co = jobs[jid]
rows.append({
"property": key,
"checkin": ci,
"checkout": co,
"rate": _lowest_rate(res),
})
pending.discard(jid)
elif status == "failed":
key, ci, co = jobs[jid]
print(f" {key} {ci} failed: {s.get('error')}")
pending.discard(jid)
if pending:
time.sleep(poll_every)
if pending:
print(f" {len(pending)} pulls still running at timeout")
return rows
def _lowest_rate(res):
"""Pull the lowest nightly rate out of a property result, or None."""
if res.get("price") is not None:
return float(res["price"])
rooms = res.get("rooms") or []
prices = [float(r["price"]) for r in rooms if r.get("price") is not None]
return min(prices) if prices else None
jobs = submit_grid()
print(f"submitted {len(jobs)} pulls")
rows = collect(jobs)
print(f"collected {len(rows)} rate points")
Submitting first and polling second turns a 40-pull grid from a long sequential wait into a few minutes of wall-clock time — the jobs run concurrently up to your account's concurrency cap, and your script just watches the queue drain. Tagging each job id with its (property, window) is what lets you reassemble the grid afterward, since the jobs come back in whatever order they finish.
Step 4: Compute Where You Land Against the Comp Set
Now collapse the rate points into a per-window comparison. For each window, separate your own rate from the comps, compute the comp median and minimum, and derive the gap. The median tells you where the field is clustered; the minimum tells you the floor a price-shopping guest will see. Both matter: being above the median is fine if you are differentiated, but being above the maximum — or far above the median on a soft date — is the signal to act.
from statistics import median
def build_grid(rows):
"""Group rate points by window; compute own-vs-comp metrics per window."""
by_window = {}
for r in rows:
by_window.setdefault((r["checkin"], r["checkout"]), {})[r["property"]] = r["rate"]
grid = []
for (ci, co), prices in sorted(by_window.items()):
own = prices.get("own")
comps = [p for k, p in prices.items() if k != "own" and p is not None]
if own is None or not comps:
grid.append({"checkin": ci, "checkout": co, "own": own,
"comp_median": None, "comp_min": None,
"gap_to_median": None, "position": "no_data"})
continue
cmed, cmin = median(comps), min(comps)
gap = own - cmed
# Position relative to the field
if own > max(comps):
position = "above_field" # priced over every comp
elif own > cmed:
position = "above_median"
elif own < cmin:
position = "below_field" # leaving money on the table?
else:
position = "in_range"
grid.append({
"checkin": ci, "checkout": co, "own": own,
"comp_median": round(cmed, 2), "comp_min": round(cmin, 2),
"gap_to_median": round(gap, 2), "position": position,
})
return grid
grid = build_grid(rows)
for g in grid:
print(f"{g['checkin']} own={g['own']} med={g['comp_median']} "
f"min={g['comp_min']} {g['position']}")
The position field is the whole point of the daily check. above_field on a near-term date means you are priced over every comp a guest is comparing — defensible only if you are genuinely the premium choice, and a red flag otherwise. below_field means you are the cheapest option, which on a high-demand date is unforced revenue left behind. in_range is where you usually want to sit unless you are deliberately positioning high or low.
Step 5: Flag the Windows You Need to Act On
You do not want to read forty numbers every morning; you want the two or three windows where your position crossed a threshold. Apply a simple rule — flag any window where your rate is above the comp median by more than a tolerance, or above the entire field, or below the floor on a date that is filling — and surface only those.
def flags(grid, over_median_pct=0.10):
"""Return only windows that need a pricing decision."""
out = []
for g in grid:
if g["position"] == "no_data" or g["own"] is None:
continue
med = g["comp_median"]
if g["position"] == "above_field":
out.append((g, f"priced over EVERY comp (med {med})"))
elif med and g["gap_to_median"] / med > over_median_pct:
pct = round(100 * g["gap_to_median"] / med)
out.append((g, f"{pct}% over comp median ({g['own']} vs {med})"))
elif g["position"] == "below_field":
out.append((g, f"cheapest in the set (min comp {g['comp_min']})"))
return out
for g, reason in flags(grid):
print(f"⚑ {g['checkin']}–{g['checkout']}: {reason}")
# ⚑ 2026-07-10–2026-07-12: priced over EVERY comp (med 219.0)
# ⚑ 2026-07-17–2026-07-19: 14% over comp median (245.0 vs 215.0)
A 10% tolerance over the median is a reasonable default — small gaps are noise from room-type mix and refundable-vs-nonrefundable differences, not a real positioning problem. Tune the tolerance to how aggressively you price, and add a "below the floor" alert only for dates you know are filling, since being cheapest on a soft date is fine.
Step 6: Write the Rate-Grid CSV
The morning artifact is one CSV: a row per window, your rate beside the comp median and minimum, the gap, and the position flag. Open it with your coffee, scan the flagged rows, and make your moves in the channel manager.
import csv
def write_grid_csv(grid, out_path):
fields = ["checkin", "checkout", "own", "comp_median",
"comp_min", "gap_to_median", "position"]
with open(out_path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
for g in grid:
w.writerow(g)
return len(grid)
from datetime import date
n = write_grid_csv(grid, f"rate_grid_{date.today().isoformat()}.csv")
print(f"wrote {n}-window rate grid")
Stamp the filename with the run date and keep the files — a folder of daily grids becomes a record of how each window's gap moved as the arrival date approached, which is the closest thing to a demand signal you get from public data. When a window's median climbs day over day, the field is firming and you have room to raise; when it sags, demand is soft and holding a premium will cost you the booking.
Scaling This to a Standing Daily Pipeline
The pipeline above is a one-shot morning run. The revenue-manager shape is the same grid every single day, indefinitely, with someone actually told when the gap moves — and you do not want to babysit a cron job and a result store to get there.
Two things make it a standing pipeline. First, the grid is just data, so a daily run is the same submit_grid / collect / build_grid / write_grid_csv functions on a schedule — nothing changes except the rolling WINDOWS rolling forward one day each run. Second, instead of self-hosting that scheduler, LogPose exposes a monitor primitive that polls a saved Booking search on a cadence and fires an alert when a rate crosses a threshold. You point a monitor at each comp's saved search for a window and let it watch:
curl -X POST "https://api.logposervices.com/api/v1/monitors" \
-H "X-API-Key: lp_xxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.booking.com/searchresults.html?ss=Savannah&checkin=2026-07-10&checkout=2026-07-12&group_adults=2&no_rooms=1",
"name": "Savannah comp set — Jul 10 weekend",
"metric": "price",
"condition": "drops_below",
"threshold": 189.00,
"check_interval_hours": 24,
"notify_channels": ["slack"]
}'
Set condition to drops_below with a threshold at the comp floor you care about to get pinged when a competitor undercuts the field, or use changes to be alerted on any movement in a saved search. The check_interval_hours of 24 gives you the daily cadence without a cron job, and notify_channels can be any of email, webhook, telegram, slack, or discord — so the alert lands in the channel you already watch. That removes the scheduler and the result store from your build: the monitor polls daily, and you get a Slack message the morning a comp drops under your number for a date you care about, while your own grid script handles the full daily snapshot and CSV.
The Honest Fit
This approach fits an independent property — or a small managed portfolio — tracking a handful of named comps across near-term booking windows, where you want a clean daily read on where your public rate sits versus the field without standing up your own headless-browser fleet and proxy rotation. The async property endpoint, the per-window URL discipline, and the median/min/position math are the primitives that make the daily check reliable rather than a manual tab-clicking chore.
Where it is not the right tool: this is the input signal, not the pricing engine. It tells you where you sit today; it does not forecast demand, model price elasticity, ingest your pickup and pace, or push rates automatically. A full revenue-management system or channel manager does that, and if you need demand forecasting and auto-pricing across hundreds of room-nights, buy one. What this pipeline gives you is the competitive-rate signal those systems also need — pulled from the public OTA, for your exact comp set and dates, on your own terms and cadence. For an independent operator who is the RMS, that signal is the whole job.
Get Started
- Sign up at logposervices.com and generate an API key under Tool → API Keys.
export LOGPOSE_API_KEY=lp_xxxxxxx- Test one property + window, then build the grid:
curl -G "https://api.logposervices.com/api/v1/travel/booking/property" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.booking.com/hotel/us/the-marshall-house.html?checkin=2026-07-10&checkout=2026-07-12&group_adults=2&no_rooms=1"
Then define your COMP_SET and WINDOWS, submit one /api/v1/travel/booking/property?url=... job per (property, window) pair, compute your position against the comp median and minimum, and write the rate-grid CSV. Use the search endpoint (/api/v1/travel/booking/search?url=...&rows_per_page=...) when you want to discover the field in a destination rather than track named comps, and export the grid wherever your team works.
Related reading: Track hotel prices on Booking.com daily for the single-property tracking fundamentals, Scrape TripAdvisor reviews for sentiment analysis for the demand-quality side of the picture, and Competitor price monitoring for the general pattern across other verticals.
External: Booking.com, hiQ Labs v. LinkedIn.