# Price Elasticity Studio: methodology

Free, browser-only pricing analytics at https://realanalystjobs.com/learn/price-elasticity.
Engine `pricing-lab-1.4.0`.

- Source: `lib/pricing/` (engine, optimiser, validation, synthetic data, stress scenarios).
- Checks: `scripts/check-elasticity.ts` (`npm run elasticity:check`); every number quoted below comes from them.

## The pipeline at a glance

```mermaid
flowchart TD
  A[CSV upload<br/>date, product, units, price, cost<br/>+ optional promo, stock, competitor, list price, rebate] --> B{Data checks}
  B -- blocker --> X[Stop: say what to fix]
  B -- ok / warnings --> C[Weekly series per product<br/>dates and decimal commas detected<br/>daily rows summed, price = average daily price]
  C --> D[Prepare sample<br/>stockout weeks kept as censored<br/>launch ramp left out, zero-sales weeks kept]
  D --> I[Category terms<br/>demand index of other products after their own price effect<br/>average price of other products]
  I --> E[Demand model per product, PPML<br/>log regular price + promo + depth + post-promo dip + trend + 2 seasonal harmonics + index + category price + log competitor]
  E --> F{Cost moved enough?}
  F -- yes --> G[Cost-shift check<br/>stage 1 HAC F >= 10<br/>stage 2 residual t-stat]
  G -- prices chased demand --> H[Corrected estimate<br/>block-bootstrap error]
  G -- no sign --> K[Plain estimate]
  F -- no --> K
  H --> J[Checks: hold-out, feedback pricing, frequent repricing,<br/>seasonal pricing, category repricing, streaks, stockout bias]
  K --> J
  J --> P[Category pooling<br/>random effects, tau >= 0.5, corrected products excluded]
  P --> L{Evidence label}
  L -- Red --> M[No price call]
  L -- Amber / Green --> N[Curves at b and both ends of its 90% range<br/>units = Q_ref x P/P_ref ^ b]
  N --> O[Optimiser: every allowed price within guardrails]
  O --> Q{Gains at both ends of the range?}
  Q -- yes and Green --> R[Raise or Lower]
  Q -- otherwise --> T[Test first, with a test plan]
  Q -- best is current --> S[Keep]
  R --> Z[Report, scenarios, CSV exports]
  T --> Z
  S --> Z
  M --> Z
```

## 1. Data

Required: date (day or week), product ID, units sold, selling price per unit (after discounts), unit cost. If cost is missing, the user can assume cost as a share of price; a warning says profit rests on that guess.

Optional: category, promotion flag, in-stock flag, competitor price, list price and rebate (for the waterfall).

The file is parsed with a single delimiter taken from the header row, so `;` files with decimal commas work. Dates may be ISO, D/M/YYYY or M/D/YYYY:

- the order is taken from any day field over 12
- dates that fit both orders are flagged.

Daily rows are summed to Monday weeks. The weekly price is the average of the daily prices (revenue divided by units would weight cheap, busy days more and tie the price to demand).

Checks:

- **Blockers:** a missing required column, one column used for two roles, mixed currency symbols, no usable rows, or over 400 products.
- **Warnings:**
  - unreadable or ambiguous dates
  - negative or zero prices
  - returns (negative units) and duplicate rows
  - cost at or above price
  - flat prices, sales spikes and cost jumps
  - short histories
  - under 52 weeks of history
- **Info:** stockouts, gaps, and a missing promo or stock column.

## 2. Preparing the sample

- **Stockout weeks are censored, not dropped.** In a stockout week, demand was at least the units sold. The fit repeats until it settles; each round:
  1. replaces those units with the expected demand above that floor (lognormal noise with the spread seen in normal weeks)
  2. refits.

  Dropping stockout weeks biases the estimate when stockouts happen in strong, low-price weeks. Errors are computed from observed weeks only, because filled-in values carry no noise of their own.
- **Launch ramp.** If the first 8 usable weeks average under half the product's median, leading weeks are left out until a 4-week average reaches 60% of the median.
- **Zero-sales weeks** are kept.

## 3. Demand model (per product)

    E[units_t] = exp( a + b*log(price_t) + g*promo_t + h*promo_t*log(price_t / regular_t) + q*after_promo_t + d*t/T
                      + s1*sin(2*pi*t/52.18) + s2*cos(...) + s3*sin(4*pi*t/52.18) + s4*cos(...)
                      + k*index_t + x*category_price_t + c*log(competitor_t) )

