diff --git a/ARCHITECTURE-DETAIL.md b/ARCHITECTURE-DETAIL.md
new file mode 100644
index 0000000..4903a63
--- /dev/null
+++ b/ARCHITECTURE-DETAIL.md
@@ -0,0 +1,469 @@
+# Utopia Pricing Agent β Architecture (engineering detail)
+
+The technical reference: data sources, decision cascade, formulas, file map, backtests.
+For the plain-English overview see **[ARCHITECTURE.md](ARCHITECTURE.md)**.
+
+A one-page Streamlit dashboard (Utopia/CRAI design system) that turns **live COSMOS
+data** into price recommendations a human can Approve / Modify / Reject. Read-only:
+nothing is written back to COSMOS or Amazon.
+
+---
+
+## 1. High-level view
+
+```mermaid
+flowchart LR
+ U["π§ User
(same-network browser)"] --> APP
+
+ subgraph APP["app.py β presentation (Streamlit, CRAI theme)"]
+ SIDE["Sidebar
Single product / Product line
+ filters, kill switch"]
+ QUEUE["Recommendation queue
tiles Β· pills Β· rows"]
+ SECT["Per-SKU sections
price Β· inventory Β· scenarios
competitors Β· PPC Β· costs Β· AI"]
+ end
+
+ subgraph DASH["dashboard/ package"]
+ THEME["theme.py
CRAI tokens + plotly template"]
+ LIVE["live_data.py
adapter + decision engine
+ scenario economics"]
+ end
+
+ subgraph CORE["src/pricing_agent β analysis core"]
+ AN["analyze.py"]
+ MARGIN["margin_engine.py"]
+ ELAST["elasticity.py"]
+ PERF["performance.py"]
+ SVC["cosmos/service.py"]
+ CLIENT["cosmos/client.py"]
+ end
+
+ COSMOS[("COSMOS API")]
+ APIFY[("Apify β optional")]
+
+ APP --> THEME
+ APP --> LIVE
+ LIVE --> AN
+ AN --> MARGIN & ELAST & PERF
+ AN --> SVC --> CLIENT --> COSMOS
+ LIVE --> SVC
+ AN -.optional.-> APIFY
+```
+
+---
+
+## 2. Layers
+
+| Layer | Files | Responsibility |
+|---|---|---|
+| **Presentation** | `app.py` | All rendering, zero pricing logic. Session state (approve/modify/reject, filters, per-SKU section + window), staged progress loader, session-state cache. |
+| **Design system** | `dashboard/theme.py`, `.streamlit/config.toml` | CRAI palette (cream `#f4f0e8`, teal `#0c8276`, coral `#df4f33`, navy `#22304e`), Inter font, plotly template. |
+| **Adapter + engine** | `dashboard/live_data.py` | Builds the per-SKU dict; **decides** the action; computes **scenario economics** (elasticity projection β bulk reconciliation β calibration); exposes `scenarios_for_window()`. |
+| **Competitive state** | `src/pricing_agent/competitive_state.py` | The one competitive fact the cascade may read. Adapts either scraper into a typed `WON`/`LOST_PRICE`/`LOST_ELIGIBILITY`/`SUPPRESSED` state with a source and a timestamp, gates it on age, and logs disagreement between sources. |
+| **Analysis core** | `src/pricing_agent/analyze.py` | Orchestrates one SKU: fees β trend β bulk β ad cost β elasticity β actual-profit evidence. |
+| **Money math** | `tools/margin_engine.py` | Pure: break-even, MAP, contribution margin, suggested price. |
+| **Statistics** | `elasticity.py`, `performance.py` | Log-log elasticity fit, profit-optimal sweep, actual-profit aggregation. |
+| **Data access** | `cosmos/{client,service,models}.py` | Auth + retry client; endpoint calls + response flattening; typed pydantic models. |
+
+---
+
+## 3. Data sources β what each COSMOS endpoint feeds
+
+```mermaid
+flowchart TB
+ subgraph COSMOS["COSMOS API"]
+ TH["/sales-insight/takehome-calculator
nested fees.breakdown Β· cost.breakdown"]
+ INVP["/invp-insight
trend + inventory + dateMap PROJECTIONS"]
+ BULK["/sales-insight/bulk-calculator
storage + total take-home"]
+ SI["/sales-insight (daily, 6-month)
price Β· units Β· revenue Β· profit Β· ad spend"]
+ PROD["/products
brand Β· marketplace"]
+ CAMP["/api/campaigns Β· /adsApi
budget Β· ACoS Β· ad sales (not yet wired)"]
+ end
+
+ TH -->|"_flatten_takehome()"| FEES["Fee model
referral% Β· FBA Β· landed Β· returns"]
+ INVP --> TREND["Velocity + cover days"]
+ INVP --> INVPROJ["Inventory Outlook tab
real weekly units/value/cover/arrivals"]
+ BULK --> STORAGE["Storage + take-home (scenarios)"]
+ SI --> HIST["180-day daily series
(window filter + calibration)"]
+ SI --> ADS["Ad spend / TACoS (PPC tab)"]
+ PROD --> META["Brand / marketplace"]
+```
+
+**Three response quirks handled:**
+
+- **Fees come nested** (`fees.breakdown["Referral Fee"]`, `"$ 9.28"` strings).
+ `service._flatten_takehome()` normalises them β without it every fee parsed to 0 (the
+ old "$0.99 / break-even $0" bug).
+- **INVP `dateMap`** holds COSMOS's own **forward inventory projection** (weekly units,
+ value, cover days, warehouse arrivals). The Inventory tab renders this directly β not
+ a locally-invented forecast.
+- **SKU lookups are CONTAINS/relevance searches, not equality.** See Β§3.1 β this one
+ silently bound the wrong product's data to a SKU.
+
+### 3.1 Exact-SKU joins (`service._exact_row`)
+
+Neither `/api/products` nor `/api/invp-insight` has an equality filter, and both used to
+fall back to `data[0]` "if COSMOS returned a fuzzy match set". That bound one product's
+ASIN, cost, brand and inventory to a **different product's SKU**.
+
+Observed live: `get_product("UBCFKFITTEDSHEETWHITECALKING")` β a SKU COSMOS does not carry
+at all β returned `UBMICROFIBERGUSSETPILLOWWHITEQUEEN` / `B08DTH86Q2`.
+
+Two independent problems, and each guard is necessary:
+
+1. **Wrong parameter.** `get_product` queried `q=` (relevance across the whole catalogue).
+ For `UBMICROFIBERDUVETTWINWHITE` the correct row sat on **page 2 of 100-row pages**,
+ behind 100 unrelated products β a 20-row lookup never saw it. `sku=` returns it first.
+2. **`sku=` is still a CONTAINS filter.** `sku=UBCFKMATTRESSPROTECTORTWIN88` returns three
+ rows: the real one (`B00MRH9NCK`), the `...BOX` variant (`B09K7HXJ4M`), and a
+ `WAL...` Walmart row whose "ASIN" (`8946709597`) is not an ASIN. **All three are
+ `marketplace: AMAZON_USA`**, so the marketplace check alone does not separate them β
+ only the exact SKU test does.
+
+`_exact_row()` requires an exact SKU match **and** the right marketplace, and returns
+`None` otherwise, logging what it rejected. Blast radius of the old behaviour:
+`analyze_price` assigns `asin = product.asin` whenever INVP has none, so the competitive
+scrape would have run against an unrelated listing; and `get_invp`'s `skuPrefix` matches
+every colour variant, so a sibling's inventory and cover days fed `LOW_STOCK` /
+`EXCESS_STOCK` directly β a sibling reading 12 units / 3 days would fire a stockout raise
+on a SKU holding 4,000 units.
+
+Genuinely **not** in this COSMOS integration β shown as "β", never faked: ad-attributed
+sales / ACoS / campaign budget (live in `/api/campaigns` + `/adsApi`, not yet wired), and
+historical competitor prices (Apify gives a current snapshot only).
+
+**Competitor data is no longer display-only.** COSMOS has no Buy Box, no rival price and no
+third-party offer anywhere in it β that gap is filled by a scrape, and two of its facts
+(our Buy Box being suppressed, and a rival materially undercutting us) reach the verdict.
+They are the only two, they are bounded by the guardrails, and their absence changes
+nothing. See Β§6.
+
+---
+
+## 4. Per-SKU pipeline (one "Analyze")
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant A as app.py
+ participant L as live_data.build_live_sku
+ participant AN as analyze.py
+ participant S as CosmosService
+ U->>A: Single product / Product line
+ A->>L: get_live_data(skus, progress_cb)
+ Note over A: staged progress bar (3β10β45β82β94β100%)
+ L->>S: get_current_price
+ L->>S: get_sales_history (180d, parallel windows)
+ L->>AN: analyze_price (fees, trend, bulk, elasticity, evidence)
+ L->>S: get_invp (real inventory projection)
+ L->>S: bulk_quote (storage)
+ L->>L: decide action + scenario economics + 30d/6mo calibration
+ L-->>A: {summary, details, errors} (session-cached)
+ A-->>U: queue + expandable per-SKU analysis
+```
+
+---
+
+## 5. Scenario economics (the heart of the Scenarios tab)
+
+For each candidate price, one consistent chain:
+
+```mermaid
+flowchart LR
+ W["Window filter
7/14/30/90d Β· 6mo"] --> BASE["Baseline velocity
= avg units/day in window"]
+ BASE --> DEMAND["Units(p) = units Γ (p/cur)^elasticity"]
+ DEMAND --> REV["Revenue = units Γ p Γ 30"]
+ REV --> AD["Ad spend = TACoS Γ revenue"]
+ DEMAND --> TH["Take-home (bulk calculator fee model)"]
+ TH --> GROSS["Gross = take-home β storage β ad"]
+ GROSS --> CAL["Γ realization factor
(actual booked Γ· modeled at current)"]
+ CAL --> NET["Net profit / 30d"]
+```
+
+Key rules:
+
+- **Current row = FACT**, not a projection: real units, real revenue, real ad spend, real
+ booked profit. Its price is the **average price sold** (revenue Γ· units) so
+ `price Γ units Γ 30 = revenue` reconciles β this is *below* list when promos ran, and
+ changes with the window because the avg selling price differed period to period. The
+ **list price is fixed**.
+- **Calibration:** raw bulk-calculator profit over-states reality (prices at list, ignores
+ real returns/promos). A **realization factor** = actual booked profit Γ· modeled profit at
+ the current price scales every projected row.
+- **The elasticity used for projections is gated** (`projection_elasticity()`). It honours
+ the same `actionable` test the decision does: a slope whose 95% CI spans zero β or a
+ **positive** slope, which `estimate_elasticity` can return since `actionable` requires
+ `e < 0` but the value is not clamped β falls back to `FALLBACK_ELASTICITY`. Ungated, a
+ positive slope projected that *raising* price sells *more*, and that number drives the
+ 30-day impact tile, the portfolio opportunity total and the queue sort.
+ `elasticity_is_fitted` records which was used.
+- **β** marks the highest-net-profit price in the current view.
+
+---
+
+## 6. Decision engine (deterministic, first match wins)
+
+```mermaid
+flowchart TD
+ S([signals]) --> R0{cost data = 0?}
+ R0 -- yes --> INV["π INVESTIGATE Β· NO_COST_DATA"]
+ R0 -- no --> RB{our Buy Box suppressed?}
+ RB -- yes --> INVB["π INVESTIGATE Β· BUYBOX_SUPPRESSED"]
+ RB -- no --> R1{price < break-even?}
+ R1 -- yes --> UP1["β raise to safe floor Β· BELOW_BREAK_EVEN"]
+ R1 -- no --> R2{losing money after ads?}
+ R2 -- yes --> UP2["β raise Β· LOSING_MONEY"]
+ R2 -- no --> R3{cover β€ 35d?}
+ R3 -- yes --> UP3["β +5% Β· LOW_STOCK"]
+ R3 -- no --> R4{β30% sales, no cause?}
+ R4 -- yes --> INV2["π INVESTIGATE Β· UNEXPLAINED_DROP"]
+ R4 -- no --> R5{cover β₯ 90d?}
+ R5 -- yes --> DN["β β5% Β· EXCESS_STOCK"]
+ R5 -- no --> RC{rival β₯3% below AND corroborated?}
+ RC -- yes --> DNC["β toward rival Β· COMPETITOR_UNDERCUT"]
+ RC -- no --> R6{profit-optimal β current?}
+ R6 -- yes --> MOVE["β/β toward optimal Β· PROFIT_OPTIMAL"]
+ R6 -- no --> R7{a price we ran booked more?}
+ R7 -- yes --> BO["β/β toward it Β· BEST_OBSERVED"]
+ R7 -- no --> HOLD["β MAINTAIN Β· NO_SIGNALS"]
+```
+
+Guardrails: floor = highest of four break-evens Γ 1.02, ceiling = current Γ 1.25; the
+recommended move is capped at Β±5% per step; the kill switch pauses all approvals.
+
+### AD_SPIRAL β applied after the cascade
+
+When ad cost per unit climbs almost as fast as price, each extra $1 of price buys only
+cents of contribution and **no** price reaches break-even. That verdict (`AD_SPIRAL`,
+Investigate-and-hold) is applied *after* the cascade and overrides whatever fired β so
+what it must **not** override is named explicitly:
+
+```python
+AD_SPIRAL_YIELDS_TO = frozenset({"NO_COST_DATA", "BUYBOX_SUPPRESSED", "LOW_STOCK"})
+```
+
+| Yields to | Why |
+|---|---|
+| `NO_COST_DATA` | With `costPerUnit`/`fbaFee` at 0, `fixed` is understated, which makes the `contribution <= 0` test **easier** to hit. A missing-COGS SKU would be sent to the ad console when the fix is a data-entry field. |
+| `BUYBOX_SUPPRESSED` | A listing nobody can buy from has no meaningful ad economics. |
+| `LOW_STOCK` | The only one that changes a **price**, not just a label. A shelf about to empty gets +5% whatever the ad slope does β those units sell regardless, so the only question is what we get for them. Holding sells the last of the stock cheap. |
+
+Deliberately narrow: `EXCESS_STOCK` does **not** outrank it, because cutting price to clear
+stock is exactly the move that cannot work when ads eat the contribution.
+
+### Competitor rules β the two that can move a price, and what bounds them
+
+Both branches read a single `CompetitiveState`
+([competitive_state.py](src/pricing_agent/competitive_state.py)), never a raw scrape:
+
+| Rule | Fires when | Effect |
+|---|---|---|
+| `BUYBOX_SUPPRESSED` | Amazon is not showing our offer | **Investigate, hold.** Placed directly under `NO_COST_DATA`: the only two states where the answer is "go and fix something" rather than "set a price". |
+| `COMPETITOR_UNDERCUT` | cheapest rival β₯ `competitor_undercut_material_pct` below us, **and the basis qualifies** (below) | **Decrease toward the rival**, floored and step-capped like every other branch. |
+
+**Basis decides what "qualifies" means**, because `competitor_min` can be a price of two
+different things:
+
+- `same-asin-buybox` (Apify) β another seller's offer on **our own listing**. Only
+ `LOST_PRICE` fires; a rival holding the Buy Box *above* us is `LOST_ELIGIBILITY`, where
+ cutting donates margin. Losing the Buy Box on price **is** the corroboration.
+- `like-for-like-sheet` β a **rival brand's** equivalent variant, matched on size + colour.
+ This additionally requires **corroboration**: either demand has materially dropped, or we
+ are not actually winning the Buy Box.
+
+ *Why:* on its own a sheet row says only "a different brand is cheaper". True, reportable,
+ but not evidence the gap is costing us anything β we can sit 3% dearer, hold our own Buy
+ Box and sell fine on brand, reviews or the Prime badge. And because this branch sits
+ **above** `PROFIT_OPTIMAL`, an uncorroborated cut could overrule an elasticity fit that
+ wanted a *raise*. Switch: `competitor_sheet_requires_corroboration` (default `true`).
+
+ The documented `UNEXPLAINED_DROP` fall-through is unaffected β
+ `UBMICROFIBERDUVETKINGPURPLE` and `UBMICROFIBERBS4PCFULLGREY` arrive here *with* a
+ velocity drop, which is the first form of corroboration.
+
+ An uncorroborated material undercut is still **reported** in the root cause ("Competitor
+ undercut not acted on"), so it never looks like missing data.
+
+A third signal, a **competitor premium** while we hold the Buy Box, is a narrative note
+only. It never sets a price and never changes an action.
+
+Ordering is deliberate: **inventory risk outranks competitor position, which outranks
+profit-optimal.** Chasing a rival down while the shelf is emptying pays margin to sell out
+faster.
+
+Three properties make this safe to ship:
+
+1. **Fail-safe.** Absent, failed, stale (> `competitor_state_max_age_hours`) and
+ "ownership unknown" all collapse to one flag, and the cascade then computes exactly the
+ verdict it computed before competitor data existed. Competitor data can only ever *add*
+ a verdict.
+2. **Never below break-even.** The rival price is a *candidate* (`comp_match`), not a
+ decision.
+3. **One named reason per verdict.** No blended scores β every fired rule is traceable to a
+ single reason code, and `logger.info` names the SKU, the rule, the state and the source.
+
+Thresholds live in [config/pricing_rules.yaml](config/pricing_rules.yaml)
+(`competitor_undercut_material_pct: 0.03`, `competitor_premium_material_pct: 0.10`,
+`competitor_state_max_age_hours: 6.0`, `competitor_sheet_requires_corroboration: true`),
+not in code. The 3% floor sits above the ~2% band our own realized price already swings
+through as coupons toggle.
+
+### Inventory cover matches COSMOS Inventory Planning
+
+`cover_days` **is COSMOS's own `coverDays`**, so the dashboard and the INVP grid never quote
+two different numbers for one SKU. COSMOS counts **inbound** stock against a **7-day**
+velocity, so it reads longer than what is on the shelf β `UBMICROFIBERDUVETTWINWHITE` is
+78 days on (3,999 on hand + 1,030 inbound) Γ· 64/day, against 63 on-hand-only. Both are
+reported: the tile leads with the matched figure and appends `63 d on hand, rest inbound`.
+
+The on-hand figure remains the **fallback**, because COSMOS returns `coverDays: 0` on some
+very low-velocity SKUs that hold months of stock (`UBMICROFIBERBS4PCFULLGREY`: 167 units,
+334 real days, COSMOS said `0`). Zero satisfies neither inventory rule, so taken literally it
+silences both. The reconciled figure is computed **before** `_decide` and passed in, so the
+tile and the rule that fired read the same number by construction.
+
+**Trade-off, accepted deliberately:** stockout risk is now judged partly on stock that has not
+landed. Measured over the 56-SKU covered line, matching COSMOS moved 7 verdicts β
+`LOW_STOCK` 7 β 4, `EXCESS_STOCK` 18 β 22. The one to watch is
+`UBMICROFIBERBS4PCKINGWHITE`: **12 days on the shelf, 84 with inbound**, so it no longer
+raises. If that shipment slips, nothing protects it.
+
+Display bands are COSMOS's Alpha/Beta scheme (`theme.COVER_BANDS`, Alpha 20/40/70/100). The
+pricing **triggers** are separate and live in `pricing_rules.yaml`
+(`low_cover_days: 35`, `high_cover_days: 90`) β COSMOS's pink at 70 days is a *replenishment*
+warning, while crossing a trigger here spends margin on a 5% move.
+
+### Coverage: the comparison sheet gates competitor data, one product line at a time
+
+The competitor workbook currently covers **one product line**, so the engine reads it as the
+first competitor source and **gates on coverage**:
+
+| SKU | Competitor state |
+|---|---|
+| In the sheet | Priced from the sheet β real like-for-like rival prices, `basis=like-for-like-sheet` |
+| Not in the sheet | **`N/A`**, naming what the sheet *does* cover. No rule fires; the verdict is byte-identical to the competitor-blind one |
+
+**Coverage is the exact SKU set in the sheet, not a line prefix.** Measured against the real
+workbook, a prefix gate would be wrong in both directions: the `UBMICROFIBERDUVET` run contains
+49 `UBMICROFIBERDUVET*` SKUs **and 7 `UBMICROFIBERBS4PC*`** ones, while the line has 139 SKUs
+in COSMOS of which only 56 reached a comparison row. So the sheet's own SKU list is the
+authority, and "not in the sheet" is a **coverage hole**, never a claim that the SKU has no
+competitors.
+
+`competitor_sheet_only: true` (default while one line is under test) means an uncovered SKU gets
+N/A rather than falling through to a per-ASIN Apify scrape. Config: `competitor_sheet_path`
+(blank = auto-discover the newest `Competitor_Price_Comparison_*.xlsx`), `competitor_sheet_dirs`,
+`competitor_sheet_max_age_hours: 168` (the sheet is a 25β35 min batch run, not a live feed).
+
+Two sheet-driven refinements, both from real rows:
+
+- **A rival whose own Buy Box is suppressed is excluded from the band.** Their price is not
+ buyable, so undercutting it donates margin for nothing.
+- **A material undercut can explain a velocity drop** (subject to the corroboration rule
+ above), converting an `UNEXPLAINED_DROP` Investigate into an actionable verdict β exactly
+ as the existing stockout branch already did.
+
+### Two scrapers, one state
+
+| Source | Authoritative for | Why |
+|---|---|---|
+| **Apify** (`tools/amazon/apify.py`) | **Buy Box state read by the engine** | The only source carrying a seller id, so the only one that can tell `WON` from `LOST_PRICE` from `LOST_ELIGIBILITY`. Those lead to opposite actions. |
+| **Playwright** (`../scraper/`) | The workbook: like-for-like size/colour matching, BSR, demand buckets, SKU gaps | Apify cannot produce any of it. Its Buy Box field knows only whether a price *rendered*, not whose it was. |
+
+The split is by **question**, not preference. `reconcile()` cross-checks the authoritative
+state against the Playwright run's own cache (`scraper/.scrape_cache.json`, keyed `ASIN@ZIP`)
+and **logs any disagreement**. A disagreement never changes the verdict.
+
+---
+
+## 6.1 Verdict backtest
+
+`scripts/backtest_competitor_rules.py` re-runs the **captured real cascade inputs** (real
+`AnalysisResult`, real 180-day history, real fee stack, real scenario grid) through the same
+`_decide`, varying only the arm. Nothing is reconstructed by hand.
+
+Competitor rules blind vs live (7 requested SKUs, 6 analysed β see Β§9):
+
+| Arm | Verdicts changed |
+|---|---|
+| Real competitor state | **0 / 6** β the fail-safe working |
+| Counterfactual 8% undercut | 4 / 6 β the others blocked by `LOW_STOCK` / `LOSING_MONEY` |
+| Counterfactual suppression | 6 / 6 β Investigate |
+
+Policy delta, old vs new (the two cascade changes), same SKUs:
+
+| Case | Changed | Notes |
+|---|---|---|
+| `SHEET_UNDERCUT_WE_WIN` | 2 / 6 | Both were being **cut while holding the Buy Box with flat demand**: `UBMICROFIBERDUVETTWINWHITE` Decrease $17.06 β **Increase $17.94** (`BEST_OBSERVED`), `UBMICROFIBERGUSSETPILLOWWHITEQUEEN` Decrease $25.01 β **Increase $27.37**. The two corroborated SKUs were unchanged. |
+| `AD_SPIRAL_LOW_STOCK` | 4 / 6 | `Investigate/AD_SPIRAL` β `Increase/LOW_STOCK` |
+| `AD_SPIRAL_NO_COST` | 6 / 6 | `Investigate/AD_SPIRAL` β `Investigate/NO_COST_DATA` |
+| **Targets below break-even, any arm** | **0** | |
+
+**The invariant is measured on the TARGET, not the step-capped first move.** A SKU already
+selling under its own ad-inclusive floor cannot be lifted over it in one 5% step, and
+reporting that deliberate multi-cycle climb as a breach buries any real one. Two SKUs are
+below floor and stepping up by design; both are reported separately.
+
+---
+
+## 7. Key formulas
+
+| Quantity | Formula |
+|---|---|
+| Take-home / unit | `pΒ·(1 β referral% β returns%) β landed β FBA β other` |
+| Break-even | `(landed + FBA + returns + other) / (1 β referral%)` |
+| Elasticity | OLS on `ln(units/day) = a + eΒ·ln(price)` over 6 months |
+| Scenario demand | `units Γ (p / pβ)^e` |
+| Realization factor | `actual booked profit (window) Γ· modeled net at current price` |
+| TACoS | `ad spend Γ· total revenue` (window) |
+| Avg sold price | `revenue Γ· units` (window) β reconciles the Current row |
+| Best observed price | `avg_price` of the best-earning band β **the price actually charged**, never the $0.50-rounded `price_band` key |
+
+---
+
+## 8. Repository map
+
+```
+pricing_agent/
+βββ app.py # dashboard (presentation only)
+βββ legacy_app.py # previous analyst UI (still runnable)
+βββ dashboard/
+β βββ theme.py # CRAI design tokens + plotly template
+β βββ live_data.py # COSMOS adapter, decision + scenario engine
+βββ src/pricing_agent/
+β βββ analyze.py # per-SKU orchestration β AnalysisResult
+β βββ competitive_state.py # canonical Buy Box state + two-scraper reconciliation
+β βββ elasticity.py # demand model + profit optimizer
+β βββ performance.py # actual-profit evidence
+β βββ tools/margin_engine.py # pure fee/break-even math (golden-tested)
+β βββ cosmos/
+β βββ client.py # auth + retry HTTP
+β βββ service.py # endpoints, _exact_row, _flatten_takehome, INVP projections
+β βββ models.py # typed COSMOS responses (pydantic)
+βββ config/ # settings + pricing_rules.yaml (incl. competitor thresholds)
+βββ scripts/
+β βββ backtest_competitor_rules.py # verdict delta + old-vs-new policy delta
+βββ .streamlit/config.toml # CRAI theme
+βββ tests/ # margin-engine golden values, test_competitor_rules.py
+ # (fail-safe + ordering invariants), test_pricing_safety.py,
+ # test_cosmos.py (exact-SKU joins)
+```
+
+---
+
+## 9. Known data gaps
+
+- **Ad-attributed sales / ACoS / campaign budget** β live in `/api/campaigns` + `/adsApi`,
+ not yet wired. Shown as "β".
+- **Historical competitor prices** β Apify returns a current snapshot only.
+- **`UBCFKFITTEDSHEETWHITECALKING` is not in COSMOS at all.** `sku=` returns zero rows on
+ any marketplace and the fee endpoint replies "Product not found". Likely delisted or
+ renamed. It now fails loudly (`get_product` β `None`) rather than adopting another
+ product's identity. A catalogue question, not a code one.
+
+---
+
+## 10. Principles
+
+1. **Deterministic core, narrative shell** β every number is a formula over COSMOS data; language models only phrase explanations.
+2. **Read-only** β the agent proposes; a human approves; nothing writes back. `submit_price_approval` remains a stub with no callers.
+3. **Honest gaps** β missing upstream data shows "β" or an explicit investigation, never a fabricated number. A wrong number is worse than a blank one.
+4. **Facts vs projections are labeled** β the Current row is real booked history; other prices are clearly modeled.
+5. **Everything reconciles** β one averaging window drives units, revenue, ads and profit so `price Γ units = revenue` always holds.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6d7bdaf..4cc8c5d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,378 +1,187 @@
-# Utopia Pricing Agent β Architecture
+# How the Pricing Agent Works
-A one-page Streamlit dashboard (Utopia/CRAI design system) that turns **live COSMOS
-data** into price recommendations a human can Approve / Modify / Reject. Read-only:
-nothing is written back to COSMOS or Amazon.
+A plain-English guide. No coding or Amazon knowledge needed.
+> Engineers: the detailed reference β data sources, formulas, file map, backtests β
+> now lives in **[ARCHITECTURE-DETAIL.md](ARCHITECTURE-DETAIL.md)**.
---
-## 1. High-level view
+## What it is
-```mermaid
-flowchart LR
- U["π§ User
(same-network browser)"] --> APP
+We sell products on Amazon. Every product has a price, and the right price is a
+constant question: too high and we stop selling, too low and we lose money on every
+sale.
- subgraph APP["app.py β presentation (Streamlit, CRAI theme)"]
- SIDE["Sidebar
Single product / Product line
+ filters, kill switch"]
- QUEUE["Recommendation queue
tiles Β· pills Β· rows"]
- SECT["Per-SKU sections
price Β· inventory Β· scenarios
competitors Β· PPC Β· costs Β· AI"]
- end
+This tool looks at one product at a time, works out what its price *should* be, and
+shows a person the answer with the reasoning behind it.
- subgraph DASH["dashboard/ package"]
- THEME["theme.py
CRAI tokens + plotly template"]
- LIVE["live_data.py
adapter + decision engine
+ scenario economics"]
- end
-
- subgraph CORE["src/pricing_agent β analysis core"]
- AN["analyze.py"]
- MARGIN["margin_engine.py"]
- ELAST["elasticity.py"]
- PERF["performance.py"]
- SVC["cosmos/service.py"]
- CLIENT["cosmos/client.py"]
- end
-
- COSMOS[("COSMOS API")]
- APIFY[("Apify β optional")]
-
- APP --> THEME
- APP --> LIVE
- LIVE --> AN
- AN --> MARGIN & ELAST & PERF
- AN --> SVC --> CLIENT --> COSMOS
- LIVE --> SVC
- AN -.optional.-> APIFY
-```
+**It never changes a price.** It makes a recommendation. A human reads it and clicks
+Approve, Modify, or Reject. Nothing is sent to Amazon.
---
-## 2. Layers
+## The one-sentence version
-| Layer | Files | Responsibility |
+> It reads our real sales history, works out what each sale actually earns after all
+> costs, checks whether we have too much or too little stock, looks at what rivals
+> charge, and then suggests a price β showing its working, and refusing to suggest
+> anything that would lose money.
+
+---
+
+## Where the numbers come from
+
+Everything starts from **COSMOS**, our internal system that already holds our Amazon
+data. The tool reads five things from it:
+
+| What | Why it matters |
+|---|---|
+| **Fees and costs** | What Amazon charges us per sale, plus what the product cost us |
+| **Sales history** (6 months, daily) | What we charged, how many we sold, what we actually earned |
+| **Stock levels** | How many units we have, and how long they'll last |
+| **Storage charges** | What Amazon bills us to warehouse unsold stock |
+| **Advertising spend** | What we paid in ads to make those sales |
+
+**What the 6 months of history is for:** it tells us which prices we have *already*
+tried and what each one really earned β so the tool can recommend a price we have
+proof about, instead of guessing. It also spots when sales have dropped well below
+normal, which is a signal something has changed.
+
+**What competitor prices are for:** only two things. If Amazon has hidden our listing,
+stop and fix that. If a rival is meaningfully cheaper **and** it is visibly costing us
+sales, move toward their price β but never below the point where we lose money.
+
+---
+
+## How it decides
+
+The tool runs down a checklist, **in order, and stops at the first thing that
+applies.** This is deliberate β it means every recommendation traces back to exactly
+one reason, and you can always ask "why this price?" and get a single answer.
+
+Roughly top to bottom:
+
+| # | If we find thisβ¦ | β¦the answer is |
|---|---|---|
-| **Presentation** | `app.py` | All rendering, zero pricing logic. Session state (approve/modify/reject, filters, per-SKU section + window), staged progress loader, session-state cache. |
-| **Design system** | `dashboard/theme.py`, `.streamlit/config.toml` | CRAI palette (cream `#f4f0e8`, teal `#0c8276`, coral `#df4f33`, navy `#22304e`), Inter font, plotly template. |
-| **Adapter + engine** | `dashboard/live_data.py` | Builds the per-SKU dict; **decides** the action; computes **scenario economics** (elasticity projection β bulk reconciliation β calibration); exposes `scenarios_for_window()`. |
-| **Competitive state** | `src/pricing_agent/competitive_state.py` | The one competitive fact the cascade may read. Adapts either scraper into a typed `WON`/`LOST_PRICE`/`LOST_ELIGIBILITY`/`SUPPRESSED` state with a source and a timestamp, gates it on age, and logs disagreement between sources. |
-| **Analysis core** | `src/pricing_agent/analyze.py` | Orchestrates one SKU: fees β trend β bulk β ad cost β elasticity β actual-profit evidence. |
-| **Money math** | `tools/margin_engine.py` | Pure: break-even, MAP, contribution margin, suggested price. |
-| **Statistics** | `elasticity.py`, `performance.py` | Log-log elasticity fit, profit-optimal sweep, actual-profit aggregation. |
-| **Data access** | `cosmos/{client,service,models}.py` | Auth + retry client; endpoint calls + response flattening; typed pydantic models. |
+| 1 | Cost information is missing | **Stop.** Can't price a product without knowing what it cost. Go fill it in. |
+| 2 | Amazon isn't showing our listing to buyers | **Stop.** A hidden listing sells nothing at any price. Fix the listing first. |
+| 3 | We're selling below what it costs us | **Raise the price.** Every sale is losing money. |
+| 4 | Ads are eating more than the sale earns | **Raise the price.** |
+| 5 | We're about to run out of stock | **Raise a little (5%)** to slow sales until more arrives. |
+| 6 | Sales dropped sharply and we don't know why | **Stop and investigate.** Don't guess with the price. |
+| 7 | We have far too much stock sitting there | **Lower a little (5%)** to shift it before storage costs mount. |
+| 8 | A rival is meaningfully cheaper *and* it's actually costing us sales | **Lower toward their price** β but never below our own break-even. |
+| 9 | The sales data suggests a more profitable price | **Move toward it.** |
+| 10 | None of the above | **Leave it alone.** |
+
+**Notice that stock problems are ranked above competitor problems.** Cutting price to
+chase a rival while the shelf is emptying just means selling out faster for less
+money.
---
-## 3. Data sources β what each COSMOS endpoint feeds
+## The safety rails
-```mermaid
-flowchart TB
- subgraph COSMOS["COSMOS API"]
- TH["/sales-insight/takehome-calculator
nested fees.breakdown Β· cost.breakdown"]
- INVP["/invp-insight
trend + inventory + dateMap PROJECTIONS"]
- BULK["/sales-insight/bulk-calculator
storage + total take-home"]
- SI["/sales-insight (daily, 6-month)
price Β· units Β· revenue Β· profit Β· ad spend"]
- PROD["/products
brand Β· marketplace"]
- CAMP["/api/campaigns Β· /adsApi
budget Β· ACoS Β· ad sales (not yet wired)"]
- end
+These apply to every recommendation, no exceptions:
- TH -->|"_flatten_takehome()"| FEES["Fee model
referral% Β· FBA Β· landed Β· returns"]
- INVP --> TREND["Velocity + cover days"]
- INVP --> INVPROJ["Inventory Outlook tab
real weekly units/value/cover/arrivals"]
- BULK --> STORAGE["Storage + take-home (scenarios)"]
- SI --> HIST["180-day daily series
(window filter + calibration)"]
- SI --> ADS["Ad spend / TACoS (PPC tab)"]
- PROD --> META["Brand / marketplace"]
-```
-
-**Two response quirks handled:**
-- **Fees come nested** (`fees.breakdown["Referral Fee"]`, `"$ 9.28"` strings). `service._flatten_takehome()` normalises them β without it every fee parsed to 0 (the old "$0.99 / break-even $0" bug).
-- **INVP `dateMap`** holds COSMOS's own **forward inventory projection** (weekly units, value, cover days, warehouse arrivals). The Inventory tab renders this directly β not a locally-invented forecast.
-
-Genuinely **not** in this COSMOS integration β shown as "β", never faked: ad-attributed
-sales / ACoS / campaign budget (live in `/api/campaigns` + `/adsApi`, not yet wired), and
-historical competitor prices (Apify gives a current snapshot only).
-
-**Competitor data is no longer display-only.** COSMOS has no Buy Box, no rival price and no
-third-party offer anywhere in it β that gap is filled by a scrape, and as of the competitor
-wiring two of its facts (our Buy Box being suppressed, and a rival materially undercutting us)
-reach the verdict. They are the only two, they are bounded by the guardrails, and their
-absence changes nothing. See Β§6.
+- **Never below break-even.** However cheap a rival is, the tool will not suggest a
+ price that loses money.
+- **Never more than 5% at once.** Big price jumps confuse both customers and our own
+ measurements. Large moves happen over several steps, each one checked.
+- **Never above 25% up from today**, so a modelling error can't produce an absurd
+ price.
+- **A kill switch** in the sidebar pauses all approvals instantly.
---
-## 4. Per-SKU pipeline (one "Analyze")
+## Two things it is careful about
-```mermaid
-sequenceDiagram
- participant U as User
- participant A as app.py
- participant L as live_data.build_live_sku
- participant AN as analyze.py
- participant S as CosmosService
- U->>A: Single product / Product line
- A->>L: get_live_data(skus, progress_cb)
- Note over A: staged progress bar (3β10β45β82β94β100%)
- L->>S: get_current_price
- L->>S: get_sales_history (180d, parallel windows)
- L->>AN: analyze_price (fees, trend, bulk, elasticity, evidence)
- L->>S: get_invp (real inventory projection)
- L->>S: bulk_quote (storage)
- L->>L: decide action + scenario economics + 30d/6mo calibration
- L-->>A: {summary, details, errors} (session-cached)
- A-->>U: queue + expandable per-SKU analysis
-```
+**A blank is better than a wrong number.** If some piece of data is missing, the tool
+shows a dash and says why. It never quietly fills in a zero or a guess β a wrong
+number that looks confident is far more dangerous than an obvious gap.
+
+**It separates what happened from what it predicts.** Rows showing real past results
+are labelled as facts. Rows showing "if we priced at X" are labelled as estimates. The
+tool also reports how wrong it has been on that specific product in the past, so you
+know how much to trust the estimate.
---
-## 5. Scenario economics (the heart of the Scenarios tab)
+## What it deliberately does not do
-For each candidate price, one consistent chain:
-
-```mermaid
-flowchart LR
- W["Window filter
7/14/30/90d Β· 6mo"] --> BASE["Baseline velocity
= avg units/day in window"]
- BASE --> DEMAND["Units(p) = units Γ (p/cur)^elasticity"]
- DEMAND --> REV["Revenue = units Γ p Γ 30"]
- REV --> AD["Ad spend = TACoS Γ revenue"]
- DEMAND --> TH["Take-home (bulk calculator fee model)"]
- TH --> GROSS["Gross = take-home β storage β ad"]
- GROSS --> CAL["Γ realization factor
(actual booked Γ· modeled at current)"]
- CAL --> NET["Net profit / 30d"]
-```
-
-Key rules:
-- **Current row = FACT**, not a projection: real units, real revenue, real ad spend, real
- booked profit. Its price is the **average price sold** (revenue Γ· units) so
- `price Γ units Γ 30 = revenue` reconciles β this is *below* list when promos ran, and
- changes with the window because the avg selling price differed period to period. The
- **list price is fixed**.
-- **Calibration:** raw bulk-calculator profit over-states reality (prices at list, ignores
- real returns/promos). A **realization factor** = actual booked profit Γ· modeled profit at
- the current price scales every projected row, anchoring net profit to what the SKU truly
- earns.
-- **Suggested price** (teal callout) is computed from the **full 6-month** window always β
- stable β independent of the display-window filter.
-- **β** marks the highest-net-profit price in the current view.
+- **It does not set prices.** Advisory only. Every change is a human decision.
+- **It does not let the AI decide anything.** An AI writes the plain-English summary
+ you read β but every number and every recommendation comes from fixed arithmetic.
+ The AI explains; it never calculates or chooses.
+- **It does not guess at missing data.**
---
-## 6. Decision engine (deterministic, first match wins)
+## Words you'll see
+
+| Term | Plain meaning |
+|---|---|
+| **SKU** | Our internal code for one specific product β e.g. a queen duvet in white |
+| **ASIN** | Amazon's code for the same thing |
+| **Buy Box** | The "Add to Cart" button. Several sellers can offer the same item; Amazon picks one to be the default. Win it and you get nearly all the sales. Lose it and sales collapse β so this matters enormously. |
+| **Suppressed** | Amazon has hidden our listing entirely. Nobody can buy it. |
+| **Break-even** | The price where we make exactly zero. Below it, every sale loses money. |
+| **Margin** | What's left over from a sale after every cost |
+| **Cover days** | How many days our current stock will last at the rate we're selling |
+| **Elasticity** | How much sales volume changes when price changes. Some products lose lots of sales from a small rise; others barely notice. |
+| **TACoS** | Advertising spend as a share of sales revenue |
+| **Backtest** | Checking a method against past data to see how accurate it would have been |
+
+---
+
+## The whole flow, start to finish
```mermaid
flowchart TD
- S([signals]) --> R0{cost data = 0?}
- R0 -- yes --> INV["π INVESTIGATE Β· NO_COST_DATA"]
- R0 -- no --> RB{our Buy Box suppressed?}
- RB -- yes --> INVB["π INVESTIGATE Β· BUYBOX_SUPPRESSED"]
- RB -- no --> R1{price < break-even?}
- R1 -- yes --> UP1["β raise to safe floor Β· BELOW_BREAK_EVEN"]
- R1 -- no --> R2{losing money after ads?}
- R2 -- yes --> UP2["β raise Β· LOSING_MONEY"]
- R2 -- no --> R3{cover < 35d?}
- R3 -- yes --> UP3["β +5% Β· LOW_STOCK"]
- R3 -- no --> R4{β30% sales, no cause?}
- R4 -- yes --> INV2["π INVESTIGATE Β· UNEXPLAINED_DROP"]
- R4 -- no --> R5{cover > 90d?}
- R5 -- yes --> DN["β β5% Β· EXCESS_STOCK"]
- R5 -- no --> RC{lost Buy Box AND rival β₯3% below?}
- RC -- yes --> DNC["β toward rival Β· COMPETITOR_UNDERCUT"]
- RC -- no --> R6{profit-optimal β current?}
- R6 -- yes --> MOVE["β/β toward optimal Β· PROFIT_OPTIMAL"]
- R6 -- no --> HOLD["β MAINTAIN Β· NO_SIGNALS"]
+ COSMOS["COSMOS
sales history Β· costs
stock Β· ad spend"]
+ RIVALS["Competitor prices
checked on Amazon
not in COSMOS"]
+ FACTS["1 Β· Work out the facts
what a sale really earns Β·
how long stock lasts Β·
best price we've run"]
+ CHECK{"2 Β· Run the checklist
stop at the first match"}
+ FIX["Go fix something
missing costs Β· hidden listing
no price helps yet"]
+ PRICE["A suggested price
up Β· down Β· leave alone"]
+ RAILS["3 Β· Safety rails
never below break-even
5% max per step
25% max above today"]
+ SCREEN["4 Β· Show a person
the price Β· the one reason
the workings Β· our accuracy"]
+ HUMAN{"5 Β· A human decides"}
+ OK["β
Approve"]
+ MOD["βοΈ Modify"]
+ NO["βοΈ Reject"]
+ STOP["Nothing is sent to Amazon
a person makes every change by hand"]
+
+ COSMOS --> FACTS
+ RIVALS --> FACTS
+ FACTS --> CHECK
+ CHECK -->|"pricing can help"| PRICE
+ CHECK -->|"something is broken"| FIX
+ PRICE --> RAILS
+ RAILS --> SCREEN
+ FIX --> SCREEN
+ SCREEN --> HUMAN
+ HUMAN --> OK & MOD & NO
+ OK & MOD & NO --> STOP
+
+ style COSMOS fill:#d9eee9,stroke:#0c8276,color:#1b2030
+ style RIVALS fill:#d9eee9,stroke:#0c8276,color:#1b2030
+ style CHECK fill:#fbf6ee,stroke:#a89f8a,color:#1b2030
+ style HUMAN fill:#fbf6ee,stroke:#a89f8a,color:#1b2030
+ style RAILS fill:#fbe6df,stroke:#df4f33,color:#1b2030
+ style FIX fill:#fbe6df,stroke:#df4f33,color:#1b2030
+ style STOP fill:#f4f0e8,stroke:#22304e,color:#1b2030
```
-Guardrails: floor = break-even Γ 1.05, ceiling = current Γ 1.25; the recommended move is
-capped at Β±5% (bigger steps need elevated approval); the kill switch pauses all approvals.
+Two things worth noticing in that picture:
-### Competitor rules β the two that can move a price, and what bounds them
+- **Step 2 can decide that pricing is the wrong tool entirely.** If the cost figures are
+ missing, or Amazon has hidden our listing, no price change helps β so it says so
+ instead of inventing a number.
+- **The safety rails sit between the suggestion and the screen.** Whatever the
+ calculations produce, nothing that would lose money reaches a person as a
+ recommendation.
-Both branches read a single `CompetitiveState`
-([competitive_state.py](src/pricing_agent/competitive_state.py)), never a raw scrape:
-
-| Rule | Fires when | Effect |
-|---|---|---|
-| `BUYBOX_SUPPRESSED` | Amazon is not showing our offer | **Investigate, hold.** Placed directly under `NO_COST_DATA`: those are the only two states where the answer is "go and fix something" rather than "set a price". A suppressed variant sells nothing at any price, so its margin and its modelled optimum both describe a listing nobody can buy from. |
-| `COMPETITOR_UNDERCUT` | `LOST_PRICE` **and** cheapest rival β₯ `competitor_undercut_material_pct` below us | **Decrease toward the rival**, floored and step-capped like every other branch. |
-
-A third signal, a **competitor premium** while we hold the Buy Box, is a narrative note only.
-It never sets a price and never changes an action β the elasticity fit is the thing with
-evidence behind it, and a premium is not grounds to overrule it.
-
-Ordering is deliberate: **inventory risk still outranks competitor position, which outranks
-profit-optimal.** Chasing a rival down while the shelf is emptying pays margin to sell out
-faster. This is visible in the backtest below β two of five real SKUs did not move under a
-counterfactual undercut precisely because `LOW_STOCK` and `LOSING_MONEY` fired first.
-
-Three properties make this safe to ship:
-
-1. **Fail-safe.** Absent, failed, stale (> `competitor_state_max_age_hours`) and
- "ownership unknown" all collapse to one flag, and the cascade then computes exactly the
- verdict it computed before competitor data existed. Nothing waits on a scrape; nothing is
- blocked by one. Competitor data can only ever *add* a verdict.
-2. **Never below break-even.** The rival price is a *candidate* (`comp_match`), not a
- decision. Verified against real SKUs with a counterfactual rival 60% below us: against a
- $14.88 floor the shipped recommendations were $15.19β$21.84, because the Β±5% step cap
- binds first. Zero violations.
-3. **One named reason per verdict.** No blended scores β every fired rule is traceable to a
- single reason code, and `logger.info` names the SKU, the rule, the state and the source.
-
-Thresholds live in [config/pricing_rules.yaml](config/pricing_rules.yaml)
-(`competitor_undercut_material_pct: 0.03`, `competitor_premium_material_pct: 0.10`,
-`competitor_state_max_age_hours: 6.0`), not in code. The 3% floor sits above the ~2% band our
-own realized price already swings through as coupons toggle.
-
-### Inventory cover matches COSMOS Inventory Planning
-
-`cover_days` **is COSMOS's own `coverDays`**, so the dashboard and the INVP grid never quote
-two different numbers for one SKU. COSMOS counts **inbound** stock against a **7-day**
-velocity, so it reads longer than what is on the shelf β `UBMICROFIBERDUVETTWINWHITE` is
-78 days on (3,999 on hand + 1,030 inbound) Γ· 64/day, against 63 on-hand-only. Both are
-reported: the tile leads with the matched figure and appends `63 d on hand, rest inbound`.
-
-The on-hand figure remains the **fallback**, because COSMOS returns `coverDays: 0` on some
-very low-velocity SKUs that hold months of stock (`UBMICROFIBERBS4PCFULLGREY`: 167 units,
-334 real days, COSMOS said `0`). Zero satisfies neither inventory rule, so taken literally it
-silences both.
-
-**Trade-off, accepted deliberately:** stockout risk is now judged partly on stock that has not
-landed. Measured over the 56-SKU covered line, matching COSMOS moved 7 verdicts β
-`LOW_STOCK` 7 β 4, `EXCESS_STOCK` 18 β 22. The one to watch is
-`UBMICROFIBERBS4PCKINGWHITE`: **12 days on the shelf, 84 with inbound**, so it no longer
-raises. If that shipment slips, nothing protects it.
-
-Display bands are COSMOS's Alpha/Beta scheme (`theme.COVER_BANDS`, Alpha 20/40/70/100). The
-pricing **triggers** are separate and live in `pricing_rules.yaml`
-(`low_cover_days: 35`, `high_cover_days: 90`) β COSMOS's pink at 70 days is a *replenishment*
-warning, while crossing a trigger here spends margin on a 5% move. Adopting the band edges
-(40/70) would put six more SKUs on a discount; the measured table is in the config beside the
-values.
-
-### Coverage: the comparison sheet gates competitor data, one product line at a time
-
-The competitor workbook currently covers **one product line**, so the engine reads it as the
-first competitor source and **gates on coverage**:
-
-| SKU | Competitor state |
-|---|---|
-| In the sheet | Priced from the sheet β real like-for-like rival prices, `basis=like-for-like-sheet` |
-| Not in the sheet | **`N/A`**, naming what the sheet *does* cover. No rule fires; the verdict is byte-identical to the competitor-blind one |
-
-**Coverage is the exact SKU set in the sheet, not a line prefix.** Measured against the real
-workbook, a prefix gate would be wrong in both directions: the `UBMICROFIBERDUVET` run contains
-49 `UBMICROFIBERDUVET*` SKUs **and 7 `UBMICROFIBERBS4PC*`** ones (variants come off the Amazon
-parent twister, and COSMOS maps those ASINs to whatever SKU codes they carry), while the line
-has 139 SKUs in COSMOS of which only 56 reached a comparison row. So the sheet's own SKU list is
-the authority, and "not in the sheet" is reported as a **coverage hole**, never as a claim that
-the SKU has no competitors.
-
-`competitor_sheet_only: true` (default while one line is under test) means an uncovered SKU gets
-N/A rather than falling through to a per-ASIN Apify scrape β so every verdict either rests on
-the sheet or says it has no competitor data. Set it `False` once coverage is broad enough for
-Apify to be a sensible fallback. Config: `competitor_sheet_path` (blank = auto-discover the
-newest `Competitor_Price_Comparison_*.xlsx`), `competitor_sheet_dirs`,
-`competitor_sheet_max_age_hours: 168` (the sheet is a 25β35 min batch run, not a live feed, so
-it gets a longer limit than the 6 h single-ASIN one).
-
-**Two bases, never conflated.** `competitor_min` can be a price of two different things, and the
-`basis` field records which:
-
-- `same-asin-buybox` (Apify) β another seller's offer on **our own listing**. Only `LOST_PRICE`
- fires the undercut rule; a rival holding the Buy Box *above* us is `LOST_ELIGIBILITY`, where
- cutting donates margin.
-- `like-for-like-sheet` β a **rival brand's** equivalent variant, matched on size + colour. A
- cheaper one fires the rule **regardless of who owns our Buy Box**: we can hold ours perfectly
- well while a different product undercuts us. This is what the comparison tool exists to
- report. It is never labelled a Buy Box loss.
-
-Two sheet-driven refinements, both from real rows:
-
-- **A rival whose own Buy Box is suppressed is excluded from the band.** Their price is not
- buyable, so undercutting it donates margin for nothing.
-- **A material undercut now explains a velocity drop.** Previously a drop with no *own* price
- change and no ad collapse was filed `UNEXPLAINED_DROP` even when the sheet held the
- explanation β observed on `UBMICROFIBERDUVETKINGPURPLE` (rival 15.6% below) and
- `UBMICROFIBERBS4PCFULLGREY` (35.2% below). A named cause now converts the Investigate into an
- actionable verdict, exactly as the existing stockout branch already did. A stockout still
- explains a drop first; an immaterial rival explains nothing.
-
-Finally, a **suppressed listing has no selling price** (it sells nothing, so COSMOS records no
-sales), which used to fail with a bare "no current selling price found in COSMOS". That error now
-names the cause, so the most actionable rows in the sheet stop looking like a data problem.
-
-### Two scrapers, one state
-
-| Source | Authoritative for | Why |
-|---|---|---|
-| **Apify** (`tools/amazon/apify.py`) | **Buy Box state read by the engine** | The only source carrying a seller id, so the only one that can tell `WON` from `LOST_PRICE` from `LOST_ELIGIBILITY`. Those lead to opposite actions (cut price vs. fix fulfilment eligibility). |
-| **Playwright** (`../scraper/`) | The workbook: like-for-like size/colour matching, BSR, demand buckets, SKU gaps | Apify cannot produce any of it. Its Buy Box field knows only whether a price *rendered*, not whose it was. |
-
-The split is by **question**, not preference, so neither source is redundant. `reconcile()`
-cross-checks the authoritative state against the Playwright run's own cache
-(`scraper/.scrape_cache.json`, keyed `ASIN@ZIP`) and **logs any disagreement** rather than
-letting a workbook and a recommendation contradict each other in front of a stakeholder. A
-disagreement never changes the verdict. Where the authoritative source has nothing usable but
-the other has a `SUPPRESSED`, that fact is promoted and the provenance recorded β discarding
-it to preserve a hierarchy would be choosing the hierarchy over the fact.
-
-Backtest (`scripts/backtest_competitor_rules.py`, 5 real SKUs, real COSMOS data):
-
-| Arm | Verdicts changed |
-|---|---|
-| Real competitor state (both cached entries stale: 66h / 146h) | **0 / 5** β the fail-safe working |
-| Counterfactual 8% undercut | 3 / 5 β the other 2 blocked by `LOW_STOCK` / `LOSING_MONEY` |
-| Counterfactual suppression | 5 / 5 β Investigate |
-| Prices shipped below break-even, any arm | **0** |
-
----
-
-## 7. Key formulas
-
-| Quantity | Formula |
-|---|---|
-| Take-home / unit | `pΒ·(1 β referral% β returns%) β landed β FBA β other` |
-| Break-even | `(landed + FBA + returns + other) / (1 β referral%)` |
-| Elasticity | OLS on `ln(units/day) = a + eΒ·ln(price)` over 6 months |
-| Scenario demand | `units Γ (p / pβ)^e` |
-| Realization factor | `actual booked profit (window) Γ· modeled net at current price` |
-| TACoS | `ad spend Γ· total revenue` (window) |
-| Avg sold price | `revenue Γ· units` (window) β reconciles the Current row |
-
----
-
-## 8. Repository map
-
-```
-pricing_agent/
-βββ app.py # dashboard (presentation only)
-βββ legacy_app.py # previous analyst UI (still runnable)
-βββ dashboard/
-β βββ theme.py # CRAI design tokens + plotly template
-β βββ live_data.py # COSMOS adapter, decision + scenario engine
-βββ src/pricing_agent/
-β βββ analyze.py # per-SKU orchestration β AnalysisResult
-β βββ competitive_state.py # canonical Buy Box state + two-scraper reconciliation
-β βββ elasticity.py # demand model + profit optimizer
-β βββ performance.py # actual-profit evidence
-β βββ tools/margin_engine.py # pure fee/break-even math (golden-tested)
-β βββ cosmos/
-β βββ client.py # auth + retry HTTP
-β βββ service.py # endpoints, _flatten_takehome, INVP projections
-β βββ models.py # typed COSMOS responses (pydantic)
-βββ config/ # settings + pricing_rules.yaml (incl. competitor thresholds)
-βββ scripts/
-β βββ backtest_competitor_rules.py # verdict delta, competitor rules blind vs live
-βββ .streamlit/config.toml # CRAI theme
-βββ tests/ # incl. margin-engine golden values and
- # test_competitor_rules.py (fail-safe + ordering invariants)
-```
-
----
-
-## 9. Principles
-
-1. **Deterministic core, narrative shell** β every number is a formula over COSMOS data; language models only phrase explanations.
-2. **Read-only** β the agent proposes; a human approves; nothing writes back.
- `submit_price_approval` remains a stub with no callers.
-3. **Honest gaps** β missing upstream data shows "β" or an explicit investigation, never a fabricated number.
-4. **Facts vs projections are labeled** β the Current row is real booked history; other prices are clearly modeled.
-5. **Everything reconciles** β one averaging window drives units, revenue, ads and profit so `price Γ units = revenue` always holds.
+Everything on the screen traces back to a specific number from COSMOS. Nothing is
+invented along the way.
diff --git a/README.md b/README.md
index 09ef639..46bf0dc 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,12 @@ when `APIFY_TOKEN` is set: SKU β COSMOS ASIN β scrape `amazon.com/dp/{ASIN}`
token the competitive gate stays `UNKNOWN` (COSMOS has no Buy Box data).
For a covered product line the **comparison workbook** takes precedence over the per-ASIN
-scrape β see *Coverage* in [ARCHITECTURE.md](ARCHITECTURE.md), which also documents which of
-the two scrapers is authoritative for Buy Box state and why.
+scrape β see *Coverage* in [ARCHITECTURE-DETAIL.md](ARCHITECTURE-DETAIL.md), which also
+documents which of the two scrapers is authoritative for Buy Box state and why.
+
+New to the project? [ARCHITECTURE.md](ARCHITECTURE.md) is a short, plain-English
+explanation of what the tool does and how it decides β no coding or Amazon knowledge
+assumed. [ARCHITECTURE-DETAIL.md](ARCHITECTURE-DETAIL.md) is the engineering reference.
## Setup
diff --git a/app.py b/app.py
index df732a1..56e5045 100644
--- a/app.py
+++ b/app.py
@@ -12,6 +12,7 @@ Run: streamlit run app.py
from __future__ import annotations
import hmac
+import html
import os
import sys
from pathlib import Path
@@ -114,9 +115,20 @@ h1, h2, h3, h4 { letter-spacing:-.01em; }
[data-testid="stExpander"] summary span.green { color:#0a6f65 !important; }
[data-testid="stExpander"] summary [data-testid="stMarkdownContainer"] span[style*="color: rgb(255, 43, 43)"],
[data-testid="stExpander"] summary span.red { color:#b23a22 !important; }
-/* Stat tiles live in a real auto-fit grid, so they reflow 5 β 4 β 3 β 2 β 1
- across the window width instead of being squeezed into unreadable slivers. */
-.tiles { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(11.5rem, 1fr)); }
+/* Stat tiles reflow with the window instead of being squeezed into slivers.
+ The column count is keyed to HOW MANY tiles there are, because plain auto-fit
+ orphans the last one: six tiles in a 980px content column packed 5 + 1, leaving a
+ lone card against four empty slots, and every note inside the five wrapped to four
+ lines. Three across gives 3+3 (single SKU) and 3+2 (portfolio) β no orphan row and
+ roughly double the width per note. Only on a genuinely wide window do they open out
+ to one row, where they still have room to breathe. */
+.tiles { display:grid; gap:14px; grid-template-columns:repeat(3, minmax(0, 1fr)); }
+@media (min-width: 1650px) {
+ .tiles.t5 { grid-template-columns:repeat(5, minmax(0, 1fr)); }
+ .tiles.t6 { grid-template-columns:repeat(6, minmax(0, 1fr)); }
+}
+@media (max-width: 1100px) { .tiles { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 560px) { .tiles { grid-template-columns:1fr; } }
.tl { background:#fffdf9; border:1px solid #e3ddd0; border-radius:14px; padding:16px 18px;
box-shadow:0 1px 2px rgba(27,32,48,.05), 0 8px 24px rgba(27,32,48,.06); }
.tl-ic { width:36px; height:36px; border-radius:10px; background:#d9eee9; display:flex;
@@ -143,13 +155,26 @@ h1, h2, h3, h4 { letter-spacing:-.01em; }
.brandlogo .t2 { font-size:.72rem; color:#7c8092; }
.invt-wrap { overflow-x:auto; border:1px solid #e3ddd0; border-radius:12px; background:#fffdf9; }
.invt { border-collapse:collapse; width:100%; font-size:.78rem; }
+/* Numeric columns are RIGHT-aligned with tabular (fixed-width) figures, so digits
+ line up by place value down the column and magnitudes can be compared at a glance.
+ Centred money was the default here, which staggers the decimal point on every row
+ and makes $9,801 and $981 look the same length. Headers follow their column.
+ `.tnum` opts a cell out (the first column, and any text cell). */
+.invt { font-variant-numeric: tabular-nums; font-feature-settings:"tnum" 1, "lnum" 1; }
.invt th { background:#faf7f1; padding:9px 10px; color:#4a4f60; font-weight:600;
- border-bottom:1px solid #e3ddd0; white-space:nowrap; text-align:center; }
+ border-bottom:1px solid #e3ddd0; white-space:nowrap; text-align:right; }
.invt th:first-child, .invt td:first-child { text-align:left; position:sticky; left:0;
background:#fffdf9; box-shadow:1px 0 0 #e3ddd0; min-width:215px; }
.invt th:first-child { background:#faf7f1; }
-.invt td { padding:9px 10px; border-left:1px solid #ece6d9; text-align:center;
+.invt td { padding:9px 10px; border-left:1px solid #ece6d9; text-align:right;
min-width:98px; vertical-align:middle; }
+/* Text cells (labels, prose advice) stay left-aligned β right-aligning a sentence
+ gives it a ragged left edge, which is much harder to read than ragged right. */
+.invt th.txt, .invt td.txt { text-align:left; }
+/* Sub-values stacked under a figure (the "avg sold" / "β ran Nd" provenance lines,
+ and the inventory cell's value line) inherit the column's right edge, so the
+ number and its caption share one alignment spine. */
+.invt .cell-val, .invt .p-s { text-align:inherit; }
.invt .p-t { font-weight:700; color:#1b2030; font-size:.82rem; }
.invt .p-s { color:#7c8092; font-size:.71rem; margin-top:2px; white-space:nowrap; }
.invt .cell-main { font-weight:800; font-size:.93rem; color:#1b2030; }
@@ -226,7 +251,10 @@ h1, h2, h3, h4 { letter-spacing:-.01em; }
min-width:42% !important;
}
[class*="st-key-pillrow"] [data-testid="stColumn"] { min-width:8rem !important; }
- .tiles { grid-template-columns:repeat(auto-fit, minmax(9.5rem, 1fr)); gap:10px; }
+ /* Two across, not auto-fit: in a ~485px column auto-fit still packed THREE tiles
+ at ~150px each, which is narrower than the same tiles get on a desktop and left
+ every note wrapping to four lines. Two is the widest count that stays legible. */
+ .tiles { grid-template-columns:repeat(2, minmax(0, 1fr)) !important; gap:10px; }
.loadcard { margin-top:6vh; padding:22px 18px; }
.loadcard .lc-b { font-size:.72rem; letter-spacing:-.5px; }
}
@@ -347,10 +375,23 @@ def _parse_skus(raw: str) -> tuple:
# ---------------------------------------------------------------- helpers
def esc(s: str) -> str:
- """Escape $ so Streamlit markdown never enters LaTeX math mode."""
+ """Escape $ so Streamlit markdown never enters LaTeX math mode.
+
+ MARKDOWN ONLY. Inside a raw HTML block this is the wrong tool β Streamlit does not
+ run LaTeX there, so it would render a literal backslash. Use `esc_html` instead.
+ """
return s.replace("$", "\\$")
+def esc_html(s) -> str:
+ """Escape a value for interpolation into one of the raw HTML tables.
+
+ Amazon listing titles routinely carry `&`, quotes and the odd angle bracket; left
+ raw they either swallow the rest of the cell or surface as a stray entity.
+ """
+ return html.escape(str(s), quote=True)
+
+
def dash(v, fmt: str = "{}") -> str:
"""Format a value, or an em-dash when it is missing."""
if v is None or (isinstance(v, float) and pd.isna(v)):
@@ -358,11 +399,34 @@ def dash(v, fmt: str = "{}") -> str:
return fmt.format(v)
+# U+2212 MINUS SIGN, not the ASCII hyphen. It is the same width as the digits in a
+# tabular-numeral column so negative rows stay aligned, and it matches the "β2%" /
+# "β5%" scenario labels the engine already emits. Used for every negative money and
+# percentage in the UI so one screen never shows three different minus glyphs.
+MINUS = "β"
+
+
+def money(v: float, dp: int = 0) -> str:
+ """Signed currency with the minus OUTSIDE the symbol: β$108, not $-108.
+
+ Python's `f"${v:,.0f}"` puts the sign where the digits start, which reads as a
+ dollar sign applied to a negative number rather than a negative amount, and in a
+ right-aligned column it also shunts the $ out of line with the rows above it.
+ """
+ return f"{MINUS if v < 0 else ''}${abs(v):,.{dp}f}"
+
+
+def pct(v: float, dp: int = 1, signed: bool = True) -> str:
+ """Percentage with a real minus sign and an explicit + when signed."""
+ sign = MINUS if v < 0 else ("+" if signed else "")
+ return f"{sign}{abs(v):.{dp}f}%"
+
+
def compact_money(v: float) -> str:
"""$33k rather than $32,974 β false precision from a model with a known error
band reads as certainty the number does not have."""
a = abs(v)
- sign = "-" if v < 0 else ""
+ sign = MINUS if v < 0 else ""
if a >= 1_000_000:
return f"{sign}${a / 1_000_000:.1f}M"
if a >= 1_000:
@@ -386,6 +450,27 @@ def money_range(v: float, err: float | None) -> str:
return f"{compact_money(min(lo, hi))}β{compact_money(max(lo, hi))}"
+def econ_rows(d: dict) -> tuple[dict, dict]:
+ """(economics at TODAY's price, economics at the price we recommend TODAY).
+
+ The second one is deliberately matched on `rec_price`, NOT on `rec_key`. When a
+ move is step-capped the two are different prices: `rec_key` names the rung the
+ cascade chose β the $20.00 destination β while the headline recommends $17.94 on
+ the way there. Quoting the destination's units, profit and margin beside a $17.94
+ headline promises what that move does not deliver.
+
+ Both the header tiles and the queue decision card read this, so they cannot drift:
+ the tiles were fixed for this and the card was not, and the card sat directly under
+ "β RAISE $17.09 β $17.94" showing the economics of $20.00.
+ """
+ econ = d.get("scen_econ") or []
+ by_key = {x["key"]: x for x in econ}
+ cur = by_key.get("current") or {}
+ rec = next((x for x in econ if abs(x["price"] - d["rec_price"]) < 0.011),
+ by_key.get(d.get("rec_key")) or {})
+ return cur, rec
+
+
def unit_take_home(price: float, d: dict) -> float:
"""Per-unit take-home under this SKU's COSMOS fee model."""
rp = d.get("referral_pct", REFERRAL_PCT)
@@ -398,9 +483,9 @@ def _price_move_html(delta_pct: float) -> str:
"""Colored arrow + percentage chip for a price move vs current."""
if abs(delta_pct) < 0.05:
return 'β 0.0%'
- if delta_pct > 0:
- return (f'β² +{delta_pct:.1f}%')
- return (f'βΌ {delta_pct:.1f}%')
+ colour = "#0a6f65" if delta_pct > 0 else "#c9442b"
+ return (f''
+ f'{"β²" if delta_pct > 0 else "βΌ"} {pct(delta_pct)}')
WINDOW_OPTS = {"7 days": 7, "14 days": 14, "30 days": 30, "90 days": 90,
@@ -491,8 +576,8 @@ def render_scenarios(d: dict, key_prefix: str, compact: bool = False):
a1, a2, a3 = st.columns(3)
a1.metric(f"Units/day (last {days}d)", f"{cur['units_day']:,}",
f"avg sold ${price_check:,.2f}/unit", delta_color="off")
- a2.metric("Net profit /30d", f"${cur['net_30d']:,.0f}",
- f"on ${cur['revenue_30d']:,.0f} revenue", delta_color="off")
+ a2.metric("Net profit /30d", money(cur["net_30d"]),
+ f"on {money(cur['revenue_30d'])} revenue", delta_color="off")
if best:
lbl = "Best price = hold" if best["key"] == "current" else "Best price (projected)"
a3.metric(lbl, f"${best['price']:.2f}",
@@ -528,18 +613,18 @@ def render_scenarios(d: dict, key_prefix: str, compact: bool = False):
move_cell = _price_move_html(x["delta_pct"])
rows_html.append(
f'
| Product Description | Daily Sale | {heads}
|---|---|
{cfg["title"]} '
- f'{cfg["sku"]} Β· {asin_html}{reviews} '
+ f' | {esc_html(cfg["title"])} '
+ f'{esc_html(cfg["sku"])} Β· {asin_html}{reviews} '
f'π¦ {on_hand:,.0f} on hand Β· π {total_inbound:,.0f} inbound'
f'{f" (+{later_inbound:,.0f} later)" if later_inbound else ""} Β· '
f'class {cfg["inv_class"].capitalize()} | '
@@ -1152,7 +1239,7 @@ def _on_progress(frac: float, message: str):
history, elasticity & bulk economics