Continuous Pricing

In this example, we will use class-based continuous pricing (CBC) in the three-market demo network. With discrete pricing, a carrier offers fares from a fixed set of booking classes. Continuous pricing allows the offered price to vary between those fares, while still using booking classes to record sales and forecast demand.

We will compare a baseline with three ways of recording continuously priced sales. Only AL1 will use continuous pricing, so we can also see how its pricing decisions affect its competitor, AL2.

import passengersim as pax

pax.versions()
passengersim 0.93
passengersim.core 0.93

Setting Up the Network

We’ll load the standard 3MKT demo configuration, which gives the two carriers the same schedules and capacities in each market.

cfg = pax.Config.from_yaml(pax.demo_network("3MKT/DEMO"))

For this example, we will use a completely unrestricted network. We remove both product restrictions and advance-purchase (AP) restrictions, allowing customers to buy down to the lowest available fare for a given itinerary. Customers still differ in willingness to pay, and availability still depends on the carrier’s revenue management controls.

from passengersim.config.manipulate import strip_ap_restrictions, strip_fare_restrictions

cfg = strip_fare_restrictions(strip_ap_restrictions(cfg))

We assign both carriers the Qu revenue management (RM) system, which combines hybrid Q path forecasting with UDP optimization and path-level bid price controls. Both carriers use the same Frat5 curve, curve_C, to describe sellup behavior, and we enable the Q history needed for forecasting.

We also explicitly set the fare-adjustment scale to 0.5. Later, we will use this same value for cp_scale: the continuous-pricing adjustment must match the RM system’s fare adjustment to translate bid prices back to customer prices consistently.

fa_scale = 0.5

for c in cfg.carriers.values():
    c.rm_system = "Qu"
    c.rm_system_options = {"fare_adjustment_scale": fa_scale}
    c.frat5 = "curve_C"
    c.store_q_history = True

For this notebook, we will suppress file output and keep the results in memory. We then create an Experiments object from our shared configuration; each experiment will modify its own copy of that configuration.

cfg.outputs._write_no_files()
ex = pax.Experiments(cfg)

Defining the Pricing Experiments

We will run four experiments with this network. In the first, neither carrier uses continuous pricing. This baseline keeps the shared configuration unchanged and provides a reference for the three CBC scenarios.

@ex
def baseline(cfg):
    # The baseline configuration does not use continuous pricing.
    return cfg

For the other three experiments, we enable CBC for AL1 and leave AL2 without continuous pricing. CBC combines the path’s bid price with a willingness-to-pay adjustment based on the Frat5 curve. All three scenarios use the same pricing algorithm and scale; what changes is how sales and availability are recorded for forecasting.

  • cbc_highestclosed_as_open records continuously priced sales in the highest closed class and treats that class as open in the recorded availability history.

  • cbc_highestclosed records sales in the same class but retains its ordinary closed status in the history. This will lead to the forecast for that class being higher than otherwise, as detruncation will apply.

  • cbc_lowestopen records sales in the lowest open class, with no corresponding change to availability history.

The cp_record_highest_closed_as_open flag affects history recording, not the price calculation at the time of an offer. However, the resulting history can change later forecasts and bid prices, so these choices can affect revenue as well as the reported class mix.

@ex
def cbc_highestclosed_as_open(cfg):
    cfg.carriers.AL1.cp_algorithm = "CBC"
    cfg.carriers.AL1.cp_record = "highest_closed"
    cfg.carriers.AL1.cp_record_highest_closed_as_open = True
    cfg.carriers.AL1.cp_scale = fa_scale
    # The `cp_scale` must match the fare adjustment scale used by the RM system.
    # This example the Qu system, which has a default scale of 0.5.
    return cfg


@ex
def cbc_highestclosed(cfg):
    cfg.carriers.AL1.cp_algorithm = "CBC"
    cfg.carriers.AL1.cp_record = "highest_closed"
    cfg.carriers.AL1.cp_record_highest_closed_as_open = False
    cfg.carriers.AL1.cp_scale = fa_scale
    return cfg


