Dynamic pricing with AI: from elasticity to guardrails

TL;DR

Difficulty: Intermediate

Dynamic pricing is elasticity plus guardrails, inside Irish and EU 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.
  • Irish 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 Limerick cookware shop, with the Irish and EU 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. EU consumer law describes it as changing the price "in a highly flexible and quick manner in response to market demands",1 and the Competition and Consumer Protection Commission (CCPC) put the Irish position in one line: "companies in Ireland are allowed to respond to market demand".2 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 Irish and EU rules on price transparency and competition that the engine has to respect.

Throughout I use one invented business. Garryowen Kitchen Supply is an online cookware shop in Limerick at garryowenkitchen.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:

η=%ΔQ%ΔP\eta = \frac{\%\Delta Q}{\%\Delta P}

Here η\eta (eta) is the price elasticity, %ΔQ\%\Delta Q is the percentage change in units sold and %ΔP\%\Delta P 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 1.10−2.5=0.791.10^{-2.5} = 0.79 of the units on a constant elasticity curve, so about 158 sell. Revenue falls from 200×36=€7,200200 \times 36 = €7{,}200 to 158×39.60=€6,257158 \times 39.60 = €6{,}257.

The brush sells 300 a month at €6, with an assumed elasticity of minus 0.6. A 10% rise to €6.60 keeps 1.10−0.6=0.9441.10^{-0.6} = 0.944 of the units, so about 283 sell. Revenue rises from 300×6=€1,800300 \times 6 = €1{,}800 to 283×6.60=€1,868283 \times 6.60 = €1{,}868.

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:

ln⁡Q=a+ηln⁡P+ε\ln Q = a + \eta \ln P + \varepsilon

Here ln⁡Q\ln Q is the natural logarithm of weekly units sold, ln⁡P\ln P the logarithm of the price that week, aa a constant, η\eta the elasticity, which in this form is simply the slope, and ε\varepsilon 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 −2.5±1.96×0.4-2.5 \pm 1.96 \times 0.4, 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:3

P∗=MC1+1/ηP^{*} = \frac{MC}{1 + 1/\eta}

Here P∗P^{*} is the contribution maximising price, MCMC is the marginal cost, the cost of selling one more unit, and η\eta 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: 1/η=1/(−2.5)=−0.41 / \eta = 1 / (-2.5) = -0.4.

Step two: 1+(−0.4)=0.61 + (-0.4) = 0.6.

Step three: P∗=20/0.6=€33.33P^{*} = 20 / 0.6 = €33.33.

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 20/(1−1/3.3)=20/0.697=€28.7020 / (1 - 1/3.3) = 20 / 0.697 = €28.70. At minus 1.7 it gives 20/(1−1/1.7)=20/0.412=€48.5720 / (1 - 1/1.7) = 20 / 0.412 = €48.57. 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 (34.20−20)×196=€2,783(34.20 - 20) \times 196 = €2{,}783. At €37.80 it is (37.80−20)×172=€3,062(37.80 - 20) \times 172 = €3{,}062. At €36 the model predicts 184 units, so (36−20)×184=€2,944(36 - 20) \times 184 = €2{,}944. 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:

Pmin⁡=c1−mP_{\min} = \frac{c}{1 - m}

Here Pmin⁡P_{\min} is the lowest price the engine may set, cc is the unit cost and mm 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 20/(1−0.25)=€26.6720 / (1 - 0.25) = €26.67. A 25% markup would have given only 20×1.25=€2520 \times 1.25 = €25, and at €25 the margin is 5/25=20%5 / 25 = 20\%, not 25%.

Monday: the model proposes €37.80 from €36. The change is 1.80/36=5%1.80 / 36 = 5\%, under the 8% review line. The 5% step allows up to 36×1.05=€37.8036 \times 1.05 = €37.80, 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 15.10/37.80=40%15.10 / 37.80 = 40\%. Over 8%, so it goes to the review queue untouched, and Garryowen Kitchen's owner decides. Under the Irish rules below, the answer is probably to hold the price and order more stock.

Irish price transparency rules shape what the engine may do

