Dynamic pricing with AI: from elasticity to guardrails
TL;DR
Difficulty: Intermediate
Dynamic pricing is elasticity plus guardrails, inside UK law.
- Elasticity says how much demand moves when price moves, and it differs by product.
- The markup rule sets the price from cost and elasticity, and a loose elasticity means a loose price.
- A demand model predicts units at candidate prices, then you pick the best contribution.
- Guardrails: review large proposals before clamping, a true margin floor, small steps, a cooldown.
- UK rules: total prices, no false urgency, no repricing during checkout, and never agree prices with rivals.
Dynamic pricing is not changing prices wildly. It is knowing how sensitive each product's demand is, setting the price that pays, and keeping an algorithm inside rules. I work through elasticity, the markup rule, a demand model and the guardrails on an invented Bristol cookware shop, with the UK price transparency and competition rules the engine must respect.
Dynamic pricing is elasticity plus guardrails
Most articles on dynamic pricing open with a big number about Amazon or the size of the pricing software market. The earlier version of this one did too: Amazon's price changes per day, a market forecast to 2033, a McKinsey revenue figure. I could not reach a primary source for any of them, so they are gone, and nothing below depends on them.
What does matter is this. Dynamic pricing is not about changing prices wildly. The Competition and Markets Authority (CMA) defines it as firms adjusting prices "rapidly and frequently in response to changing demand conditions", and says it "can be consistent with effective competition and good outcomes for consumers".1 Done well, it rests on one number, price elasticity, and on the rules that stop an algorithm doing something technically optimal and commercially daft.
This is a practical guide. I walk through how to estimate elasticity from your own orders, how to turn it into a price, the Solidus and Rails engine that applies it with guardrails, and the UK rules on price transparency and competition that the engine has to respect.
Throughout I use one invented business. Avon Kitchen Supply is an online cookware shop in Bristol at avonkitchen.example. Its two products in this article are a 26cm cast iron pan, which costs £20 to buy in and deliver, and a branded pan cleaning brush, which costs £2. Nobody real, and every number below is made up so that every step can be shown.
Price elasticity is the number that matters
Price elasticity of demand measures how much demand moves when the price moves:
Here (eta) is the price elasticity, is the percentage change in units sold and is the percentage change in price. It is almost always negative, because a higher price sells fewer units. Foundations: elasticity
If a product has an elasticity of minus 1.5, a 10% price rise loses roughly 15% of units. At minus 0.3, the same rise loses roughly 3%, and demand barely notices. Below minus one in value, demand is called elastic: revenue falls when you raise the price. Between zero and minus one it is inelastic: revenue rises.
Worked example: two products, one 10% rise
The pan sells 200 a month at £36, and the example assumes an elasticity of minus 2.5. A 10% rise to £39.60 keeps of the units on a constant elasticity curve, so about 158 sell. Revenue falls from to .
The brush sells 300 a month at £6, with an assumed elasticity of minus 0.6. A 10% rise to £6.60 keeps of the units, so about 283 sell. Revenue rises from to .
Same rise, opposite results. That is why one pricing rule for the whole shop is wrong.
Estimating elasticity from your own orders
You can estimate elasticity from your own sales, as long as a product has had a few price changes and enough orders. The standard tool is a log log regression:
Here is the natural logarithm of weekly units sold, the logarithm of the price that week, a constant, the elasticity, which in this form is simply the slope, and the part of demand the model does not explain. Foundations: regression and Foundations: logarithms
import json
import numpy as np
import pandas as pd
import statsmodels.api as sm
def estimate_elasticity(orders_csv, min_weeks=30, min_prices=3):
"""Export from Solidus: product_id, price, quantity, order_date."""
df = pd.read_csv(orders_csv, parse_dates=["order_date"])
df["week"] = df["order_date"].dt.to_period("W").dt.start_time
weekly = (df.groupby(["product_id", "week"])
.agg(price=("price", "mean"), units=("quantity", "sum"))
.reset_index())
results = {}
for product_id, g in weekly.groupby("product_id"):
g = g[g["units"] > 0]
if len(g) < min_weeks or g["price"].round(2).nunique() < min_prices:
continue
X = sm.add_constant(np.log(g["price"]))
fit = sm.OLS(np.log(g["units"]), X).fit()
results[str(product_id)] = {
"elasticity": round(fit.params.iloc[1], 2),
"std_error": round(fit.bse.iloc[1], 2),
"weeks": len(g),
"price_range": [round(g["price"].min(), 2), round(g["price"].max(), 2)],
}
return results
if __name__ == "__main__":
print(json.dumps(estimate_elasticity("solidus_orders_export.csv"), indent=2))
Three changes from the earlier version. It built weeks from the week number alone, so week 10 of two different years merged into one. It added 1 to every count before taking the logarithm, which bends the slope for slow sellers. And it reported no standard error, which is the number that tells you how far to trust the elasticity.
One caution matters more than any line of code. Prices in a shop rarely change at random. If you cut prices during the Christmas rush, the regression sees low prices and high sales together and credits the price for what the season did. Control for the season and for promotions, or the elasticity will be wrong. That is the whole subject of modelling demand and price elasticity with regression, and it is worth reading before you trust a number from this script.
An example: the pan's export covers 52 weeks at four different prices between £32 and £40. The script returns an elasticity of minus 2.5 with a standard error of 0.4. A rough 95% range is , so minus 3.3 to minus 1.7. Elastic for certain, but the exact figure is loose, and the next section shows why that matters.
The price that pays is set by contribution
Elasticity alone does not tell you the price. Maximising revenue is the wrong target, because revenue ignores what each unit costs. Maximise contribution, price minus the cost of one more unit, times units, and under constant elasticity a clean rule falls out, the markup rule from any economics course:2
Here is the contribution maximising price, is the marginal cost, the cost of selling one more unit, and is the elasticity, which must be below minus one for the rule to give an answer. It is the same rule as in modelling demand and price elasticity and, written with a positive elasticity, in stochastic optimisation for budget and pricing. Foundations: marginal versus average
Worked example: the markup rule on the pan
Step one: .
Step two: .
Step three: .
The pan is priced at £36, slightly above the rule's answer. Now feed in the ends of the range from the last section. At minus 3.3 the rule gives . At minus 1.7 it gives . A loose elasticity means a loose price, which is why the engine below moves prices in small, logged steps and measures what happens, rather than jumping to the formula's answer.
For the brush, at minus 0.6, the rule has no answer: demand is inelastic, so every rise in the tested range adds contribution. That does not mean "raise it forever". It means the data says nothing about prices above the range you have tried, and the next step is a small, careful test above it.
A demand model, not a price model
Once elasticity is understood, you can add other signals: stock levels, the season, your promotion calendar, competitors' public prices. The earlier version of this article trained XGBoost to predict "revenue per unit" and called the prediction the optimal price. That was wrong. Revenue per unit is simply the price that was charged, so the model learned to repeat past prices, not to find a better one.
The right shape is a demand model. Predict units from the price and the other signals, then try candidate prices and pick the one with the highest contribution:
import numpy as np
import pandas as pd
from xgboost import XGBRegressor
FEATURES = ["price", "competitor_min_price", "days_of_stock", "week_of_year",
"is_promotion", "category_demand_index"]
def fit_demand_model(history):
model = XGBRegressor(n_estimators=300, max_depth=4, learning_rate=0.05,
monotone_constraints="(-1,0,0,0,0,0)") # more price, never more units
model.fit(history[FEATURES], history["units"])
return model
def best_price(model, today, unit_cost, current_price, step=0.05):
candidates = np.round(current_price * np.arange(1 - step, 1 + step + 0.001, 0.01), 2)
rows = [{**today, "price": p} for p in candidates]
units = model.predict(pd.DataFrame(rows)[FEATURES])
contribution = (candidates - unit_cost) * units
return float(candidates[int(np.argmax(contribution))])
The monotone constraint tells the trees that a higher price can never sell more units, which stops them learning nonsense from noisy weeks. And the candidates stay within 5% of today's price, because the model only knows prices close to the ones it has seen. Test it as you would a forecast, on weeks it has never seen, which I describe in AI powered demand forecasting.
An example: for the pan at £36, the model predicts 196 units at £34.20 and 172 at £37.80. Contribution at £34.20 is . At £37.80 it is . At £36 the model predicts 184 units, so . The best candidate in this window is £37.80, a rise, even though the markup rule pointed down. The difference is the other signals: in this example stock is low and a competitor has sold out. Which one is right is a question for a test, not for an argument.
Guardrails are the most important part
The model will sometimes propose something technically optimal and commercially daft. Imagine the pan is mentioned on a cooking programme and demand triples overnight. The pure optimisation says: raise the price 40%. At that demand, the maths is right. Your customers would screenshot it, and the shop's reputation would pay for weeks.
The engine therefore proposes, checks, then constrains. The check comes first: a large proposal goes to a person before any clamp can hide how large it was, the same order as in AI powered marketing automation.
module DynamicPricing
class Engine
GUARDRAILS = {
min_gross_margin: 0.25, # price at least cost / (1 - 0.25)
max_step: 0.05, # at most 5% per change
review_above: 0.08, # a proposal over 8% goes to a person
cooldown_hours: 24, # at most one change a day
max_above_rrp: 0.0 # never above the recommended retail price
}.freeze
def reprice(variant, proposed)
current = variant.price.to_d
change = ((proposed - current) / current).abs
return PriceReviewQueue.create!(variant: variant, current: current, proposed: proposed) if change > GUARDRAILS[:review_above]
return :cooldown if PriceChangeLog.where(variant: variant).where("created_at > ?", GUARDRAILS[:cooldown_hours].hours.ago).exists?
price = constrain(variant, current, proposed)
return :unchanged if price == current
Spree::Variant.transaction do
variant.update!(price: price) # Solidus keeps the price on the variant's default price record
PriceChangeLog.create!(variant: variant, old_price: current, proposed: proposed, new_price: price)
end
end
private
def constrain(variant, current, proposed)
step = current * GUARDRAILS[:max_step]
price = proposed.clamp(current - step, current + step)
floor = variant.cost_price / (1 - GUARDRAILS[:min_gross_margin])
price = [price, floor].max
rrp = variant.recommended_retail_price # the shop's own field
price = [price, rrp * (1 + GUARDRAILS[:max_above_rrp])].min if rrp
price.round(2)
end
end
end
One correction from the earlier version. It called cost * 1.10 a "10% minimum margin". That is a 10% markup, which is a 9.1% margin. The margin floor is cost divided by one minus the margin:
Here is the lowest price the engine may set, is the unit cost and is the minimum gross margin as a share of the price. Foundations: percentages
Worked example: three proposals for the pan
The pan costs £20, so the floor at a 25% margin is . A 25% markup would have given only , and at £25 the margin is , not 25%.
Monday: the model proposes £37.80 from £36. The change is , under the 8% review line. The 5% step allows up to , the floor is far below, and the RRP is £40. The price moves to £37.80.
Tuesday: the model proposes £38.50. The last change was less than 24 hours ago, so nothing happens.
After the cooking programme: the model proposes £52.90 from £37.80, a change of . Over 8%, so it goes to the review queue untouched, and Avon Kitchen's owner decides. Under the UK rules below, the answer is probably to hold the price and order more stock.
UK price transparency rules shape what the engine may do
Consumer law does not ban dynamic pricing. The CMA says so plainly, and adds that how a business implements and communicates it is what the law cares about.1 Since 6 April 2025 the rules on unfair commercial practices sit in Part 4 of the Digital Markets, Competition and Consumers Act 2024 (DMCCA), the same rules I set out for delivery charges in clear and consistent messaging. Three parts matter for a pricing engine.
A misleading action includes "an overall presentation which is likely to deceive the average consumer", even if every piece of information is true.3 A misleading omission includes information given "in a way that is unclear or untimely".4 And an invitation to purchase must show the total price, including charges the consumer will necessarily pay, and any delivery charges or the fact that they may be payable.5 The CMA's price transparency guidance names showing a headline price and adding mandatory charges later "drip pricing", and says it is prohibited.6 The CMA and the courts can fine traders up to 10% of worldwide turnover.7
On dynamic pricing in particular, the CMA says it is more likely to be concerned when consumers do not know it is being used, feel pressured into quick decisions because prices may rise suddenly, when vulnerable consumers end up paying more, or when it is used to gain or keep market power.1 Its tips for businesses that use dynamic pricing say: be clear that prices can change, tell customers what drives the changes or the range where that helps, present it prominently rather than in small print, do not push people into snap decisions, and do not change the price while a customer is paying.8 The list of practices banned outright also includes falsely claiming an offer is available only for a very limited time to rush a decision.9
For the engine, that becomes a short list of rules:
- Freeze the basket. A price shown at the invitation to purchase holds through checkout. The engine never reprices a variant that sits in a basket being paid for.
- Total price everywhere. Every price the engine writes appears with delivery handled as the CMA's guidance describes, on the page and in the feed.
- No false urgency. No "price rises in 10 minutes" unless it is true, and no countdowns attached to a price that is not about to change.
- Say that prices move. A plain line on the product page or in the delivery and pricing page that prices change with demand and stock.
An example: a customer adds the pan at £37.80 at 9pm. At 9.05pm the engine runs and would raise it to £39.69. Because the pan is in a basket that has reached checkout, the change waits. She pays £37.80 plus the delivery charge she saw on the product page. The new price applies to the next customer, and the change is logged with the signals that caused it.
Competition law: react to the market, never agree with it
Pricing against competitors is normal. Reacting on your own to prices your rivals publish is the competition the law wants. The line is agreement and shared confidential data.
The CMA's guidance for online sellers says repricing software "can be used to encourage healthy competition", and that using it to carry out a price fixing agreement is illegal: in 2016 it fined an online seller over £160,000 for agreeing with a competitor not to undercut each other on Amazon's UK site, an agreement run through automated repricing software.10 The CMA's paper on algorithms adds two subtler routes: hub and spoke, where competitors use the same pricing software or intermediary, which can facilitate information exchange, and autonomous tacit collusion, where pricing algorithms learn to keep prices high without communicating at all.11 In a simulated market, AI pricing agents based on Q learning did exactly that.12 The UK rules on cartels cover price fixing and sharing commercially sensitive information, including through a third party.13
So the engine reads only the shop's own data and competitors' public prices. If you buy a repricing service, ask whether its recommendations use any other client's non public data. The game theory behind this, and where the line sits for a platform serving rival shops, is in when your AI starts fighting itself.
Collecting those public prices is its own question. The earlier version suggested commercial scraping services "for anti bot handling". I have removed that. If a site blocks automated access, respect it. Read and follow its robots.txt, the standard for which is RFC 9309,14 read its terms, identify your bot honestly with a contact address, and prefer a price comparison feed or a supplier's price list where one exists. Whether collecting a particular competitor's prices is lawful is a question for a solicitor, not for a script.
An example: a rival lists the same pan at £34.99. Avon Kitchen's engine sees that from the rival's public product page, collected once a day by an honest bot that obeys robots.txt. It feeds competitor_min_price into the demand model, which suggests a small cut. That is competition. An email between the two owners agreeing that neither goes below £35 would be a cartel, whatever software carried it out.
Personal prices are a different and riskier thing
Everything above prices by product, stock, season and the market. Pricing by who the customer is, from their personal data, adds data protection law. Under the UK GDPR a significant decision based solely on automated processing, one with legal or similarly significant effects, needs safeguards: information about the decision, a way to make representations, human intervention and a way to contest it.15 Whether a personal price is significant depends on the case, and the CMA's concern about vulnerable consumers paying more points the same way.1 Pricing by product rather than by person avoids both questions, which is why the engine above prices by product.
Getting started
- Export your orders. Product, price at the time of sale, quantity, date. That is the minimum for elasticity.
- Run the elasticity script, with the season and promotions controlled for. This alone tells you where a price rise will not hurt, even if you never automate anything.
- Start with rules, not a model. "If days of stock fall below 14 and sales are slowing, cut 3%" is transparent and easy to check.
- Add the demand model when rules feel limiting, tested on weeks it has never seen.
- Keep the guardrails on, and freeze prices in baskets that are being paid for.
- Log everything. Every proposal, every signal, every guardrail that fired. It is your training data for the next model and your answer when someone asks why a price changed.
Run the engine once or twice a day. Dynamic pricing does not mean frantic pricing.