What this document is: the complete, step-by-step account of everything that
was built and done in etl/ — the environment, the four-stage pipeline, the
eBay price scraping, and the manual pricing repair that ran on top of it.
Written as a working reference for the Data Engineer role, not as a tutorial.
Location of the code: etl/ — driver notebook etl/main_pipeline.ipynb,
SQL stage functions and the shell runner etl/run_etl.sh; connection/ — the
Python connection helpers; scraped_data/ — the eBay scraper and its outputs.
data/*.csv ──► public ──► staging ──► transformed ──► clean
(Misata, (reset (etl.extract) (etl.transform) (etl.load())
63,170 rows) each run)
│
└──► main_pipeline.ipynb
cells 8–70: pricing repair
(brands, eBay prices, unit_cost,
retail_price, revenue)
Two layers of work live here:
- The repeatable pipeline — 9 documented data-quality issues, each with a decision, a rule table, and a gate that fails the run if the fix regresses.
- A one-off pricing rebuild — the source prices were unusable, so real
market prices were scraped from eBay and written back into
public. (Full detail inPricingIssueFix.md.)
The split matters: layer 1 is re-runnable, layer 2 is a correction applied
directly to the public tables and will be wiped if step 0 (CSV reset) is
re-run.
compose.yaml—postgres:16service (retailspg, port 5432, named volumeretailspg-data) plus anetlservice behind theetlprofile that copiesetl/into the image and runs the SQL files withpsqlin order (extract → transform → load).- Secrets are files, never env vars in git:
secrets/database_url.txt,secrets/postgres_password.txt, mounted at/run/secrets/*. docker/etl.Dockerfilebuilds onpostgres:16-alpine, setsRAW_SALES_CSV=/app/data/raw/raw_sales.csv.
docker compose up -d # database only
docker compose --profile etl run --rm etl # run the whole pipeline in Docker| File | Used by | Resolution order |
|---|---|---|
connectdb.py |
the notebook | secrets/database_url.txt (walking up from cwd) → DATABASE_URL env |
secretsget.py |
containerised scripts | /run/secrets/<name> → env var → local secrets/<name>.txt |
secretsget.get_secret(name) reads Docker's /run/secrets first so the same
code works inside the container and on the laptop; both strip() the trailing
newline and fail loudly with a message naming all three places it looked.
retailAnalytics/ is a venv (Python 3.14.7, matches the notebook kernel
retailAnalytics (3.14.7)). requirements.txt (repo root) is a fully pinned freeze —
pandas 3.0.5, SQLAlchemy 2.0.52, psycopg2-binary 2.9.12, jupysql 0.11.1,
matplotlib-inline, ipykernel, numpy.
data/ holds the Misata-generated star schema (public domain, CC0):
dim_date 730, dim_product 400, dim_customer 2,000, dim_store 40,
fact_sales 60,000 — 63,170 rows total. data/INTEGRITY.txt records the
independent DuckDB verification: zero orphans on all four joins, zero duplicate
dimension keys, category revenue share exactly 42/26/20/12 %, monthly revenue on a declared curve by sold_at (three months only: 2024-01,
2024-12, 2025-12). The raw dim_date (730 rows, 464 distinct days) does not match
sold_at on 59,278 rows; the pipeline rebuilds it (issue #62).
The notebook's first real action, guarded by CONFIRM_RESET = True:
DROP SCHEMA IF EXISTS clean, transformed, staging, etl CASCADE;- Drop the hand-edited artefacts (
public.fact_sales_clean,customer_dedup_map, all five base tables). SET search_path TO public;+schema.sql→ recreates the empty star schema.COPY public.<table> FROM STDIN WITH CSV HEADERfordim_date,dim_product,dim_customer,dim_store,fact_sales.
Helper functions defined here and used for the rest of the notebook:
run_file(path)— executes a.sqlfile on a raw psycopg2 connection.query(sql)—pd.read_sqlhelper; every SELECT in the notebook goes through it.LOAD_RAW_SALES = True— separatelyCOPYs the originalsale_id, categorypairs intostaging.raw_sales, because those columns get overwritten later and the originals are needed for the audit.
Run once, before the stages; safe to re-run. Creates the etl schema and
four rule tables. The design rule: seeds only insert when the table is
empty (WHERE NOT EXISTS / ON CONFLICT DO NOTHING) so hand-edits are never
overwritten.
| Table | Solves | How |
|---|---|---|
etl.product_category_map |
Issue 2 | product_name → category. Seeded by name: any name that has a Home row anywhere is Home, everything else Electronics → 42 names mapped |
etl.quantity_rules |
Issue 1 | category + name_pattern → pack/unit + units_per_pack. Electronics '%4-Pack%' = 4 units per pack; everything else 1 unit |
etl.pricing_rules |
Issues 5, 9 | category + name_pattern → unit_cost, list_price, source, with CHECK (list_price >= unit_cost) so a negative margin is impossible to insert. Seeded as an explicit PLACEHOLDER: median source unit_cost per name × 1.60 |
etl.decision_log |
4, 5, 2, 8, 7 | One row per issue: decision ∈ accept/fix/reject + the reasoning |
etl.decision_log verbatim:
| # | Decision | Rationale |
|---|---|---|
| 4 | accept | discount_pct is a constant 0.35 everywhere — no variation to clean; documented as a flat-rate assumption |
| 5 | fix | 78 % of source products had unit_cost > list_price (generator defect, not clearance). Prices come from etl.pricing_rules |
| 2 | accept | fact_sales keeps its original 4 categories (42/26/20/12); dim_product.category keeps original labels; product_group holds the name-based classification |
| 8 | reject | Do not derive fact_sales.category from dim_product — that would delete Apparel/Grocery. Flag the disagreement in category_matches_product instead |
| 7 | accept | Official revenue = round(quantity * unit_price * (1 - discount_pct), 2); the source column is kept for audit only |
etl.extract() — one call, idempotent:
CREATE SCHEMA IF NOT EXISTS staging;- Drop and recreate
staging.dim_date / dim_product / dim_customer / dim_store / fact_salesasCREATE TABLE AS TABLE public.<table>— a structural copy, no logic, so the stage is a true snapshot. TRUNCATE staging.raw_sales(the audit pair table; loaded by the notebook viaCOPY).
etl.validate_raw_sales() — RAISE EXCEPTION if raw_sales is empty or has
duplicate sale_id. Called from inside transform().
Note recorded in the file: the CSV import cannot happen inside PostgreSQL, so
raw_sales is loaded client-side (COPY from the notebook, or
\copy in psql).
In the Docker run, etl/run_etl.sh does that client-side load: from
data/raw/raw_sales.csv if that file exists, otherwise from the sale_id and
category columns of data/fact_sales.csv, otherwise from public.fact_sales.
There is no standalone Python extract script in the repository.
etl.transform() drops and rebuilds all of transformed, and raises
exceptions rather than emitting bad data. It also emits NOTICEs that the
notebook prints back — that is the run log.
RAISE NOTICE the count of rows where month_name disagrees with
to_char(full_date,'Month'), then rebuild the date attributes from
full_date: day_of_week, trimmed month_name, quarter, and
is_weekend (ISO dow 6 or 7). Gate: every month_name must equal its month
number, else EXCEPTION.
RAISE NOTICEthe source count ofunit_cost > list_price(313).base—btrim()the name, category, subcategory.cat—LEFT JOIN etl.product_category_mapon the trimmed name →product_group.priced—LEFT JOIN LATERALonetl.pricing_rules, most specificname_patternfirst (ORDER BY length(name_pattern) DESC, rule_id LIMIT 1), carryingpricing_rule_idfor traceability.- Adds computed columns
cost_gt_price,margin_pct,pricing_rule_id. - Three gates: no product without a
product_group, none without a pricing rule, none withcost_gt_price.
customer_ranked: normalise (trimmed name, lowercased email,NULLIF('')), count orders per customer, and scorecompleteness= how many of name/city/country/segment are present.- Survivor per group via
first_value() OVER (PARTITION BY coalesce(email || '|' || name_norm, '#' || customer_key) ORDER BY n_orders DESC, completeness DESC, customer_key)— duplicate = same email AND same name; a NULL email or name can never merge anyone. customer_dedup_map(loser → survivor),customer_duplicate_pairs(side-by-side comparison of every same-email pair withsame_city,same_segment,mergedflags),customer_review(same email, different names → deliberately NOT merged), anddim_customer= survivors only.fact_sales.customer_keyis rewritten to the survivor key, so revenue follows the person.- Gate: no remaining
(email, lower(name))duplicate indim_customer.
qruleresolvesquantity_basis/units_per_packper product viaquantity_rules.- Selects the fact rows with:
customer_key→ survivor,categoryfromraw_saleswhen loaded (authoritative original) else the staging column,category_matches_product(Issue 8 flag),quantity_basis,units_sold,qty_at_max,revenue_expected, andrevenue_mismatch(> 0.05deviation).
- fact row count unchanged — a lost row means a bad join or quantity rule.
- no duplicate
sale_id. - no sales row without a quantity rule.
- no orphan
date_key/product_key/customer_key/store_key. - no duplicate customers.
- category distribution still 42 / 26 / 20 / 12 % — Option A is only Option A if the four original categories survive the journey.
Then two NOTICEs become the run report:
Issue 3: source month_name mismatches = 667
Issue 5: source products with unit_cost > list_price = 313
Issue 7: stored revenue differs from the standard formula on 99.91 percent of rows
Issue 8: sale category equals product category on 27.5 percent of rows
customer_ranked is dropped at the end — it is scaffolding, not output.
etl.load() refuses to run without transformed.fact_sales, then rebuilds
clean as a constrained, self-documenting star schema:
| Table | Constraints added |
|---|---|
clean.dim_date |
PK date_key, CHECK that month_name position = month number (Issue 3) |
clean.dim_product |
PK product_key, product_group NOT NULL, CHECK (list_price >= unit_cost) (Issue 5) |
clean.dim_customer / dim_store |
PK |
clean.fact_sales |
PK sale_id, CHECK (0 <= discount_pct < 1) (Issue 4), FKs to all four dimensions |
clean.customer_dedup_map / customer_review / customer_duplicate_pairs |
the Issue 6 audit trail, published |
clean.v_fact_sales |
view joining the fact to the product, exposing product_category, product_group, and net_revenue = revenue_expected |
Business definitions are stored in the database as column comments, so they
travel with the data: revenue = "Source revenue, kept for audit only. Not
reliable: see revenue_mismatch"; revenue_expected = "Official net revenue";
units_sold = "Quantity in single units".
pipeline.sql then adds the one-liner etl.run_pipeline() =
transform() + load().
cell 1 imports + get_engine() + paths (SQL_DIR, DATA_DIR) + run_file()/query() helpers
cell 2 ## Step 2: reset public from the CSVs (guarded by CONFIRM_RESET)
cell 3 the DROP / schema.sql / COPY block
cell 5 ## Step 3: install the rules and the three stage functions
cell 7 ## Step 4: run extract -> transform -> load
SELECT etl.extract() → COPY raw_sales → SELECT etl.transform() → SELECT etl.load()
then prints every NOTICE containing "Issue"
Cell 7 is where the four Issue N lines above come from — the pipeline's own
self-report, captured through conn.notices so RAISE NOTICE output is not
lost in the noise.
The pipeline's pricing_rules are a placeholder (median cost × 1.60). Real
prices had to come from a marketplace. Both scrapers read products.txt
(399–400 lines, one "Brand Base Name" per line, written by notebook cells 30
and 33) and use the eBay Browse API with OAuth client-credentials, a cached
token (expires_in - 60), X-EBAY-C-MARKETPLACE-ID: EBAY_US, and only
urllib + csv + statistics — no SDK.
Current state of the code. The two scripts described below (v1 and v2) were merged into the single
scraped_data/scrape.py, which writesscraped_data/final_prices.csv(v1 behaviour) andscraped_data/brand_prices.csv(v2 behaviour) and looks forproducts.txtinetl/,scraped_data/anddata/. The v1/v2 names below are kept to tell the history.
export EBAY_CLIENT_ID=... EBAY_CLIENT_SECRET=... EBAY_ENV=production
python scraped_data/scrape.py 10 # test on the first 10 names- Search each full
"Brand Product"name; keep listings whose title contains ≥ 60 % of the name's words (MATCH = 0.6); price = median, needsMIN_LISTINGS = 3. - Re-search every name that failed without the brand.
- Recover brands by regex-scanning the surviving titles against a 104-brand list; assign each still-unpriced row a free brand that has a price, preferring the row's own brand.
assertall output names unique →final_prices.csv(old_name, product_name, price).
Why it was dropped: it guessed brands from title text with a hand-written
list. The notebook shows the result — scraped_prices.csv loaded into
public.scraped_prices came back with 368 of 400 prices as NaN, because the
brands in the names were invented and no such listing existed.
The insight: don't guess brands — ask eBay. For each brand-less base name:
fieldgroups=ASPECT_REFINEMENTSon a 1-result search, then read eBay's own Brand aspect distribution anddominantCategoryId. Generic values (unbranded,does not apply,n/a, …) are dropped; the topMAX_BRANDS = 15bymatchCountare kept.- Per brand, re-search with
aspect_filter=categoryId:<cat>,Brand:<brand>(the|in brand names is escaped for the filter syntax),limit=100, filterpriceCurrency:USD,price:[5..5000],conditionIds:{1000}— USD, new only. - Listing hygiene: title must cover ≥ 60 % of the base-name words, and
must not match
SKIP = case|cover|replacement|parts|broken|repair|manual|skin|strap|protector|sticker— accessories and spare parts are the single biggest source of fake price matches. A brand needsMIN_PER_BRAND = 2survivors. - Record median, plus
min/maxandn_used. 4 threads, 0.2 s politeness sleep, errors logged per search instead of aborting the run. - Brand prefix stripping uses
POOL(40 brands) so"Sony 27" Monitor 144Hz"→27" Monitor 144Hz.
Output brand_prices.csv → public.brand_data:
529 rows, 42 base names, 387 distinct real brands (Silpat, LÉKUÉ, Thermos,
MOVA, Tefal, Kingston, …), each with ebay_matches, n_used, price, low,
high. Loaded by notebook cell 34; cells 37–38 normalise non-brands
('', n/a, none, unknown, not specified, kitchen, anything containing
specified) to Non Brand.
Both scripts support python <script> N to limit the run for testing, and both
fail fast on bad credentials by calling token() before any work starts.
This is the manual, one-off layer on top of the pipeline. Summary here, full
write-up in PricingIssueFix.md.
| Cells | Action |
|---|---|
| 8–17 | Rebuild dim_product.category from clean.dim_product.product_group (drop → re-add → UPDATE ... FROM), then sync fact_sales.category |
| 19–25 | Give each row a unique name: ROW_NUMBER() OVER (PARTITION BY product_name ORDER BY product_key) against a 38-brand array → 42 names become 400 |
| 26–33 | Export products.txt; load scraped_prices.csv (the failed v1 attempt, kept as evidence) |
| 34–39 | Load brand_prices.csv → brand_data; normalise non-brands; cell 39 shows the median-price brand per product as a sanity view |
| 40–49 | Assign real brands + real prices: exact name match first (32 rows), then fill each base name from unclaimed brands ordered by ebay_matches (276 rows), assert 0 duplicate names, stage into dim_product_sync, then UPDATE dim_product SET product_name, list_price |
| 50–58 | Create ref_cost_benchmark (category × subcategory cost ratio bands), back up unit_cost_original, then unit_cost = ROUND(list_price * (ratio_low + ratio_high) / 2, 2) — reverse-margin derivation; drop the backup column again |
| 59–62 | Verify 0 products with unit_cost >= list_price, compare avg old vs new cost per band, drop unit_cost_original |
| 63–70 | Rename list_price → retail_price and fact_sales.unit_price → retail_price; propagate: fact_sales.retail_price = dim_product.retail_price and revenue = ROUND(retail_price * (1 - discount_pct) * quantity, 2) |
Result against the live database: 0 loss-making products (was 313), 0 revenue mismatches (was 99.91 % of rows), 400 unique product names, margins 17 / 23 / 29 / 31.5 / 38.5 / 46 % — one per category × subcategory band.
| File | Role |
|---|---|
main_pipeline.ipynb |
The driver. 71 cells: setup → reset → rules → stages → pricing repair |
rules.sql |
etl schema: category map, quantity rules, pricing rules, decision log |
extract.sql |
etl.extract() + etl.validate_raw_sales() |
transform.sql |
etl.transform() — the bulk of the logic, 8 gates |
load.sql |
etl.load() — clean schema, PKs/FKs/CHECKs, view, comments |
pipeline.sql |
etl.run_pipeline() convenience wrapper |
run_etl.sh |
Docker/CLI runner: waits for the DB, loads public from data/ if missing, runs extract → transform → load, checks the 60,000-row result |
connection/connectdb.py |
Engine factory for the notebook |
connection/secretsget.py |
Docker /run/secrets → env → local file secret resolution |
scraped_data/scrape.py |
Merged eBay scraper: v1 full-name search plus v2 Brand-aspect refinement and per-brand median prices |
scraped_data/products.txt |
Input list for the scraper: one "Brand Base Name" per line |
scraped_data/*.csv |
Scraper outputs: scraped_prices.csv, final_prices.csv, brand_prices.csv |
requirements.txt |
Pinned venv freeze (Python 3.14) |
__pycache__/ |
Stale build artefacts — connectdb, scrape |
- 92 products still carry the source floor price (
5.00) because their base name ran out of distinct brands. RaiseMAX_BRANDS, or allow a brand to be reused across products. - 42 product names still say
Not Specified— cell 38 cleaned the SQL table, but cell 43 rebuilt assignments from the stale in-memory DataFrame loaded at cell 34. Re-run cells 43–48 after re-readingbrand_data. etl.pricing_rulesis still the placeholder (median cost × 1.60). Now that real eBay prices exist, the rules should be seeded frombrand_data.priceso a fresh pipeline run produces good prices without the manual repair.- Scratch tables left in
public:scraped_prices,brand_data,dim_product_sync. Move to ascratch/stagingschema or drop them. main_pipeline.ipynbhas saved outputs and aCONFIRM_RESET = Truedefault — re-running from the top destroys the pricing repair (step 9).etl/run_etl.sh(Docker/CI) and the notebook run the same pipeline two different ways; pick one as canonical.- eBay prices are a single marketplace (US), new-condition only, median of
live listings, no shipping, no regional variance.
brand_data.low/highshould be used as sanity bands in any downstream margin report.