- Estimated by Poisson pseudo-maximum likelihood (IRLS). PPML handles zeros and stays consistent when the noise is not Poisson, as long as the mean is right.
- **`b` is the regular-price elasticity.** The promotion depth term `h` lets a temporary deal draw a different response (stockpiling), and `q` captures the dip in the week after a promotion, so `b` is identified by regular-price moves.
- **Promotion lift** is reported at the average observed discount depth: `exp(g + h * mean depth) - 1`, the extra units beyond what the price cut alone explains.
- **Second seasonal harmonic** with 104 or more weeks.
- **Category index:** for categories with 5 or more products, `index_t` averages the other products' log units above their own mean that week.
  - Each product's own price effect is removed first, using a quick first-pass fit, so the index absorbs shared shocks such as a holiday peak.
  - When a whole category is repriced together, the first-pass errors still leak into the estimate, and the range does not reflect that. Such products cannot be Green: the index, or the other products' shelf prices, moving with the product's own price caps them at Amber. Correlations are on regular weeks with each series' trend removed (correlation above 0.3 for the index, above 0.6 for prices), so shared promotions and shared inflation do not count.
- **Category price:** `category_price_t` is the average log shelf price (the regular price in force, so promotions do not count) of the other products in the category, each minus its own mean, so substitution towards cheaper sister products is allowed for. It is left out when it moves almost exactly with the product's own price (correlation above 0.8).
- **Errors** are Newey-West with `max(4, floor(sqrt(n)))` lags, widened by **1.45**.
  - The factor was chosen on the base synthetic data and the stress scenarios; without it, coverage was about 80%.
  - It was then checked on fresh seeds it never saw: 92% on base seeds 501 to 524, and 87% on fresh stress seeds.
  - It is an assumption calibrated on simulations. It over-covers easy cases and still under-covers prices reset from recent sales (about 61%).

## 4. Did price follow demand?

All three checks below partly protect against demand-led pricing; none removes the risk.

1. **Cost-shift (control function)**, when log cost varies (standard deviation over 0.02):
   - **Stage 1:** log price on log cost and the same controls, with a HAC (Newey-West) F of at least 10. Plain OLS errors would overstate F, because price is a step function of cost.
   - **Stage 2:** add the stage 1 residual to the demand model. If its t-statistic is beyond 1.96, the corrected `b` is used. Its error comes from a moving-block bootstrap (blocks of 8 weeks, 120 draws) of both stages, widened like the rest.
   - Corrected products stay out of the category pool.
2. **Feedback pricing:** the correlation between each regular price change and the fit residuals before it, over two windows: the 4 weeks before (monthly reviews) and the week before (weekly repricing). A t-statistic over 2 means price was being reset from recent sales (Amber).
3. **Frequent repricing:** the regular price changed in over half of consecutive weeks. Without a passing cost check this is Amber; if the changes also lean towards following recent sales (feedback t over 1.5), it is Red, because in testing such ranges almost never held the truth.

## 5. Other checks

- **Hold-out:** refit without the last 20% of weeks and forecast them. Compare the WAPE with a naive mean of the last 13 training weeks.
- **Seasonal pricing:** year 1 against year 2 of the detrended regular price, week by week, compared with year 2 shifted round by 5 to 47 weeks.
  - A circular shift keeps step-shaped prices intact, so ordinary step pricing is not mistaken for a calendar pattern.
  - It is flagged (Red) when the aligned correlation is above 0.5 and beats every shifted alignment. That is 9 of 720 base products.
- **Streaks:** the lag-1 autocorrelation of the fit residuals. Over 0.3 means ranges may be too narrow.
- **Stockout bias:** stockout weeks with expected demand 15% above normal weeks, or prices 3% lower.

## 6. Category pooling

Random-effects meta-analysis (DerSimonian-Laird) with a spread floor `tau >= 0.5`.

- `w = tau^2 / (tau^2 + se^2)`
- `b = w*own + (1-w)*mu`
- variance `w*se^2 + (1-w)^2*var(mu)`

Products whose price never moved get the category estimate for reference, labelled Red. The floor stops a noisy category from collapsing every range onto its mean. Pooling uses each product's data twice (in its estimate and in the mean), which the app discloses.

## 7. Evidence labels

| Label | Rule |
|---|---|
| Red | Under 26 usable weeks; under 3 distinct regular prices or under 2% typical movement; estimate at or above zero; seasonal pricing |
| Amber | Range includes zero or is wider than 1.2; own data weight under 50%; corrected for demand-led pricing; feedback pricing; repricing in over half of weeks without a passing cost check; category repriced together; launch ramp; streaky residuals; biased stockouts; over 15% stockout weeks; hold-out worse than naive by 10% |
| Green | None of the above |

Labels are decision-support heuristics, not proof that an estimate is causal.