@ex
def cbc_lowestopen(cfg):
    cfg.carriers.AL1.cp_algorithm = "CBC"
    cfg.carriers.AL1.cp_record = "lowest_open"
    cfg.carriers.AL1.cp_scale = fa_scale
    return cfg

Running the Experiments

The decorated functions register the four configurations; they do not run the simulations yet. We can now run them together and use the returned results to compare the scenarios.

result = ex.run()

Comparing Carrier Performance

First, let’s compare average revenues. Use the reference selector to compare each scenario with the baseline or with another recording option. AL1’s results show the effect of adopting continuous pricing, while AL2’s results show the competitive response even though we have not changed AL2’s configuration.

result.fig_carrier_revenues()

In the results shown here, recording in the lowest open class gives AL1 the largest revenue gain: about 11% above the baseline. The magnitude of this gain may seem large at first blush, but the effect is highly magnified in this network, as we’ve removed all the restrictions and there are no time of day or carrier preferences, so AL1 can capture large swathes of market share simply by lowering prices by a dollar.

Recording in the highest closed class gives a much smaller gain, while also treating that class as open in the history leaves AL1 slightly below its baseline revenue. AL2 loses revenue in all three cases, even though its own pricing settings are unchanged.

Revenue alone does not tell us whether a carrier is filling more seats or collecting more revenue from each passenger. The load-factor comparison adds the capacity-utilization side of the story. Read the two charts together when evaluating the tradeoff between sales volume and price.

result.fig_carrier_load_factors()

AL1 fills more of its capacity in all three CBC experiments. But the scenario with the highest load factor, cbc_highestclosed_as_open, is also the one where AL1’s revenue falls below the baseline. Selling more seats does not necessarily mean earning more revenue. In this run, recording in the lowest open class gives AL1 both higher revenue and a higher load factor than the baseline, without filling as much capacity as the other CBC scenarios.

Following the Booking Process

To explore how these outcomes develop, we can compare bid prices over the booking horizon. A bid price represents the opportunity cost of selling capacity now rather than retaining it for future demand. CBC uses these bid prices as an input to the offered price, along with the willingness-to-pay adjustment from the Frat5 curve.

Differences between the three CBC scenarios can therefore reflect the feedback from recorded sales and availability history into forecasts and subsequent bid prices.

result.fig_bid_price_history()

Next, we’ll focus on AL1 and break its bookings down by time frame and booking class. This shows when sales occur and where they are recorded. Since the experiments deliberately change the recording rule, a shift between classes does not by itself imply that customers paid the published fare of the new class.

result.fig_bookings_by_timeframe(by_carrier="AL1", by_class=True)

We can look at revenue over the same time frames to complement the booking counts. This helps distinguish periods and classes that contribute many sales from those that contribute more revenue. For continuously priced sales, the revenue reflects the price paid, even when the booking is recorded in a class with a different published fare.

result.fig_segmentation_by_timeframe(by_carrier="AL1", by_class=True, metric="revenue")

Identifying Continuously Priced Sales

Finally, the continuous-pricing segmentation chart separates total sales from the subset sold at continuous prices. The colored bars show total sales by recorded booking class and experiment; the black overlays show continuously priced sales. These overlays are counts, not uncertainty intervals.

Compare AL1 across the three CBC scenarios to see how the recording rule changes the distribution across classes. The baseline and AL2 provide checks: neither uses continuous pricing, so neither should have continuously priced sales.

result.fig_cp_segmentation()

These experiments show why the recording convention matters when using continuous pricing. It changes the history available to the RM system, which can change future offers and ultimately the carrier’s performance. Recording in the lowest open class performs best for AL1 in the results shown here; we should not assume that the same ranking will hold for every network or demand setting.