Data AnalyticsPythonSQLData EngineeringProduct ManagementPricing & FP&A

RetailPulse

Pricing and promotion analytics for a grocery chain: which discounts actually make money, which prices can move, and how much stock each promo really needs.

▶ Try the live MVPRead the PRDPython notebookExcel model

FreshCart is a fictional retailer and all data is synthetic. The problem is inspired by pricing work I've done in retail; no client data is used.

Problem971 promotions planned on gut feel. Volume went up, but nobody knew if they made money, and deep promos kept running out of stock.
My roleProduct manager, data engineer and analyst: discovery, PRD, star schema and quality gate, SQL, Python models (elasticity, forecasting, A/B test), simulator, Excel model.
ToolsPython (pandas, NumPy, SciPy, matplotlib) · SQL (window functions, CTEs) · Star schema · JavaScript · Excel
ImpactFound that only 33% of promos paid back (net −$454K); price model recovers true elasticities with r = 0.97; promo-week forecast error cut from 44% to 14%; coupon A/B test stopped a rollout that grew sales but not profit.
01 · THE PROBLEM

Promotions that grow volume and shrink profit

FreshCart is a 40-store grocery chain. Category managers run promotions every week and are judged on unit sales. Three things were going wrong:

  • Promo profit was invisible. Lift was measured in units. Nobody counted the discount cost, the sales stolen from the sibling brand, or the dip the following week.
  • Promos ran out of stock. Every promo was planned with the same flat 2x inventory rule. Promo weeks stocked out 14% of the time vs. 0.1% for regular weeks.
  • Price changes were guesses. FY2025 price increases lifted gross margin 4.7% while units fell 5.8%, but nobody could say which products could take an increase.
Problem statement: How might we help category managers set prices and promotions that grow profit, not just volume, and stock them correctly?
02 · USERS & DISCOVERY
PersonaJob to be doneWhat they told us
Category ManagerDecide which promos to repeat or stop"My bonus is on units, so I go deep on discounts."
Pricing AnalystSet regular prices"Every category gets the same 3% increase."
Supply Chain PlannerOrder stock for promo weeks"We know 2x is wrong, but what else do we use?"
FP&A (supporting)Explain margin and promo ROI"I can't split price from volume from mix."

Key insights (simulated interviews)

  • The scorecard drives the behavior. Measuring units rewards deep discounts, so the fix starts with the metric.
  • Shoppers swap, they don't add. When the National brand is on sale, the FreshCart version of the same item sells less.
  • Planners need a number, not a model. Output must be a recommended order quantity per promo.
03 · MY THOUGHT PROCESS

Approaches I compared

ApproachAccuracyExplainableDecision
Same discount rules for every productLowYes✗ ignores very different price sensitivity
SQL baseline: average of prior 4 weeksMediumYesKeep for quick checks (Query 4)
Regression per product with promo, seasonality, sibling and post-promo controlsHighYes✓ MVP
Machine-learning demand modelPossibly higherHarderV2, once trust is built

Key decision: explainability over a few points of accuracy. Category managers have to defend a "stop this promo" call to their leadership, so every number traces back to an elasticity they can see.

What to build first (RICE)

FeatureReachImpactConfidenceEffortRICEDecision
Promo scorecard (net margin per promo)30390%240.5MVP
Price & promo simulator30380%324MVP
Promo demand forecast & stock check15280%212MVP
Competitor price tracking30250%56V2
Automated price changes30330%64.5Not doing

Reach = users per quarter; Impact on a 1–3 scale; Effort in person-months.

04 · PRODUCT REQUIREMENTS

PRD summary

  • Goal: turn net promo margin from −$454K to break-even or better within two planning cycles.
  • Must-haves: simulator with net-margin waterfall, Repeat/Fix/Stop scorecard, elasticity with uncertainty, stock check for upcoming promos.
  • Success metrics: share of promos that pay back (33% → 50%), promo-week stockouts (14% → ≤ 5%), promo forecast error ≤ 15%.
  • Out of scope: automated price changes, competitor prices, personalized pricing.