Consumer law does not ban dynamic pricing. EU law treats prices that move with market demand as ordinary and asks for extra information only when a price is personalised,1 and the CCPC says plainly that companies in Ireland may respond to market demand.2 In Ireland the rules on unfair and misleading commercial practices sit in Part 3 of the Consumer Protection Act 2007, as amended by the Consumer Rights Act 2022, the same rules I set out for delivery charges in clear and consistent messaging. Three parts matter for a pricing engine.

A practice is misleading if it would be likely to deceive or mislead the average consumer about the price of a product or "the manner in which that price is calculated", and where it does that through information, "it is not a defence in any proceeding to show that the information is factually correct".4 A practice is also misleading if it gives material information "in a manner that is unclear, unintelligible, ambiguous or untimely".5 And an invitation to purchase must show the price inclusive of taxes and "any freight, delivery or postal charges that apply in relation to the product".6 The CCPC's pricing guidance for businesses says "There should be no hidden charges", and that the trader must tell the consumer about additional charges, "for example postal or delivery charges, before they buy".7 The CCPC enforces the Act, and a misleading commercial practice is a criminal offence: on indictment, a fine of up to €60,000 for a first conviction and up to €100,000 for a later one.8

On dynamic pricing in particular, the Oasis ticket sale of 2024 is the Irish case to know. The CCPC opened an in depth investigation into Ticketmaster Ireland after reviewing more than 100 complaints, saying there were "legitimate concerns over how consumers were treated".2 The Act's list of practices banned outright includes falsely claiming that a product is available only for a limited time, or on particular terms for a limited time, to rush a decision.9 And before a consumer is bound by a distance contract, the Consumer Rights Act 2022 requires the trader to give the total price inclusive of taxes and any additional delivery charges.10 Read together: be clear that prices can change, present the price and how it is worked out plainly rather than in small print, do not push people into snap decisions, and do not change the price while a customer is paying.

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 Act and the CCPC's guidance describe, 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 European Commission's guidelines on Article 101 of the Treaty on the Functioning of the European Union (TFEU) say "using publicly available data to feed algorithmic software is legal", but an algorithm that carries out an agreement between competitors is collusion by code, typically a cartel, and firms cannot avoid liability because their prices were set by algorithms. They also describe hub and spoke, where sensitive information passes between competitors through a third party, such as a pricing tool from a single IT company that various competitors use.11 Research adds autonomous tacit collusion, where pricing algorithms learn to keep prices high without communicating at all. In a simulated market, AI pricing agents based on Q learning did exactly that.12 In Ireland, section 4 of the Competition Act 2002 prohibits agreements and concerted practices that directly or indirectly fix selling prices, and Article 101 TFEU says the same wherever trade between member states is affected.13 The CCPC treats the exchange of competitively sensitive information as a breach in its own right,14 and lists the penalties for price fixing as up to 10 years in prison and fines of up to €50 million or 20% of turnover.15

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,16 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. Garryowen 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 GDPR a person has the right not to be subject to a decision based solely on automated processing that has legal or similarly significant effects on them, apart from narrow exceptions, and where an exception applies the business must provide at least human intervention, a way to express a view and a way to contest the decision.17 Whether a personal price is significant depends on the case. Consumer law points the same way: the Consumer Rights Act 2022 requires the trader to tell the consumer before the contract that a price was personalised on the basis of automated decision making.10 Pricing by product rather than by person avoids both questions, which is why the engine above prices by product.

Getting started

  1. Export your orders. Product, price at the time of sale, quantity, date. That is the minimum for elasticity.
  2. 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.
  3. 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.
  4. Add the demand model when rules feel limiting, tested on weeks it has never seen.
  5. Keep the guardrails on, and freeze prices in baskets that are being paid for.
  6. 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, and your customers will thank you for it.

Sources