## 8. Curves, optimisation and test plans

**Today's price** (the "current" price in every table, guardrail and export) is the latest regular price, or the median of the last 4 regular weeks when the price moves in most weeks, so one noisy week does not set it.

The curve's anchor is the last 13 normal weeks:

- `P_ref` is their mean regular price.
- `Q_ref` is their units, moved to `P_ref` with `b` and to an average season with the fitted seasonal terms.

The demand curve and its outcomes:

    units(P)   = Q_ref * (P / P_ref)^b
    revenue(P) = P * units(P)
    profit(P)  = (P * (1 - rebate/P_ref) - unit_cost) * units(P)

The optimiser checks every allowed price, so it is exact. Guardrails:

- the largest move
- a margin floor
- price endings (default "charm": x.x9 under 10, .99 from 10)
- staying within regular prices already tried (plus or minus 3%)
- a competitor cap
- floors, ceilings and locks.

What it recommends:

- **Raise or Lower:** the move gains at both ends of the 90% range (monotone in `b`, so both ends suffice) and the label is Green.
- **Test first:** otherwise.
- **Keep:** the best price is within 0.5% of today, or only the price-ending rule stands in the way (the message says so).
- **No call:** Red.

Test plan for each Test first product:

- **Test price:** the optimiser's proposal.
- **Pass line:** the unit change at which the new price stops paying, `margin_now / margin_at_test - 1`.
- **Length:** `1.25 * ((1.96 + 0.84) * noise / |log(expected) - log(pass)|)^2` weeks for 80% power at 5% two-sided.
  - `noise` is the product's week-to-week log residual spread, stretched by `(1 + rho) / (1 - rho)` when sales are streaky.
  - The 25% allows for the baseline being an estimate. Bias in that baseline does not shrink with time, so a store or alternating test is safer.
- **Alternating blocks everywhere you sell:** 2 weeks at one price, then 2 at the other.
  - The first week of each block is ignored, while stocked-up shoppers settle.
  - It needs `4 * ((1.96 + 0.84) * noise / delta)^2` measured weeks, so twice that in calendar weeks.
  - Differencing adjacent blocks cancels slow drifts, so the unstretched noise is used.
- **Stores:** the number of stores, half on test and half as control, needed to read it in 8 weeks.
  - Each store is compared with its own weeks before the test, which doubles the variance.
  - Each store's noise adds Poisson noise `1 / units per store`, assuming the volume splits evenly.
- **No practical test:** when none of these designs settles the question within 26 weeks (or 50 stores), the product is a Keep, with one of three reasons:
  - under 1% change at the best guess: too close to call
  - a gain at both ends of the range that is too small or noisy to verify: the cautious-end gain is quoted ("at least X%"), and a small move is described as low-risk
  - a gain at the best guess but a possible loss: test several similar products together, or leave it.

## 9. What the checks show

**Base synthetic data.** 24 datasets, each with:

- 30 products in 4 categories over 104 weeks, with known elasticities
- promotions, season, trend and competitor prices
- costs that move both ways with 70% pass-through
- three built-in traps: prices that never move, prices that chase demand, and random stockouts.

| Check | Result |
|---|---|
| 90% range holds the true elasticity, Green and Amber | 471 of 518 (91%) |
| Green alone | 130 of 151 (86%) |
| Price never moved, labelled Red | 72 of 72 |
| Price chased demand, labelled Green | 0 of 48 |
| Random-stockout products, range holds the truth | 65 of 70 (93%) |
| Raise or Lower calls that lose profit under the TRUE elasticity | 1 of 87, worst -0.8%, median true gain +4.9% |
| Test-first calls that would lose profit if applied without a test | 0 of 92 |

**Stress scenarios.** Patterns the base generator does not contain, 60 products each (adapted from the pricing economist review). The widening factor was tuned on these runs, so they are not validation on their own; see the held-out rows below.

| Scenario | Coverage | Green/Amber/Red | Confident calls (lose) |
|---|---|---|---|
| Persistent demand shocks (AR 0.9) | 89% | 5/52/3 | 0 |
| Price reset every 8 weeks from recent sales | 61% | 1/58/1 | 1 (0) |
| Prices raised only in a 6-week holiday peak | 33% | 0/15/45 | 0 |
| Holiday peak with larger regular moves | 90% | 12/37/11 | 7 (0) |
| Launch ramp with an introductory price | 100% | 0/60/0 | 0 |
| About 1.5 units a week | 100% | 0/55/5 | 0 |
| Deep promotions with stockpiling | 90% | 1/9/50 | 0 |
| Cost inflation with a curved demand trend | 100% | 0/60/0 | 0 |
| Fixed stock that sells out in strong weeks | 100% | 0/59/1 | 0 |
| All stress scenarios | 379 of 424 (89%) | | 8 (0) |