Read the full PRD (PDF) Download Word version
05 · DATA ENGINEERING

Star schema

Four raw extracts become one fact table at product × region × week grain (49,920 rows over 104 weeks) with four dimensions.

dim_product120 products · item · brand · category · base price · unit cost
fct_salesdate_key · product_id · region_id · promo_id
list_price · price_paid
units_sold · inventory_units · stockout_flag
revenue · cogs · gross_margin · discount_amount
dim_promo971 promos · discount % · promo type · vendor funding %
dim_region4 regions · store count (40 stores)
dim_date104 weeks · year · week of year · quarter
ab_test_digital_couponseparate experiment table: 40 stores × 16 weeks
Raw CSV extractssales, products, promos, regions
→
Stagingtypes, derived margin & discount
→
Quality gate5 checks; any failure blocks the load
→
Star schemafact + 4 dimensions
→
ModelsPython: elasticity, forecast
→
Appssimulator, scorecard, Excel

Download the data: fact_sales_weekly · dim_product · dim_promo · dim_region · ab_test

06 · SQL

The SQL behind the analysis

All 7 queries ran on the RetailPulse dataset; results are actual outputs. Skills shown: DDL, data validation, CTEs, self-joins, window functions (rolling AVG, cumulative SUM), conditional aggregation.

Staging turns four raw extracts into a star schema: one sales fact table at product × region × week grain, plus product, region, promo and date dimensions.

CREATE TABLE dim_date AS
SELECT DISTINCT
    week_start                                    AS date_key,
    CAST(STRFTIME('%Y', week_start) AS INTEGER)   AS year,
    CAST(STRFTIME('%W', week_start) AS INTEGER)   AS week_of_year,
    (CAST(STRFTIME('%m', week_start) AS INTEGER) + 2) / 3 AS quarter
FROM raw_fact_sales_weekly;

CREATE TABLE fct_sales AS
SELECT
    f.week_start                       AS date_key,
    f.product_id, f.region_id, f.promo_id,
    f.list_price, f.price_paid,
    f.units_sold, f.inventory_units, f.stockout_flag,
    f.revenue, f.cogs,
    f.revenue - f.cogs                 AS gross_margin,
    (f.list_price - f.price_paid) * f.units_sold AS discount_amount
FROM raw_fact_sales_weekly f;
▸ Result (5 rows)
table_namerow_count
fct_sales49,920
dim_product120
dim_region4
dim_promo971
dim_date104

Download all SQL (.sql)

07 · PYTHON ANALYSIS

Modeling, validation, forecasting and experimentation

9 steps, all run on the dataset. Outputs and charts below are real. Download the notebook to run it yourself.

Join the fact table to its dimensions and confirm the scale of the stockout problem.

import pandas as pd, numpy as np
import matplotlib.pyplot as plt
from scipy import stats

fact   = pd.read_csv("data/fact_sales_weekly.csv", parse_dates=["week_start"])
prod   = pd.read_csv("data/dim_product.csv")
region = pd.read_csv("data/dim_region.csv")
promo  = pd.read_csv("data/dim_promo.csv", parse_dates=["week_start"])

df = (fact.merge(prod, on="product_id")
          .merge(region, on="region_id")
          .merge(promo[["promo_id", "discount_pct", "promo_type", "vendor_funding_pct"]], on="promo_id", how="left"))