Footnotes

  1. Directive (EU) 2019/2161 of the European Parliament and of the Council (the Omnibus Directive), recital 45, EUR-Lex, 2019, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32019L2161

  2. Competition and Consumer Protection Commission, CCPC opens investigation into Ticketmaster sale of Oasis tickets, statement, CCPC, 6 September 2024, https://www.ccpc.ie/consumers/2024/09/06/ccpc-opens-investigation-into-ticketmaster-sale-of-oasis-tickets/

  3. Robert S. Pindyck, Lecture Notes on Pricing, 15.013 Industrial Economics for Strategic Decisions, MIT Sloan School of Management, August 2010, https://www.mit.edu/~rpindyck/Courses/Pricing_10.pdf

  4. Law Reform Commission, Consumer Protection Act 2007, revised, section 43 (Misleading: false, misleading or deceptive information), subsections (1) to (4), updated to 24 November 2025, Revised Acts, https://revisedacts.lawreform.ie/eli/2007/act/19/revised/en/html

  5. Law Reform Commission, Consumer Protection Act 2007, revised, section 46 (Misleading: withholding, omitting or concealing material information), subsections (1) and (2), updated to 24 November 2025, Revised Acts, https://revisedacts.lawreform.ie/eli/2007/act/19/revised/en/html

  6. Law Reform Commission, Consumer Protection Act 2007, revised, section 2(1), definition of invitation to purchase, and section 46(3)(c) and (d), updated to 24 November 2025, Revised Acts, https://revisedacts.lawreform.ie/eli/2007/act/19/revised/en/html

  7. Competition and Consumer Protection Commission, Product pricing obligations for Irish businesses, CCPC, accessed November 2026, https://www.ccpc.ie/information-for-businesses/selling-goods-and-services/pricing-and-price-reductions

  8. Law Reform Commission, Consumer Protection Act 2007, revised, sections 47 (Offence: misleading commercial practices), 74 (Consumer's right of action for damages) and 79 (Fines and penalties), updated to 24 November 2025, Revised Acts, https://revisedacts.lawreform.ie/eli/2007/act/19/revised/en/html

  9. Law Reform Commission, Consumer Protection Act 2007, revised, section 55 (Prohibited commercial practices), subsection (1)(n), updated to 24 November 2025, Revised Acts, https://revisedacts.lawreform.ie/eli/2007/act/19/revised/en/html

  10. Government of Ireland, Consumer Rights Act 2022, Schedule 3 (Information to be provided: off premises contract or distance contract), clauses (f), (g) and (i), Irish Statute Book, 2022, https://www.irishstatutebook.ie/eli/2022/act/37/schedule/3/enacted/en/html

  11. European Commission, Guidelines on the applicability of Article 101 of the Treaty on the Functioning of the European Union to horizontal co operation agreements, Official Journal of the European Union, C 259, 21 July 2023, paragraphs 379 and 402, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:52023XC0721%2801%29

  12. Emilio Calvano, Giacomo Calzolari, Vincenzo Denicolò and Sergio Pastorello, Artificial Intelligence, Algorithmic Pricing, and Collusion, American Economic Review, volume 110, issue 10, pages 3267 to 3297, 2020, https://www.aeaweb.org/articles?id=10.1257/aer.20190623

  13. Competition Act 2002, section 4, Irish Statute Book, https://www.irishstatutebook.ie/eli/2002/act/14/section/4/enacted/en/html, and Treaty on the Functioning of the European Union, Article 101, EUR-Lex, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:12016E101

  14. Competition and Consumer Protection Commission, Competition law breaches, CCPC, accessed November 2026, https://www.ccpc.ie/information-for-businesses/guidance-for-businesses/competition-law-guidance/competition-law-breaches

  15. Competition and Consumer Protection Commission, Understanding and preventing price fixing, CCPC, accessed November 2026, https://www.ccpc.ie/information-for-businesses/guidance-for-businesses/competition-law-guidance/competition-law-breaches/cartels/price-fixing

  16. Martijn Koster, Gary Illyes, Henner Zeller and Lizzi Sassman, RFC 9309: Robots Exclusion Protocol, IETF, September 2022, https://www.rfc-editor.org/rfc/rfc9309.html

  17. Regulation (EU) 2016/679 (General Data Protection Regulation), Article 22(1) to (3), automated individual decision making, including profiling, EUR-Lex, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679

If you want to know which of your products can take a price rise and which cannot, bring a year of orders to the free workshop and we will run the elasticity numbers together. The first two weeks of work are on me.