Flight-Search / backend /price_engine.py
fyliu's picture
Add benchmark mode: freeze system date to 2026-04-01 for deterministic behavior
bc18056
"""Fare calculation engine.
==========================================================================
PRICING OVERVIEW
==========================================================================
Every flight price is computed from a deterministic formula with 7
multipliers applied to a distance-based base fare. Additional discounts
are applied at the itinerary level for connecting flights and round trips.
--------------------------------------------------------------------------
1. BASE FARE
--------------------------------------------------------------------------
base_usd = BASE_FIXED_USD + (distance_km * BASE_PER_KM_USD)
= 40 + (distance_km * 0.08)
Rationale: $40 fixed cost covers airport fees/taxes; $0.08/km covers
fuel and operating cost. A 5,000 km flight has base = $440.
--------------------------------------------------------------------------
2. PER-FLIGHT MULTIPLIERS (applied multiplicatively)
--------------------------------------------------------------------------
final = base * M_class * M_day * M_time * M_season * M_demand
* M_advance * M_jitter
2a. Cabin class (M_class)
economy 1.0x
premium_econ 1.6x
business 3.2x
first 5.5x
2b. Day of week (M_day) — based on departure date's weekday
Mon–Wed 0.90x (mid-week discount)
Thu 1.00x
Fri 1.15x (weekend premium)
Sat 1.05x
Sun 1.10x
2c. Time of day (M_time) — based on departure hour (local time)
06:00–08:00 1.10x (morning rush)
09:00–15:00 1.00x (off-peak)
16:00–19:00 1.15x (evening rush)
22:00–05:00 0.85x (red-eye discount)
2d. Seasonality (M_season) — based on departure month
Jan, Feb 0.85x (winter off-season)
Mar 0.95x
Apr 1.00x
May 1.05x
Jun 1.15x (summer peak)
Jul 1.20x (summer peak)
Aug 1.15x (summer peak)
Sep 0.90x (shoulder season)
Oct 0.95x
Nov 1.00x
Dec 1.40x (Christmas peak)
EU summer bonus: flights to European airports (continent="EU")
during Jun–Aug get an extra +0.15 added to the season multiplier.
2e. Route demand (M_demand) — based on carrier competition
1 carrier 1.20x (monopoly surcharge)
2–3 carriers 1.00x
4+ carriers 0.95x (high-competition discount)
2f. Advance booking (M_advance) — days between booking and departure
0–3 days 1.50x (last-minute premium)
4–7 days 1.35x
8–14 days 1.20x
15–21 days 1.10x
22–60 days 1.00x (sweet spot)
61–90 days 0.90x (early-bird discount)
91+ days 0.95x (far-out, slight premium vs sweet spot)
2g. Seeded jitter (M_jitter) — deterministic per-flight randomness
1.0 ± 0.08 (±8%, drawn from uniform distribution)
Uses SHA-256 seeded RNG so same search → same jitter.
Minimum price floor: $25 (after all multipliers and surcharges).
2h. Short-distance surcharge — flat fee for very short flights
Routes ≤ 500 km get +$50–$150 (random, deterministic per flight).
Waived when the leg is part of a connecting itinerary.
Still applies to round-trip flights (each direction independently).
--------------------------------------------------------------------------
3. ITINERARY-LEVEL DISCOUNTS
--------------------------------------------------------------------------
3a. Connecting-flight base discount
Each leg of a multi-segment itinerary is priced individually
using the formula above, then multiplied by CONNECTING_BASE_DISCOUNT
(0.75). This gives a 25% per-leg discount vs buying each segment
as a separate one-way ticket — simulating how airlines sell
through-fares cheaper than two one-ways.
3b. Same-airline connection discount
If ALL segments of a connecting itinerary are operated by the
SAME carrier, the total price is further multiplied by
SAME_AIRLINE_CONNECTION_DISCOUNT (0.88), giving an extra 12%
off on top of the base connection discount.
Combined effect: each leg at 0.75, then total * 0.88 =
effective ~34% discount vs separate one-ways on same carrier.
Rationale: airlines offer lower fares when they control the
entire itinerary (no interline agreement needed, guaranteed
connections, single PNR).
3c. Round-trip same-airline discount
After generating both outbound and return flights for a round-trip
search, return flights that share at least one carrier with ANY
outbound flight get multiplied by ROUND_TRIP_SAME_AIRLINE_DISCOUNT
(0.92), giving an 8% discount on the return leg.
Rationale: airlines incentivize round-trip bookings on their own
metal. A passenger who flies AA outbound and AA return pays less
per leg than mixing carriers.
--------------------------------------------------------------------------
4. CALENDAR PRICING
--------------------------------------------------------------------------
The calendar endpoint (/api/calendar) shows the cheapest representative
price per day. It uses compute_price() with:
- departure_hour = 12 (noon, off-peak → M_time = 1.00)
- booking_date = target_date - 14 days (M_advance = 1.20)
This gives a "typical" price that varies only by day-of-week and
season, useful for the date-picker price overlay.
==========================================================================
"""
from __future__ import annotations
import random
from datetime import date, timedelta
from .config import (
ADVANCE_MULTIPLIERS,
BASE_FIXED_USD,
BASE_PER_KM_USD,
CLASS_MULTIPLIERS,
DAY_MULTIPLIERS,
EU_CONTINENTS,
EU_SUMMER_BONUS,
EU_SUMMER_MONTHS,
HIGH_COMPETITION_DISCOUNT,
JITTER_RANGE,
MONOPOLY_ROUTE_BONUS,
SEASON_MULTIPLIERS,
SHORT_DISTANCE_FEE_MAX,
SHORT_DISTANCE_FEE_MIN,
SHORT_DISTANCE_THRESHOLD_KM,
)
def compute_price(
distance_km: int,
cabin_class: str,
departure_date: date,
departure_hour: int,
num_carriers: int,
dest_continent: str,
rng: random.Random,
booking_date: date | None = None,
is_connection: bool = False,
) -> float:
"""Compute a single-flight price using the 7-multiplier formula.
This is the core pricing function. It computes the fare for one
segment (one takeoff–landing pair). Itinerary-level discounts
(connection, round-trip) are applied separately by the caller.
Args:
distance_km: Great-circle distance of the route.
cabin_class: One of "economy", "premium_economy", "business", "first".
departure_date: Date of departure (affects day-of-week, season).
departure_hour: Hour of departure in local time 0–23 (affects time-of-day).
num_carriers: Number of airlines operating this route (affects demand).
dest_continent: Destination airport continent code (for EU summer bonus).
rng: Seeded Random instance (for deterministic jitter).
booking_date: Date of booking (defaults to today; affects advance multiplier).
is_connection: True when this leg is part of a connecting itinerary
(suppresses the short-distance surcharge).
Returns:
Price in USD, rounded to nearest dollar, minimum $25.
"""
# --- Base fare ---
base = BASE_FIXED_USD + (distance_km * BASE_PER_KM_USD)
# --- Multiplier 1: Cabin class ---
class_mult = CLASS_MULTIPLIERS.get(cabin_class, 1.0)
# --- Multiplier 2: Day of week ---
day_mult = DAY_MULTIPLIERS.get(departure_date.weekday(), 1.0)
# --- Multiplier 3: Time of day ---
if 6 <= departure_hour <= 8:
time_mult = 1.10 # Morning peak
elif 16 <= departure_hour <= 19:
time_mult = 1.15 # Evening peak
elif departure_hour >= 22 or departure_hour <= 5:
time_mult = 0.85 # Red-eye discount
else:
time_mult = 1.00
# --- Multiplier 4: Seasonality ---
season_mult = SEASON_MULTIPLIERS.get(departure_date.month, 1.0)
if dest_continent in EU_CONTINENTS and departure_date.month in EU_SUMMER_MONTHS:
season_mult += EU_SUMMER_BONUS
# --- Multiplier 5: Route demand (carrier competition) ---
if num_carriers == 1:
demand_mult = 1.0 + MONOPOLY_ROUTE_BONUS
elif num_carriers >= 4:
demand_mult = 1.0 - HIGH_COMPETITION_DISCOUNT
else:
demand_mult = 1.0
# --- Multiplier 6: Advance booking ---
if booking_date is None:
from .benchmark import today
booking_date = today()
days_advance = max(0, (departure_date - booking_date).days)
advance_mult = 1.0
for threshold, mult in ADVANCE_MULTIPLIERS:
if days_advance <= threshold:
advance_mult = mult
break
# --- Multiplier 7: Seeded jitter (±8%) ---
jitter = 1.0 + rng.uniform(-JITTER_RANGE, JITTER_RANGE)
price = (base * class_mult * day_mult * time_mult
* season_mult * demand_mult * advance_mult * jitter)
# Short-distance surcharge: random $50–$150 fee on very short flights.
# Waived when the leg is part of a connecting itinerary.
if distance_km <= SHORT_DISTANCE_THRESHOLD_KM and not is_connection:
price += rng.randint(SHORT_DISTANCE_FEE_MIN, SHORT_DISTANCE_FEE_MAX)
return max(25.0, round(price, 0))
def compute_calendar_price(
distance_km: int,
cabin_class: str,
target_date: date,
num_carriers: int,
dest_continent: str,
rng: random.Random,
) -> float:
"""Compute the cheapest representative price for a given date.
Used by the calendar endpoint to show day-by-day pricing.
Assumes noon departure (off-peak) and 14-day advance booking
to give a stable "typical" price that varies only by day-of-week
and season.
"""
return compute_price(
distance_km=distance_km,
cabin_class=cabin_class,
departure_date=target_date,
departure_hour=12,
num_carriers=num_carriers,
dest_continent=dest_continent,
rng=rng,
booking_date=target_date - timedelta(days=14),
)