df["week_idx"] = ((df.week_start - df.week_start.min()).dt.days // 7).astype(int)
df["woy"]      = df.week_idx % 52
df["on_promo"] = df.promo_id.notna().astype(int)
# net unit cost after vendor funding (national-brand promos are 50% supplier-funded)
df["net_unit_cost"] = np.where(df.units_sold > 0, df.cogs / df.units_sold.clip(lower=1), df.unit_cost)

print(f"{len(df):,} rows | {df.product_id.nunique()} products | "
      f"{df.region_id.nunique()} regions | {df.week_idx.nunique()} weeks")
print(f"Revenue ${df.revenue.sum()/1e6:,.1f}M | promo weeks stocked out: "
      f"{df.loc[df.on_promo==1,'stockout_flag'].mean():.0%} vs "
      f"{df.loc[df.on_promo==0,'stockout_flag'].mean():.1%} on regular weeks")
▸ Output
49,920 rows | 120 products | 4 regions | 104 weeks Revenue $169.9M | promo weeks stocked out: 14% vs 0.1% on regular weeks

Download the notebook (.ipynb)

08 · LIVE MVP

Try it: simulate a promo, review the scorecard, check stock

Start with Snacks → Potato Chips at 20% off, then switch the inventory plan to "Order to forecast". Then try a 30% discount on any Dairy product.

09 · FINANCIAL IMPACT

Why margin grew while revenue fell

StepGross marginWhat it means
FY2024 gross margin$28.84M33.5% of revenue
Volume effect−$1.68M5.8% fewer units
Mix effect+$0.01MLittle shift between high- and low-margin items
Rate effect (price & cost)+$3.03MPrice increases and per-unit changes
FY2025 gross margin$30.21M36.0% of revenue (+4.7%)

Revenue fell 2.4%, but margin rose 4.7%. Price increases more than paid for the lost volume overall. The elasticity model shows where this works (dairy, produce) and where it doesn't (snacks, beverages).

Promo economics

The promo program generated +$1.10M gross incremental margin, but cannibalization and pull-forward turned that into −$454K net on $3.21M of FreshCart-funded discounts. Snacks was the only category with positive net promo margin (+$44K).

Download the Excel model

Includes a promo ROI calculator that matches the simulator to the dollar, a discount × vendor-funding sensitivity table, and the margin bridge built with formulas from product-level data.

10 · CHALLENGES & HOW I SOLVED THEM
ChallengeHow I solved it
First model failed: elasticity estimates correlated only 0.33 with the truthDiagnosed it: prices barely changed outside promos, so price and promo effects were tangled. Regional price zones and quarterly price reviews added real price variation. Correlation rose to 0.97.
First scorecard said 75% of promos should stopThe data ignored vendor funding, which pays for much of a national-brand discount in real grocery. Adding it gave a realistic picture: 33% repeat, 46% fix, 21% stop.
Stockouts hide true demandExcluded stockout weeks from model fitting, then estimated lost sales separately (132K units).
Promo lift looked bigger than it wasAdded sibling-promo and post-promo features, so cannibalization and pull-forward are measured, not ignored.
Excel and the simulator disagreed by $665Excel assumed the sibling's margin equaled the promoted product's. Added the real sibling margin; both now show $614.
The model "wants" big price increases on inelastic itemsAdded a price-image caveat and a +5–8% cap recommendation; large changes must be A/B tested first.
Confidence intervals cover the truth only 82% of the time, not 95%Reported it openly. Likely causes: cross-category cannibalization the model doesn't capture, and stockout censoring. Queued for V2.
11 · RESULTS & WHAT'S NEXT
33%of 971 promos actually paid back
r = 0.97model vs. true elasticity
44% → 14%promo-week forecast error
+3.8% / +0.8%coupon test: sales vs. margin

Recommendations

Promotions
Score category managers on net promo margin, not units. Cap dairy and produce discounts at 10–15%; stop deep discounts on inelastic items.
Coupons
Don't roll out the digital coupon program as tested: sales +3.8% but margin flat. Redesign toward private-label and higher-margin items, then re-test.
Inventory
Replace the flat 2x rule with forecast-based orders. 3 of the next 22 promos are already at high stockout risk.

What I'd do next

  • Ask RetailPulse: natural-language questions on this data, using the AskData pattern.
  • Vendor negotiation view: show each supplier how much funding a promo needs to break even.
  • Competitor prices and an ML demand model, compared against this baseline on the same holdout.
  • Fix CI coverage by modeling cross-category cannibalization.