**Held out:** data the widening factor never saw.

| Check | Result |
|---|---|
| Base seeds 501 to 524, range holds the truth | 468 of 509 (92%) |
| Fresh stress seeds, range holds the truth | 220 of 252 (87%) |
| Fresh stress seeds, confident calls that lose | 2 of 7, worst -0.2% |

Across all three sets, 3 of 102 confident calls would lose profit, the worst by 0.8%.

Other checks:

- the optimiser equals brute force on every product
- the margin floor, maximum move and rounding are respected
- day/month dates, decimal commas (3,49 €, 1.234,50), mixed decimal styles, ambiguous values like 1.050, hostile project files, the cost-share fallback and CSV formula neutralising are each tested, in this studio and in the MMM lab's parser
- a 390-product fit takes about 8 s in Node (the app runs it in a Web Worker so the page stays responsive).

**Weak spots, stated plainly:**

- **Prices raised only in a holiday peak** cannot be separated from the peak. Most of those products are Red, and the rest get a test, not a call.
- **Prices reset from recent sales:** coverage is 61%, but the labels keep almost all of them out of Green.
- **Prices stepped exactly every 13 weeks** can line up with the calendar by chance and be flagged Red (about 1 in 6 in testing): no call rather than a wrong one.
- **Random weekly prices** (a price experiment) are occasionally labelled Red by the feedback check (6 of 120 in testing): again no call rather than a wrong one.
- **A whole category repriced at once:** in the economist's test only 55% of ranges held the truth, so those products are capped at Amber.
- **A mid-history break in demand:** about 32% of ranges held the truth, but all were Amber or Red, with no confident call.

## 10. Research: what Competera and Pricefx publicly document

Public product pages and documentation were read on 10 Nov 2026. No logins, private endpoints or app code were used. The table records documented features only; anything else is marked as unknown.

| Capability | Competera | Pricefx |
|---|---|---|
| Elasticity / demand model | Deep learning over 20+ pricing and non-pricing factors; cross-elasticities in every recommendation (method undocumented) | Optional in List Price Optimization; elasticity per segment in Negotiation Guidance |
| Optimisation guardrails | Rule builder with margin floors and competitive constraints, price ladders (Anchor Management), rounding rules | Lower and upper bounds, max change, brand and tier alignment; floor, target and ceiling per segment |
| Simulation | What-if simulations and live A/B tests | Price List Impact Simulation replays transactions under new prices; elasticity is not included out of the box, so volume is held fixed |
| Price waterfall | Blog material | List price, then invoice price (after on-invoice discounts), then net price (after rebates and early-payment discounts), then pocket price (after cost-to-serve), then pocket margin |
| Confidence | A model confidence level among its quality metrics | Not documented |

Sources:

- https://competera.ai/products/technology
- https://competera.ai/products/pricing-platform
- https://competera.ai/resources/articles/rounding-rules-pricing-tips-and-tricks-to-make-customers-purchase-your-goods
- https://knowledge.pricefx.com/accelerators-documentation-2/latest/accelerators/price-list-impact-simulation-accelerator/overview-price-list-impact-simulation
- https://knowledge.pricefx.com/accelerators-documentation-2/latest/accelerators/price-list-impact-simulation-accelerator/business-user-reference-optimization-price-list-impact-simulation/default-waterfall-description-optimization-price-list-impact-simulation
- https://knowledge.pricefx.com/accelerators-documentation-2/latest/accelerators/list-price-optimization-accelerator/overview-optimization-list-price
- https://knowledge.pricefx.com/accelerators-documentation-2/latest/accelerators/negotiation-guidance-accelerator/overview-optimization-negotiation-guidance
- https://www.pricefx.com/platform/ai-optimization

What this studio deliberately adds:

- **Ranges everywhere,** and a label that says when not to act.
- **A check for demand-led pricing.**
- **Recommendations that must survive the uncertainty,** not just the point estimate.
- **The static (volume held fixed) what-if next to the elastic one,** so the value of elasticity is visible.

## 11. Limits and roadmap

**Limits:**

- Observational data gives association; a price test is the confirmation.
- The category's average price is in each demand model, but prices are optimised one product at a time and a specific sister product's cannibalisation is not modelled.
- The constant-elasticity curve is trusted near prices already tried.
- There is no segmentation by store, region or channel.

**Next:**

- cross-price elasticity between named product pairs
- price ladders (size and brand alignment)
- per-segment elasticity
