first commit

main
Talha Ahmed 2026-07-31 19:42:26 +05:00
commit 7644202e77
80 changed files with 24094 additions and 0 deletions

70
.env.example Normal file
View File

@ -0,0 +1,70 @@
# Copy to .env and fill in real values. .env is git-ignored — never commit secrets.
# ─── Backend selection ───
# data provider: cosmos | mock
DATA_BACKEND=cosmos
# tracker (SKU list in / decisions out): csv | gsheets
TRACKER_BACKEND=csv
# ─── COSMOS API (primary data source: cost, fees, profit, price) ───
COSMOS_BASE_URL=https://cosmos-api.utopiadeals.com
COSMOS_EMAIL=you@utopiabrands.com
COSMOS_PASSWORD=
# Marketplace enum used by COSMOS (NOT the Amazon marketplace id)
COSMOS_MARKETPLACE=AMAZON_USA
COSMOS_TIMEOUT_SECONDS=30
# ─── LLM (optional; falls back to a deterministic template if unset) ───
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1
# Deep analysis uses a big reasoning model (gpt-5.1 default; gpt-5-pro for max depth)
OPENAI_ANALYSIS_MODEL=gpt-5.1
OPENAI_REASONING_EFFORT=high
# ─── CSV tracker (used when TRACKER_BACKEND=csv) ───
TRACKER_CSV_PATH=./data/sample_skus.csv
TRACKER_CSV_OUT=./data/sample_skus_out.csv
AUDIT_LOG_PATH=./data/audit_log.csv
# ─── Google Sheets tracker (used when TRACKER_BACKEND=gsheets) ───
GOOGLE_APPLICATION_CREDENTIALS=./config/service_account.json
TRACKER_SHEET_ID=
TRACKER_WORKSHEET=master
# ─── Apify (Buy Box / competitor price on the ASIN's Amazon PDP) ───
# Flow: SKU → COSMOS ASIN → Apify scrapes https://www.amazon.com/dp/{ASIN}
# Without APIFY_TOKEN the competitive gate stays UNKNOWN (COSMOS has no Buy Box data).
APIFY_TOKEN=
APIFY_ACTOR_ID=junglee/Amazon-crawler
# Your Amazon merchant id (seller.id on the PDP). STRONGLY RECOMMENDED: it detects
# WON vs LOST_PRICE *and* filters our own offers out of the competitor band. Without it,
# our own listings are counted as competitors.
APIFY_SELLER_ID=
APIFY_MAX_OFFERS=10
APIFY_ZIP_CODE=10001
APIFY_TIMEOUT_SECONDS=300
# Speed: cost is per actor RUN (~30-45s cold start), not per ASIN. Measured: 3 ASINs in
# one run = 47s vs ~145s one-by-one. So ASINs are batched into a single run, and raw
# payloads are cached (competitor prices don't move minute-to-minute; the actor bills
# per event). Set the TTL to 0 to disable caching.
APIFY_BATCH_SIZE=20
APIFY_CACHE_TTL_SECONDS=21600
APIFY_CACHE_PATH=./data/apify_cache.json
# ─── Competitor comparison workbook (the sibling scraper's output) ───
# For a COVERED product line this takes precedence over the per-ASIN Apify scrape: it carries
# like-for-like rivals matched on size + colour, which Apify cannot produce.
#
# Leave the path blank to auto-discover the newest Competitor_Price_Comparison_*.xlsx in the
# directories below (newest by modification time wins).
COMPETITOR_SHEET_PATH=
# Semicolon-separated search path for auto-discovery.
COMPETITOR_SHEET_DIRS=./competetor;../scraper
# true = a SKU the sheet does not cover gets an explicit N/A (the default while one product
# line is under test — every verdict then either rests on the sheet or says it has no
# competitor data, and no stray scrape can price a SKU the sheet was meant to govern).
# false = uncovered SKUs fall back to a per-ASIN Apify scrape. Switch once coverage is broad.
COMPETITOR_SHEET_ONLY=true
# ─── Mock Amazon fixtures (used only when DATA_BACKEND=mock) ───
MOCK_FIXTURES_PATH=./tests/fixtures/amazon_mock.json

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Secrets
.env
config/service_account.json
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
# Runtime output / state
data/*_out.csv
data/audit_log.csv
*.sqlite
*.db
# OS / IDE
.DS_Store
.vscode/
.idea/

16
.streamlit/config.toml Normal file
View File

@ -0,0 +1,16 @@
[theme]
base = "light"
primaryColor = "#0c8276"
backgroundColor = "#f4f0e8"
secondaryBackgroundColor = "#fffdf9"
textColor = "#1b2030"
font = "sans serif"
[browser]
gatherUsageStats = false
[client]
toolbarMode = "minimal"
[server]
runOnSave = true

378
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,378 @@
# Utopia Pricing Agent — Architecture
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<br/>(same-network browser)"] --> APP
subgraph APP["app.py — presentation (Streamlit, CRAI theme)"]
SIDE["Sidebar<br/>Single product / Product line<br/>+ filters, kill switch"]
QUEUE["Recommendation queue<br/>tiles · pills · rows"]
SECT["Per-SKU sections<br/>price · inventory · scenarios<br/>competitors · PPC · costs · AI"]
end
subgraph DASH["dashboard/ package"]
THEME["theme.py<br/>CRAI tokens + plotly template"]
LIVE["live_data.py<br/>adapter + decision engine<br/>+ 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<br/>nested fees.breakdown · cost.breakdown"]
INVP["/invp-insight<br/>trend + inventory + dateMap PROJECTIONS"]
BULK["/sales-insight/bulk-calculator<br/>storage + total take-home"]
SI["/sales-insight (daily, 6-month)<br/>price · units · revenue · profit · ad spend"]
PROD["/products<br/>brand · marketplace"]
CAMP["/api/campaigns · /adsApi<br/>budget · ACoS · ad sales (not yet wired)"]
end
TH -->|"_flatten_takehome()"| FEES["Fee model<br/>referral% · FBA · landed · returns"]
INVP --> TREND["Velocity + cover days"]
INVP --> INVPROJ["Inventory Outlook tab<br/>real weekly units/value/cover/arrivals"]
BULK --> STORAGE["Storage + take-home (scenarios)"]
SI --> HIST["180-day daily series<br/>(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.
---
## 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<br/>7/14/30/90d · 6mo"] --> BASE["Baseline velocity<br/>= 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<br/>(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.
---
## 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{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"]
```
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.
### 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`: 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 2535 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.

File diff suppressed because it is too large Load Diff

119
README.md Normal file
View File

@ -0,0 +1,119 @@
# Pricing & Profitability Analyst — AI Agent (Pilot)
The first buildable agent from the [Amazon 1000+ product launch team](../agents_plans/).
It automates the Pricing Analyst's gate: for each SKU it builds the full fee stack,
computes **contribution margin / break-even / MAP**, checks **competitive price + Buy Box /
suppression**, then **proposes** a price and an `APPROVED` / `BLOCKED` / `NEEDS_REVIEW`
decision. A human approves before anything is written back to the master tracker.
Built on **LangGraph** (stateful, gated, auditable) + **OpenAI** (narrative only —
never arithmetic). All money math lives in a pure, unit-tested module.
## Architecture
```
enrich ─▶ compute_margin ─▶ fetch_competitive ─▶ evaluate ─▶ human_review (interrupt) ─▶ write_back
│ provider │ │ margin_engine │ │ provider │ │ gate │ │ HITL │ │ tracker │
```
- **`providers.py`** — market-data seam: `cosmos` (live) ↔ `mock` (offline). Nodes are
backend-agnostic.
- **`cosmos/`** — real COSMOS integration: `client.py` (auth + token refresh + retries),
`service.py` (products, take-home fee quote, **invp sales-trend + inventory**, **bulk
calculator**, price read, guarded price-write stub), `models.py` (typed responses).
- **`analyze.py`** — the price verdict: combines per-unit margin (`takehome-calculator`),
the **6-month sales trend** (`invp-insight`: `averageSale6Months` vs 7/30-day), and **total
economics** (`bulk-calculator`: volume storage on current inventory) → GOOD / CAUTION / POOR.
- **`tools/margin_engine.py`** — pure formulas. `stack_from_quote()` builds the fee stack from
COSMOS's real dollar fees; `worst_case_stack()` is the synthetic mock path. Golden values
(mock): break-even **$16.76**, MAP **$19.00**, CM **~28%**.
- **`tools/gate.py`** — deterministic APPROVED/BLOCKED/NEEDS_REVIEW (playbook §13).
- **`tools/tracker.py`** — SKU list in / decisions out: `csv``gsheets`.
- **`llm.py`** — OpenAI structured output; falls back to a template if no key.
- **`graph.py`** — LangGraph wiring + `interrupt()` for human approval.
Competitive price / Buy Box / suppression comes from **Apify** (`junglee/Amazon-crawler`)
when `APIFY_TOKEN` is set: SKU → COSMOS ASIN → scrape `amazon.com/dp/{ASIN}`. Without the
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.
## Setup
```bash
pip install -e ".[dev]"
cp .env.example .env # then fill COSMOS_EMAIL / COSMOS_PASSWORD
# Optional Buy Box: set APIFY_TOKEN (and APIFY_SELLER_ID for WON vs LOST_PRICE)
```
`.env` (git-ignored) holds secrets. Defaults: `DATA_BACKEND=cosmos`, `TRACKER_BACKEND=csv`.
Set `DATA_BACKEND=mock` to run fully offline (tests + local dev, no credentials).
## Dashboard UI
```bash
pip install -r requirements.txt # streamlit + plotly + pandas + numpy
streamlit run app.py
```
One-page pricing dashboard in the Utopia/CRAI design system (cream paper, teal
primary, coral accent, navy chrome): stat tiles, action pills
(↑ Raise / ↓ Lower / → Hold / 🔍 Check), and a recommendation queue where each SKU
expands into Approve / Modify / Reject plus seven analysis tabs — price & demand,
inventory outlook, scenarios, competitors, PPC, costs, and AI reasoning.
Two ways to analyze (sidebar):
- **Single product** — enter one SKU for a full price analysis.
- **Product line** — enter a SKU prefix and a product-count slider to analyze a
whole line at once.
Live COSMOS data only: every number comes from the real pipeline (take-home fees,
6-month history, elasticity, actual profit, optional competitor Buy Box prices).
The previous analyst UI is still available: `streamlit run legacy_app.py`.
## Run (CLI)
```bash
# ANALYZE a price — full breakdown + 6-month trend + AI narrative + suggested price:
python -m pricing_agent.cli --sku <REAL_SKU> --price 24.99 --analyze
# BULK — scan a product line, ranked worst-first with suggested prices:
python -m pricing_agent.cli --bulk --sku-prefix UBMICRO --limit 15
# Live COSMOS, one real SKU at a candidate price (interactive approval):
python -m pricing_agent.cli --sku <REAL_SKU> --price 24.99
# Non-interactive (approve healthy, reject the rest):
python -m pricing_agent.cli --sku <REAL_SKU> --price 24.99 --auto smart
# Offline demo on the sample tracker:
python -m pricing_agent.cli --all --backend mock --auto smart
# Live COSMOS read-only health check:
python scripts/cosmos_smoke.py <REAL_SKU> 24.99
```
Outputs: `data/sample_skus_out.csv` (pricing columns + status) and `data/audit_log.csv`.
## Test
```bash
pytest # margin engine, gate, COSMOS mapping, end-to-end graph (all offline)
```
## Going live
| Switch | What you need |
|---|---|
| `DATA_BACKEND=cosmos` | `COSMOS_EMAIL` / `COSMOS_PASSWORD` in `.env` (already wired) |
| Price write-back to COSMOS | Confirm the `price_adjustment.edit_price` POST/PUT endpoint, then fill `CosmosPricingService.submit_price_approval()` |
| `TRACKER_BACKEND=gsheets` | Google service-account JSON + `TRACKER_SHEET_ID` |
| Live LLM rationale | `OPENAI_API_KEY` in `.env` |
| Buy Box / competitor gate | `APIFY_TOKEN` in `.env` (scrapes ASIN PDP via `junglee/Amazon-crawler`) |
| Confirm Buy Box owner | Set `APIFY_SELLER_ID` to your Amazon merchant id |
Every configurable value lives in [.env.example](.env.example) (credentials, tokens, paths)
or [config/pricing_rules.yaml](config/pricing_rules.yaml) (thresholds and policy). The COSMOS
endpoints themselves are catalogued in `COSMOS_API.postman_collection.json`.

2024
app.py Normal file

File diff suppressed because it is too large Load Diff

143
claude.md Normal file
View File

@ -0,0 +1,143 @@
# System Prompt — Competitor Pricing Integration
## Working directory
`D:\Talha\Amazon agents\pricing_agent\competetor`
This is the standalone COSMOS + Amazon-scraper competitor comparison tool
(`backend/pipeline.py`, `backend/cosmos.py`, `backend/scraper.py`, `backend/matching.py`,
`backend/workbook.py`). It currently runs independently and produces
`Competitor_Price_Comparison.xlsx`. It is **not wired into** the pricing agent's
recommendation engine (`src/pricing_agent/`), which currently treats competitor data
(via a separate Apify scraper) as **display-only** and never lets it affect a verdict.
Your job has three parts. Do them in this order. Do not skip to part 3 before 1 and 2 are done.
---
## Part 1 — Fix known/likely bugs in the competitor pipeline
Audit the codebase in `competetor/` for these specific failure classes (some are
confirmed historical bugs in the sibling pricing-agent codebase — check whether the
same mistakes exist here, since both pull from COSMOS):
1. **Response-shape bugs from COSMOS.** `takehome-calculator` returns fees as nested,
formatted strings (e.g. `"$ 9.28"`), not numbers. Confirm `cosmos.py` actually parses
these correctly — a naive numeric cast silently produces zeros, which then look like
valid (wrong) margin data. Write a unit test that would have caught it if it's broken.
2. **Per-day vs per-month unit bugs.** Any storage/fee figure pulled from COSMOS should
be verified against a known reference rate, not assumed. If `competetor/` independently
computes any cost/margin figure, cross-check its units the same way — don't trust a
number just because it "looks plausible."
3. **`/api/products?q=<ASIN>` ignores the filter and returns the unfiltered catalogue.**
Confirm the ASIN→SKU join in `cosmos.py` runs off the **cached catalogue** with an
exact match, never off a live filtered query. If a join can't find an exact match, it
must fail safe (blank, not a wrong SKU's cost attached).
4. **Marketplace leakage.** Any product lookup must match on SKU **and**
`marketplace == "AMAZON_USA"` (or your real equivalent) or CA/TEST/BOX variants leak
into a US pricing sheet.
5. **Currency and geo-conversion.** Verify the scraper actually confirms the US ZIP
cookie took effect before trusting any price, and that a non-USD price is **rejected**,
never silently treated as dollars.
6. **Buy Box vs list price confusion.** `itemPrice` / list price is not the real selling
price and historically read 2852% high. Confirm every place a "price" is used for a
margin or comparison calc uses the realized/live price, not list price, and that the
two are never blended without a label.
7. **Stale-cache bugs.** Confirm `.scrape_cache.json` actually honors its 6h TTL and that
error/failed scrapes are never cached (a captcha or block should not freeze into a
6-hour hole of missing data).
8. **Fuzzy-match bugs.** Confirm colour fuzzy-matching requires one label to be a genuine
subset of the other (not just a shared word — "Ice Blue" must never match "Baby Blue"),
and that size is **never** fuzzed.
9. Run whatever existing offline tests exist (`test_matching.py`, `test_gap.py`,
`test_config.py`, `test_cosmos.py`, `test_workbook.py`) and fix anything failing.
Add new tests for any bug you fix so it can't silently return.
Report every bug found and fixed, with before/after evidence (a specific SKU/ASIN where
the output changed), not just "fixed it."
---
## Part 2 — Make the Competitors tab the single source of competitor truth
The output sheet (or dashboard tab, if this is being surfaced in the Streamlit app) must
show, per matched row:
- Our live scraped price **and** Buy Box status (`buybox` / `suppressed` / `unknown`) —
never silently substitute COSMOS's realized price without labeling it as a fallback.
- Each competitor's price, Buy Box winner, BSR (overall + sub-category, with the
sub-category name shown — ranks in different categories are not comparable and must
say so, never show a bare "we're #2 vs their #1" if the categories differ).
- `Bought past month` as a labeled lower bound, not a precise number.
- Whether a row is an exact match, a fuzzy match (flagged, both raw labels shown), or
unmatched (ours-only exclusive / their gap).
- A blank cell with a stated reason for anything we couldn't get — never a fabricated
or defaulted value. This is non-negotiable: a wrong number is worse than a blank one.
If any of this is missing or mislabeled today, fix it before wiring it into decisions —
the decision engine in Part 3 will consume exactly what this tab shows, so garbage here
becomes a wrong price recommendation there.
---
## Part 3 — Wire competitor data into the pricing decision, without breaking the
## deterministic core
Currently the pricing agent's decision cascade (`analyze.py` / the verdict logic) is
first-match-wins and competitor data is explicitly excluded from it — it only feeds a
separate "Competitors" display tab. Change this deliberately and narrowly:
1. **Add competitor-derived triggers to the cascade, don't replace any existing rule.**
Insert these checks in the existing ordered cascade (inventory risk still outranks
profit optimization — do not reorder that):
- Our Buy Box is **suppressed**`Investigate / BUYBOX_SUPPRESSED`. This is more
urgent than a profit-optimal reprice: a suppressed listing sells nothing at any price.
- We do **not** hold the Buy Box and a competitor is priced meaningfully below us
(define "meaningfully" as a config constant, not a magic number in the code) →
bias the recommendation toward matching/undercutting within the existing
guardrails (floor at break-even × 1.05, ceiling at current × 1.25, ±5% per step) —
never below break-even regardless of what a competitor charges.
- We hold the Buy Box and are priced well above the field with no elasticity
justification → this can support (not solely drive) a "consider raising" signal,
but must not override the elasticity-fit optimal price — surface it as a note in
the narrative, not a silent override.
- If competitor data is missing, stale (past scraper cache TTL), or the scrape
failed for a SKU → fall back to today's behavior exactly (competitor-blind
verdict). Never block or delay a verdict because competitor data isn't available.
2. **Keep it labeled and auditable.** Whatever triggers a rule must be traceable to a
single named condition, exactly like the existing cascade — no blended scores. Log
which rule fired for every SKU touched by this change.
3. **Do not let the LLM touch any of this.** `generate_narrative` may explain that a
competitor undercut us; it must not decide anything or compute any number.
4. **Reconcile the two scrapers.** Decide whether the Apify path or this Playwright
path is now authoritative for Buy Box/competitor state, or share one cache keyed by
ASIN+ZIP between them — do not let both run independently and disagree silently.
Whichever you keep, it must expose `suppressed` / `lost_price` / `won` state to the
decision engine, not just a raw price number.
5. **Test it against real history.** Before shipping, run a backtest (same style as the
61.9%→45.2% storage-fix backtest already done for this codebase) comparing verdicts
with and without the competitor rules on real SKUs, and report the delta — including
any SKU where the new rule changes the verdict, and why.
---
## Constraints that apply throughout
- Missing data is shown as "—" or an explicit Investigate verdict — never a fabricated
number, and never silently defaulted to zero.
- The pricing agent is advisory only. Nothing you build here writes a price back to
Amazon or COSMOS. `submit_price_approval` remains a stub unless explicitly asked
otherwise.
- Any new number that ends up in a scenario/recommendation must be traceable to a named
source field, exactly like the existing data dictionary — update the docs
(`ARCHITECTURE.md` / the README) with any new field or rule you add.
- Don't add scope beyond this: no new sixth COSMOS endpoint, no new speculative feature,
unless it's required to fix a bug or complete the wiring above.
## What to report back when done
1. List of bugs found and fixed, each with the specific evidence that proves the fix.
2. Diff of the decision cascade showing exactly where and how competitor rules were
inserted, and the backtest delta.
3. Screenshot or sample output of the corrected Competitors tab.
4. Any COSMOS/Amazon data gap you hit that blocks something in this list (call it out
explicitly rather than working around it with an assumption).

0
config/__init__.py Normal file
View File

90
config/pricing_rules.yaml Normal file
View File

@ -0,0 +1,90 @@
# Pricing rules — the encoded policy of the Pricing & Profitability Analyst playbook.
# All money math in tools/margin_engine.py reads these values. Change policy here,
# not in code.
# Contribution-margin floor. A SKU must clear this at the WORST-CASE referral fee
# or it does not get Pricing Approved. Playbook standard: 25% floor, 30%+ preferred.
margin_floor: 0.25
margin_target: 0.30
# Returns reserve, taken as a % of selling price (playbook: ~2%).
returns_reserve_pct: 0.02
# Allocated monthly storage per unit, flat $ (playbook: ~$0.25 for the sheet set).
storage_alloc: 0.25
# Worst-case referral fee % used for the margin GATE. Amazon Home & Kitchen = 15%.
# We always test the floor against this, never the optimistic modeled referral.
worst_case_referral_pct: 0.15
# Rounding for customer-facing prices (charm pricing to .99). Set to null to disable.
charm_ending: 0.99
# Price-competitiveness tolerance vs the competitive median (playbook KPI: within 5%).
price_competitiveness_tolerance: 0.05
# ── inventory cover: where a price move is triggered ────────────────────────────
# These are PRICING triggers: at or below `low` the engine raises 5% to slow the burn, at or
# above `high` it cuts 5% to clear stock. They were module constants in dashboard/live_data.py;
# they live here because they are policy, and because they need to be reconcilable with the
# COSMOS Inventory Planning bands that colour the same numbers on screen.
#
# THEY DELIBERATELY DO NOT EQUAL THE COSMOS BANDS. COSMOS's Alpha scheme calls 40-70 days
# "healthy" (green) and 70-100 "high" (pink), but pink there is a REPLENISHMENT warning — it
# tells a buyer not to reorder yet. It is not an instruction to discount, and the engine here
# spends real margin when it fires.
#
# Measured on 47 SKUs of the covered product line, on live COSMOS data:
#
# thresholds raise +5% cut -5% no action
# 35 / 90 (these) 6 20 21
# 40 / 70 (band edges) 7 26 14
# 20 / 100 (red bands) 2 16 29
#
# Adopting the band edges would put six more SKUs on a 5% discount — every one of them in the
# 70-90 day window, none of them actually at risk of ageing out. So the bands stay COSMOS's
# for display parity, the triggers stay where they are, and the UI states which is which.
# To converge them, set these to 40 / 70; nothing else needs to change.
low_cover_days: 35
high_cover_days: 90
# ── competitor-driven pricing rules ─────────────────────────────────────────────
# Master switch for the two competitor rules ONLY: BUYBOX_SUPPRESSED and
# COMPETITOR_UNDERCUT. Set false and the cascade behaves exactly as it did before competitor
# data existed — it routes through the SAME fail-safe path as missing/stale data, so there is
# only ever one "pretend competitor data isn't there" code path to reason about.
#
# This is NOT the global kill switch. That one lives in the sidebar (app.py) and pauses
# APPROVALS across the board without touching the cascade. The two are independent in both
# directions: you can disable competitor rules while approvals continue normally, and you can
# pause approvals while competitor rules keep informing the verdicts a human is reading.
#
# Every other cascade rule — cost data, break-even, ad losses, inventory, profit-optimal — is
# untouched by this flag.
competitor_rules_enabled: true
# These gate the COMPETITOR branches of the live decision cascade. They live here, not in
# code, because every one of them is a policy judgement someone may want to change without
# a deploy.
# How far below us a rival has to sit before "they undercut us" is a fact rather than noise.
# 3% is above the ~2% band our own realized price already swings through as coupons toggle
# (the mattress protector moves $11.71-$12.98 on its own), so anything under it cannot be
# distinguished from our own price wobble.
competitor_undercut_material_pct: 0.03
# How far ABOVE the rival field we have to sit before it is worth remarking on while we
# still hold the Buy Box. Only ever a narrative note -- it never moves a price on its own.
competitor_premium_material_pct: 0.10
# How old competitor state may be and still inform a verdict. Matched to the Playwright
# scraper's own 6h cache TTL so the two cannot disagree about what "stale" means. Past this
# the verdict is computed competitor-BLIND rather than on a price that has since moved.
competitor_state_max_age_hours: 6.0
# The comparison WORKBOOK gets its own, much longer limit. It is a 25-35 minute batch run over
# a whole product line, not a single-ASIN scrape, so nobody re-runs it hourly and the 6h rule
# above would make every sheet unusable by the next morning. Raise or lower this as the
# refresh cadence settles; set it to null to accept a sheet of any age.
competitor_sheet_max_age_hours: 168.0

129
config/settings.py Normal file
View File

@ -0,0 +1,129 @@
"""Central configuration loader.
Reads environment (.env) via pydantic-settings and the pricing policy from
config/pricing_rules.yaml. One import point for the whole app.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# Project root = parent of this config/ directory.
ROOT = Path(__file__).resolve().parent.parent
RULES_PATH = ROOT / "config" / "pricing_rules.yaml"
class PricingRules(BaseModel):
"""Typed view of pricing_rules.yaml."""
margin_floor: float = 0.25
margin_target: float = 0.30
returns_reserve_pct: float = 0.02
storage_alloc: float = 0.25
worst_case_referral_pct: float = 0.15
charm_ending: float | None = 0.99
price_competitiveness_tolerance: float = 0.05
# Inventory cover PRICING triggers — see the comments in pricing_rules.yaml for why these
# are not the COSMOS display bands.
low_cover_days: int = 35
high_cover_days: int = 90
# Competitor-driven cascade rules — see the comments in pricing_rules.yaml.
# Scoped kill switch for BUYBOX_SUPPRESSED + COMPETITOR_UNDERCUT only. Independent of the
# sidebar's global approval pause.
competitor_rules_enabled: bool = True
competitor_undercut_material_pct: float = 0.03
competitor_premium_material_pct: float = 0.10
competitor_state_max_age_hours: float = 6.0
competitor_sheet_max_age_hours: float | None = 168.0
class Settings(BaseSettings):
"""Environment-driven settings."""
model_config = SettingsConfigDict(
env_file=str(ROOT / ".env"), env_file_encoding="utf-8", extra="ignore"
)
# LLM
openai_api_key: str | None = None
openai_model: str = "gpt-4.1" # fast gate rationale
# Deep analysis uses a big reasoning model (gpt-5.1 default; gpt-5-pro for max depth)
openai_analysis_model: str = "gpt-5.1"
# none | low | medium | high ('minimal' is rejected by gpt-5.1).
# The model only EXPLAINS numbers the rules already computed — it never calculates or
# decides — so heavy reasoning buys nothing. Measured on one SKU: high 128s, medium 38s,
# low 9s. 'low' was also the only setting that respected the 200-word cap and kept the
# currency formatting, so it is both the fastest and the most accurate choice here.
openai_reasoning_effort: str = "low"
# Backends
data_backend: str = "cosmos" # cosmos | mock
tracker_backend: str = "csv" # csv | gsheets
# COSMOS API (primary data source)
cosmos_base_url: str = "https://cosmos-api.utopiadeals.com"
cosmos_email: str | None = None
cosmos_password: str | None = None
cosmos_marketplace: str = "AMAZON_USA"
cosmos_timeout_seconds: float = 30.0
# Google Sheets
google_application_credentials: str | None = None
tracker_sheet_id: str | None = None
tracker_worksheet: str = "master"
# CSV backend
tracker_csv_path: str = str(ROOT / "data" / "sample_skus.csv")
tracker_csv_out: str = str(ROOT / "data" / "sample_skus_out.csv")
audit_log_path: str = str(ROOT / "data" / "audit_log.csv")
# Mock Amazon fixtures
mock_fixtures_path: str = str(ROOT / "tests" / "fixtures" / "amazon_mock.json")
# Apify — public Amazon PDP / Buy Box / offers (fills competitive gap COSMOS lacks)
apify_token: str | None = None
apify_actor_id: str = "junglee/Amazon-crawler"
apify_seller_id: str | None = None # our Amazon merchant id; enables WON vs LOST_PRICE
apify_max_offers: int = 10
apify_zip_code: str = "10001"
apify_timeout_seconds: float = 300.0
# Cost is per actor RUN (container cold start ~30-45s), not per ASIN. Measured: 3 ASINs
# in one run = 46.8s vs ~145s scraped one-by-one. So batch, and cache the raw payload.
apify_batch_size: int = 20 # ASINs per actor run
apify_cache_ttl_seconds: float = 21600.0 # 6h; 0 disables the cache
apify_cache_path: str = str(ROOT / "data" / "apify_cache.json")
# Competitor comparison workbook — the sibling scraper's output, currently covering ONE
# product line. Leave blank to auto-discover the newest
# `Competitor_Price_Comparison_*.xlsx` in the directories below.
competitor_sheet_path: str | None = None
# Where to look when auto-discovering, newest file wins. Semicolon-separated.
competitor_sheet_dirs: str = ";".join([
str(ROOT / "competetor"),
str(ROOT.parent / "scraper"),
])
# When a sheet IS loaded, is it the ONLY competitor source?
#
# True (the default while a single line is under test) means a SKU the sheet does not cover
# gets an explicit N/A instead of falling through to a per-ASIN Apify scrape. That keeps the
# test honest -- every verdict either rests on the sheet or says it has no competitor data --
# and it stops a stray scrape quietly pricing a SKU the sheet was supposed to govern.
# Set False once coverage is broad enough for Apify to be a sensible fallback again.
competitor_sheet_only: bool = True
@lru_cache(maxsize=1)
def get_rules() -> PricingRules:
if RULES_PATH.exists():
data = yaml.safe_load(RULES_PATH.read_text(encoding="utf-8")) or {}
return PricingRules(**data)
return PricingRules()
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

1
dashboard/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Dashboard package: theme, demo data, and the live COSMOS data adapter."""

40
dashboard/line.py Normal file
View File

@ -0,0 +1,40 @@
"""Ranking rules for a product line.
Kept out of ``app.py`` so it can be imported and tested without executing the
Streamlit page. Deciding which 10 of 99 products to spend an hour analyzing is
real logic, not presentation.
"""
from __future__ import annotations
# Label shown in the picker → (field to sort on, descending?).
# Ordered by how often each is the right question to ask of a line.
LINE_SORTS: dict[str, tuple[str, bool]] = {
"Sales velocity (30d)": ("avg_30d", True),
"Inventory on hand": ("inventory", True),
"Cover days — lowest first": ("cover_days", False),
"Reorder size (min PO)": ("min_po_quantity", True),
"AZ": ("sku", False),
}
def line_sort_key(product: dict, field: str):
"""Sort key tolerating both text and numeric fields, and missing values.
Two things it has to get right:
* **Mixed types.** A single numeric key raises ``TypeError`` the moment the
user picks AZ, because it compares ``str`` against ``0``.
* **Missing values sort last, in either direction.** A SKU with no cover-days
reading is not the most urgent item in the line but a plain ``or 0``
makes it exactly that when sorting ascending.
"""
value = product.get(field)
if isinstance(value, str):
return (False, value.upper())
return (value is None, value if value is not None else 0)
def rank(products: list[dict], sort_label: str) -> list[dict]:
"""Order a line by one of `LINE_SORTS`, falling back to the first rule."""
field, reverse = LINE_SORTS.get(sort_label, next(iter(LINE_SORTS.values())))
return sorted(products, key=lambda p: line_sort_key(p, field), reverse=reverse)

1917
dashboard/live_data.py Normal file

File diff suppressed because it is too large Load Diff

154
dashboard/theme.py Normal file
View File

@ -0,0 +1,154 @@
"""Design tokens + plotly template — CRAI / Utopia Brands design system.
Palette lifted from crai.utopiabrands.com (auth.css / app.css): warm cream paper,
off-white cards, teal primary, coral accent, navy chrome, Inter type.
"""
import plotly.graph_objects as go
import plotly.io as pio
# --- brand (chrome only — never a data series color) ---
BRAND = "#0c8276" # primary teal (buttons, active states)
BRAND_DARK = "#0a6f65"
BRAND_SOFT = "#d9eee9" # tinted backgrounds (icons, active states)
CORAL = "#df4f33" # accent / danger
CORAL_SOFT = "#fbe6df"
NAVY = "#22304e" # dark chrome (logo gradient start)
PLUM = "#7a4a8c"
# --- chrome & ink (light mode, warm-cream look) ---
SURFACE = "#fffdf9" # cards + chart surface
PAGE = "#f4f0e8" # page plane (warm paper)
INK = "#1b2030"
INK2 = "#4a4f60"
MUTED = "#7c8092"
GRID = "#ece6d9"
AXIS = "#d6cfbf"
BORDER = "#e3ddd0"
DELTA_GOOD = "#0a6f65"
# --- categorical series (fixed slot order — color follows the entity) ---
SERIES = {
"us": "#22304e", # slot 1 navy: our price / our units
"competitor": "#df4f33", # slot 2 coral: competitor median
"alt": "#0c8276", # slot 3 teal: secondary measure
"gray": "#7c8092", # individual competitors (muted, non-slot)
}
US_BAND = "rgba(34,48,78,0.13)" # forecast p10p90 band fill
# --- inventory cover bands (COSMOS Inventory Planning) -----------------------------
# The same five bands and the same colours the INVP grid uses, so a number that reads green
# there does not read amber here. Alpha and Beta are COSMOS's own velocity classes and they
# band differently — a Beta SKU is judged against tighter thresholds than an Alpha.
#
# Purple = too little cover, green = healthy, red = too much. Colour is never the only
# signal: every use pairs it with the number and a word.
#
# These are DISPLAY bands. They are deliberately NOT the pricing thresholds
# (LOW_COVER_DAYS 35 / HIGH_COVER_DAYS 90 in live_data.py), which trigger price moves and
# are a separate policy question — COSMOS's own API default is a 60-day REPLENISHMENT
# target, not a pricing trigger. Keeping them apart is intentional; collapsing them would
# silently re-tune the cascade.
COVER_BANDS = {
"alpha": [(20, "#dcd0ec", "very low"), (40, "#b9a3dc", "low"),
(70, "#77e800", "healthy"), (100, "#fadbd9", "high"),
(None, "#fb5b57", "excess")],
"beta": [(15, "#dcd0ec", "very low"), (30, "#b9a3dc", "low"),
(60, "#77e800", "healthy"), (90, "#fadbd9", "high"),
(None, "#fb5b57", "excess")],
}
def cover_band(days: float | None, inv_class: str = "alpha") -> tuple[str, str]:
"""`(hex colour, label)` for a cover-days figure, on COSMOS's own banding.
An unknown cover gets a neutral swatch and the word "unknown" rather than a colour that
would imply a judgement we have not made.
"""
if days is None:
return (BORDER, "unknown")
bands = COVER_BANDS.get((inv_class or "alpha").lower(), COVER_BANDS["alpha"])
for upper, colour, label in bands:
if upper is None or days < upper:
return (colour, label)
return bands[-1][1], bands[-1][2]
# --- status palette (reserved; always icon + label, never color alone) ---
STATUS = {
"good": "#0c8276",
"warning": "#cf971f",
"serious": "#df4f33",
"critical": "#a83a24",
"neutral": "#7c8092",
}
CONFIDENCE = { # tier -> (status key, dot emoji for labels)
"High": ("good", "🟢"),
"Medium": ("warning", "🟡"),
"Low": ("serious", "🟠"),
"Insufficient": ("neutral", ""),
}
REC_STATUS = { # workflow status -> (status key, icon)
"Pending": ("warning", ""),
"Approved": ("good", ""),
"Rejected": ("neutral", "✖️"),
}
def chip(text: str, hexcolor: str) -> str:
"""Soft pill chip; text stays readable ink-on-tint (never color-alone meaning)."""
return (
f'<span style="background:{hexcolor}17;color:{INK};border:1px solid {hexcolor}55;'
f'border-radius:999px;padding:3px 11px;font-size:0.78rem;font-weight:600;'
f'white-space:nowrap;margin-right:6px;display:inline-block;margin-bottom:4px">{text}</span>'
)
def conf_badge(tier: str, score: int) -> str:
key, dot = CONFIDENCE[tier]
return chip(f"{dot} {tier} confidence ({score})", STATUS[key])
def status_badge(status: str) -> str:
key, icon = REC_STATUS[status]
return chip(f"{icon} {status}", STATUS[key])
def tile(icon: str, value: str, label: str, note: str = "",
accent: str | None = None) -> str:
"""Off-white stat card with a teal-tinted icon square (CRAI style).
`accent` tints the icon square only a band colour marks the reading without repainting
the card, so the tiles still read as one set and the colour never becomes the only signal
(the band's word travels in `note`).
"""
note_html = f'<div class="tl-n">{note}</div>' if note else ""
ic_style = f' style="background:{accent}"' if accent else ""
return (
f'<div class="tl"><div class="tl-ic"{ic_style}>{icon}</div>'
f'<div class="tl-v">{value}</div><div class="tl-l">{label}</div>{note_html}</div>'
)
def register_template() -> None:
tpl = go.layout.Template()
tpl.layout = go.Layout(
paper_bgcolor=SURFACE,
plot_bgcolor=SURFACE,
font=dict(family='Inter, system-ui, -apple-system, "Segoe UI", sans-serif',
color=INK, size=12),
colorway=[SERIES["us"], SERIES["competitor"], SERIES["alt"]],
xaxis=dict(gridcolor=GRID, linecolor=AXIS, zerolinecolor=AXIS, tickcolor=AXIS,
tickfont=dict(color=MUTED)),
yaxis=dict(gridcolor=GRID, linecolor=AXIS, zerolinecolor=AXIS, tickcolor=AXIS,
tickfont=dict(color=MUTED)),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
font=dict(color=INK2, size=11)),
margin=dict(l=10, r=10, t=42, b=10),
hovermode="x unified",
hoverlabel=dict(bgcolor=SURFACE, font=dict(color=INK, size=11), bordercolor=AXIS),
)
pio.templates["pricing"] = tpl
pio.templates.default = "pricing"

1
data/apify_cache.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,205 @@
[
{
"sku": "UHHANGERPLASTICWHITE50",
"asin": "B06X421WJ6",
"our_price": 22.989999771118164,
"status": "WON",
"buy_box_price": 22.99,
"buy_box_seller": "Utopia Brands",
"offers": [
[
"Utopia Brands",
22.99,
true,
true
],
[
"Amazon Resale",
22.53,
false,
false
],
[
"Amazon Resale",
22.76,
false,
false
],
[
"Utopia Brands",
29.99,
true,
false
],
[
"Amazon.com",
30.99,
false,
false
]
],
"third_party": [
[
"Amazon Resale",
22.53,
false,
false
],
[
"Amazon Resale",
22.76,
false,
false
],
[
"Amazon.com",
30.99,
false,
false
]
],
"competitor_min": 30.99,
"undercut_pct": -0.34797739489028007,
"usable": true,
"unusable_because": null,
"reason": "Featured offer $22.99; seller=Utopia Brands; seller_id=A3AQP8TDYVYCGL; we hold the Featured Offer. Competitors: Amazon Resale @ $22.53 (Used - Very Good); Amazon Resale @ $22.76 (Used - Like New); Amazon.com @ $30.99 Our own offers: Utopia Brands @ $22.99 [Buy Box]; Utopia Brands @ $29.99"
},
{
"sku": "UBPILLOWSQUARECOTTONCOVER18X18",
"asin": "B0714K41PB",
"our_price": 23.889999389648438,
"status": "WON",
"buy_box_price": 23.89,
"buy_box_seller": "Utopia Brands",
"offers": [
[
"Utopia Brands",
23.89,
true,
true
],
[
"Utopia Brands",
28.89,
true,
false
]
],
"third_party": [],
"competitor_min": null,
"undercut_pct": null,
"usable": true,
"unusable_because": null,
"reason": "Featured offer $23.89; seller=Utopia Brands; seller_id=A3AQP8TDYVYCGL; we hold the Featured Offer. Competitors: none \u2014 we are the only seller on this ASIN. Our own offers: Utopia Brands @ $23.89 [Buy Box]; Utopia Brands @ $28.89"
},
{
"sku": "UBCFKMATTRESSPADQUEEN",
"asin": "B00NESCOY0",
"our_price": 20.989999771118164,
"status": "WON",
"buy_box_price": 20.99,
"buy_box_seller": "Utopia Brands",
"offers": [
[
"Utopia Brands",
20.99,
true,
true
]
],
"third_party": [],
"competitor_min": null,
"undercut_pct": null,
"usable": true,
"unusable_because": null,
"reason": "Featured offer $20.99; seller=Utopia Brands; seller_id=A3AQP8TDYVYCGL; we hold the Featured Offer. Competitors: none \u2014 we are the only seller on this ASIN. Our own offers: Utopia Brands @ $20.99 [Buy Box]"
},
{
"sku": "UBCFKENCASEMENTMATTRESSQUEEN10",
"asin": "B00U6HREPQ",
"our_price": 22.489999771118164,
"status": "WON",
"buy_box_price": 22.49,
"buy_box_seller": "Utopia Brands",
"offers": [
[
"Utopia Brands",
22.49,
true,
true
],
[
"Amazon Resale",
21.37,
false,
false
],
[
"Utopia Brands",
26.99,
true,
false
]
],
"third_party": [
[
"Amazon Resale",
21.37,
false,
false
]
],
"competitor_min": null,
"undercut_pct": null,
"usable": true,
"unusable_because": null,
"reason": "Featured offer $22.49; seller=Utopia Brands; seller_id=A3AQP8TDYVYCGL; we hold the Featured Offer. Competitors: Amazon Resale @ $21.37 (Used - Like New) Our own offers: Utopia Brands @ $22.49 [Buy Box]; Utopia Brands @ $26.99"
},
{
"sku": "UBCOMFORTERQUEENWHITE2",
"asin": "B00S1TC442",
"our_price": 29.979999542236328,
"status": "SUPPRESSED",
"buy_box_price": null,
"buy_box_seller": "Utopia Brands",
"offers": [
[
"Utopia Brands",
null,
true,
true
],
[
"Utopia Brands",
19.49,
true,
false
],
[
"Utopia Brands",
29.98,
true,
false
],
[
"TJXD",
30.88,
false,
false
]
],
"third_party": [
[
"TJXD",
30.88,
false,
false
]
],
"competitor_min": 30.88,
"undercut_pct": -0.03002002906957137,
"usable": true,
"unusable_because": null,
"reason": "No featured/Buy Box price on the PDP. Competitors: TJXD @ $30.88 Our own offers: Utopia Brands @ n/a [Buy Box]; Utopia Brands @ $19.49 (Used - Like New); Utopia Brands @ $29.98"
}
]

5
data/sample_skus.csv Normal file
View File

@ -0,0 +1,5 @@
SKU,ASIN,Product Name,Marketplace,Category,Landed Cost,Target Selling Price,Referral Fee %
UBMICROFIBER4PCQUEENGREY,B0MICROFIBERQ,Microfiber 4 Piece Queen Sheet Set - Grey,US,Home & Kitchen,8.00,24.99,12
UBTHINMARGINPILLOW,B0THINMARGIN,Memory Foam Pillow Standard,US,Home & Kitchen,7.50,17.49,15
UBSUPPRESSEDTOWEL,B0SUPPRESSED,Cotton Bath Towel 6 Pack,US,Home & Kitchen,9.00,29.99,15
UBLOSTBOXSHEET,B0LOSTPRICE,Microfiber Sheet Set King - White,US,Home & Kitchen,9.50,24.99,15
1 SKU ASIN Product Name Marketplace Category Landed Cost Target Selling Price Referral Fee %
2 UBMICROFIBER4PCQUEENGREY B0MICROFIBERQ Microfiber 4 Piece Queen Sheet Set - Grey US Home & Kitchen 8.00 24.99 12
3 UBTHINMARGINPILLOW B0THINMARGIN Memory Foam Pillow Standard US Home & Kitchen 7.50 17.49 15
4 UBSUPPRESSEDTOWEL B0SUPPRESSED Cotton Bath Towel 6 Pack US Home & Kitchen 9.00 29.99 15
5 UBLOSTBOXSHEET B0LOSTPRICE Microfiber Sheet Set King - White US Home & Kitchen 9.50 24.99 15

843
legacy_app.py Normal file
View File

@ -0,0 +1,843 @@
"""Pricing & Profitability Analyst — web UI.
Three workflows: one SKU, a product line, or a catalog-wide money-at-risk scan.
Verdicts come from deterministic rules over COSMOS's actual profit; the language
model only writes the explanation. Read-only nothing is ever written back.
Run: streamlit run app.py
"""
from __future__ import annotations
import sys
from pathlib import Path
# Make src/ + project root importable when launched via `streamlit run`.
ROOT = Path(__file__).resolve().parent
for p in (str(ROOT / "src"), str(ROOT)):
if p not in sys.path:
sys.path.insert(0, p)
import logging
import streamlit as st
from config.settings import get_settings
from pricing_agent.analyze import (
AnalysisResult,
_build_service,
analyze_price,
analyze_sku,
generate_analysis_narrative,
)
from pricing_agent.fmt import usd
from pricing_agent.logging_config import ListHandler, setup_logging
setup_logging() # console logs (visible in the terminal running streamlit)
st.set_page_config(page_title="Pricing & Profitability Analyst", page_icon="",
layout="wide", initial_sidebar_state="collapsed")
PRODUCT_LINE_LIMIT = 15 # default number of SKUs analyzed per product-line request
TONE = {"GOOD": "ok", "CAUTION": "warn", "POOR": "bad"}
# Ordered exactly like the FBA Profitability Calculator.
FEE_ORDER = [
("itemPrice", "Item price"), ("referralFee", "Amazon referral fee"),
("fbaFee", "FBA fee"), ("lowInventoryFee", "Low inventory fee"),
("inboundPlacementFee", "Inbound placement fee"), ("returnFee", "Return fee"),
("eprFee", "EPR fee"), ("vat", "VAT"), ("costSubtotal", "Cost subtotal"),
("marginImpact", "Margin impact"), ("logisticsCost", "Logistics cost"),
("dutyAmount", "Duty amount"), ("costPerUnit", "Cost per unit"),
("orderHandling", "Order handling"), ("weightHandling", "Weight handling"),
("warehouseExpense", "Warehouse expense"),
]
# Ordered like the FBA Bulk Calculator.
BULK_ORDER = [
("itemPrice", "Item price"), ("saleUnits", "Sales units"), ("marketing", "Marketing"),
("inventory", "Inventory"), ("storageCharges", "Storage charges"),
("takeHome", "Take home"), ("takeHomePerUnit", "Take home per unit"),
("referralFee", "Referral fee"), ("fbaFee", "FBA fee"),
("lowInventoryFee", "Low inventory fee"), ("inboundPlacementFee", "Inbound placement fee"),
("returnFee", "Return fee"), ("costPerUnit", "Cost per unit"), ("vat", "VAT"),
]
_UNIT_FIELDS = {"saleUnits", "inventory"}
CSS = """
<style>
:root{
--ink:#111827; --ink-2:#374151; --muted:#6b7280; --faint:#9ca3af;
--line:#e5e7eb; --line-2:#f0f1f3; --paper:#ffffff; --soft:#f8f9fb;
--accent:#1d4ed8;
--ok:#15803d; --ok-bg:#f0fdf4; --ok-line:#bbf7d0;
--warn:#b45309; --warn-bg:#fffbeb; --warn-line:#fde68a;
--bad:#b91c1c; --bad-bg:#fef2f2; --bad-line:#fecaca;
}
.block-container{max-width:1180px; padding-top:2.4rem; padding-bottom:5rem;}
html,body,[data-testid="stAppViewContainer"]{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif;
color:var(--ink);
}
#MainMenu,footer{visibility:hidden;}
/* Masthead */
.masthead{border-bottom:2px solid var(--ink); padding-bottom:.7rem; margin-bottom:.6rem;}
.mh-title{font-size:1.5rem; font-weight:640; letter-spacing:-.022em; line-height:1.2;}
.mh-sub{font-size:.82rem; color:var(--muted); margin-top:.25rem;}
/* Section label */
.sec{font-size:.7rem; font-weight:680; letter-spacing:.1em; text-transform:uppercase;
color:var(--faint); margin:1.9rem 0 .55rem; padding-bottom:.35rem;
border-bottom:1px solid var(--line);}
.sec:first-child{margin-top:.4rem;}
/* Status pill */
.pill{display:inline-block; font-size:.68rem; font-weight:700; letter-spacing:.075em;
text-transform:uppercase; padding:.22rem .55rem; border-radius:3px; border:1px solid;
vertical-align:.14em;}
.pill.ok{color:var(--ok); background:var(--ok-bg); border-color:var(--ok-line);}
.pill.warn{color:var(--warn); background:var(--warn-bg); border-color:var(--warn-line);}
.pill.bad{color:var(--bad); background:var(--bad-bg); border-color:var(--bad-line);}
.pill.mute{color:var(--muted); background:var(--soft); border-color:var(--line);}
/* Record header (SKU) */
.rec{display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; margin-bottom:.15rem;}
.rec .sku{font-size:1.12rem; font-weight:640; letter-spacing:-.01em;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
.rec-meta{font-size:.76rem; color:var(--faint); margin-bottom:1rem;}
/* Notes / banners */
.note{border:1px solid var(--line); border-left:3px solid var(--faint); background:var(--soft);
padding:.7rem .9rem; border-radius:0 4px 4px 0; font-size:.88rem; margin:.5rem 0 .9rem;
line-height:1.55;}
.note.bad{border-left-color:var(--bad); background:var(--bad-bg); border-color:var(--bad-line);}
.note.warn{border-left-color:var(--warn); background:var(--warn-bg); border-color:var(--warn-line);}
.note.ok{border-left-color:var(--ok); background:var(--ok-bg); border-color:var(--ok-line);}
.note.verdict{border-left-color:var(--ink); background:var(--paper); border-color:var(--line);}
.note b{font-weight:640;}
/* Stat grid */
.stats{display:grid; gap:0; border:1px solid var(--line); border-radius:5px; overflow:hidden;
grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); background:var(--line);}
.stat{background:var(--paper); padding:.75rem .9rem .8rem;}
.stat .k{font-size:.68rem; font-weight:600; letter-spacing:.055em; text-transform:uppercase;
color:var(--faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.stat .v{font-size:1.28rem; font-weight:620; letter-spacing:-.018em; margin-top:.28rem;
font-variant-numeric:tabular-nums;}
.stat .s{font-size:.72rem; color:var(--muted); margin-top:.15rem; font-variant-numeric:tabular-nums;}
.stat .v.pos,.stat .s.pos{color:var(--ok);}
.stat .v.neg,.stat .s.neg{color:var(--bad);}
/* Ledger (profit bridge, fee cards) */
.ledger{border:1px solid var(--line); border-radius:5px; padding:.15rem .95rem;}
.lrow{display:flex; justify-content:space-between; align-items:baseline; gap:1rem;
padding:.5rem 0; border-bottom:1px solid var(--line-2); font-size:.87rem;}
.lrow:last-child{border-bottom:none;}
.lrow .lbl{color:var(--ink-2);}
.lrow .hint{color:var(--faint); font-size:.74rem; margin-left:.35rem;}
.lrow .val{font-variant-numeric:tabular-nums; font-weight:560; white-space:nowrap;}
.lrow.sub .lbl,.lrow.sub .val{color:var(--faint); font-size:.74rem;}
.lrow.total{border-top:1px solid var(--ink); border-bottom:none; margin-top:.1rem;}
.lrow.total .lbl{font-weight:660; color:var(--ink);}
.lrow.total .val{font-weight:700; font-size:1.02rem;}
.lrow.total .val.pos{color:var(--ok);} .lrow.total .val.neg{color:var(--bad);}
/* Reasons */
.reasons{border:1px solid var(--line); border-radius:5px; padding:.3rem 0; counter-reset:r;}
.reason{display:flex; gap:.75rem; padding:.55rem .95rem; font-size:.87rem; line-height:1.5;
border-bottom:1px solid var(--line-2);}
.reason:last-child{border-bottom:none;}
.reason .n{counter-increment:r; color:var(--faint); font-variant-numeric:tabular-nums;
font-weight:640; min-width:1.1rem;}
/* Summary rows (product line) */
.srow{display:flex; align-items:baseline; gap:.7rem; padding:.6rem .2rem;
border-bottom:1px solid var(--line-2); font-size:.86rem;}
.srow .sku{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-weight:600;
min-width:16rem;}
.srow .body{color:var(--ink-2); line-height:1.5;}
.srow .body b,.srow .body .b{font-weight:640; color:var(--ink);}
.srow .body .neg{color:var(--bad); font-weight:640;}
.caption{font-size:.76rem; color:var(--muted); line-height:1.55; margin-top:.45rem;}
/* Streamlit widget restyle */
[data-testid="stVerticalBlockBorderWrapper"]:has(>div>[data-testid="stVerticalBlock"]){
border-radius:6px;}
.stButton>button{border-radius:5px; font-weight:560; font-size:.87rem; border:1px solid var(--line);
padding:.4rem .9rem; transition:none;}
.stButton>button:hover{border-color:var(--ink-2); color:var(--ink);}
.stButton>button[kind="primary"]{background:var(--ink); border-color:var(--ink); color:#fff;}
.stButton>button[kind="primary"]:hover{background:var(--accent); border-color:var(--accent);}
.stTextInput input,.stNumberInput input,.stSelectbox div[data-baseweb="select"]>div{
border-radius:5px; font-size:.88rem;}
[data-testid="stTable"] table{font-size:.82rem; border-collapse:collapse;}
[data-testid="stTable"] thead th{font-size:.67rem; font-weight:660; letter-spacing:.06em;
text-transform:uppercase; color:var(--faint); border-bottom:1px solid var(--ink)!important;
background:transparent;}
[data-testid="stTable"] tbody td{border-bottom:1px solid var(--line-2)!important;
font-variant-numeric:tabular-nums;}
[data-testid="stTable"] tbody tr:hover{background:var(--soft);}
[data-testid="stExpander"] summary{font-size:.84rem; font-weight:560;}
[data-testid="stExpander"] details{border-radius:5px; border-color:var(--line);}
.stTabs [data-baseweb="tab"]{font-size:.85rem; font-weight:560;}
[data-testid="stMetricValue"]{font-size:1.3rem; font-weight:620;}
hr{margin:1.6rem 0; border-color:var(--line);}
</style>
"""
# ── Rendering primitives ───────────────────────────────────────────────
def _html(markup: str) -> None:
"""Emit raw HTML. '$' is entity-escaped so Streamlit's markdown pass
doesn't mistake dollar amounts for inline LaTeX."""
st.markdown(markup.replace("$", "&#36;"), unsafe_allow_html=True)
def _md(s) -> str:
"""Escape '$' for plain Streamlit markdown (same LaTeX problem)."""
return str(s).replace("$", "\\$")
_money = usd # accounting style: the sign leads the symbol (-$3.55, never $-3.55)
def _tone(v) -> str:
try:
return "neg" if float(v) < 0 else "pos"
except (TypeError, ValueError):
return ""
def _sec(title: str) -> None:
_html(f'<div class="sec">{title}</div>')
def _note(kind: str, body: str) -> None:
_html(f'<div class="note {kind}">{body}</div>')
def _pill(rating: str) -> str:
return f'<span class="pill {TONE.get(rating, "mute")}">{rating.title()}</span>'
def _stats(items) -> None:
"""items: (label, value, sub|None, tone|None)."""
cells = ""
for label, value, sub, tone in items:
t = f" {tone}" if tone else ""
s = f'<div class="s{t}">{sub}</div>' if sub else ""
cells += f'<div class="stat"><div class="k">{label}</div><div class="v{t}">{value}</div>{s}</div>'
_html(f'<div class="stats">{cells}</div>')
def _ledger(rows) -> str:
"""rows: (label, hint|None, value, is_total)."""
out = ""
for label, hint, value, total in rows:
cls = "lrow total" if total else "lrow"
vt = f" {_tone(value)}" if total else ""
h = f'<span class="hint">{hint}</span>' if hint else ""
out += (f'<div class="{cls}"><span class="lbl">{label}{h}</span>'
f'<span class="val{vt}">{_money(value)}</span></div>')
return f'<div class="ledger">{out}</div>'
def _calc_card(detail: dict, order, highlight: str) -> str:
"""Field → value ledger; the highlighted total is always rendered last."""
rows = ""
for key, label in order:
if key == highlight or key not in detail or detail[key] is None:
continue
v = detail[key]
disp = f"{float(v):,.0f}" if key in _UNIT_FIELDS else _money(v)
rows += f'<div class="lrow"><span class="lbl">{label}</span><span class="val">{disp}</span></div>'
cb = detail.get("costBreakdown")
if isinstance(cb, dict) and cb:
parts = " · ".join(f"{k} {_money(v)}" for k, v in cb.items())
rows += (f'<div class="lrow"><span class="lbl">Cost breakdown</span>'
f'<span class="val">{_money(sum(cb.values()))}</span></div>'
f'<div class="lrow sub"><span class="lbl">{parts}</span><span class="val"></span></div>')
if highlight in detail and detail[highlight] is not None:
label = next((l for k, l in order if k == highlight), "Take home")
v = detail[highlight]
rows += (f'<div class="lrow total"><span class="lbl">{label}</span>'
f'<span class="val {_tone(v)}">{_money(v)}</span></div>')
return f'<div class="ledger">{rows}</div>'
# ── Services ───────────────────────────────────────────────────────────
@st.cache_resource
def get_service():
return _build_service(get_settings())
@st.cache_data(ttl=3600, show_spinner="Loading product catalog (one-time)…")
def all_catalog_skus() -> list[str]:
"""Full catalog SKU list (demand-ordered), cached for the session."""
return get_service().list_all_skus()
# ── Result view ────────────────────────────────────────────────────────
def render_result(r: AnalysisResult, defer_narrative: bool = False):
"""Render one analysed SKU. With `defer_narrative`, the Analysis section reserves a
slot and returns it, so the caller can render the numbers immediately and fill in the
(slower) written analysis afterwards."""
n_months = len(r.price_performance)
slot = None
_html(f'<div class="rec"><span class="sku">{r.sku}</span>{_pill(r.rating.value)}</div>'
f'<div class="rec-meta">'
+ (f"ASIN {r.asin} &nbsp;·&nbsp; " if r.asin else "")
+ f"Evaluated at {_money(r.price)}</div>")
# Actual results outrank any modelled margin, so they open the record.
if r.actual_profit_per_unit is not None and r.actual_profit_per_unit < 0:
_note("bad", f"<b>Actually losing money — {_money(r.actual_profit_per_unit)} per unit.</b> "
"Real COSMOS profit over the last 3 months, including storage, refunds, promo "
"and ads."
+ (f" Lost money in <b>{r.unprofitable_months} of {n_months}</b> observed months."
if r.unprofitable_months else ""))
elif r.unprofitable_months >= 3:
_note("bad", f"<b>Unprofitable in {r.unprofitable_months} of the last {n_months} months</b> "
"on actual profit.")
elif r.is_losing_money:
_note("bad", f"<b>Losing money after ads.</b> Net {_money(r.net_per_unit_incl_ad)} per unit at "
f"{_money(r.price)} once {_money(r.ad_cost_per_unit)} of ad spend is counted.")
_note("verdict", f"<b>Verdict.</b> {r.headline}")
delta = (f"{r.suggested_price - r.current_price:+.2f} vs current"
if (r.suggested_price and r.current_price) else None)
_stats([
("Current price", _money(r.current_price), "Live, from COSMOS", None),
("Suggested", _money(r.suggested_price), delta or "", None),
("Break-even", _money(r.break_even), "Excludes ads", None),
("Margin", f"{r.margin_pct*100:.1f}%", "Pre-ads", None),
("Take-home / unit", _money(r.take_home_per_unit), "Pre-ads", None),
])
if r.competitive is not None or r.gate_decision:
_sec("Buy Box & competitors — live Amazon (Apify)")
comp = r.competitive
gate_tone = {"BLOCKED": "neg", "NEEDS_REVIEW": "neg"}.get(r.gate_decision or "")
stats = []
if r.gate_decision:
stats.append(("Pricing gate", r.gate_decision, "Deterministic rules", gate_tone))
if comp is not None:
stats.append((
"Buy Box",
comp.buy_box_status.value,
(f"{comp.buy_box_seller_name or ''} @ {_money(comp.buy_box_price)}"
if comp.buy_box_price is not None
else (comp.reason[:80] if comp.reason else "")),
"neg" if comp.is_suppressed else None,
))
if comp.competitive_median is not None:
stats.append((
"Market band",
_money(comp.competitive_median),
f"{_money(comp.competitive_low)} {_money(comp.competitive_high)}",
None,
))
stats.append((
"Offers",
str(len(comp.offers)),
"Named sellers on this ASIN",
None,
))
if stats:
_stats(stats)
# COSMOS current vs live Amazon featured — common source of “UI ≠ presentation”
if comp is not None and comp.buy_box_price is not None:
ref = r.current_price if r.current_price is not None else r.price
if ref and abs(comp.buy_box_price - ref) / ref >= 0.05:
_note(
"warn",
f"<b>Price mismatch.</b> COSMOS/analyzed {_money(ref)} but Amazon Buy Box "
f"is {_money(comp.buy_box_price)} "
f"({comp.buy_box_seller_name or 'seller'}). "
f"Margin at the COSMOS price can look fine while the live listing is cheaper. "
f"Check <b>Test a specific price</b> and re-run at {_money(comp.buy_box_price)} "
f"(or your candidate, e.g. $24.99) to match presentation-style reasoning."
)
if r.gate_reasons:
for gr in r.gate_reasons:
_note("warn" if r.gate_decision == "NEEDS_REVIEW"
else ("bad" if r.gate_decision == "BLOCKED" else "ok"),
f"<b>Gate.</b> {gr}")
if comp is not None and comp.offers:
st.table([{
"Seller": o.seller_name,
"Price": _money(o.price) if o.price is not None else "",
"Buy Box": "Yes" if o.is_buy_box else "",
"Condition": o.condition or "",
} for o in comp.offers])
_html('<div class="caption">Sellers and prices scraped from the ASIN product page '
'(Apify). Same listing — other merchants competing for the Buy Box — not rival '
'product ASINs.</div>')
elif comp is not None and not comp.offers:
_note("warn", "Apify returned no named competitor offers this scrape "
"(Buy Box may be suppressed or Amazon blocked the offers panel).")
if r.ad_cost_per_unit is not None:
_sec("Observed evidence — what actually happened")
_stats([
("Best observed price", _money(r.best_observed_price),
(f"{r.best_observed_price - r.price:+.2f} vs current"
if (r.best_observed_price and r.price) else "Highest real profit/day"), None),
("Actual profit / day there", _money(r.best_observed_profit_day), "At that price",
_tone(r.best_observed_profit_day)),
("Actual profit / unit", _money(r.actual_profit_per_unit), "Last 3 months",
_tone(r.actual_profit_per_unit)),
("Ad cost / unit", _money(r.ad_cost_per_unit), "6-mo spend ÷ units", None),
])
# Reconciles COSMOS take-home with real profit — without it, +$4.30 and
# $1.90 look contradictory.
if r.ad_cost_per_unit is not None and r.actual_profit_per_unit is not None:
_sec("Profit bridge — why take-home is not real profit")
_html(_ledger([
("Take-home / unit", "price referral FBA inbound returns COGS",
r.take_home_per_unit, False),
("Less ad spend / unit", "not included in take-home", -(r.ad_cost_per_unit or 0), False),
("Net after ads", "modelled", r.net_per_unit_incl_ad, True),
("Less refunds, promo, storage, logistics", "also not in take-home",
-(r.unmodeled_cost_gap or 0), False),
("Actual profit / unit", "COSMOS's real profit field", r.actual_profit_per_unit, True),
]))
if r.unmodeled_cost_gap and r.unmodeled_cost_gap > 0.25:
_note("warn", f"The modelled margin <b>overstates profit by {_money(r.unmodeled_cost_gap)} "
"per unit</b> (unmodeled storage, refunds, promo, logistics). Trust the "
"actual figures.")
if r.reasons:
_sec("Why this verdict — every reason")
rows = "".join(f'<div class="reason"><span class="n">{i}</span><span>{reason}</span></div>'
for i, reason in enumerate(r.reasons, 1))
_html(f'<div class="reasons">{rows}</div>'
'<div class="caption">These are the exact deterministic rule outputs that produced '
'the verdict — no AI involved. The AI analysis only explains them.</div>')
if r.narrative:
_sec("Analysis")
with st.container(border=True):
st.markdown(_md(r.narrative))
elif defer_narrative:
_sec("Analysis")
slot = st.empty()
slot.caption("Writing the analysis…")
if r.price_performance:
_sec("Price performance — actual, by month")
best = max(r.price_performance, key=lambda m: m["actual_profit_per_day"])
st.table([{
"Month": m["month"],
"Price": _money(m["avg_price"]),
"Units/day": f"{m['units_per_day']:,.0f}",
"Actual profit/day": _money(m["actual_profit_per_day"]),
"Profit/unit": _money(m["profit_per_unit"]),
"Ad/unit": _money(m["ad_per_unit"]),
"": "◀ best" if m is best else "",
} for m in r.price_performance])
_html('<div class="caption">Real COSMOS profit, including storage, refunds, promo and ads. '
'Months confound seasonality, ad spend, promotions and stock availability — read this '
'as evidence in context, not proof of causation.</div>')
_sec("Demand & inventory")
_stats([
("7-day", f"{(r.avg_7d or 0):,.0f}/day", None, None),
("30-day", f"{(r.avg_30d or 0):,.0f}/day", None, None),
("6-month", f"{(r.avg_6m or 0):,.0f}/day", r.trend.title(), None),
("Inventory", f"{r.inventory:,.0f}",
f"{r.cover_days} days cover" if r.cover_days is not None else None, None),
])
if r.monthly_take_home is not None:
_html(f'<div class="caption">Estimated monthly take-home {_money(r.monthly_take_home)}, '
f'after {_money(r.storage_charges)} of storage on current stock.</div>')
with st.expander("Evidence used — verify the numbers"):
st.markdown(_md(
f"- **Analyzed at** {_money(r.price)} · **current price** {_money(r.current_price)}\n"
f"- **Take-home/unit** (COSMOS, excl. ads) {_money(r.take_home_per_unit)} · "
f"**margin** {r.margin_pct*100:.1f}% · **break-even** {_money(r.break_even)}\n"
f"- **Ad cost/unit** {_money(r.ad_cost_per_unit)} · "
f"**net after ads (modelled)** {_money(r.net_per_unit_incl_ad)}\n"
f"- **Actual profit/unit**, last 3 months: {_money(r.actual_profit_per_unit)} · "
f"**unprofitable months** {r.unprofitable_months}/{n_months}\n"
f"- **Best observed price** {_money(r.best_observed_price)}"
f"{_money(r.best_observed_profit_day)}/day actual profit\n"
f"- **Model overstates profit by** {_money(r.unmodeled_cost_gap)}/unit\n"
f"- **6-mo demand** {(r.avg_6m or 0):,.0f}/day ({r.trend}, {r.demand_change_pct}% vs 6-mo) · "
f"**inventory** {r.inventory:,.0f} ({r.cover_days} days cover)"))
# The model is secondary to observed evidence, so it stays collapsed.
if r.demand_curve:
with st.expander("Model — elasticity & profit curve (directional only)"):
if r.elasticity and r.elasticity.get("elasticity") is not None:
e = r.elasticity
st.caption(f"Elasticity {e['elasticity']} · confidence {e['confidence']} · "
f"r²={e.get('r2')} over {e['n']} months, price moved "
f"{e.get('price_spread_pct')}%.")
st.table([{
"Price": _money(s["price"]),
"Exp. units/day": f"{s['units_per_day']:,.0f}",
"Net/unit after ad": _money(s["net_per_unit"]),
"Modelled daily profit": _money(s["daily_profit"]),
"": "◀ optimum" if (r.profit_optimal_price
and abs(s["price"] - r.profit_optimal_price) < 0.01) else "",
} for s in r.demand_curve])
st.caption("A model, not evidence. "
+ ("The optimum lies beyond the observed price range — treat it as a "
"direction, never an exact target." if r.extrapolated
else "The optimum is within the observed price range.")
+ " The actual-performance table above outranks it.")
elif r.scenarios:
_sec("Price scenarios — volume held constant")
st.table([{"Price": _money(s["price"]), "Margin": f"{s['margin_pct']*100:.1f}%",
"Take-home/unit": _money(s["take_home_unit"]),
"Monthly profit": _money(s["monthly_profit_held"]),
"": "◀ current" if s["is_current"] else ""} for s in r.scenarios])
_html('<div class="caption">At current volume — elasticity is not available for this SKU.</div>')
if r.fees_detail or r.bulk_detail:
_sec("Source calculations")
cols = st.columns(2, gap="medium")
if r.fees_detail:
with cols[0]:
st.caption("FBA profitability")
_html(_calc_card(r.fees_detail, FEE_ORDER, highlight="takeHome"))
_html('<div class="caption">This “take home” excludes ad spend, refunds, promo and '
'logistics — see the profit bridge for real per-unit profit.</div>')
if r.bulk_detail:
with cols[1]:
st.caption("Bulk — one month against current stock")
_html(_calc_card(r.bulk_detail, BULK_ORDER, highlight="takeHome"))
return slot
# ── Workflows ──────────────────────────────────────────────────────────
@st.cache_data(ttl=1800, show_spinner=False)
def sku_history(sku: str):
"""Six months of daily actuals for one SKU. Cached: the twelve windows behind it are
the single most expensive call in the app, and re-analysing a SKU is common."""
return get_service().get_sales_history(sku, days=180)
def run_single(sku: str, price: float | None) -> None:
"""Full evidence-based analysis of one SKU (at a given price, or its current price).
The numbers render as soon as they exist; the written analysis lands afterwards, so
the page is useful in seconds rather than after the model finishes.
"""
settings, svc = get_settings(), get_service()
at = f"at ${price:.2f}" if price else "at its current price"
with_competitive = bool(settings.apify_token)
with st.status(f"Analyzing {sku} {at}", expanded=True) as status:
status.write("Six-month actual profit history, ad cost and best observed price")
history = sku_history(sku)
status.write(f"Loaded {len(history)} days of history")
status.write("Fees, landed cost, current price, demand and inventory")
if with_competitive:
status.write("Buy Box + competitor offers via Apify (may take ~3090s)")
kw = dict(svc=svc, with_narrative=False, with_elasticity=True, history=history,
with_competitive=with_competitive)
r = (analyze_price(sku, price, settings, **kw) if price
else analyze_sku(sku, settings, **kw))
status.update(label=f"{sku}{r.rating.value.title()}"
+ (f" · gate {r.gate_decision}" if r.gate_decision else ""),
state="complete", expanded=False)
slot = render_result(r, defer_narrative=True)
if slot is not None:
r.narrative = generate_analysis_narrative(r)
with slot.container(border=True):
st.markdown(_md(r.narrative))
def run_catalog_scan(days: int) -> None:
"""Catalog-wide money-at-risk scan using COSMOS's ACTUAL profit. Read-only."""
from pricing_agent.performance import fix_suggestion, loss_reason, root_cause_split
svc = get_service()
with st.status(f"Scanning the catalog — last {days} days…", expanded=True) as status:
status.write("Pulling every SKU's daily actuals from COSMOS (bulk, paginated)")
status.write("Summing real profit, ad spend and units per SKU")
status.write("Diagnosing each money-loser: ads or below cost")
rows = svc.get_catalog_performance(days=days)
status.update(label=f"Scanned {len(rows):,} SKUs with sales",
state="complete", expanded=False)
if not rows:
_note("warn", "No SKUs had sales in that window.")
return
losers = [r for r in rows if r["actual_profit"] < 0]
thin = [r for r in rows if 0 <= r["profit_per_unit"] < 0.50]
ads_cause, cost_cause = root_cause_split(losers)
bleed = sum(r["actual_profit"] for r in losers)
gain = sum(r["actual_profit"] for r in rows if r["actual_profit"] > 0)
_sec(f"Money at risk — last {days} days, actual COSMOS profit")
_stats([
("SKUs with sales", f"{len(rows):,}", None, None),
("Losing money", f"{len(losers):,}", _money(bleed), "neg"),
("Thin, under $0.50/unit", f"{len(thin):,}", None, None),
("Net across catalog", _money(gain + bleed), None, _tone(gain + bleed)),
("Annualized bleed", _money(bleed * 365 / days), "from the money-losers", "neg"),
])
_sec("Root cause — which lever to pull")
_stats([
("Ads are the cause", f"{len(ads_cause):,} SKUs",
"Profitable without ad spend — cut or retarget ads", None),
("Below cost before ads", f"{len(cost_cause):,} SKUs",
"Loses money at zero ad spend — raise price or cut COGS", None),
])
def _table(items, n=100):
return [{
"SKU": r["sku"], "Price": _money(r["avg_price"]), "Units": f"{r['units']:,}",
"Ad/unit": _money(r["ad_per_unit"]), "Profit/unit": _money(r["profit_per_unit"]),
"Actual profit": _money(r["actual_profit"]),
"Why it loses money": loss_reason(r["profit_per_unit"], r["ad_per_unit"], r["avg_price"]),
"How to fix it": fix_suggestion(r["profit_per_unit"], r["ad_per_unit"], r["avg_price"]),
} for r in items[:n]]
t1, t2, t3 = st.tabs([f"Losing money · {len(losers):,}",
f"Ads are the cause · {len(ads_cause):,}",
f"Thin margin · {len(thin):,}"])
with t1:
st.dataframe(_table(losers), use_container_width=True, hide_index=True)
_html('<div class="caption">Worst first. Every row states why it loses money and how to fix '
'it. To verify: if <code>profit/unit + ad/unit &gt; 0</code>, ads caused the loss.</div>')
with t2:
st.dataframe(_table(sorted(ads_cause, key=lambda r: r["actual_profit"])),
use_container_width=True, hide_index=True)
_note("ok", "These flip to profitable by <b>cutting ad spend alone</b> — no price change, no "
"customer impact. The “how to fix it” column gives the target ad spend per unit.")
with t3:
st.dataframe(_table(sorted(thin, key=lambda r: -r["units"])),
use_container_width=True, hide_index=True)
_html('<div class="caption">Read-only — nothing is written anywhere. Open any SKU under '
'<b>Single product</b> for its full evidence-based report.</div>')
def run_product_line(token: str, total: int, skus: list) -> None:
"""Analyze a chosen set of a product line's SKUs, then a one-line summary report."""
settings, svc = get_settings(), get_service()
subset = total > len(skus)
_sec(f"Product line “{token}” — {len(skus)} of {total} SKUs, highest demand first")
rows = []
prog = st.progress(0.0, text="Loading the line's six-month actual profit history…")
status = st.status(f"Analyzing {len(skus)} SKUs…", expanded=True)
# One shared fetch of the whole line's 6-month daily actuals: `skuPrefix` matches by
# substring, so this costs ~12 calls TOTAL instead of ~12 per SKU. Every SKU then gets
# the same full evidence (ad cost, real profit, best observed price) as Single product.
history = {}
try:
status.write("Loading six-month actual profit history for the whole line (shared)")
prog.progress(0.0, text=f"Fetching the line's history (bounded to the {len(skus)} "
f"selected SKUs)…")
history = svc.get_sales_history_bulk(token, days=180, wanted=set(skus))
status.write(f"History loaded for {len(history)} of {len(skus)} SKUs")
except Exception as e:
status.write(f"History unavailable ({e}) — falling back to margin-only analysis")
for i, sku in enumerate(skus, 1):
status.update(label=f"Analyzing {i}/{len(skus)}{sku}")
try:
hist = history.get(sku)
r = analyze_sku(sku, settings, svc=svc, with_narrative=False,
with_elasticity=bool(hist), history=hist)
rows.append(r)
actual = (f" · actual {_money(r.actual_profit_per_unit)}/unit"
if r.actual_profit_per_unit is not None else "")
status.write(f"{sku}{r.rating.value.title()}{actual}")
head = (f"{i}. {sku}{r.rating.value.title()} · now {_money(r.current_price)}, "
f"suggest {_money(r.suggested_price)}{actual}")
with st.expander(head, expanded=False):
render_result(r)
except Exception as e:
status.write(f"{sku} — failed: {e}")
prog.progress(i / len(skus), text=f"{i} of {len(skus)} analyzed")
prog.empty()
status.update(label=f"Analyzed {len(rows)} of {len(skus)} SKUs", state="complete",
expanded=False)
if not rows:
return
order = {"POOR": 0, "CAUTION": 1, "GOOD": 2}
losing = [r for r in rows if r.actual_profit_per_unit is not None
and r.actual_profit_per_unit < 0]
counts = {k: sum(1 for r in rows if r.rating.value == k) for k in ("GOOD", "CAUTION", "POOR")}
_sec("Summary report")
_stats([
("SKUs analyzed", f"{len(rows)}", f"of {total} in the line" if subset else "whole line", None),
("Good", f"{counts['GOOD']}", None, "pos" if counts["GOOD"] else None),
("Caution", f"{counts['CAUTION']}", None, None),
("Poor", f"{counts['POOR']}", None, "neg" if counts["POOR"] else None),
("Actually losing money", f"{len(losing)}", "on real COSMOS profit",
"neg" if losing else None),
])
if losing:
_note("bad", f"<b>{len(losing)} of {len(rows)} SKUs are actually losing money</b> on real "
"COSMOS profit, which includes ads, refunds, promo and storage.")
body = ""
for r in sorted(rows, key=lambda x: order.get(x.rating.value, 3)):
move = ""
if r.current_price and r.suggested_price:
d = r.suggested_price - r.current_price
move = f" ({d:+.2f})" if abs(d) >= 0.5 else " (hold)"
if r.actual_profit_per_unit is not None:
cls = "neg" if r.actual_profit_per_unit < 0 else "b"
econ = (f'<span class="{cls}">actual {_money(r.actual_profit_per_unit)}/unit</span> '
f"(ads {_money(r.ad_cost_per_unit)}/unit) · {r.unprofitable_months} of "
f"{len(r.price_performance)} months lost money")
else:
econ = f"{r.margin_pct*100:.0f}% margin (pre-ads, no history)"
body += (f'<div class="srow">{_pill(r.rating.value)}'
f'<span class="sku">{r.sku}</span>'
f'<span class="body">now <b>{_money(r.current_price)}</b>, suggest '
f'<b>{_money(r.suggested_price)}</b>{move} · {econ} · '
f'6-mo {(r.avg_6m or 0):,.0f}/day ({r.trend}) — {r.headline}</span></div>')
_html(body)
# ── Page ───────────────────────────────────────────────────────────────
_html(CSS)
_html('<div class="masthead"><div class="mh-title">Pricing &amp; Profitability Analyst</div>'
'<div class="mh-sub">Amazon Seller Central · live COSMOS data · read-only. Verdicts come from '
'deterministic rules over actual profit; the language model only explains them. '
'Single-SKU analysis also pulls Buy Box / competitor sellers via Apify when '
'<code>APIFY_TOKEN</code> is set (product-line bulk skips Apify for speed).</div></div>')
if not get_settings().openai_api_key:
_note("warn", "No <code>OPENAI_API_KEY</code> is set — rule-based analysis only, no written "
"narrative.")
if "action" not in st.session_state:
st.session_state.action = None
left, right = st.columns(2, gap="large")
with left:
with st.container(border=True):
st.markdown("##### Single product")
st.caption("Full evidence: six-month real profit, ad cost, best observed price, written "
"analysis.")
sku_in = st.text_input("SKU", key="single_sku",
placeholder="UBMICROFIBERGUSSETPILLOWWHITEQUEEN")
use_price = st.checkbox(
"Test a specific price instead of the current one",
key="use_price",
help="Unchecked = COSMOS current price (can lag Amazon). "
"Checked = evaluate a candidate (e.g. $24.99 for the presentation case).",
)
price_in = None
if use_price:
price_in = st.number_input("Price", min_value=0.01, value=24.99, step=0.01,
key="single_price")
st.caption("Presentation case: SKU above @ **$24.99** → expect margin floor "
"**BLOCKED** + Apify competitor table.")
if st.button("Analyze product", type="primary", use_container_width=True,
disabled=not sku_in.strip()):
st.session_state.action = ("single", sku_in.strip(), price_in)
with right:
with st.container(border=True):
st.markdown("##### Product line")
st.caption("Every SKU in a line, worst first, with a one-line summary each.")
line_in = st.text_input("Product line or SKU prefix", key="line_q",
placeholder="PILLOW · TOWEL · UBCFK · MATTRESSPROTECTOR")
query = line_in.strip()
matches, total = [], 0
if query:
needle = query.upper()
matches = [s for s in all_catalog_skus() if needle in s.upper()]
total = len(matches)
# Streamlit keeps a widget's value in session_state across reruns. When the line
# changes, `total` (the max) changes too — a stale value would exceed the new max
# and the input would refuse to update. Reset it whenever the line changes, and
# clamp it if it ever overshoots.
if st.session_state.get("_last_line") != query:
st.session_state.pop("line_n", None)
st.session_state["_last_line"] = query
elif total and st.session_state.get("line_n", 0) > total:
st.session_state["line_n"] = min(PRODUCT_LINE_LIMIT, total)
if query and total:
st.caption(f"**{total} SKUs** match this line.")
# Quick presets — set the value BEFORE the number_input is created.
presets = [("5", 5), ("15", 15), ("25", 25), (f"All ({total})", total)]
for col, (label, val) in zip(st.columns(len(presets)), presets):
if col.button(label, key=f"preset_{label}", use_container_width=True):
st.session_state["line_n"] = max(1, min(val, total))
st.rerun()
n_in = st.number_input(f"How many to analyze — 1 to {total}, highest demand first",
min_value=1, max_value=total,
value=min(PRODUCT_LINE_LIMIT, total), step=1, key="line_n")
mins = (60 + int(n_in) * 2.0) / 60 # one shared history fetch + per-SKU analysis
st.caption(f"About {mins:.1f} min — loads the line's six-month actual profit history "
f"once, then analyzes {int(n_in)} SKUs. Stop takes effect between SKUs; a "
f"data call already in flight has to finish first.")
if st.button("Analyze line", type="primary", use_container_width=True):
st.session_state.action = ("line", query, total, matches[:int(n_in)])
elif query:
st.caption("No SKUs match that text. Try `PILLOW`, `TOWEL` or `UBCFK`.")
st.button("Analyze line", use_container_width=True, disabled=True)
else:
st.caption("Type a product line above to see how many SKUs it contains.")
st.button("Analyze line", use_container_width=True, disabled=True)
with st.container(border=True):
s1, s2, s3 = st.columns([3, 1, 1])
with s1:
st.markdown("##### Catalog money-at-risk scan")
st.caption("Every SKU's actual profit, including storage, refunds, promo and ads. Finds the "
"money-losers and says whether ads or price is to blame. Read-only.")
with s2:
scan_days = st.selectbox("Window", [15, 30, 60], index=1, key="scan_days",
format_func=lambda d: f"Last {d} days")
with s3:
st.write("")
if st.button("Run scan", use_container_width=True):
st.session_state.action = ("scan", int(scan_days))
st.divider()
act = st.session_state.action
if not act:
_note("", "Analyze a <b>single product</b> for the full evidence-based report, scan a "
"<b>product line</b>, or run the <b>catalog money-at-risk scan</b> to find every SKU "
"that is actually losing money.")
else:
# Rendered BEFORE the long run so it stays clickable while the analysis streams. Clicking
# any widget mid-run makes Streamlit abort the script and rerun — that is the stop.
stop_col, label_col = st.columns([1, 5])
if stop_col.button("Stop / clear", use_container_width=True, key="stop_btn"):
st.session_state.action = None
st.rerun()
label_col.caption("Press stop to cancel a running analysis, or to clear these results.")
cap = ListHandler()
logging.getLogger("pricing_agent").addHandler(cap)
try:
if act[0] == "single":
run_single(act[1], act[2])
elif act[0] == "scan":
run_catalog_scan(act[1])
else:
run_product_line(act[1], act[2], act[3])
except Exception as e:
_note("bad", f"Analysis failed: {e}")
finally:
logging.getLogger("pricing_agent").removeHandler(cap)
if cap.records:
with st.expander(f"Steps and data fetched — {len(cap.records)} calls logged"):
st.code("\n".join(cap.records), language="log")

289
ppt.md Normal file
View File

@ -0,0 +1,289 @@
---
marp: true
title: Pricing & Profitability Agent — Executive Briefing
paginate: true
---
# Pricing & Profitability Agent
### Finding the money we're leaving on the table — one SKU at a time
An AI agent that reads our live Amazon data, tells us **which SKUs actually lose money**, **why**, and **what to do about it** — with the evidence to prove it.
*Executive briefing · Utopia Brands*
---
## The one number that matters
> Across the catalog, **1,053 SKUs are losing money right now.**
>
> That is **$1.17M in the last 30 days** — roughly **$14M/year**.
- Scanned **4,192 SKUs** that had sales (of ~8,728 in the catalog)
- **336 of those losers are fixable by cutting ad spend alone** — no price change, no customer impact
- **736 need a price or cost fix**
The agent didn't estimate this. It read Amazon's own realized profit, SKU by SKU.
---
## Why we couldn't see this before
The number we *thought* was our price was often wrong.
| SKU | What the tool "listed" | What we **actually sold at** |
|---|---|---|
| Microfiber Gusset Pillow (Queen) | **$33.89** | **$23.39** |
- At **$33.89** the SKU looks like a **30% margin hero** — clears target
- At the **real $23.39**, margin is **5.5%** — far below our floor, quietly losing money
- Confirmed three ways: COSMOS realized revenue ÷ units **and** Amazon's live featured offer — they agree to the cent
**The agent always prices on what customers actually paid — not a reference number.**
---
## What the agent does, in one line
> For any SKU, product line, or the whole catalog, it computes the **true profitability**, renders a **verdict**, and shows **every reason and the raw evidence** — read-only, nothing is changed without a human.
**Three ways to run it:**
- **One product** — deep dive with full fee stack + 6-month history
- **A product line** — ranked worst-first, with a suggested price for each
- **The whole catalog** — the money-at-risk scan that found the $14M
---
## How it works — the pipeline
```
↓ Fetch fees + the REAL selling price → what it costs us
↓ Fetch demand + inventory → can it sell
↓ Fetch 6 months of daily actuals → what really happened
↓ Deterministic rules → GOOD / CAUTION / POOR (no AI — 42 unit tests)
↓ AI writes the plain-English explanation (never calculates, never decides)
↓ Output: verdict + every reason + the evidence to check it
```
**Key design choice:** the math and the decision are **pure code and fully tested.** The AI only writes the narrative. It can never invent a number or move a price.
---
## What data we gather (and gate on)
All of it is **live**, pulled from our COSMOS system per SKU:
| Signal | Source | Why it matters |
|---|---|---|
| **Real selling price** | `sales-insight` (revenue ÷ units) | The truth — not a list price |
| Fee stack (referral, FBA, returns, EPR/VAT) | `takehome-calculator` | Every cost Amazon takes |
| Landed cost (COGS + freight + duty) | `takehome-calculator` | What the unit costs us |
| 6-month demand, velocity, days of cover | `invp-insight` | Can it actually sell |
| Storage vs inventory on hand | `bulk-calculator` | Overstock draining profit |
| **Actual profit (real P&L)** | `sales-insight.profit` | Includes ads, refunds, promo, storage |
| Ad spend per unit | `sales-insight.marketingCost` | The #1 hidden profit killer |
**Competitor price / Buy Box** — COSMOS has none of this. We source it from **our own curated competitor sheet** (see next slide), with a live Amazon scrape as an optional top-up.
---
## Where competitor data comes from — and why not scraping alone
Competitor pricing isn't in COSMOS, so we bring it in from **two sources — the sheet first, the scraper second:**
| Source | Role | Strength |
|---|---|---|
| **Our competitor sheet** (pre-scraped, curated) | **Primary** — the data we gate on | Whole catalog at once, verified, stable, zero per-call cost, works offline |
| **Live Amazon scrape** (Apify) | **Top-up** — on-demand freshness for a single SKU | Real-time snapshot when we need "right now" |
**Why we don't rely on the live scraper alone:**
- **It's unreliable per-run** — the same Amazon page flaps between "suppressed" and "live offers" between scrapes; a single reading can be wrong
- **It doesn't scale** — ~15 sec per product and pay-per-scrape, so it can't cover the whole catalog economically
- **It's a snapshot, not history** — no trend, and it can be blocked, rate-limited, or redirected by Amazon's anti-bot defences
- **The sheet is controllable and auditable** — we own it, we can verify it, and it feeds every SKU consistently
> **Bottom line:** the sheet is the reliable backbone; the live scrape is a convenience layer on top — never the sole source of truth.
---
## The rules — a fixed checklist, no AI
Every decision is a checklist run **top to bottom. The first rule that matches wins.**
No judgment calls, no guessing — the same inputs always give the same answer.
**The thresholds every rule uses (set once, in one config file):**
| Margin floor | Margin target | Worst-case referral | Returns | Storage |
|:---:|:---:|:---:|:---:|:---:|
| **25%** | **30%** | **15%** | **2%** | **$0.25/unit** |
> The golden rule: **real recorded profit beats profit-on-paper.** If Amazon's own
> numbers show a loss, no amount of "good margin on the calculator" turns it green.
---
## Rule set 1 — the verdict (🟢 / 🟡 / 🔴)
What the app shows for each product. First match wins:
| # | Check | Verdict |
|:---:|---|---|
| 1 | **Actually losing money?** (real profit/unit < 0, or 3+ months lost money) | 🔴 **POOR** *overrides everything below* |
| 2 | Losing money after ads? (net after ad spend < 0) | 🔴 POOR |
| 3 | Price below break-even? | 🔴 POOR — loses on every sale |
| 4 | Margin < 25% floor **and** demand weak? | 🔴 POOR not viable |
| 5 | Margin < 25% floor **but** demand strong? | 🟡 CAUTION underpriced, raise price |
| 6 | Overstocked? (storage eats profit, or >120 days cover) | 🟡 CAUTION — run a promo |
| 7 | Sales declining vs the 6-month trend? | 🟡 CAUTION — watch it |
| 8 | **Otherwise** — clears margin, healthy demand | 🟢 **GOOD** |
---
## Rule set 2 — the approval gate
The go / no-go decision, always tested at the worst-case **15%** referral:
| # | Check | Decision |
|:---:|---|---|
| 1 | Price below break-even | ⛔ **BLOCKED** — loss-making |
| 2 | Margin below the 25% floor | ⛔ **BLOCKED** — the main gate |
| 3 | Listing suppressed (Buy Box hidden) | ⛔ **BLOCKED** — don't launch, ads would waste |
| 4 | Lost the Buy Box on price | 🔶 **NEEDS REVIEW** — match or hold? |
| 5 | Otherwise | ✅ **APPROVED** |
**A human still approves before any price is written back.** The rules propose; a person decides.
---
## The core money math
Every dollar figure comes from these formulas — **pure, exact, unit-tested.**
**Margin & break-even**
```
margin = take-home ÷ price
break-even = (landed + FBA + returns + other) ÷ (1 referral%)
```
**Minimum profitable price** (solves for a 30% target margin, rounds to `.99`)
```
suggested = (landed + FBA + other) ÷ (1 referral% returns% 30%)
```
**The gate always tests against the worst-case 15% referral fee** — if a SKU clears the floor at the worst case, it is genuinely safe.
> Policy lives in one config file: **25% margin floor, 30% target, 2% returns, 15% worst-case referral.** Change the policy, not the code.
---
## The formula that changes the answer: the profit bridge
Paper margin says one thing; reality says another. This reconciles them:
```
take-home (fees + COGS only) +4.30
ad spend 3.55
= net after ads +0.75
refunds / promo / storage / logistics 2.65 ← the "unmodeled gap"
= ACTUAL profit / unit 1.90
```
- A SKU can show **+$4.30 "profit"** and actually **lose $1.90** per unit
- The gap is real cost the fee calculator never sees
- The agent leads with the **actual number** — and flags when the model overstates profit
**This is the difference between a dashboard that looks healthy and one that tells the truth.**
---
## The evidence layer — "what actually happened"
Instead of trusting a model, we pool Amazon's own profit data:
```
actual profit/unit = Σ profit ÷ Σ units
best observed price = the price that earned the most real profit/day
unprofitable months = count of months that actually lost money
```
- Pooled by month **and** by **$0.50 price band** — so we can see the price that truly performed best
- Sometimes **the best price we ever charged still lost money***that itself is the finding:* the floor is above anything we've tried.
**We also model price elasticity** (how demand responds to price) — but label it **"directional only,"** because our list prices barely move, so the data is thin. We're honest about what we don't yet know.
---
## Worked example — the agent's reasoning, end to end
**SKU:** Microfiber Gusset Pillow · **Candidate price:** $24.99
| Step | Result |
|---|---|
| Landed cost (COSMOS) | $6.65 |
| Contribution margin @ $24.99 | **10.6%** |
| Break-even / MAP floor | $21.88 / $24.80 |
| Buy Box (live Amazon scrape) | WON — Utopia Brands @ $23.39 |
| **Suggested price** to clear target | **$34.99** |
> **Decision: 🔴 BLOCKED** — margin **10.6%** is below the **25% floor**, even at the worst-case referral. The margin rule fires *before* Buy Box: winning the Buy Box doesn't rescue an unprofitable price.
**Action:** hold below floor, move toward **$34.99** to clear the 30% target.
---
## How much to trust each part
| Capability | Status | Trust |
|---|---|---|
| Cost, margin, break-even | ✅ Built | **High** — exact (but excludes ads alone) |
| Ad cost, true margin, profit bridge | ✅ Built | **High** |
| **Actual profit evidence** | ✅ Built | **Highest — real P&L, overrides everything** |
| Catalog money-at-risk scan | ✅ Built | **Highest** |
| Elasticity / profit optimizer | ✅ Built | Directional only |
| Competitor / Buy Box / suppression | ✅ Built | Sheet-backed (primary) + live scrape top-up |
| Price write-back + approval workflow | ❌ Remaining | Needs write endpoint + guardrails |
**Everything the agent claims, it can show you the raw numbers for.** Nothing is a black box.
---
## What's live today vs. what's next
**✅ Live now**
- Full profitability + evidence engine on live COSMOS data
- Catalog-wide money-at-risk scan (the $14M finding)
- Per-SKU verdict, reasons, suggested price, AI explanation
- Read-only — safe to run against production
**❌ To unlock the value**
1. **Turn diagnosis into action** — an "ads-fix" worklist for the 336 fastest wins
2. **Hardened guardrails** — real break-even (incl. ads + leakage), cap moves at ±10%
3. **Price write-back with human approval** — every change reviewed + audit-logged
4. **Deliberate ±5% price tests** — makes elasticity trustworthy in ~3 months
5. **Automation** — daily scan, alert on *new* money-losers
---
## The ask
**We've built the diagnosis. It found ~$14M/year of profit leakage — with the evidence.**
To convert that into recovered profit, we need to:
- **Approve the ads-fix pilot** — 336 SKUs, no price change, fastest money in the catalog
- **Green-light the write-back + approval workflow** — so the agent's recommendations can actually move prices, safely and auditably
- **Authorize deliberate price testing** on high-volume SKUs — to make the demand model reliable
> The tool already tells us where the money is.
> The next phase is about **going and getting it.**
---
# Thank you
**Pricing & Profitability Agent**
Live data · Deterministic, tested math · AI explains, never decides · Full transparency
*Questions?*

44
pyproject.toml Normal file
View File

@ -0,0 +1,44 @@
[project]
name = "pricing-agent"
version = "0.1.0"
description = "Pricing & Profitability Analyst AI agent (LangGraph + OpenAI) for Amazon product launches"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"langgraph>=0.2.0",
"langchain-openai>=0.2.0",
"openai>=1.40.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"pyyaml>=6.0",
"python-dotenv>=1.0.0",
"tenacity>=8.3.0",
# The HTTP client behind every COSMOS call (cosmos/client.py), imported at module level.
# It was missing here and in requirements.txt, so a clean install failed on import.
"requests>=2.31.0",
# Reads the competitor comparison workbook (competitor_sheet.py).
"openpyxl>=3.1.0",
# Backend integrations (optional at runtime; only needed for live backends)
"gspread>=6.0.0",
"google-auth>=2.30.0",
"apify-client>=1.8.0",
]
[project.optional-dependencies]
sp_api = ["python-amazon-sp-api>=1.8.0"]
ui = ["streamlit>=1.36.0", "plotly>=5.22", "pandas>=2.1", "numpy>=1.26"]
dev = ["pytest>=8.0.0"]
[project.scripts]
pricing-agent = "pricing_agent.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src", "."]
testpaths = ["tests"]

37
requirements.txt Normal file
View File

@ -0,0 +1,37 @@
# Runtime for the Streamlit dashboard: streamlit run app.py
#
# The installable package and the CLI / LangGraph path are declared in pyproject.toml
# (`pip install -e ".[ui,dev]"`). This file is the smaller, dashboard-only set.
# --- UI ---
streamlit>=1.36
plotly>=5.22
pandas>=2.1
numpy>=1.26
# --- config + models ---
pydantic>=2.7.0
pydantic-settings>=2.3.0
pyyaml>=6.0
python-dotenv>=1.0.0
# --- COSMOS access ---
# requests is the HTTP client behind EVERY COSMOS call (cosmos/client.py) and tenacity is its
# retry policy. requests is a module-level import, so without it the dashboard fails on
# import — not at the first request.
requests>=2.31.0
tenacity>=8.3.0
# --- competitor data ---
# openpyxl reads the comparison workbook (competitor_sheet.py). Imported lazily, but on the
# DEFAULT path: COMPETITOR_SHEET_ONLY=true routes every covered SKU through it.
openpyxl>=3.1.0
# Per-ASIN Buy Box scrape, used when a SKU falls outside the sheet's coverage.
apify-client>=1.8.0
# --- LLM narrative (optional) ---
# Nothing the engine decides depends on this: with no OPENAI_API_KEY, or if the import fails,
# llm.py falls back to a deterministic template and the recommendation is identical.
# `langchain-openai` is the real import — `openai` alone does not enable it, it is only a
# transitive dependency of this package.
langchain-openai>=0.2.0

View File

@ -0,0 +1,282 @@
"""Verdict backtest: what do the competitor rules actually change, on real SKUs?
Same shape as the storage-fix backtest (61.9% -> 45.2%), but the quantity under test is not
an error percentage -- it is the VERDICT. So this reports, per SKU, the action and the reason
code with the competitor rules blind vs. live, and names every SKU that moved and why.
Method
------
The real pipeline runs ONCE per SKU. `dashboard.live_data._decide` is wrapped so the exact
arguments it was handed in production -- the real AnalysisResult, the real 180-day history,
the real fee stack, the real scenario grid, the real inventory outlook -- are captured. Every
arm below then re-runs that same captured input through the same cascade, varying ONLY the
competitor state. Nothing is reconstructed by hand, so no arm can drift from what the live
dashboard would actually compute.
Four arms:
BLIND comp=None -- the behaviour before this change
REAL the actual competitor state -- what production would do right now
CF_UNDERCUT counterfactual: a rival holds the Buy Box `--undercut` below us
CF_SUPPRESSED counterfactual: our Buy Box is suppressed
The REAL arm is built from the Apify data ALREADY ON DISK (`data/apify_cache.json`) rather
than by scraping. The actor is pay-per-run, and a backtest is not a reason to spend: the
cached payloads are real responses for real ASINs, and reading them exercises the same
`from_apify` adapter production uses. Pass `--scrape` to fetch fresh data instead, which does
cost money -- it is opt-in for that reason, never the default.
The counterfactuals exist because the real arm can only exercise the states our data happens
to be in today. They are labelled as counterfactual everywhere and are never presented as
observed fact.
python scripts/backtest_competitor_rules.py --skus SKU1,SKU2 --undercut 0.08
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
for p in (ROOT / "src", ROOT):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import dashboard.live_data as L # noqa: E402
from pricing_agent.competitive_state import CompetitiveState # noqa: E402
from pricing_agent.schemas import BuyBoxStatus # noqa: E402
NOW = datetime.now(timezone.utc)
# Defaults: the two ASINs with real cached competitive data, plus a slice of the duvet line
# the workbook tool was last run against.
DEFAULT_SKUS = [
"UBMICROFIBERGUSSETPILLOWWHITEQUEEN",
"UBCFKFITTEDSHEETWHITECALKING",
"UBMICROFIBERDUVETQUEENWHITE",
]
def capture_decide():
"""Wrap _decide so we keep the real arguments it was called with."""
calls: list[dict] = []
original = L._decide
def spy(r, cur_price, hist, fp, scen, outlook=None, comp=None):
calls.append({"r": r, "cur_price": cur_price, "hist": hist, "fp": fp,
"scen": scen, "outlook": outlook, "comp": comp})
return original(r, cur_price, hist, fp, scen, outlook, comp)
L._decide = spy
return calls, original
def with_comp_match(call: dict, rival: float | None):
"""The captured scenario grid, plus the `comp_match` candidate build_live_sku would add.
Without this the counterfactual arms are untestable: the undercut branch requires a priced
`comp_match` candidate to move toward, and build_live_sku only adds one when competitor
state was usable AT PIPELINE TIME. Reusing the captured grid therefore holds the rule
permanently off and the arm would report "no change" for the wrong reason.
The row is computed the same way `_scenarios` computes every other row -- same elasticity,
same fee stack -- so the candidate is priced consistently with its neighbours rather than
pasted in.
"""
scen = call["scen"]
if not rival or rival <= 0:
return scen
cur = call["cur_price"]
row_cur = scen.set_index("scenario").loc["current"]
el = ((call["r"].elasticity or {}).get("elasticity")) or L.FALLBACK_ELASTICITY
units_day = float(row_cur["units_day"])
inventory = float(row_cur["cover_days"]) * max(units_day, 0.1)
units = units_day * (rival / cur) ** el if cur > 0 else units_day
th = L._take_home(rival, call["fp"])
extra = {
"scenario": "comp_match", "price": round(rival, 2), "units_day": round(units, 1),
"revenue_30d": round(units * 30 * rival), "profit_30d": round(units * 30 * th),
"margin_pct": round(th / rival * 100, 1) if rival else 0.0,
"cover_days": round(inventory / max(units, 0.1)),
}
import pandas as pd
return pd.concat([scen, pd.DataFrame([extra])], ignore_index=True)
def rerun(call: dict, comp, *, rival: float | None = None) -> tuple[str, str, str, float]:
"""Re-run the captured input through the real cascade with a different comp state.
Returns the SHIPPABLE price too, i.e. after the same guardrails build_live_sku applies:
lifted to the safe floor, then capped at one MAX_STEP_PCT move. Reporting the raw target
would let a "match the rival at $8" recommendation look like it shipped when the floor
would in fact have stopped it.
"""
scen = with_comp_match(call, rival) if rival else call["scen"]
action, rec, _cons, _aggr, reasons, _root, objective = L._decide(
call["r"], call["cur_price"], call["hist"], call["fp"], scen,
call["outlook"], comp,
)
cur = call["cur_price"]
target = float(scen.set_index("scenario").loc[rec, "price"])
floor = L.price_floor(call["r"], cur).get("floor") or 0.0
if action == "Investigate":
shipped = cur
else:
shipped = max(target, floor) if floor else target
step = cur * L.MAX_STEP_PCT
if abs(shipped - cur) > step + 0.005:
shipped = cur + (step if shipped > cur else -step)
return action, ",".join(reasons), objective, round(shipped, 2)
def state_from_disk(asin: str | None, our_price: float):
"""The real competitor state for this ASIN from the Apify cache — no network, no spend.
Uses the same `item_to_competitive` -> `from_apify` path production uses, so the state is
gated (age, unknown ownership) exactly as it would be live. Returns None when the cache
holds nothing for this ASIN, which is itself a real and common condition.
"""
import json
from config.settings import get_rules, get_settings
from pricing_agent.competitive_state import from_apify
from pricing_agent.tools.amazon.apify import item_to_competitive
s, rules = get_settings(), get_rules()
try:
raw = json.loads(Path(s.apify_cache_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
entry = raw.get((asin or "").upper())
if not isinstance(entry, dict) or "item" not in entry:
return None
snap = item_to_competitive(entry["item"], asin=asin, our_price=our_price,
our_seller_id=s.apify_seller_id)
return from_apify(
snap, our_price=our_price,
as_of=datetime.fromtimestamp(float(entry["ts"]), tz=timezone.utc),
max_age_hours=rules.competitor_state_max_age_hours, now=NOW)
def cf(status: BuyBoxStatus, our_price: float, rival: float | None) -> CompetitiveState:
"""A COUNTERFACTUAL state — fresh by construction, so the age gate cannot mask the rule."""
return CompetitiveState(
status=status, our_price=our_price, buy_box_price=rival, competitor_min=rival,
competitor_median=rival, rivals=1 if rival else 0, source="counterfactual",
as_of=NOW, reason="counterfactual injected by the verdict backtest",
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--skus", default=",".join(DEFAULT_SKUS))
ap.add_argument("--undercut", type=float, default=0.08,
help="counterfactual rival price, as a fraction below ours")
ap.add_argument("--scrape", action="store_true",
help="fetch FRESH competitor data (COSTS MONEY — the Apify actor is "
"pay-per-run). Off by default; the cached payloads on disk are real.")
args = ap.parse_args()
skus = tuple(s.strip() for s in args.skus.split(",") if s.strip())
calls, original = capture_decide()
try:
print(f"running the real pipeline for {len(skus)} SKU(s) — this fetches live COSMOS "
f"data and may take a minute each")
print("competitor data: " + ("FRESH SCRAPE (paid)" if args.scrape
else "read from data/apify_cache.json (free)") + "\n")
data = L.get_live_data(skus, with_competitive=args.scrape)
finally:
L._decide = original
details, errors = data.get("details") or {}, data.get("errors") or {}
for sku, err in errors.items():
print(f"!! {sku}: {err}")
# Pair each captured call with its SKU by current price — build_live_sku calls _decide
# exactly once per SKU.
by_price = {round(c["cur_price"], 4): c for c in calls}
rows = []
for sku in skus:
d = details.get(sku)
if not d:
continue
cur = round(float(d.get("current_price") or 0), 4)
call = by_price.get(cur)
if call is None:
print(f"!! {sku}: could not pair a captured cascade call (price {cur})")
continue
# With --scrape the pipeline already built a real state; otherwise read the real
# cached payload off disk. Either way the REAL arm is real Apify data.
comp = call["comp"]
if not args.scrape or comp is None or not comp.usable:
from_disk = state_from_disk(d.get("asin"), cur)
if from_disk is not None:
comp = from_disk
rival = round(cur * (1 - args.undercut), 2)
arms = {
"BLIND": rerun(call, None),
"REAL": rerun(call, comp),
"CF_UNDERCUT": rerun(call, cf(BuyBoxStatus.LOST_PRICE, cur, rival), rival=rival),
"CF_SUPPRESSED": rerun(call, cf(BuyBoxStatus.SUPPRESSED, cur, None)),
}
rows.append((sku, cur, comp, arms))
print("\n" + "=" * 100)
print("VERDICT BACKTEST — competitor rules blind vs live, on real SKUs")
print("=" * 100)
for sku, cur, comp, arms in rows:
print(f"\n{sku} current ${cur:.2f}")
st = (f"{comp.status.value} via {comp.source}" if comp is not None else "none")
usable = "usable" if (comp is not None and comp.usable) else (
f"NOT usable — {comp.unusable_because}" if comp is not None else "n/a")
print(f" real competitor state : {st}")
print(f" : {usable}")
if comp is not None and comp.competitor_min:
print(f" : cheapest rival ${comp.competitor_min:.2f} "
f"({comp.rivals} rival offer(s))")
floor = L.price_floor(call["r"], cur).get("floor") or 0.0
print(f" break-even floor : ${floor:.2f}"
f" (counterfactual rival ${rival:.2f})")
for arm, (action, reasons, obj, shipped) in arms.items():
moved = "" if arms[arm] == arms["BLIND"] else " <<< CHANGED"
tag = " (counterfactual)" if arm.startswith("CF_") else ""
flag = ""
if floor and shipped < floor - 0.005:
flag = " *** BELOW FLOOR — INVARIANT VIOLATED ***"
print(f" {arm:14} {action:12} ${shipped:>7.2f} {reasons:42} "
f"{obj}{moved}{tag}{flag}")
print("\n" + "=" * 100)
real_changed = [s for s, _c, _st, a in rows if a["REAL"] != a["BLIND"]]
cf_u_changed = [s for s, _c, _st, a in rows if a["CF_UNDERCUT"] != a["BLIND"]]
cf_s_changed = [s for s, _c, _st, a in rows if a["CF_SUPPRESSED"] != a["BLIND"]]
print(f"SKUs analysed : {len(rows)}")
print(f"Verdicts changed by REAL competitor data : {len(real_changed)} "
f"{real_changed}")
print(f"Verdicts that WOULD change on a {args.undercut:.0%} undercut : "
f"{len(cf_u_changed)} {cf_u_changed}")
print(f"Verdicts that WOULD change if suppressed : {len(cf_s_changed)} "
f"{cf_s_changed}")
# The invariant that matters most: no arm, however cheap the counterfactual rival, may
# ship a price under break-even.
violations = []
for sku, cur, _comp, arms in rows:
floor = L.price_floor(by_price[round(cur, 4)]["r"], cur).get("floor") or 0.0
if not floor:
continue
for arm, (_action, _reasons, _obj, shipped) in arms.items():
if shipped < floor - 0.005:
violations.append(f"{sku}/{arm} ${shipped:.2f} < ${floor:.2f}")
print(f"Prices shipped BELOW break-even (any arm) : {len(violations)} {violations}")
print("\nA REAL delta of 0 is the fail-safe working, not the feature missing: with no "
"usable\ncompetitor state every verdict is byte-identical to the competitor-blind "
"one.\nThe counterfactual arms show the rules do fire once state IS usable.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,357 @@
"""End-to-end pricing pipeline validation (COSMOS + Apify + suggested price + gate).
Runs against live .env credentials (conda env Talha):
conda run -n Talha python scripts/e2e_pipeline_validate.py
conda run -n Talha python scripts/e2e_pipeline_validate.py UBMICROFIBERGUSSETPILLOWWHITEQUEEN 24.99
Writes:
data/e2e_pipeline_results.json
E2E_PIPELINE_REPORT.md (overwritten with this run's findings)
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT))
from config.settings import Settings, get_rules, get_settings # noqa: E402
from langgraph.types import Command # noqa: E402
from pricing_agent.analyze import analyze_price, suggested_price # noqa: E402
from pricing_agent.graph import build_graph # noqa: E402
from pricing_agent.providers import get_provider # noqa: E402
from pricing_agent.schemas import Decision, FeeQuote, SkuInput # noqa: E402
from pricing_agent.tools.gate import evaluate_gate # noqa: E402
from pricing_agent.tools.margin_engine import stack_from_quote # noqa: E402
def _checks() -> list[dict]:
return []
def main() -> int:
sku = sys.argv[1] if len(sys.argv) > 1 else "UBMICROFIBERGUSSETPILLOWWHITEQUEEN"
price = float(sys.argv[2]) if len(sys.argv) > 2 else 24.99
# Reload settings so fresh .env APIFY_* are picked up (lru_cache may be warm).
get_settings.cache_clear()
settings = Settings()
rules = get_rules()
checks: list[dict] = []
report: dict = {
"sku": sku,
"candidate_price": price,
"apify_token_set": bool(settings.apify_token),
"apify_seller_id": settings.apify_seller_id,
"margin_target": rules.margin_target,
"margin_floor": rules.margin_floor,
"checks": checks,
}
print(f"E2E SKU={sku} @ ${price:.2f}")
print(f"APIFY_TOKEN set={bool(settings.apify_token)} "
f"SELLER_ID={settings.apify_seller_id or '(none)'}")
print("---")
# ── 1. Provider enrich + fees (COSMOS) ─────────────────────────────
t0 = time.time()
provider = get_provider(settings)
sku_in = SkuInput(
sku=sku,
asin=None,
product_name="",
marketplace="US",
category="",
landed_cost=0.0,
target_price=price,
)
try:
enriched = provider.enrich(sku_in)
stack = provider.fee_stack(enriched)
checks.append({
"name": "cosmos_enrich_and_fees",
"pass": bool(enriched.asin) and stack.selling_price == price,
"elapsed_s": round(time.time() - t0, 1),
"asin": enriched.asin,
"landed_cost": enriched.landed_cost,
"fba_fee": stack.fba_fee,
"referral_pct": stack.referral_pct,
"cm_pct": round(stack.contribution_margin_pct, 4),
"break_even": stack.break_even_price,
"map_floor": stack.map_floor,
"detail": f"ASIN={enriched.asin} CM={stack.contribution_margin_pct*100:.1f}%",
})
except Exception as e: # noqa: BLE001
checks.append({
"name": "cosmos_enrich_and_fees",
"pass": False,
"error": str(e),
"elapsed_s": round(time.time() - t0, 1),
})
print("FAIL: COSMOS enrich/fees:", e)
_write(report)
return 1
print(f"[1] COSMOS OK asin={enriched.asin} CM={stack.contribution_margin_pct*100:.1f}% "
f"BE=${stack.break_even_price:.2f} MAP=${stack.map_floor:.2f}")
# ── 2. Suggested price math + re-check margin ─────────────────────
t0 = time.time()
sug = suggested_price(stack, rules.margin_target)
sug_ok = False
sug_cm = None
sug_detail = ""
try:
if sug is None:
sug_detail = "suggested_price returned None"
else:
# Re-stack at suggestion with scaled referral/returns (same model as unit test).
requote = FeeQuote(
selling_price=sug,
landed_cost=stack.landed_cost,
fba_fee=stack.fba_fee,
referral_amt=stack.referral_pct * sug,
returns_reserve=(stack.returns_reserve / stack.selling_price) * sug
if stack.selling_price else 0.0,
other_fees=stack.storage_alloc,
source="e2e-reprice",
)
restack = stack_from_quote(requote, rules)
sug_cm = restack.contribution_margin_pct
charm_ok = round(sug - int(sug), 2) == 0.99
clears = sug_cm + 1e-9 >= rules.margin_target
sug_ok = charm_ok and clears
sug_detail = (
f"suggested=${sug:.2f} charm={charm_ok} "
f"CM_at_sug={sug_cm*100:.1f}% >= target {rules.margin_target*100:.0f}% → {clears}"
)
# Also ask COSMOS takehome at the suggestion when possible.
try:
live = provider.fee_stack(enriched.model_copy(update={"target_price": sug}))
sug_detail += f" | COSMOS_CM_at_sug={live.contribution_margin_pct*100:.1f}%"
except Exception as e: # noqa: BLE001
sug_detail += f" | COSMOS_reprice_skip={e}"
except Exception as e: # noqa: BLE001
sug_detail = str(e)
checks.append({
"name": "suggested_price_clears_target",
"pass": sug_ok,
"elapsed_s": round(time.time() - t0, 1),
"suggested_price": sug,
"cm_at_suggested": sug_cm,
"detail": sug_detail,
})
print(f"[2] SUGGESTED {sug_detail}{'PASS' if sug_ok else 'FAIL'}")
# ── 3. Apify competitive (live Buy Box) ───────────────────────────
t0 = time.time()
try:
comp = provider.competitive(enriched)
apify_ok = (
bool(settings.apify_token)
and comp.buy_box_status.value != "UNKNOWN"
or (comp.buy_box_price is not None)
)
# Soften: price present is enough to call Apify usable even if UNKNOWN status
usable = comp.buy_box_price is not None or comp.is_suppressed
checks.append({
"name": "apify_competitive",
"pass": usable,
"elapsed_s": round(time.time() - t0, 1),
"buy_box_status": comp.buy_box_status.value,
"buy_box_price": comp.buy_box_price,
"competitive_low": comp.competitive_low,
"competitive_median": comp.competitive_median,
"competitive_high": comp.competitive_high,
"is_suppressed": comp.is_suppressed,
"reason": comp.reason,
"detail": (
f"{comp.buy_box_status.value} bb=${comp.buy_box_price} "
f"band={comp.competitive_low}-{comp.competitive_high}"
),
})
print(f"[3] APIFY {comp.buy_box_status.value} bb=${comp.buy_box_price} "
f"band={comp.competitive_low}-{comp.competitive_median}-{comp.competitive_high} "
f"{'PASS' if usable else 'FAIL'}")
print(f" {comp.reason}")
except Exception as e: # noqa: BLE001
checks.append({
"name": "apify_competitive",
"pass": False,
"error": str(e),
"elapsed_s": round(time.time() - t0, 1),
})
print("[3] APIFY FAIL:", e)
_write(report)
return 1
# ── 4. Gate decision ──────────────────────────────────────────────
t0 = time.time()
decision, reasons = evaluate_gate(
gate_stack=stack, competitive=comp, rules=rules
)
checks.append({
"name": "evaluate_gate",
"pass": decision in (Decision.APPROVED, Decision.NEEDS_REVIEW, Decision.BLOCKED),
"elapsed_s": round(time.time() - t0, 1),
"decision": decision.value,
"reasons": reasons,
"detail": f"{decision.value}: {reasons[0] if reasons else ''}",
})
print(f"[4] GATE {decision.value}")
for r in reasons:
print(f"{r}")
# ── 5. Analyze path (suggested + verdict; COSMOS-heavy) ───────────
t0 = time.time()
try:
analysis = analyze_price(sku, price, settings, with_narrative=False)
a_ok = analysis.suggested_price is not None
# Suggested from analyze should match (or be very close to) provider-stack suggestion
delta = None
if sug is not None and analysis.suggested_price is not None:
delta = abs(analysis.suggested_price - sug)
a_ok = a_ok and delta < 0.02 # same charm price within a cent
checks.append({
"name": "analyze_suggested_consistent",
"pass": a_ok,
"elapsed_s": round(time.time() - t0, 1),
"analyze_suggested": analysis.suggested_price,
"provider_suggested": sug,
"delta": delta,
"rating": analysis.rating.value,
"margin_pct": analysis.margin_pct,
"take_home": analysis.take_home_per_unit,
"detail": (
f"analyze_sug=${analysis.suggested_price} provider_sug=${sug} "
f"delta={delta} rating={analysis.rating.value}"
),
})
print(f"[5] ANALYZE sug=${analysis.suggested_price} "
f"rating={analysis.rating.value} CM={analysis.margin_pct*100:.1f}% "
f"{'PASS' if a_ok else 'FAIL'}")
except Exception as e: # noqa: BLE001
checks.append({
"name": "analyze_suggested_consistent",
"pass": False,
"error": str(e),
"elapsed_s": round(time.time() - t0, 1),
})
print("[5] ANALYZE FAIL:", e)
# ── 6. Full LangGraph pipeline (enrich→…→Apify→gate→HITL auto) ───
t0 = time.time()
try:
graph = build_graph()
config = {"configurable": {"thread_id": f"e2e:{sku}:{int(time.time())}"}}
state = graph.invoke({"sku_input": enriched}, config=config)
interrupts = state.get("__interrupt__")
if interrupts:
payload = interrupts[0].value
# smart: approve only APPROVED
action = (
"approve"
if payload.get("decision") == Decision.APPROVED.value
else "reject"
)
state = graph.invoke(
Command(resume={"action": action, "price": None, "note": f"e2e-{action}"}),
config=config,
)
proposal = state.get("decision")
graph_ok = proposal is not None
checks.append({
"name": "langgraph_e2e",
"pass": graph_ok,
"elapsed_s": round(time.time() - t0, 1),
"decision": proposal.decision.value if proposal else None,
"recommended_price": proposal.recommended_price if proposal else None,
"buy_box": (
proposal.competitive.buy_box_status.value if proposal else None
),
"buy_box_price": (
proposal.competitive.buy_box_price if proposal else None
),
"cm_pct": (
proposal.fee_stack.contribution_margin_pct if proposal else None
),
"written": state.get("written"),
"detail": (
f"{proposal.decision.value} @ ${proposal.recommended_price:.2f} "
f"bb={proposal.competitive.buy_box_status.value}"
if proposal else "no proposal"
),
})
print(f"[6] GRAPH {checks[-1]['detail']} written={state.get('written')} "
f"{'PASS' if graph_ok else 'FAIL'}")
except Exception as e: # noqa: BLE001
checks.append({
"name": "langgraph_e2e",
"pass": False,
"error": str(e),
"elapsed_s": round(time.time() - t0, 1),
})
print("[6] GRAPH FAIL:", e)
report["checks"] = checks
report["all_passed"] = all(c.get("pass") for c in checks)
_write(report)
print("---")
print(f"ALL PASSED: {report['all_passed']}")
return 0 if report["all_passed"] else 1
def _write(report: dict) -> None:
out_json = ROOT / "data" / "e2e_pipeline_results.json"
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
lines = [
"# E2E pricing pipeline validation report",
"",
f"| | |",
f"|---|---|",
f"| **SKU** | `{report.get('sku')}` |",
f"| **Candidate price** | ${report.get('candidate_price')} |",
f"| **Apify token set** | {report.get('apify_token_set')} |",
f"| **Seller id** | `{report.get('apify_seller_id') or ''}` |",
f"| **Margin target / floor** | "
f"{(report.get('margin_target') or 0)*100:.0f}% / "
f"{(report.get('margin_floor') or 0)*100:.0f}% |",
f"| **All passed** | **{report.get('all_passed')}** |",
"",
"## Checks",
"",
"| Check | Pass | Detail |",
"|---|---|---|",
]
for c in report.get("checks", []):
detail = (c.get("detail") or c.get("error") or "").replace("|", "/")
lines.append(
f"| `{c.get('name')}` | {'PASS' if c.get('pass') else 'FAIL'} | {detail} |"
)
lines += [
"",
"## Notes",
"",
"- Suggested price is derived from the fee stack (COSMOS costs/fees + margin target), "
"charm-rounded to `.99`, then re-checked that CM ≥ target.",
"- Apify fills Buy Box / competitive band after COSMOS resolves the ASIN.",
"- LangGraph path: enrich → margin → competitive → evaluate → human_review → write_back.",
"",
f"Raw JSON: [`data/e2e_pipeline_results.json`](data/e2e_pipeline_results.json)",
"",
]
(ROOT / "E2E_PIPELINE_REPORT.md").write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {out_json}")
print(f"Wrote {ROOT / 'E2E_PIPELINE_REPORT.md'}")
if __name__ == "__main__":
raise SystemExit(main())

80
scripts/serve_lan.ps1 Normal file
View File

@ -0,0 +1,80 @@
<#
.SYNOPSIS
Run the pricing dashboard so anyone on the same network can open it.
.DESCRIPTION
Binds Streamlit to every network interface and prints the URL to share.
Streamlit only listens on this machine by default, which is why colleagues
cannot reach it without this.
The dashboard authenticates to COSMOS with the credentials in .env and shows
live cost and margin data, so set a shared password before sharing the URL:
$env:APP_PASSWORD = "something-not-guessable"
Windows Firewall blocks the port for other machines until you allow it once,
from an ELEVATED PowerShell:
New-NetFirewallRule -DisplayName "Utopia Pricing Agent" `
-Direction Inbound -Protocol TCP -LocalPort 8501 `
-Action Allow -Profile Private
Keep the rule on the Private profile. On a Public profile Windows treats the
network as untrusted, and so should you.
.PARAMETER Port
Port to listen on. Default 8501.
.EXAMPLE
.\scripts\serve_lan.ps1
.\scripts\serve_lan.ps1 -Port 8080
#>
[CmdletBinding()]
param(
[int]$Port = 8501,
[string]$Python = "C:\Users\talha.ahmed\AppData\Local\anaconda3\envs\Talha\python.exe"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
if (-not (Test-Path $Python)) {
throw "Python not found at $Python. Pass -Python <path-to-python.exe>."
}
if (-not (Test-Path (Join-Path $root ".env"))) {
Write-Warning ".env not found — COSMOS calls will fail. Copy .env.example and fill it in."
}
# The address colleagues type. Pick the real adapter, not WSL/Hyper-V virtual ones.
$ip = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object {
$_.IPAddress -notlike '127.*' -and
$_.IPAddress -notlike '169.254.*' -and
$_.InterfaceAlias -notlike '*WSL*' -and
$_.InterfaceAlias -notlike '*Default Switch*' -and
$_.InterfaceAlias -notlike '*Hyper-V*'
} | Select-Object -First 1 -ExpandProperty IPAddress
if (-not $ip) { $ip = "<this-machine-ip>" }
$locked = [bool]$env:APP_PASSWORD
Write-Host ""
Write-Host " Utopia Pricing Agent" -ForegroundColor Cyan
Write-Host " ---------------------------------------------"
Write-Host " Share this URL: " -NoNewline
Write-Host "http://${ip}:${Port}" -ForegroundColor Green
Write-Host " On this machine: http://localhost:${Port}"
if ($locked) {
Write-Host " Password: set (APP_PASSWORD)" -ForegroundColor Green
} else {
Write-Host " Password: NOT SET - anyone on this network can read" -ForegroundColor Yellow
Write-Host " live cost and margin data. Set APP_PASSWORD." -ForegroundColor Yellow
}
Write-Host " Stop with Ctrl+C"
Write-Host ""
& $Python -m streamlit run app.py `
--server.address 0.0.0.0 `
--server.port $Port `
--server.headless true

View File

@ -0,0 +1,16 @@
"""Pricing & Profitability Analyst agent package.
Ensures the project root is importable so `import config.settings` resolves
whether the package is run from a source checkout, a test, or the CLI.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Project root = pricing_agent/ (two levels up from this file: src/pricing_agent/).
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
__version__ = "0.1.0"

View File

@ -0,0 +1,859 @@
"""Price analysis: is a price GOOD given profitability AND the sales trend?
Combines three COSMOS signals:
1. per-unit margin (takehome-calculator)
2. 6-month sales trend (invp-insight: averageSale6Months vs 7/30-day)
3. total economics (bulk-calculator: volume - storage on current inventory)
Produces a plain verdict GOOD / CAUTION / POOR with the reasons. Read-only;
no approval, no writes.
"""
from __future__ import annotations
import logging
import math
from enum import Enum
from pydantic import BaseModel
from config.settings import PricingRules, Settings, get_rules, get_settings
from pricing_agent.fmt import usd
from pricing_agent.schemas import CompetitiveSnapshot, FeeStack, SkuInput
logger = logging.getLogger("pricing_agent.analyze")
# A break-even beyond this multiple of the highest price ever charged is not a
# price target — it means the SKU cannot be priced into profit and the lever is
# ad efficiency or COGS.
UNREACHABLE_MULTIPLE = 1.5
# The bulk calculator reports storage per DAY; every "monthly" figure here has to
# scale it. See dashboard.live_data._storage_30d for how this was established.
DAYS_PER_MONTH = 30
class Rating(str, Enum):
GOOD = "GOOD"
CAUTION = "CAUTION"
POOR = "POOR"
class AnalysisResult(BaseModel):
sku: str
asin: str | None = None
price: float
current_price: float | None = None # live selling price (COSMOS default itemPrice)
# profitability
take_home_per_unit: float
margin_pct: float
break_even: float | None = None
# Break-even that carries advertising — the price below which every unit loses
# money in practice. `break_even` above is fees + COGS only.
break_even_with_ads: float | None = None
# Break-even solved against the FITTED ad curve, i.e. allowing ad cost per unit
# to rise with price as it actually does. When ad cost climbs nearly as fast as
# price, no price clears the bar and this is None with the flag below set.
break_even_ad_curve: float | None = None
ad_curve_unrecoverable: bool = False
contribution_per_dollar: float | None = None # margin rate ad slope
# Break-even fitted to ACTUAL booked profit across observed prices (carries
# ads, refunds, promos, storage — everything COSMOS books).
break_even_empirical: float | None = None
break_even_empirical_fit: dict | None = None
total_cost_per_unit: float | None = None
demand_change_pct: float | None = None # 30-day avg vs 6-month baseline, %
scenarios: list[dict] = [] # margin/profit at nearby price points
# sales trend (avg daily units)
avg_7d: float | None = None
avg_30d: float | None = None
avg_6m: float | None = None
trend: str = "unknown" # rising | stable | declining | unknown
# inventory / bulk
inventory: float = 0.0
cover_days: int | None = None
monthly_units: float = 0.0
monthly_take_home: float | None = None
storage_charges: float | None = None
# recommendation
suggested_price: float | None = None
# ad cost + true (ad-inclusive) economics
ad_cost_per_unit: float | None = None
net_per_unit_incl_ad: float | None = None
true_margin_pct: float | None = None
is_losing_money: bool = False
# EVIDENCE (leads): what actually happened at each observed price
price_performance: list = [] # monthly: price, units/day, ACTUAL profit/day
price_bands: list = [] # pooled by price band
best_observed_price: float | None = None
best_observed_profit_day: float | None = None
actual_profit_per_unit: float | None = None
unprofitable_months: int = 0
unmodeled_cost_gap: float | None = None # modelled net actual profit per unit
# observed price range with a real sample behind it (the tested range)
observed_price_min: float | None = None
observed_price_max: float | None = None
# how ad cost per unit actually moves with price (fitted on observed bands)
ad_cost_model: dict | None = None
# MODEL (context only): elasticity + profit optimization
elasticity: dict | None = None
price_history: list = [] # monthly avg_price / units_per_day / ad_per_unit
profit_optimal_price: float | None = None
profit_optimal_daily: float | None = None
demand_curve: list = [] # optimizer sweep table
extrapolated: bool = False # optimal price beyond observed range
# True when the sweep winner sat on the edge of the search window — then it is
# where the search stopped, not a maximum, and must not be recommended.
profit_optimal_is_corner: bool = False
# closed-form optimum implied by the fitted elasticity; when this is wildly
# above the tested range it is evidence the elasticity is unusable, not a target
profit_optimal_unconstrained: float | None = None
# why profit_optimal_price was withheld, when it was
profit_optimal_blocked_reason: str | None = None
# False when the elasticity CI spans zero — the model must not drive price
elasticity_actionable: bool = False
# full breakdowns (all fields from the FBA calculators — for display)
fees_detail: dict | None = None # full take-home fee stack
bulk_detail: dict | None = None # bulk calculator (storage, totals)
# verdict
rating: Rating = Rating.CAUTION
headline: str = ""
reasons: list[str] = []
narrative: str = "" # OpenAI-written analysis (optional)
# Marketplace / Buy Box (Apify) — filled when APIFY_TOKEN is set and with_competitive=True
competitive: CompetitiveSnapshot | None = None
gate_decision: str | None = None
gate_reasons: list[str] = []
def suggested_price(stack: FeeStack, target_margin: float) -> float | None:
"""Lowest price that clears the target contribution margin, charm-rounded to .99.
Derived from the fee model in `stack` (no extra API call). Referral and the
returns reserve scale with price; FBA, landed cost, and other fees are fixed:
margin(p) = 1 - referral% - returns% - fixed/p
p* = fixed / (1 - referral% - returns% - target)
A lower price sells more, so the *lowest* profitable price is the volume-friendly
recommendation.
"""
price = stack.selling_price
if price <= 0:
return None
referral_pct = stack.referral_pct
returns_pct = stack.returns_reserve / price
fixed = stack.landed_cost + stack.fba_fee + stack.storage_alloc # storage_alloc = other fees
denom = 1 - referral_pct - returns_pct - target_margin
if denom <= 0:
return None
p = fixed / denom
charm = math.floor(p) + 0.99
if charm < p:
charm += 1
return round(charm, 2)
def price_scenarios(
stack: FeeStack, monthly_units: float, storage: float = 0.0,
deltas=(-3, -2, -1, 0, 1),
) -> list[dict]:
"""Margin, take-home/unit, and monthly profit at nearby prices — HELD volume.
Exact (matches the calculator's linear fee model) so it needs no extra API calls.
Volume is held constant on purpose: without a demand-elasticity model we do NOT
guess unit lift; the table shows the pure margin/price trade-off at today's volume.
"""
price = stack.selling_price
if price <= 0:
return []
referral_pct = stack.referral_pct
returns_pct = stack.returns_reserve / price
fixed = stack.landed_cost + stack.fba_fee + stack.storage_alloc
out = []
for d in deltas:
p = round(price + d, 2)
if p <= 0:
continue
th = p * (1 - referral_pct - returns_pct) - fixed
out.append({
"price": round(p, 2),
"take_home_unit": round(th, 2),
"margin_pct": round(th / p, 4) if p else 0.0,
"monthly_profit_held": round(th * monthly_units - storage, 2),
"is_current": d == 0,
})
return out
def classify_trend(recent: float | None, baseline: float | None) -> str:
"""Direction of recent sales vs the 6-month baseline."""
if not recent or not baseline:
return "unknown"
r = recent / baseline
if r >= 1.08:
return "rising"
if r <= 0.92:
return "declining"
return "stable"
def price_verdict(
*,
margin_pct: float,
trend: str,
selling: bool,
cover_days: int | None,
monthly_take_home: float | None,
rules: PricingRules,
) -> tuple[Rating, str, list[str]]:
"""Combine profitability + demand + inventory into a verdict."""
reasons: list[str] = []
floor, target = rules.margin_floor, rules.margin_target
m = margin_pct * 100
# 1. Per-unit profitability is the first gate.
if margin_pct < 0:
# Below break-even — every unit sold loses money, regardless of demand.
reasons.append(f"Loss-making: {m:.0f}% margin — below break-even.")
return (Rating.POOR,
"Loss-making — every sale loses money. Raise the price above break-even.",
reasons)
if margin_pct < floor:
if selling and trend in ("rising", "stable"):
reasons.append(f"Only {m:.0f}% margin (below {floor*100:.0f}% floor) but demand is {trend}.")
return (Rating.CAUTION,
"Underpriced — strong demand but margin below floor. Raise the price.",
reasons)
reasons.append(f"Only {m:.0f}% margin and demand is weak/declining.")
return (Rating.POOR, "Not viable at this price — thin margin and soft demand.", reasons)
# 2. Total economics — is storage on excess stock eating the profit?
if monthly_take_home is not None and monthly_take_home < 0:
reasons.append("Monthly take-home is negative — storage on excess inventory exceeds sales profit.")
return (Rating.POOR,
"Losing money overall — overstocked; storage outweighs sales profit.", reasons)
if cover_days is not None and cover_days > 120:
reasons.append(f"Overstocked: ~{cover_days} days of cover — storage is draining profit.")
return (Rating.CAUTION,
"Healthy unit margin but heavily overstocked — run a promo to move stock.", reasons)
# 3. Demand direction.
if not selling:
reasons.append("Little/no recent sales.")
return (Rating.CAUTION, "Good margin but weak demand — pricing isn't the problem, visibility is.", reasons)
if trend == "declining":
reasons.append(f"{m:.0f}% margin is healthy, but sales are declining vs the 6-month trend.")
return (Rating.CAUTION, "Good margin but sales softening — watch demand.", reasons)
tier = "strong" if margin_pct >= target else "healthy"
reasons.append(f"{m:.0f}% margin ({tier}) and demand is {trend}.")
return (Rating.GOOD, f"Good price — clears margin and demand is {trend}.", reasons)
_ANALYST_SYSTEM = (
"You are a senior Amazon FBA pricing & profitability analyst. You are given a SKU's full "
"fee breakdown, 6-month sales trend, inventory, bulk economics, a rule-based verdict, a "
"SUGGESTED price, and a price_scenarios table (margin & monthly profit at nearby prices, "
"at held volume). Explain your thinking so the reader can SEE WHY.\n\n"
"STRICT RULES — EVIDENCE FIRST:\n"
"- `actual_monthly_performance` is REAL history (COSMOS's own profit, incl. storage, "
"refunds, promo and ads). It OUTRANKS any modelled margin. Lead with it.\n"
"- If `actual_profit_per_unit_last_3mo` < 0 or `months_unprofitable_of_observed` >= 3, "
"LEAD with that: the SKU actually loses money. Say so plainly, with the figures.\n"
"- Recommend the `best_observed_price` as the primary target and justify it with what "
"actually happened there (units/day and actual profit/day). If every observed month lost "
"money, say the best observed price is still loss-making and price must go above it.\n"
"- If `model_overstates_profit_per_unit_by` is set, note the modelled margin overstates "
"profit by that much (unmodeled costs) — trust the actual number.\n"
"- NEVER present `break_even_price` as a safe price floor: it excludes ad spend and the "
"unmodeled costs, so the SKU can lose money well above it. The real floor is the "
"`best_observed_price` — and if even that lost money, the floor is ABOVE it.\n"
"- Do not recommend a price that history shows was loss-making.\n"
"- `price_elasticity` and `profit_optimal_price` are a MODEL, secondary. Cite them only as "
"supporting context, note the confidence, and if `profit_optimal_is_extrapolated` is true "
"say it is beyond the observed price range — a direction, never a precise target.\n"
"- Caveat honestly: month comparisons confound seasonality, ad spend, promos and stock.\n"
"- Compare `current_selling_price` to the recommended price and lead with the move "
"(hold / raise to $X / lower to $X) and the $ and % change.\n"
"- Use `break_even_price` EXACTLY as given. Do NOT recompute it or call the total cost "
"per unit the break-even — they are different numbers (both are provided).\n"
"- Quantify demand: cite `demand_change_pct_30d_vs_6month` (e.g. 'down ~25% vs the "
"6-month average, 666 -> 502/day'), not just 'declining'.\n"
"- Reason over the price_scenarios table explicitly (compare margin and monthly profit at "
"the listed prices). State clearly that unit-volume lift from a price cut is NOT modeled "
"(no elasticity data), so lower-price rows assume held volume.\n"
"- If overstocked, explain WHY a temporary promo beats a permanent list-price cut: "
"promos are temporary and reversible, preserve the list price / perceived value, and are "
"better for Buy Box stability.\n"
"- Give a Confidence level and say what it is based on.\n\n"
"LENGTH IS A HARD RULE: the entire response must be UNDER 200 WORDS. Write for a busy "
"category manager. Never repeat a number you already used. Never quote raw field names "
"(write 'actual profit per unit', not `actual_profit_per_unit_last_3mo`). Do not restate "
"the tables — the reader can see them. No bullet lists of monthly figures.\n\n"
"Write EXACTLY these five short sections:\n"
"**Verdict:** one sentence — is the current price making money, in reality.\n"
"**Confidence:** High / Medium / Low + a half-sentence why.\n"
"**What actually happened:** 2-3 sentences of evidence — the best observed price and its "
"real profit, how many months lost money, the current actual profit/unit.\n"
"**Why:** 2-3 sentences — the single biggest cause (usually ad spend and unmodeled costs "
"eating the pre-ad margin), plus demand/inventory in one clause.\n"
"**Recommendation:** 2-3 sentences — one concrete action anchored on the best observed "
"price, and the price floor never to go below.\n\n"
"Cite only the numbers provided. Never invent numbers. Dense, specific, no filler."
)
def generate_analysis_narrative(r: "AnalysisResult") -> str:
"""OpenAI-written analysis using all fields + 6-month trend. Template fallback."""
settings = get_settings()
facts = {
"sku": r.sku, "asin": r.asin,
"current_selling_price": r.current_price,
"analyzed_at_price": r.price,
"margin_pct_at_analyzed_price": round(r.margin_pct * 100, 1),
"take_home_per_unit": r.take_home_per_unit,
# IMPORTANT: use these exact values, do not recompute them.
"break_even_price": r.break_even,
"total_cost_per_unit": r.total_cost_per_unit,
"suggested_price": r.suggested_price,
"sales_trend_avg_units_per_day": {
"7d": r.avg_7d, "30d": r.avg_30d, "6_month": r.avg_6m, "direction": r.trend,
},
"demand_change_pct_30d_vs_6month": r.demand_change_pct,
"inventory": r.inventory, "cover_days": r.cover_days,
"monthly_units_est": r.monthly_units, "monthly_take_home": r.monthly_take_home,
"storage_charges": r.storage_charges,
"price_scenarios_held_volume": r.scenarios,
# EVIDENCE — what actually happened (trust this over the model)
"actual_monthly_performance": r.price_performance,
"best_observed_price": r.best_observed_price,
"best_observed_actual_profit_per_day": r.best_observed_profit_day,
"actual_profit_per_unit_last_3mo": r.actual_profit_per_unit,
"months_unprofitable_of_observed": r.unprofitable_months,
"model_overstates_profit_per_unit_by": r.unmodeled_cost_gap,
# MODEL — context only
"ad_cost_per_unit": r.ad_cost_per_unit,
"net_per_unit_after_ad": r.net_per_unit_incl_ad,
"true_margin_pct_after_ad": (round(r.true_margin_pct * 100, 1)
if r.true_margin_pct is not None else None),
"is_losing_money_after_ad": r.is_losing_money,
"price_elasticity": r.elasticity,
"monthly_price_history": r.price_history,
"profit_optimal_price": r.profit_optimal_price,
"profit_optimal_is_extrapolated": r.extrapolated,
"fee_breakdown": r.fees_detail, "bulk": r.bulk_detail,
"verdict": r.rating.value, "verdict_headline": r.headline,
"pricing_gate": r.gate_decision,
"gate_reasons": r.gate_reasons,
}
if r.competitive is not None:
c = r.competitive
facts["amazon_buy_box"] = {
"status": c.buy_box_status.value,
"price": c.buy_box_price,
"seller": c.buy_box_seller_name,
"band_low_median_high": [
c.competitive_low, c.competitive_median, c.competitive_high,
],
"offers": [
{"seller": o.seller_name, "price": o.price, "buy_box": o.is_buy_box}
for o in c.offers[:8]
],
}
if not settings.openai_api_key:
return (f"{r.headline} At ${r.price:.2f} the contribution margin is "
f"{r.margin_pct*100:.1f}% (${r.take_home_per_unit:.2f}/unit take-home). "
f"6-month demand averages {r.avg_6m or 0:,.0f}/day ({r.trend}); "
f"{r.inventory:,.0f} units on hand"
+ (f" (~{r.cover_days} days cover)." if r.cover_days else ".")
+ (f" Suggested price ${r.suggested_price:.2f} to clear the target margin."
if r.suggested_price else ""))
import json
prompt = [("system", _ANALYST_SYSTEM),
("human", "Analyze this SKU:\n" + json.dumps(facts, default=str))]
# Try the big reasoning model first, then the fast model, then a template.
for model, reasoning in ((settings.openai_analysis_model, True),
(settings.openai_model, False)):
try:
logger.info("AI analysis: calling %s%s", model,
f" (reasoning={settings.openai_reasoning_effort})" if reasoning else "")
out = _invoke_openai(model, prompt, reasoning, settings).strip()
logger.info("AI analysis: %s returned %d chars", model, len(out))
return out
except Exception as e:
logger.warning("AI analysis: %s failed (%s), trying fallback", model, e)
continue
return f"{r.headline} (LLM unavailable; showing rule-based verdict.)"
def _invoke_openai(model: str, prompt, reasoning: bool, settings) -> str:
"""Call an OpenAI model via LangChain. Reasoning models (gpt-5/o*) omit temperature
and take a reasoning_effort; others use a low temperature."""
from langchain_openai import ChatOpenAI
is_reasoning = model.startswith(("gpt-5", "o1", "o3", "o4"))
kwargs = dict(model=model, api_key=settings.openai_api_key, timeout=180)
if is_reasoning and reasoning:
effort = settings.openai_reasoning_effort
if effort == "minimal": # gpt-5.1 rejects it; would silently fall back to gpt-4.1
effort = "low"
kwargs["reasoning_effort"] = effort
else:
kwargs["temperature"] = 0.2
msg = ChatOpenAI(**kwargs).invoke(prompt)
return msg.content or ""
def _build_service(settings: Settings):
from pricing_agent.cosmos.client import CosmosClient
from pricing_agent.cosmos.service import CosmosPricingService
client = CosmosClient(
base_url=settings.cosmos_base_url,
email=settings.cosmos_email or "",
password=settings.cosmos_password or "",
timeout=settings.cosmos_timeout_seconds,
)
return CosmosPricingService(client, marketplace=settings.cosmos_marketplace)
def _elasticity_block(svc, sku: str, stack, rules, history=None) -> dict:
"""Fetch sales history once, then derive:
EVIDENCE (leads): actual profit at each observed price, best observed price,
how many months actually lost money.
MODEL (context): ad cost, price elasticity, profit-optimal price.
Returns a dict of AnalysisResult fields (empty on insufficient data). Slow (~12 API
calls for the 6-month window) so only run for detailed single-SKU analysis.
"""
from pricing_agent.elasticity import (
estimate_elasticity, monthly_aggregate, optimize_price, unconstrained_optimum,
)
from pricing_agent.performance import (
ad_cost_model, ad_per_unit_at, best_observed, empirical_break_even,
monthly_performance, observed_price_range, price_band_performance,
recent_profit_per_unit, reconcile, unprofitable_months,
)
if history is None:
logger.info("evidence: pulling 6-month sales history")
hist = svc.get_sales_history(sku, days=180)
else:
logger.info("evidence: using pre-fetched history (%d days)", len(history))
hist = history
monthly = monthly_aggregate(hist)
if len(monthly) < 3:
return {}
ad = sum(m["ad_per_unit"] for m in monthly[-3:]) / min(3, len(monthly)) # recent ad/unit
price = stack.selling_price
referral_pct = stack.referral_pct
returns_pct = stack.returns_reserve / price if price else 0.0
fixed = stack.landed_cost + stack.fba_fee + stack.storage_alloc
margin_rate = 1 - referral_pct - returns_pct
# ── EVIDENCE: what actually happened, from COSMOS's own `profit` field ──
perf = monthly_performance(hist)
bands = price_band_performance(hist, band=0.50, min_days=7)
best = best_observed(bands)
actual_ppu = recent_profit_per_unit(perf, months=3)
n_bad = unprofitable_months(perf)
ad_model = ad_cost_model(bands)
obs_range = observed_price_range(bands)
def ad_at(p: float) -> float:
"""Ad cost per unit at price p — from observed behaviour, not constant TACoS."""
return ad_per_unit_at(p, ad_model, ad)
def net(p: float) -> float: # net profit/unit at price p, INCLUDING ad cost
return p * margin_rate - fixed - ad_at(p)
take_home = stack.profit
net_incl_ad = round(take_home - ad, 2)
if best:
logger.info("evidence: best observed price $%.2f -> $%.0f/day actual profit "
"(%d/%d months unprofitable)", best["price_band"],
best["actual_profit_per_day"], n_bad, len(perf))
out = {
"price_performance": perf,
"price_bands": bands,
"best_observed_price": (best["price_band"] if best else None),
"best_observed_profit_day": (best["actual_profit_per_day"] if best else None),
"actual_profit_per_unit": actual_ppu,
"unprofitable_months": n_bad,
"unmodeled_cost_gap": reconcile(actual_ppu, net_incl_ad),
# model-side economics
"ad_cost_per_unit": round(ad, 2),
"net_per_unit_incl_ad": net_incl_ad,
"true_margin_pct": round(net_incl_ad / price, 4) if price else None,
# Actual profit wins over the modelled net when we have it.
"is_losing_money": (actual_ppu < 0) if actual_ppu is not None else (net_incl_ad < 0),
"price_history": monthly,
# ── FLOORS: the fee-stack break-even ignores advertising entirely ──
"break_even_with_ads": (round((fixed + ad) / margin_rate, 2)
if margin_rate > 0 else None),
"ad_cost_model": ad_model,
"observed_price_min": obs_range[0] if obs_range else None,
"observed_price_max": obs_range[1] if obs_range else None,
}
emp_be = empirical_break_even(bands)
if emp_be:
out["break_even_empirical"] = emp_be["price"]
out["break_even_empirical_fit"] = emp_be
# Break-even against the ad curve. Holding ad cost flat understates the bar
# whenever ad/unit climbs with price:
# p·m fixed (a + b·p) = 0 → p = (fixed + a) / (m b)
# `m b` is what each extra $1 of price is really worth. When ads eat almost
# all of it there is no price that clears the bar, and raising price is simply
# the wrong lever — the fix is ad efficiency or COGS.
if ad_model and ad_model.get("r2", 0) >= 0.25 and margin_rate > 0:
b, a = ad_model["slope"], ad_model["intercept"]
contribution = margin_rate - b
out["contribution_per_dollar"] = round(contribution, 4)
if contribution <= 0:
out["ad_curve_unrecoverable"] = True # price never catches ads
else:
be_curve = (fixed + a) / contribution
out["break_even_ad_curve"] = round(be_curve, 2)
# A break-even far outside anything ever charged is not a target, it
# is a diagnosis: the SKU cannot be priced into profit, and treating
# that number as a floor would put an absurd price on screen.
hi = obs_range[1] if obs_range else None
if hi and be_curve > hi * UNREACHABLE_MULTIPLE:
out["ad_curve_unrecoverable"] = True
el = estimate_elasticity(monthly)
out["elasticity"] = el
out["elasticity_actionable"] = bool(el and el.get("actionable"))
if el and el.get("elasticity"):
prices = [m["avg_price"] for m in monthly]
lo, hi = min(prices), max(prices)
ref = monthly[-1]
best, table = optimize_price(
floor=round(lo * 0.9, 2), ceiling=round(hi * 1.15, 2),
ref_price=ref["avg_price"], ref_units=ref["units_per_day"],
elasticity=el["elasticity"], net_profit_fn=net, step=1.0,
)
# Where the model's own maximum actually sits, independent of the sweep
# window. A p* far above the tested range is evidence against the fit.
p_star = unconstrained_optimum(
elasticity=el["elasticity"], margin_rate=margin_rate,
fixed_per_unit=fixed + ad_at(hi))
out["profit_optimal_unconstrained"] = p_star
if best:
out["demand_curve"] = table
out["profit_optimal_is_corner"] = bool(best.get("is_corner"))
out["extrapolated"] = best["price"] > hi
# Only publish a profit-optimal price when it is a genuine interior
# maximum AND the elasticity behind it is statistically usable.
if best.get("is_corner") or not out["elasticity_actionable"]:
reason = ("sweep hit the search ceiling (not a maximum)"
if best.get("is_corner")
else "elasticity CI spans zero")
out["profit_optimal_blocked_reason"] = reason
logger.info("profit-optimal $%.2f suppressed — %s",
best["price"], reason)
else:
out["profit_optimal_price"] = best["price"]
out["profit_optimal_daily"] = best["daily_profit"]
logger.info("elasticity %.2f → profit-optimal $%.2f ($%.0f/day)",
el["elasticity"], best["price"], best["daily_profit"])
return out
def analyze_price(
sku: str, price: float, settings: Settings | None = None, svc=None,
with_narrative: bool = False, with_elasticity: bool = False, history=None,
with_competitive: bool = False, current_price: float | None = None,
) -> AnalysisResult:
"""Run the full price analysis for one SKU at one candidate price (COSMOS).
Pass a shared ``svc`` (CosmosPricingService) to reuse one authenticated session
across many SKUs (bulk mode). ``with_narrative`` adds an OpenAI-written summary.
``with_elasticity`` adds ad cost + 6-month elasticity + profit optimization (slow).
``with_competitive`` scrapes Buy Box / competitor offers via Apify when
``APIFY_TOKEN`` is set (slow; use for single-SKU UI, not bulk catalog scans).
"""
from pricing_agent.cosmos.service import takehome_to_feequote
from pricing_agent.tools.margin_engine import stack_from_quote
settings = settings or get_settings()
rules = get_rules()
svc = svc or _build_service(settings)
logger.info("== analyzing %s @ $%.2f ==", sku, price)
# 1. per-unit margin — keep the FULL take-home response for display
logger.info("step 1/4: fetching fee stack (takehome-calculator)")
takehome = svc.get_takehome(sku, price)
quote = takehome_to_feequote(takehome)
stack = stack_from_quote(quote, rules)
logger.info(" -> margin %.1f%%, break-even $%.2f", stack.contribution_margin_pct * 100,
stack.break_even_price)
# current selling price — reuse the caller's value when given (analyze_sku already
# looked it up); only fetch when we weren't handed one, to avoid a duplicate call.
if current_price is None:
current_price = svc.get_current_price(sku)
# 2. sales trend + inventory
logger.info("step 2/4: fetching 6-month sales trend + inventory (invp-insight)")
invp = svc.get_invp(sku)
avg_7d = invp.avg_7d if invp else None
avg_30d = invp.avg_30d if invp else None
avg_6m = invp.avg_6m if invp else None
inventory = invp.inventory if invp else 0.0
cover_days = invp.cover_days if invp else None
asin = invp.asin if invp else None
if not asin:
product = svc.get_product(sku)
asin = product.asin if product else None
trend = classify_trend(avg_30d, avg_6m)
selling = bool((avg_6m or avg_30d or 0) > 0)
# 3. bulk economics — a month of sales at this price, net of storage on stock
monthly_units = round((avg_30d or avg_6m or 0) * 30)
monthly_take_home = storage_charges = None
bulk_detail = None
if invp is not None and monthly_units > 0:
logger.info("step 3/4: bulk economics (bulk-calculator) for ~%d units/mo", monthly_units)
bulk = svc.bulk_quote(sku, price, monthly_units, inventory)
monthly_take_home = bulk.take_home
# The calculator's storageCharges is a PER-DAY figure — see the note on
# dashboard.live_data._storage_30d for the evidence. Everything here is on
# a monthly basis (monthly_units, monthly_take_home), so scale it.
storage_charges = (bulk.storage_charges or 0.0) * DAYS_PER_MONTH
bulk_detail = bulk.model_dump(by_alias=True)
rating, headline, reasons = price_verdict(
margin_pct=stack.contribution_margin_pct, trend=trend, selling=selling,
cover_days=cover_days, monthly_take_home=monthly_take_home, rules=rules,
)
# Quantified demand change (30-day vs 6-month baseline) + price scenarios.
demand_change = None
if avg_6m:
demand_change = round(((avg_30d or avg_6m) - avg_6m) / avg_6m * 100, 1)
scenarios = price_scenarios(stack, monthly_units, storage=storage_charges or 0.0)
logger.info("step 4/4: verdict %s - %s", rating.value, headline)
sug = suggested_price(stack, rules.margin_target)
if sug is not None:
logger.info(" -> suggested price $%.2f (clears %.0f%% target)", sug,
rules.margin_target * 100)
if sug is not None and stack.contribution_margin_pct < rules.margin_floor:
reasons.append(f"Suggested price ≥{rules.margin_target*100:.0f}% margin: ${sug:.2f}.")
# 5. Buy Box / competitor offers (Apify) — optional, single-SKU path
competitive = None
gate_decision = None
gate_reasons: list[str] = []
if with_competitive and settings.apify_token:
from pricing_agent.providers import get_provider
from pricing_agent.tools.gate import evaluate_gate
logger.info("step 5: Apify competitive scrape for ASIN %s", asin)
provider = get_provider(settings)
sku_in = SkuInput(
sku=sku,
asin=asin,
product_name="",
marketplace="US",
category="",
landed_cost=stack.landed_cost,
target_price=price,
)
if not sku_in.asin:
sku_in = provider.enrich(sku_in)
asin = sku_in.asin or asin
competitive = provider.competitive(sku_in)
decision, gate_reasons = evaluate_gate(
gate_stack=stack, competitive=competitive, rules=rules
)
gate_decision = decision.value
logger.info(
" -> Buy Box %s @ %s (%s) · %d offers · gate %s",
competitive.buy_box_status.value,
(f"${competitive.buy_box_price:.2f}"
if competitive.buy_box_price is not None else "n/a"),
competitive.buy_box_seller_name or "",
len(competitive.offers),
gate_decision,
)
if competitive.buy_box_price is not None:
reasons.append(
f"Buy Box: {competitive.buy_box_status.value}"
f"{competitive.buy_box_seller_name or 'seller'} @ "
f"${competitive.buy_box_price:.2f}."
)
# COSMOS "current" often lags Amazon's featured offer — flag it.
ref = current_price if current_price is not None else price
if ref and abs(competitive.buy_box_price - ref) / ref >= 0.05:
reasons.append(
f"Amazon featured ${competitive.buy_box_price:.2f} differs from "
f"COSMOS/analyzed ${ref:.2f} — margin at the COSMOS price can mislead; "
f"re-test with “Test a specific price” set to the Buy Box amount."
)
elif competitive.is_suppressed:
reasons.append("Buy Box suppressed / no featured price on the PDP.")
if competitive.offers:
top = ", ".join(
f"{o.seller_name} @ "
f"{('$' + format(o.price, '.2f')) if o.price is not None else 'n/a'}"
for o in competitive.offers[:4]
)
reasons.append(f"Competitor offers: {top}.")
reasons.append(f"Pricing gate: {gate_decision}.")
result = AnalysisResult(
sku=sku, asin=asin,
price=price, current_price=current_price,
take_home_per_unit=round(quote.net_takehome or stack.profit, 2),
margin_pct=round(stack.contribution_margin_pct, 4),
break_even=round(stack.break_even_price, 2),
total_cost_per_unit=round(stack.total_cost, 2),
demand_change_pct=demand_change,
scenarios=scenarios,
avg_7d=avg_7d, avg_30d=avg_30d, avg_6m=avg_6m, trend=trend,
inventory=inventory, cover_days=cover_days, monthly_units=monthly_units,
monthly_take_home=(round(monthly_take_home, 2) if monthly_take_home is not None else None),
storage_charges=(round(storage_charges, 2) if storage_charges is not None else None),
suggested_price=sug,
fees_detail=takehome.model_dump(by_alias=True),
bulk_detail=bulk_detail,
rating=rating, headline=headline, reasons=reasons,
competitive=competitive,
gate_decision=gate_decision,
gate_reasons=gate_reasons,
)
# Evidence (actual profit history) + ad cost + elasticity (single-SKU detailed mode).
if with_elasticity:
try:
for k, v in _elasticity_block(svc, sku, stack, rules, history=history).items():
setattr(result, k, v)
# Actual results override margin-based optimism.
ppu, n_bad = result.actual_profit_per_unit, result.unprofitable_months
n_months = len(result.price_performance)
if ppu is not None and ppu < 0:
result.rating = Rating.POOR
result.headline = ("Chronically unprofitable — actually loses money at the "
"current price.")
result.reasons.insert(0, (
f"ACTUAL LOSS: {usd(ppu)} profit/unit over the last 3 months "
f"(COSMOS actuals, incl. storage, refunds, promo, ads)."))
elif n_bad >= 3:
result.rating = Rating.POOR
result.headline = (f"Unprofitable in {n_bad} of the last {n_months} months "
"(actual).")
result.reasons.insert(0, f"Lost money in {n_bad}/{n_months} observed months.")
if n_bad and n_bad == n_months:
result.reasons.insert(0, "Unprofitable at EVERY observed price — "
"raise well above the best observed price, or delist.")
if result.unmodeled_cost_gap and result.unmodeled_cost_gap > 0.25:
result.reasons.append(
f"Modelled net overstates profit by ${result.unmodeled_cost_gap:.2f}/unit "
f"(unmodeled storage/refunds/promo/logistics) — trust the actual.")
except Exception as e:
logger.warning("evidence/elasticity analysis failed: %s", e)
if with_narrative:
result.narrative = generate_analysis_narrative(result)
return result
def _recommended_price(svc, sku: str, rules, start: float = 29.99, max_iter: int = 4) -> float:
"""Fixpoint price P where suggested_price(fees @ P) == P.
A one-pass estimate off a fixed reference price is wrong for SKUs whose return fee
is a flat amount (not a % of price), because the returns-as-% term shifts with price.
Iterating to the fixpoint removes that ~$1 inconsistency (verified on
UBCFKMATTRESSPROTECTORTWIN88: 15.99 -> 16.99). Converges in ~2-3 steps.
"""
from pricing_agent.cosmos.service import takehome_to_feequote
from pricing_agent.tools.margin_engine import stack_from_quote
price = start
sug = start
for _ in range(max_iter):
stack = stack_from_quote(takehome_to_feequote(svc.get_takehome(sku, price)), rules)
nxt = suggested_price(stack, rules.margin_target)
if nxt is None:
return round(price, 2)
sug = nxt
if abs(sug - price) < 0.02: # suggested == the price we evaluated at
break
price = sug
return sug
def analyze_recommended(
sku: str, settings: Settings | None = None, svc=None, with_narrative: bool = True,
with_elasticity: bool = False, with_competitive: bool = False,
) -> AnalysisResult:
"""Analyze a SKU at its own recommended (margin-floor) price."""
settings = settings or get_settings()
rules = get_rules()
svc = svc or _build_service(settings)
sug = _recommended_price(svc, sku, rules)
return analyze_price(sku, sug, settings, svc=svc, with_narrative=with_narrative,
with_elasticity=with_elasticity, with_competitive=with_competitive)
def analyze_sku(
sku: str, settings: Settings | None = None, svc=None, with_narrative: bool = True,
with_elasticity: bool = True, history=None, with_competitive: bool = False,
) -> AnalysisResult:
"""Assess a SKU AT ITS CURRENT PRICE (so margin/net/losing-money reflect reality),
with the suggested + profit-optimal price as the recommendation. Falls back to the
recommended price if the current price can't be read. Pass `history` to reuse a
pre-fetched 6-month history (product-line mode) and skip ~12 API calls."""
settings = settings or get_settings()
svc = svc or _build_service(settings)
cp = svc.get_current_price(sku)
if cp:
return analyze_price(sku, cp, settings, svc=svc, with_narrative=with_narrative,
with_elasticity=with_elasticity, history=history,
with_competitive=with_competitive, current_price=cp)
return analyze_recommended(sku, settings, svc=svc, with_narrative=with_narrative,
with_elasticity=with_elasticity,
with_competitive=with_competitive)
def analyze_catalog(
skus: list[str], settings: Settings | None = None, at: str = "suggested", svc=None
) -> list[AnalysisResult]:
"""Bulk-analyze many SKUs on one authenticated session.
at="suggested" evaluates each SKU at its own recommended price; a numeric string
evaluates every SKU at that fixed price.
"""
settings = settings or get_settings()
rules = get_rules()
svc = svc or _build_service(settings)
fixed_price = None if at == "suggested" else float(at)
results: list[AnalysisResult] = []
for sku in skus:
try:
if fixed_price is not None:
results.append(analyze_price(sku, fixed_price, settings, svc=svc))
continue
# Derive the recommended price (fixpoint), then analyze the SKU at it.
sug = _recommended_price(svc, sku, rules)
results.append(analyze_price(sku, sug, settings, svc=svc))
except Exception as e: # keep the batch going
results.append(AnalysisResult(
sku=sku, price=0.0, take_home_per_unit=0.0, margin_pct=0.0,
rating=Rating.POOR, headline=f"analysis failed: {e}",
))
return results

View File

@ -0,0 +1,301 @@
"""Walk-forward backtest — does the profit model actually predict this SKU?
Every other module answers "what should the price be?". This one answers the
question that has to come first: **how wrong has the model been on this SKU?**
Without it, a change to the engine is an opinion.
Method (pure, no I/O it reuses the 180-day history the dashboard already
fetched, so scoring a SKU costs nothing extra):
for each fold:
train on history[:cut]
predict the profit the SKU would book over history[cut:cut+holdout]
AT THE PRICE IT ACTUALLY CHARGED there
score against what COSMOS actually booked
Predicting at the realised price is deliberate: we are scoring the *profit
model*, not the price choice. A model that cannot predict the profit of a price
you already ran has no business recommending a price you have not.
Three methods are scored side by side so improvements are demonstrable:
``anchored`` demand anchored at the realised price, ad cost per unit
fitted against price, no calibration
``anchored_gap`` the same, minus a FIXED unmodeled cost per unit measured on
the training window. COSMOS books costs the fee model never
sees (real returns, promos, removals, logistics); subtracting
a constant $/unit corrects the level without touching the
slope between prices unlike a multiplicative factor.
``persistence`` assume the next window repeats the last one. The honest
baseline: a model that cannot beat it adds nothing.
``legacy`` the previous engine demand anchored at LIST price, ad spend
at constant TACoS, and a multiplicative realisation factor
"""
from __future__ import annotations
from .elasticity import estimate_elasticity, monthly_aggregate
from .performance import ad_cost_model, ad_per_unit_at, price_band_performance
METHODS = ("anchored", "anchored_gap", "persistence", "legacy")
# Only these may be SELECTED to price with. `persistence` has no price response at
# all — it cannot answer "what if I change the price", so it is a yardstick, not a
# model. `legacy` is structurally wrong (list anchoring, constant TACoS); if it
# happens to win a 3-fold contest that is luck, not a reason to adopt it.
PRICING_METHODS = ("anchored", "anchored_gap")
# Switch away from the default only on a materially better score, so a handful of
# folds cannot flip the engine on noise.
SELECTION_MARGIN = 0.15
DEFAULT_METHOD = "anchored_gap"
def _day_rows(history) -> list[dict]:
"""Daily rows in chronological order, skipping days with no sales."""
rows = []
for p in history or []:
if not p.sale_price or (p.units or 0) <= 0:
continue
d = p.date # MM/DD/YYYY
rows.append({
"sort": d[6:] + d[:2] + d[3:5],
"price": float(p.sale_price),
"units": float(p.units or 0),
"revenue": float(p.revenue or (p.sale_price or 0) * (p.units or 0)),
"ad": float((p.marketing_cost or 0) + (getattr(p, "promotion_spend", 0) or 0)),
"profit": float(p.profit or 0),
})
rows.sort(key=lambda r: r["sort"])
return rows
def _agg(rows: list[dict]) -> dict | None:
n = len(rows)
if not n:
return None
units = sum(r["units"] for r in rows)
if units <= 0:
return None
revenue = sum(r["revenue"] for r in rows)
return {"days": n, "units": units, "revenue": revenue,
"ad": sum(r["ad"] for r in rows), "profit": sum(r["profit"] for r in rows),
"units_day": units / n, "price": revenue / units}
def _take_home(price: float, fp: dict) -> float:
return (price * (1 - fp["referral_pct"] - fp["returns_pct"])
- fp["cost"] - fp["fba"] - fp["variable"])
def unmodeled_gap_per_unit(train: dict, fp: dict, storage_30d: float,
ad_model: dict | None) -> float:
"""Per-unit cost COSMOS books that the fee model does not see.
Measured on the training window at the price actually realised there:
(modelled net actual net) ÷ units. Applied as a constant $/unit deduction,
so the level moves but the slope between prices does not.
"""
units = train["units"]
if units <= 0:
return 0.0
ad_pu = ad_per_unit_at(train["price"], ad_model, train["ad"] / units)
modelled = (_take_home(train["price"], fp) - ad_pu) * units - storage_30d
actual = train["profit"]
return (modelled - actual) / units
def _predict(method: str, train: dict, test_price: float, fp: dict,
storage_30d: float, elasticity: float | None, ad_model: dict | None,
list_price: float) -> float:
"""Predicted booked profit over a 30-day window at `test_price`."""
train_ad_pu = train["ad"] / train["units"]
if method == "persistence":
# No model at all: repeat the training window's profit rate.
return train["profit"] / train["days"] * 30
anchor = train["price"] if method.startswith("anchored") else list_price
if elasticity is not None and anchor > 0:
units_day = train["units_day"] * (test_price / anchor) ** elasticity
else:
units_day = train["units_day"]
units_30d = units_day * 30
gross = _take_home(test_price, fp) * units_30d - storage_30d
if method.startswith("anchored"):
ad = ad_per_unit_at(test_price, ad_model, train_ad_pu) * units_30d
else: # legacy: constant TACoS
tacos = train["ad"] / train["revenue"] if train["revenue"] else 0.0
ad = tacos * units_30d * test_price
net = gross - ad
if method == "anchored_gap":
# Scale the training window's per-unit gap to a 30-day basis.
gap_pu = unmodeled_gap_per_unit(train, fp, storage_30d * train["days"] / 30.0,
ad_model)
net -= gap_pu * units_30d
if method == "legacy":
# Multiplicative realisation factor fitted at the training price.
train_units_30d = train["units_day"] * 30
modelled_now = (_take_home(list_price, fp) * train_units_30d - storage_30d
- (train["ad"] / train["revenue"] if train["revenue"] else 0.0)
* train_units_30d * list_price)
actual_now = train["profit"] / train["days"] * 30
if modelled_now > 0:
f = actual_now / modelled_now
if 0.02 <= f <= 3.0:
net *= f
return net
def walk_forward(history, fee_params: dict, storage_30d: float = 0.0,
*, list_price: float | None = None, holdout_days: int = 30,
min_train_days: int = 90, max_folds: int = 4) -> dict | None:
"""Score the profit model against held-out history.
Returns {folds, scores: {method: {mae, mape, bias, n}}, best, n_folds} or None
when there isn't enough history to hold anything out.
"""
rows = _day_rows(history)
if len(rows) < min_train_days + holdout_days:
return None
cuts = []
cut = len(rows) - holdout_days
while cut >= min_train_days and len(cuts) < max_folds:
cuts.append(cut)
cut -= holdout_days
cuts.reverse()
folds, errors = [], {m: [] for m in METHODS}
for cut in cuts:
train_rows, test_rows = rows[:cut], rows[cut:cut + holdout_days]
train, test = _agg(train_rows), _agg(test_rows)
if not train or not test:
continue
# Everything below is fitted on the TRAINING slice only.
class _P: # minimal SalesDay stand-in
__slots__ = ("date", "sale_price", "units", "revenue", "profit",
"marketing_cost", "promotion_spend")
train_hist = []
for r in train_rows:
p = _P()
p.date = f"{r['sort'][4:6]}/{r['sort'][6:8]}/{r['sort'][:4]}"
p.sale_price, p.units = r["price"], r["units"]
p.revenue, p.profit = r["revenue"], r["profit"]
p.marketing_cost, p.promotion_spend = r["ad"], 0.0
train_hist.append(p)
bands = price_band_performance(train_hist, band=0.50, min_days=7)
adm = ad_cost_model(bands)
el = estimate_elasticity(monthly_aggregate(train_hist))
# Only a statistically usable elasticity is allowed to move volume —
# the same gate the live engine applies.
e = el["elasticity"] if (el and el.get("actionable")) else None
actual = test["profit"] / test["days"] * 30
fold = {"cut_day": cut, "train_days": train["days"], "test_days": test["days"],
"test_price": round(test["price"], 2),
"train_price": round(train["price"], 2),
"actual_net_30d": round(actual), "elasticity_used": e, "pred": {}}
for m in METHODS:
pred = _predict(m, train, test["price"], fee_params, storage_30d, e, adm,
list_price or train["price"])
fold["pred"][m] = round(pred)
errors[m].append((pred - actual, actual))
folds.append(fold)
if not folds:
return None
scores = {}
for m in METHODS:
errs = errors[m]
n = len(errs)
mae = sum(abs(e) for e, _ in errs) / n
bias = sum(e for e, _ in errs) / n
denom = sum(abs(a) for _, a in errs)
scores[m] = {"mae": round(mae), "bias": round(bias),
"mape": round(sum(abs(e) for e, _ in errs) / denom * 100, 1)
if denom else None, "n": n}
best = min(scores, key=lambda m: scores[m]["mae"])
# Selection among usable pricing models: keep the default unless a rival is
# materially better on this SKU's own held-out history.
selected = DEFAULT_METHOD
rival = min(PRICING_METHODS, key=lambda m: scores[m]["mae"])
if (rival != selected
and scores[rival]["mae"] < scores[selected]["mae"] * (1 - SELECTION_MARGIN)):
selected = rival
beats_baseline = scores[selected]["mae"] < scores["persistence"]["mae"]
return {"folds": folds, "scores": scores, "best": best, "selected": selected,
"beats_baseline": beats_baseline, "n_folds": len(folds),
"holdout_days": holdout_days}
# ------------------------------------------------------------------ trust
TRUST_TIERS = ("High", "Medium", "Low", "None")
def trust_score(backtest: dict | None, elasticity: dict | None,
bands: list | None, *, has_costs: bool = True,
storage_verified: bool = False) -> dict:
"""How far this SKU's model may be trusted, and what that permits.
Deliberately conservative: the tier is the WORST of the individual signals,
because one broken input is enough to make a recommendation wrong. Replaces
a confidence score derived from days-with-sales, which happily returned
"Medium" for a SKU whose elasticity could not be distinguished from zero.
"""
reasons, tier = [], "High"
def demote(to: str, why: str):
nonlocal tier
reasons.append(why)
if TRUST_TIERS.index(to) > TRUST_TIERS.index(tier):
tier = to
if not has_costs:
demote("None", "COSMOS has no COGS/FBA for this SKU — every margin is wrong")
ok_bands = [b for b in (bands or []) if b.get("enough_data")]
if len(ok_bands) < 2:
demote("Low", f"only {len(ok_bands)} price point(s) with a real sample — "
f"nothing to learn a price response from")
elif len(ok_bands) < 4:
demote("Medium", f"{len(ok_bands)} sampled price points — thin evidence base")
if not (elasticity or {}).get("actionable"):
demote("Medium", "elasticity is not statistically usable; recommendations "
"rest on observed prices only")
if not backtest:
demote("Medium", "not enough history to hold any out — model unscored")
else:
s = backtest["scores"][backtest["selected"]]
if s["mape"] is None:
demote("Medium", "backtest inconclusive")
elif s["mape"] > 50:
demote("Low", f"backtest error {s['mape']:.0f}% of actual profit — the "
f"model does not predict this SKU")
elif s["mape"] > 25:
demote("Medium", f"backtest error {s['mape']:.0f}% of actual profit")
if not backtest["beats_baseline"]:
demote("Low", "model is beaten by simply assuming next month repeats "
"last month")
if not storage_verified:
demote("Medium", "storage cost unverified — the bulk calculator's figure is "
"far below FBA oversize rates, and the per-warehouse split "
"that would settle it (/api/warehouse-inv-levels) returns 403 "
"for these credentials")
permits = {
"High": "recommend and bulk-approve within the step cap",
"Medium": "recommend; every change needs human approval",
"Low": "propose an experiment only — no automated change",
"None": "fix the data first; no pricing action",
}[tier]
return {"tier": tier, "reasons": reasons, "permits": permits,
"auto_approve": tier == "High"}

383
src/pricing_agent/cli.py Normal file
View File

@ -0,0 +1,383 @@
"""CLI entrypoint — runs the pricing agent for one or many SKUs with HITL approval.
Examples:
python -m pricing_agent.cli --sku UBMICROFIBER4PCQUEENGREY
python -m pricing_agent.cli --all
python -m pricing_agent.cli --all --auto smart # non-interactive (for tests/CI)
--auto modes (skip the prompt):
smart approve APPROVED decisions, reject everything else
approve approve whatever is proposed
reject reject everything
"""
from __future__ import annotations
import argparse
import sys
# Windows consoles default to cp1252 and choke on box-drawing glyphs. Force UTF-8
# on our streams where supported so output is portable.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except (AttributeError, ValueError):
pass
from config.settings import get_settings
from langgraph.types import Command
from pricing_agent.graph import build_graph
from pricing_agent.schemas import Decision, SkuInput
from pricing_agent.state import PricingState
from pricing_agent.tools.tracker import get_tracker
BAR = "" * 68
def _fmt_money(v) -> str:
return f"${float(v):.2f}" if v is not None else ""
def _fmt_units(v) -> str:
return f"{v:,.0f}/day" if v else ""
def _print_analysis_block(a: dict) -> None:
"""Sales trend + inventory + verdict, shown on every card when available (COSMOS)."""
icon = {"GOOD": "🟢", "CAUTION": "🟡", "POOR": "🔴"}.get(a.get("rating"), "")
print(" SALES TREND (avg units/day)")
print(f" 7d / 30d / 6-month: {_fmt_units(a.get('avg_7d'))} / "
f"{_fmt_units(a.get('avg_30d'))} / {_fmt_units(a.get('avg_6m'))}"
f"{str(a.get('trend', '')).upper()}")
inv = a.get("inventory") or 0
cover = a.get("cover_days")
print(f" INVENTORY: {inv:,.0f} units"
+ (f" ({cover} days cover)" if cover is not None else ""))
if a.get("monthly_take_home") is not None:
st = a.get("storage")
print(f" Est. monthly take-home: ${a['monthly_take_home']:,.2f}"
+ (f" (after ${st:,.2f} storage)" if st else ""))
if a.get("suggested_price") is not None:
print(f" SUGGESTED PRICE (≥ target margin): ${a['suggested_price']:.2f}")
print(BAR)
print(f" {icon} VERDICT: {a.get('rating')}{a.get('headline')}")
for reason in a.get("reasons", []):
print(f"{reason}")
print(BAR)
def _print_proposal(p: dict) -> None:
print("\n" + BAR)
print(f" SKU: {p['sku']} ASIN: {p.get('asin') or ''}")
print(f" PROPOSED DECISION: {p['decision']}")
print(BAR)
print(f" Recommended price : {_fmt_money(p['recommended_price'])}")
print(f" Contribution margin: {p['contribution_margin_pct'] * 100:.1f}%")
print(f" Break-even : {_fmt_money(p['break_even'])}")
print(f" MAP floor : {_fmt_money(p['map_floor'])}")
print(f" Buy Box : {p['buy_box']}")
if p.get("buy_box_seller") or p.get("buy_box_price") is not None:
seller = p.get("buy_box_seller") or ""
bb = p.get("buy_box_price")
bb_bit = f" @ ${float(bb):.2f}" if bb is not None else ""
print(f" Buy Box seller : {seller}{bb_bit}")
offers = p.get("competitor_offers") or []
if offers:
print(" COMPETITORS (seller @ price)")
for o in offers:
tag = " ← Buy Box" if o.get("is_buy_box") else ""
price = o.get("price")
print(f"{o.get('seller_name') or 'Unknown'}"
f" {_fmt_money(price) if price is not None else 'n/a'}{tag}")
print(BAR)
if p.get("analysis"):
_print_analysis_block(p["analysis"])
print(f" Rationale: {p['rationale']}")
print(f" Action : {p['recommended_action']}")
print(BAR)
def _prompt_human(p: dict) -> dict:
_print_proposal(p)
while True:
try:
ans = input(" [a]pprove / [e]dit price / [r]eject ? ").strip().lower()
except EOFError:
# No interactive input available -> safe default is to NOT approve.
print("\n (no input; defaulting to reject)")
return {"action": "reject", "price": None, "note": "no input (EOF)"}
if ans in ("a", "approve"):
return {"action": "approve", "price": None, "note": ""}
if ans in ("e", "edit"):
try:
raw = input(" New price: ").strip()
return {"action": "edit", "price": float(raw), "note": "manual edit"}
except EOFError:
return {"action": "reject", "price": None, "note": "no input (EOF)"}
except ValueError:
print(" Invalid number, try again.")
continue
if ans in ("r", "reject"):
try:
note = input(" Reason (optional): ").strip()
except EOFError:
note = ""
return {"action": "reject", "price": None, "note": note}
print(" Please answer a, e, or r.")
def _auto_decision(p: dict, mode: str) -> dict:
if mode == "approve":
return {"action": "approve", "price": None, "note": "auto-approve"}
if mode == "reject":
return {"action": "reject", "price": None, "note": "auto-reject"}
# smart
if p["decision"] == Decision.APPROVED.value:
return {"action": "approve", "price": None, "note": "auto-smart approve"}
return {"action": "reject", "price": None, "note": "auto-smart reject (not APPROVED)"}
def run_one(graph, sku_input: SkuInput, auto: str | None) -> PricingState:
config = {"configurable": {"thread_id": f"sku:{sku_input.sku}"}}
state = graph.invoke({"sku_input": sku_input}, config=config)
# Handle the human-review interrupt.
interrupts = state.get("__interrupt__")
if interrupts:
payload = interrupts[0].value
decision = _auto_decision(payload, auto) if auto else _prompt_human(payload)
state = graph.invoke(Command(resume=decision), config=config)
return state # type: ignore[return-value]
def _summary(state: PricingState) -> str:
d = state.get("decision")
written = state.get("written")
if d is None:
return "no decision produced"
tag = "WRITTEN" if written else "not written"
return f"{d.sku}: {d.decision.value} @ ${d.recommended_price:.2f} [{tag}]"
def run_analysis(sku: str, price: float, settings) -> int:
"""Print the price verdict: profitability + 6-month sales trend + inventory."""
from pricing_agent.analyze import analyze_price
r = analyze_price(sku, price, settings, with_narrative=True)
icon = {"GOOD": "🟢", "CAUTION": "🟡", "POOR": "🔴"}.get(r.rating.value, "")
print("\n" + BAR)
print(f" PRICE ANALYSIS: {r.sku} ASIN: {r.asin or ''} @ ${r.price:.2f}")
print(BAR)
print(" PROFITABILITY")
print(f" Take-home / unit : ${r.take_home_per_unit:.2f}")
print(f" Contribution margin: {r.margin_pct*100:.1f}%")
print(" SALES TREND (avg units/day)")
print(f" Last 7 days : {_fmt_units(r.avg_7d)}")
print(f" Last 30 days : {_fmt_units(r.avg_30d)}")
print(f" Last 6 months : {_fmt_units(r.avg_6m)} → trend: {r.trend.upper()}")
print(" INVENTORY")
print(f" On hand : {r.inventory:,.0f} units"
+ (f" ({r.cover_days} days cover)" if r.cover_days is not None else ""))
if r.monthly_take_home is not None:
print(f" Est. monthly take-home: ${r.monthly_take_home:,.2f}"
+ (f" (after ${r.storage_charges:,.2f} storage)" if r.storage_charges else ""))
if r.suggested_price is not None:
print(BAR)
print(f" 💡 SUGGESTED PRICE (≥ target margin): ${r.suggested_price:.2f}")
print(BAR)
print(f" {icon} VERDICT: {r.rating.value}{r.headline}")
for reason in r.reasons:
print(f"{reason}")
print(BAR)
if r.narrative:
print(" 🧠 AI ANALYSIS")
for line in r.narrative.splitlines():
print(f" {line}")
print(BAR)
return 0
def run_scan(settings, days: int) -> int:
"""Catalog-wide money-at-risk scan using COSMOS's ACTUAL profit numbers.
Read-only: prints suggestions, writes nothing.
"""
from pricing_agent.analyze import _build_service
svc = _build_service(settings)
print(f"Scanning the whole catalog for the last {days} days (actual profit)…\n")
rows = svc.get_catalog_performance(days=days)
if not rows:
print("No SKUs with sales in that window.", file=sys.stderr)
return 1
from pricing_agent.performance import fix_suggestion, loss_reason, root_cause_split
losers = [r for r in rows if r["actual_profit"] < 0]
thin = [r for r in rows if 0 <= r["profit_per_unit"] < 0.50]
def show(icon, r):
ppu, ad, price = r["profit_per_unit"], r["ad_per_unit"], r["avg_price"]
print(f"{icon} {r['sku'][:38]:<38} {(price or 0):>7.2f} {r['units']:>7,} "
f"{ad:>6.2f} {ppu:>9.2f} {r['actual_profit']:>13,.0f}")
print(f" WHY: {loss_reason(ppu, ad, price)}")
print(f" FIX: {fix_suggestion(ppu, ad, price)}\n")
hdr = (f"{'':2} {'SKU':<38} {'price':>7} {'units':>7} {'ad/u':>6} {'profit/u':>9} "
f"{'ACTUAL profit':>13}")
print("🔴 LOSING MONEY (worst first)")
print(hdr); print("" * len(hdr))
for r in losers[:15]:
show("🔴", r)
if thin:
print(f"🟡 THIN (< $0.50 profit/unit) — {len(thin)} SKUs, top 5 by volume")
print("" * len(hdr))
for r in sorted(thin, key=lambda x: -x["units"])[:5]:
show("🟡", r)
# Group the losers by root cause so the right lever is obvious.
ads_cause, cost_cause = root_cause_split(losers)
print(f"\n ROOT CAUSE of the {len(losers):,} money-losers:")
print(f"{len(ads_cause):,} would be PROFITABLE without ad spend → cut/optimize ads")
print(f"{len(cost_cause):,} lose money even BEFORE ads → raise price / cut COGS")
total_bleed = sum(r["actual_profit"] for r in losers)
total_gain = sum(r["actual_profit"] for r in rows if r["actual_profit"] > 0)
print("\n" + BAR)
print(f" {len(rows):,} SKUs with sales · {len(losers):,} losing money · {len(thin):,} thin")
print(f" Actual loss from money-losers: ${total_bleed:,.0f} over {days} days "
f"(~${total_bleed * 365 / days:,.0f}/yr)")
print(f" Net across the catalog: ${total_gain + total_bleed:,.0f} over {days} days")
print(BAR)
print(" Suggestion: raise price / cut ad spend on the 🔴 SKUs above — start with the")
print(" biggest losses. Run a single SKU for its full evidence-based analysis.")
print(BAR)
return 0
def run_bulk(settings, limit: int, sku_prefix: str | None, at: str) -> int:
"""Bulk catalog analysis → a ranked table (worst verdicts first)."""
from pricing_agent.analyze import _build_service, analyze_catalog
svc = _build_service(settings)
skus = svc.list_skus(limit=limit, sku_prefix=sku_prefix)
if not skus:
print("No SKUs found.", file=sys.stderr)
return 1
print(f"Analyzing {len(skus)} SKU(s) at '{at}' price… (this calls COSMOS per SKU)\n")
results = analyze_catalog(skus, settings, at=at, svc=svc)
# Worst first: POOR, then CAUTION, then GOOD.
order = {"POOR": 0, "CAUTION": 1, "GOOD": 2}
results.sort(key=lambda r: order.get(r.rating.value, 3))
icon = {"GOOD": "🟢", "CAUTION": "🟡", "POOR": "🔴"}
hdr = f"{'':2} {'SKU':<34} {'6mo/day':>8} {'trend':<9} {'suggest':>8} {'margin':>7} verdict"
print(hdr)
print("" * len(hdr))
for r in results:
print(f"{icon.get(r.rating.value, ' '):2} {r.sku[:34]:<34} "
f"{(r.avg_6m or 0):>8,.0f} {r.trend:<9} "
f"{('$'+format(r.suggested_price, '.2f')) if r.suggested_price else '':>8} "
f"{r.margin_pct*100:>6.1f}% {r.headline}")
counts = {k: sum(1 for r in results if r.rating.value == k) for k in ("GOOD", "CAUTION", "POOR")}
print("\n" + BAR)
print(f" {counts['GOOD']} GOOD · {counts['CAUTION']} CAUTION · {counts['POOR']} POOR")
print(BAR)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Amazon Pricing & Profitability Analyst agent")
parser.add_argument("--sku", help="Run a single SKU by its SKU code")
parser.add_argument("--price", type=float, help="Candidate price to evaluate (with --sku)")
parser.add_argument(
"--analyze", action="store_true",
help="Analyze a price vs profitability + 6-month sales trend (read-only, no approval)",
)
parser.add_argument(
"--bulk", action="store_true",
help="Bulk-analyze many COSMOS SKUs: trend + suggested price + verdict (read-only)",
)
parser.add_argument(
"--scan", action="store_true",
help="Catalog-wide money-at-risk scan using ACTUAL profit (read-only, prints suggestions)",
)
parser.add_argument("--days", type=int, default=30, help="Scan window in days (default 30)")
parser.add_argument("--limit", type=int, default=15, help="Max SKUs for --bulk (default 15)")
parser.add_argument("--sku-prefix", help="Filter --bulk to SKUs matching this text")
parser.add_argument(
"--at", default="suggested",
help="--bulk price point: 'suggested' (default) or a fixed price like 24.99",
)
parser.add_argument("--all", action="store_true", help="Run every SKU in the tracker")
parser.add_argument("--backend", choices=["cosmos", "mock"], help="Override DATA_BACKEND")
parser.add_argument("--tracker", help="Override TRACKER_BACKEND (csv|gsheets)")
parser.add_argument(
"--auto", choices=["smart", "approve", "reject"],
help="Non-interactive: auto-resolve the human review",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Debug-level logs")
parser.add_argument("--quiet", "-q", action="store_true", help="Warnings/errors only")
args = parser.parse_args(argv)
from pricing_agent.logging_config import setup_logging
setup_logging("DEBUG" if args.verbose else "WARNING" if args.quiet else "INFO")
settings = get_settings()
if args.backend:
settings.data_backend = args.backend
if args.tracker:
settings.tracker_backend = args.tracker
# Read-only analysis mode: profitability + 6-month sales trend -> verdict.
if args.analyze:
if not args.sku or args.price is None:
parser.error("--analyze needs --sku <CODE> and --price <N>")
return run_analysis(args.sku, args.price, settings)
# Catalog-wide money-at-risk scan (actual profit).
if args.scan:
return run_scan(settings, args.days)
# Bulk catalog analysis: trend + suggested price + verdict for many SKUs.
if args.bulk:
return run_bulk(settings, args.limit, args.sku_prefix, args.at)
if not args.sku and not args.all:
parser.error("Specify --sku <CODE> [--price N], --analyze, or --all")
# Ad-hoc single SKU at a given price (bypasses the tracker) — handy for COSMOS.
if args.sku and args.price is not None:
skus = [SkuInput(sku=args.sku, target_price=args.price, landed_cost=0.0)]
else:
tracker = get_tracker(settings)
skus = tracker.read_skus(sku=args.sku)
if not skus:
print(f"No SKUs found (sku={args.sku!r}).", file=sys.stderr)
return 1
graph = build_graph()
results = []
for si in skus:
try:
state = run_one(graph, si, args.auto)
results.append(state)
print(" =>", _summary(state))
except Exception as e: # keep batch going
print(f" !! {si.sku} failed: {e}", file=sys.stderr)
print("\n" + BAR)
print(" RUN SUMMARY")
print(BAR)
for state in results:
print(" ", _summary(state))
print(BAR)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,392 @@
"""The ONE competitive fact the decision engine is allowed to read.
Why this module exists
----------------------
There are two competitor scrapers in this workspace and they do not agree about what they
are for:
* **Apify** (`tools/amazon/apify.py`) one actor run per batch of ASINs, no browser. Reads
the featured offer, the seller id behind it, and the other offers on the same ASIN. It is
the only one of the two that can tell **WON** from **LOST_PRICE**, because that distinction
needs our own merchant id compared against the seller holding the Buy Box.
* **Playwright** (`../../scraper/backend/scraper.py`) a real Chromium, ~8.5 s per listing,
25-35 minutes for a product line. It reads far more (BSR, bought-in-past-month, the
variant twister that like-for-like matching is impossible without), but its Buy Box field
is only `'buybox' | 'suppressed' | 'unknown'`: it knows whether a featured price RENDERED,
not whose it was.
Left alone, both can run, both can produce a "competitor price" for the same ASIN, and
nothing reconciles them. That is the failure this module exists to prevent not by picking
a winner and deleting the loser, but by making them feed ONE typed state that records which
source it came from and when, and that SAYS SO when they disagree.
Which source is authoritative, and why
--------------------------------------
**Apify is authoritative for Buy Box state consumed by the decision engine.** Not because it
is better it is authoritative because it is the only one that can answer the question the
engine actually asks. "A rival undercuts us" and "a rival holds the Buy Box while priced
above us" lead to opposite actions (cut price vs go and fix fulfilment eligibility), and
telling them apart requires the seller id that only Apify returns. A Playwright listing
whose Buy Box merely rendered cannot distinguish the two, so promoting it to authority would
mean guessing on exactly the branch where guessing is expensive.
**Playwright stays authoritative for everything the workbook needs** like-for-like
size/colour matching, BSR, demand buckets, SKU gaps. Apify cannot produce any of it.
So the split is by QUESTION, not by preference, and neither source is redundant.
The fail-safe rule
------------------
Competitor data is best-effort by nature: a scrape can be blocked, time out, or simply never
have been run for a SKU. Every branch below therefore treats absent, stale and failed
identically `usable` goes False and the caller computes exactly the verdict it computed
before this module existed. A missing competitor price must never block, delay or change a
verdict; it must only ever be capable of ADDING one.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .schemas import BuyBoxStatus, CompetitiveSnapshot
logger = logging.getLogger(__name__)
# Where a state came from. Recorded rather than inferred, so a disagreement can name both
# sides instead of reporting a mystery.
SOURCE_APIFY = "apify"
SOURCE_PLAYWRIGHT = "playwright"
SOURCE_SHEET = "comparison-sheet"
# WHAT a competitor price is a price OF. These are two genuinely different comparisons and
# conflating them would mislabel a verdict:
#
# BASIS_SAME_ASIN another seller's offer on OUR OWN listing. A cheaper one means we lost the
# Buy Box on price -- same product, same page.
# BASIS_SHEET a RIVAL BRAND's equivalent product, matched like-for-like on size+colour
# by the comparison workbook. A cheaper one is a competitive-position
# signal, NOT a Buy Box loss: we may hold our own Buy Box perfectly well
# while a different product undercuts us.
#
# Both are legitimate reasons to move a price and both feed the same rule, but the basis is
# recorded on every verdict so nobody reads "a rival is cheaper" as "we lost the Buy Box".
BASIS_SAME_ASIN = "same-asin-buybox"
BASIS_SHEET = "like-for-like-sheet"
@dataclass
class CompetitiveState:
"""Competitor reality for ONE of our ASINs, from ONE named source, at ONE time.
`usable` is the only thing the cascade is allowed to branch on before reading anything
else. It is False for absent, stale, errored and UNKNOWN state alike the cascade must
not have to know the difference, because in all four cases the correct behaviour is
identical: decide competitor-blind.
"""
status: BuyBoxStatus = BuyBoxStatus.UNKNOWN
our_price: float | None = None
buy_box_price: float | None = None
buy_box_seller: str | None = None
# Rival offers only — our own offers and used stock are already excluded upstream. A
# listing we sell alone yields no band at all, which is the honest answer and is very
# different from "our price is also the lowest competitor price".
competitor_min: float | None = None
competitor_median: float | None = None
rivals: int = 0
source: str | None = None
# BASIS_SAME_ASIN or BASIS_SHEET — what `competitor_min` is a price OF. See above.
basis: str = BASIS_SAME_ASIN
as_of: datetime | None = None
reason: str = ""
# Why this state cannot be acted on, when it cannot. Carried so the UI and the audit log
# can say "no competitor data" vs "competitor data 9h old" rather than both showing "—".
unusable_because: str | None = None
notes: list[str] = field(default_factory=list)
@property
def usable(self) -> bool:
return self.unusable_because is None
@property
def undercut_pct(self) -> float | None:
"""How far below us the cheapest RIVAL sits, as a fraction of our price.
Positive = they are cheaper. Uses the cheapest rival rather than the Buy Box price
because the Buy Box may be OURS: comparing our own price to itself would report a
0% undercut on every SKU we are winning and read as "nobody is cheaper".
"""
if not self.our_price or self.competitor_min is None:
return None
return (self.our_price - self.competitor_min) / self.our_price
def age(self, now: datetime | None = None) -> timedelta | None:
if self.as_of is None:
return None
now = now or datetime.now(timezone.utc)
as_of = self.as_of if self.as_of.tzinfo else self.as_of.replace(tzinfo=timezone.utc)
return now - as_of
def _blank(reason: str) -> CompetitiveState:
return CompetitiveState(unusable_because=reason)
def unavailable(reason: str = "no competitor data for this SKU") -> CompetitiveState:
"""The state to use when there is nothing. Explicit, so callers never pass None around."""
return _blank(reason)
def from_apify(
snap: CompetitiveSnapshot | None,
*,
our_price: float | None,
as_of: datetime | None = None,
max_age_hours: float | None = None,
now: datetime | None = None,
) -> CompetitiveState:
"""Adapt the Apify snapshot — the authoritative source for Buy Box state."""
if snap is None:
return unavailable("competitor scrape did not run or failed")
# Delegate to the module that already knows what a rival is. Rebuilding this filter here
# is what went wrong: this line used to be `not o.is_ours and o.price is not None`, which
# silently let USED stock into the band.
#
# Found on live data, not in review. On B06X421WJ6 the cheapest "rival" was Amazon Resale
# at $22.53 marked *Used - Very Good*, against our $22.99 new -- while the only NEW rival
# was Amazon.com at $30.99, i.e. we were $8 cheaper, not 2% dearer. Worse, on B00U6HREPQ a
# *Used - Like New* offer at $21.37 scored a 4.98% undercut against our $22.49, clearing
# the 3% action threshold outright; only the LOST_PRICE requirement kept it from cutting
# our price to chase second-hand stock.
from .tools.amazon.apify import competitor_offers
rivals = competitor_offers(snap.offers)
st = CompetitiveState(
status=snap.buy_box_status,
our_price=our_price,
buy_box_price=snap.buy_box_price,
buy_box_seller=snap.buy_box_seller_name,
competitor_min=(min(o.price for o in rivals) if rivals else snap.competitive_low),
competitor_median=snap.competitive_median,
rivals=len(rivals),
source=SOURCE_APIFY,
as_of=as_of or (now or datetime.now(timezone.utc)),
reason=snap.reason or "",
)
return _gate(st, max_age_hours=max_age_hours, now=now)
def from_scraper_listing(
listing,
*,
our_price: float | None,
our_seller_name: str | None = None,
rival_prices: list[float] | None = None,
as_of: datetime | None = None,
max_age_hours: float | None = None,
now: datetime | None = None,
) -> CompetitiveState:
"""Adapt a Playwright `Listing` (duck-typed, so this module imports nothing from it).
The tri-state has to be RECONSTRUCTED here, because the listing does not carry one. It
knows only whether a featured price rendered. Where the PDP's "Sold By" text names us we
can say WON; where it names somebody else we can say a rival holds it. Where it names
nobody which is common, the selector is not always present the honest answer is
UNKNOWN, and UNKNOWN is not actionable. This is exactly why Apify is authoritative for
this question rather than merely preferred.
"""
if listing is None:
return unavailable("competitor scrape did not run or failed")
buybox = getattr(listing, "buybox", "unknown")
price = getattr(listing, "price", None)
err = getattr(listing, "error", None)
fields = getattr(listing, "fields", None) or {}
sold_by = (fields.get("Sold By") or "").strip()
# A suppressed Buy Box sets `error` on this scraper, so an error alone must NOT be read
# as a failure — suppression is the single most actionable fact the scraper produces.
if buybox == "suppressed":
status = BuyBoxStatus.SUPPRESSED
elif buybox == "buybox" and price is not None:
if our_seller_name and sold_by:
we_hold = our_seller_name.strip().casefold() in sold_by.casefold()
if we_hold:
status = BuyBoxStatus.WON
elif our_price is not None and price < our_price:
status = BuyBoxStatus.LOST_PRICE
else:
# Somebody else holds it at or above our price, so price is not the lever.
status = BuyBoxStatus.LOST_ELIGIBILITY
else:
status = BuyBoxStatus.UNKNOWN
else:
status = BuyBoxStatus.UNKNOWN
rivals = [p for p in (rival_prices or []) if p is not None]
st = CompetitiveState(
status=status,
our_price=our_price,
buy_box_price=price,
buy_box_seller=sold_by or None,
competitor_min=min(rivals) if rivals else None,
competitor_median=(sorted(rivals)[len(rivals) // 2] if rivals else None),
rivals=len(rivals),
source=SOURCE_PLAYWRIGHT,
as_of=as_of or (now or datetime.now(timezone.utc)),
reason=(err or ""),
)
if status is BuyBoxStatus.UNKNOWN and buybox == "buybox":
st.notes.append(
"the Playwright scrape saw a featured offer but could not say whose — it "
"carries no seller id; Apify is the authoritative source for Buy Box ownership"
)
return _gate(st, max_age_hours=max_age_hours, now=now)
def _gate(st: CompetitiveState, *, max_age_hours: float | None,
now: datetime | None) -> CompetitiveState:
"""Decide whether this state may inform a verdict at all."""
if max_age_hours is not None:
age = st.age(now)
if age is not None and age > timedelta(hours=max_age_hours):
hrs = age.total_seconds() / 3600
st.unusable_because = (
f"competitor data is {hrs:.1f}h old (limit {max_age_hours:.0f}h) — the "
f"price has had time to move, so this verdict is competitor-blind"
)
return st
if st.status is BuyBoxStatus.UNKNOWN:
st.unusable_because = (
"Buy Box ownership unknown — "
+ (st.notes[-1] if st.notes else "no seller identity on the scrape")
)
return st
def from_playwright_cache(
asin: str | None,
*,
our_price: float | None,
zip_code: str = "10001",
cache_path: str | None = None,
our_seller_name: str | None = None,
max_age_hours: float | None = None,
now: datetime | None = None,
) -> CompetitiveState | None:
"""The sibling workbook tool's last scrape of this ASIN, if it has one and it is fresh.
Read-only, best-effort, and returns None on ANY problem a missing file, a different
ZIP, unparseable JSON, no entry for this ASIN. The point is not to add a second data
source to depend on; it is to have something to CROSS-CHECK the authoritative source
against, so the workbook and the recommendation cannot quietly disagree in front of a
stakeholder. Nothing here can fail a verdict.
The cache is keyed `<ASIN>@<ZIP>` because the ZIP is what decides the price. A cache
written for a different ZIP describes a different marketplace and is ignored rather than
reinterpreted.
"""
if not asin:
return None
path = Path(cache_path) if cache_path else (
Path(__file__).resolve().parents[3] / "scraper" / ".scrape_cache.json")
try:
raw = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(raw, dict):
return None
# The workbook run stores the parent scrape under a '+v' (want_variants) key too. Either
# will do for a Buy Box cross-check; prefer the plain one.
entry = raw.get(f"{asin.upper()}@{zip_code}") or raw.get(f"{asin.upper()}@{zip_code}+v")
if not isinstance(entry, dict) or not isinstance(entry.get("row"), dict):
return None
row, ts = entry["row"], entry.get("ts")
if not ts:
return None
class _L: # duck-typed stand-in; we never import the scraper
buybox = row.get("buybox", "unknown")
price = row.get("price")
error = row.get("error")
fields = row.get("fields") or {}
return from_scraper_listing(
_L(), our_price=our_price, our_seller_name=our_seller_name,
as_of=datetime.fromtimestamp(float(ts), tz=timezone.utc),
max_age_hours=max_age_hours, now=now,
)
def reconcile(
primary: CompetitiveState | None,
secondary: CompetitiveState | None,
*,
sku: str = "",
material_pct: float = 0.03,
) -> CompetitiveState:
"""Merge two sources into the one state the engine reads, and LOG any disagreement.
`primary` wins on every contested field it is expected to be the Apify state, for the
reason in the module docstring. The point of passing `secondary` at all is not to average
the two (averaging a Buy Box status is meaningless) but to make a contradiction VISIBLE:
two scrapers quietly reporting different prices for the same ASIN is how a pricing sheet
and a pricing recommendation come to disagree with each other in front of a stakeholder.
A disagreement never changes the verdict. It is recorded on the state and logged.
"""
if primary is None and secondary is None:
return unavailable()
if primary is None:
return secondary
if secondary is None:
return primary
# The authoritative source has nothing usable but the other source does. Promote it
# rather than throw the signal away: the case that matters is SUPPRESSED, which the
# Playwright scrape reads directly off the PDP and which means the variant sells nothing
# at any price. Discarding that to preserve a tidy hierarchy would be choosing the
# hierarchy over the fact. The promotion is recorded, so the verdict names the source it
# actually rests on.
if not primary.usable and secondary.usable:
secondary.notes.append(
f"{primary.source or 'the authoritative source'} had no usable state "
f"({primary.unusable_because}); this verdict rests on {secondary.source}"
)
logger.info("competitive state for %s falls back to %s: %s",
sku or "SKU", secondary.source, primary.unusable_because)
return secondary
# Only compare where BOTH sides actually have an opinion; "one source didn't run" is not
# a disagreement, it is the normal case.
if primary.status is not BuyBoxStatus.UNKNOWN and secondary.status is not BuyBoxStatus.UNKNOWN:
# SUPPRESSED vs anything-else is the disagreement that matters: one source says the
# variant sells nothing, the other says it is on sale.
if (primary.status is BuyBoxStatus.SUPPRESSED) != (secondary.status is BuyBoxStatus.SUPPRESSED):
msg = (f"{sku or 'SKU'}: sources disagree on Buy Box suppression — "
f"{primary.source} says {primary.status.value}, "
f"{secondary.source} says {secondary.status.value}")
logger.warning(msg)
primary.notes.append(msg)
pb, sb = primary.buy_box_price, secondary.buy_box_price
if pb and sb and abs(pb - sb) / max(pb, sb) > material_pct:
msg = (f"{sku or 'SKU'}: sources disagree on the featured price — "
f"{primary.source} ${pb:.2f} vs {secondary.source} ${sb:.2f} "
f"({abs(pb - sb) / max(pb, sb):.1%} apart); using {primary.source}")
logger.warning(msg)
primary.notes.append(msg)
# A price the authoritative source could not read but the other could is worth keeping —
# as a NOTE, never promoted into the field the cascade reads.
if primary.competitor_min is None and secondary.competitor_min is not None:
primary.notes.append(
f"{secondary.source} saw a rival at ${secondary.competitor_min:.2f}; "
f"{primary.source} read no rival offers, so no competitor rule fired"
)
return primary

View File

@ -0,0 +1,351 @@
"""Competitor data read from the comparison workbook — one product line at a time.
Why this exists
---------------
The competitor workbook (`Competitor_Price_Comparison_<LINE>_<date>.xlsx`, produced by the
sibling `scraper/` tool) currently covers **one product line**. A SKU inside that line has real
like-for-like competitor prices behind it; a SKU outside it has nothing at all. So this module
answers exactly one question *is this SKU covered?* and returns competitor state when it is
and an explicit **N/A** when it is not.
That gate is the whole point. Without it the engine would treat "this SKU is not in the sheet"
and "this SKU has no competitors" as the same fact, which they are not: the first is a hole in
our coverage and the second is a claim about the market. A hole must read as a hole.
Coverage is the EXACT SKU SET in the sheet, not a prefix
--------------------------------------------------------
Tempting to gate on the line prefix, but measured against the real workbook that is wrong: the
`UBMICROFIBERDUVET` run contains **49** `UBMICROFIBERDUVET*` SKUs and **7** `UBMICROFIBERBS4PC*`
SKUs. Variants come off the Amazon parent listing's twister and COSMOS maps those ASINs back to
whatever SKU codes they carry, so a run legitimately spans sibling prefixes. Equally, the line
has 139 SKUs in COSMOS but only 56 reached a comparison row the rest matched no rival.
A prefix gate would therefore be wrong in both directions: it would claim coverage for ~83 SKUs
that have no row, and deny it to the 7 that do. So the sheet's own SKU list is the authority,
and the two failure modes are reported with different reasons.
Two parsing traps in the workbook itself
----------------------------------------
1. **The footnotes live in column A**, under the table, so a naive read of "Our SKU" yields
rows like `"Our Price is scra..."` 6 of them in the real file. Every row is therefore
required to carry a plausible ASIN before it is believed.
2. **`Our Price` is legitimately empty** on suppressed / unreadable rows (6 in the real file).
That is a fact, not a parse failure, and it must not become 0.
"""
from __future__ import annotations
import logging
import re
import statistics
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from .competitive_state import BASIS_SHEET, SOURCE_SHEET, CompetitiveState, unavailable
from .schemas import BuyBoxStatus
logger = logging.getLogger(__name__)
SHEET_NAME = "Price Comparison"
_ASIN_RE = re.compile(r"^B0[A-Z0-9]{8}$", re.I)
# 'Competitor_Price_Comparison_UBMICROFIBERDUVET_2026-07-29_1923.xlsx' -> UBMICROFIBERDUVET
_LINE_RE = re.compile(r"Competitor_Price_Comparison_([A-Z0-9]+)_", re.I)
@dataclass
class SheetRow:
"""One matched variant, as the workbook reports it."""
sku: str
asin: str | None
size: str | None
colour: str | None
our_price: float | None
our_buybox: str | None
# brand -> their price for the SAME size+colour. Only rivals with a readable price.
rivals: dict[str, float] = field(default_factory=dict)
# brand -> the rival's OWN ASIN. These are different products on different listings, not
# offers on our ASIN, so the Competitors tab has to link each one to its own page.
rival_asins: dict[str, str] = field(default_factory=dict)
# Rivals whose OWN Buy Box is suppressed: their price is not buyable, so it must not be
# undercut. Read from the Notes cell, which is where the sheet records it.
suppressed_rivals: set[str] = field(default_factory=set)
# Rivals paired on a NEAR colour rather than an exact one ('Purple' ~ 'Dusty Purple').
# The workbook is explicit that such a row is a QUESTION, not a fact -- it prints
# "NEAR colour match, verify" precisely because only a human can say whether the two are
# the same product. So a fuzzy rival may inform a human and must never move a price.
fuzzy_rivals: set[str] = field(default_factory=set)
cheapest: str | None = None
notes: str = ""
@property
def is_suppressed(self) -> bool:
return "SUPPRESS" in (self.our_buybox or "").upper()
@property
def buyable_rivals(self) -> dict[str, float]:
"""Rivals whose price a customer could actually pay. For CONTEXT and display."""
return {b: p for b, p in self.rivals.items() if b not in self.suppressed_rivals}
@property
def priceable_rivals(self) -> dict[str, float]:
"""Rivals whose price may MOVE OURS: buyable AND matched exactly.
The two exclusions answer different questions and both are required:
* suppressed -> nobody can buy at that price, so undercutting it gives away margin
for nothing;
* fuzzy -> we are not certain it is the same product, so a cut would be priced
against a guess.
Measured on the live workbook, the fuzzy exclusion alone changes real behaviour: 18 of
56 rows carry a NEAR-colour rival, and 2 of the 6 material undercuts were being driven
by one -- UBMICROFIBERDUVETKINGPURPLE ('Purple' ~ 'Dusty Purple') would have cut 15.6%
on an unverified pairing.
"""
return {b: p for b, p in self.buyable_rivals.items() if b not in self.fuzzy_rivals}
def _num(v) -> float | None:
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v)
if isinstance(v, str):
m = re.search(r"-?[\d,]+\.?\d*", v.replace("$", ""))
if m:
try:
return float(m.group(0).replace(",", ""))
except ValueError:
return None
return None
@dataclass
class CompetitorSheet:
"""The workbook, indexed by SKU, with an explicit notion of what it covers."""
path: Path
rows: dict[str, SheetRow] = field(default_factory=dict)
brands: list[str] = field(default_factory=list)
line: str | None = None
as_of: datetime | None = None
# ---------------------------------------------------------------- loading
@classmethod
def load(cls, path: str | Path) -> "CompetitorSheet":
from openpyxl import load_workbook
p = Path(path)
wb = load_workbook(p, data_only=True, read_only=True)
try:
if SHEET_NAME not in wb.sheetnames:
raise ValueError(
f"{p.name} has no '{SHEET_NAME}' sheet (found: {wb.sheetnames}). "
f"This is not a competitor comparison workbook."
)
ws = wb[SHEET_NAME]
it = ws.iter_rows(values_only=True)
header = [str(h) if h is not None else "" for h in next(it)]
idx = {h: n for n, h in enumerate(header)}
for req in ("Our SKU", "Our ASIN", "Our Price", "Our Buy Box"):
if req not in idx:
raise ValueError(f"{p.name}: '{SHEET_NAME}' has no {req!r} column")
# Rivals are discovered from the header: the sheet spends 7 columns per rival and
# names them '<Brand> Price'. Reading them rather than hardcoding 'Bedsure'/'CGK'
# is what lets a run with different competitors work unchanged.
brands = [h[: -len(" Price")] for h in header
if h.endswith(" Price") and h not in ("Our Price", "Our List Price")]
rows: dict[str, SheetRow] = {}
skipped = 0
for raw in it:
vals = list(raw) + [None] * (len(header) - len(raw))
sku = vals[idx["Our SKU"]]
asin = vals[idx["Our ASIN"]]
# The footnotes are written into column A under the table, so "Our SKU" is not
# trustworthy on its own. A real row carries a real ASIN.
if not sku or not isinstance(asin, str) or not _ASIN_RE.match(asin.strip()):
if sku:
skipped += 1
continue
notes = str(vals[idx["Notes"]] or "") if "Notes" in idx else ""
rivals, sup, fz, rasins = {}, set(), set(), {}
for b in brands:
price = _num(vals[idx[f"{b} Price"]]) if f"{b} Price" in idx else None
if price is not None:
rivals[b] = price
ra = vals[idx[f"{b} ASIN"]] if f"{b} ASIN" in idx else None
if isinstance(ra, str) and _ASIN_RE.match(ra.strip()):
rasins[b] = ra.strip()
# The sheet flags a rival's own suppression in Notes; older workbooks
# (generated before that was added) simply will not have it, which is
# handled by this being a substring test rather than a required column.
if re.search(rf"{re.escape(b)}:\s*Buy Box SUPPRESSED", notes, re.I):
sup.add(b)
# Match quality. There is NO match-type column in the workbook -- the only
# record of a near-miss is this Notes sentence, and it is written PER RIVAL
# ("Bedsure: our 'Purple' ~ their 'Dusty Purple' - NEAR colour match"), so
# the flag has to be read per rival too. A row can legitimately hold one
# exact rival and one fuzzy one, and only the exact one may price us.
#
# Scoped to that brand's own `; `-separated segment so a note about
# Bedsure cannot mark CGK fuzzy.
if re.search(rf"{re.escape(b)}:[^;]*NEAR colour match", notes, re.I):
fz.add(b)
rows[str(sku).strip().upper()] = SheetRow(
sku=str(sku).strip(),
asin=asin.strip(),
size=vals[idx.get("Size", -1)] if "Size" in idx else None,
colour=vals[idx.get("Colour", -1)] if "Colour" in idx else None,
our_price=_num(vals[idx["Our Price"]]),
our_buybox=(str(vals[idx["Our Buy Box"]])
if vals[idx["Our Buy Box"]] is not None else None),
rivals=rivals, rival_asins=rasins, suppressed_rivals=sup,
fuzzy_rivals=fz,
cheapest=(str(vals[idx["Cheapest"]]) if idx.get("Cheapest") is not None
and vals[idx["Cheapest"]] is not None else None),
notes=notes,
)
finally:
wb.close()
m = _LINE_RE.search(p.name)
sheet = cls(
path=p, rows=rows, brands=brands,
line=(m.group(1).upper() if m else None),
as_of=datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc),
)
logger.info("competitor sheet %s: %d covered SKU(s), rivals=%s, line=%s "
"(%d non-data row(s) ignored)",
p.name, len(rows), brands or "none", sheet.line, skipped)
return sheet
@classmethod
def discover(cls, *search_dirs: str | Path) -> "CompetitorSheet | None":
"""The newest `Competitor_Price_Comparison_*.xlsx` in the given directories.
Newest by modification time, because that is the run whose prices are closest to now.
Returns None rather than raising when there is nothing to find no sheet is a normal
state and the caller reports it as N/A.
"""
best: tuple[float, Path] | None = None
for d in search_dirs:
p = Path(d)
if not p.is_dir():
continue
for f in p.glob("Competitor_Price_Comparison_*.xlsx"):
if f.name.startswith("~$"): # Excel lock file
continue
ts = f.stat().st_mtime
if best is None or ts > best[0]:
best = (ts, f)
if best is None:
return None
try:
return cls.load(best[1])
except Exception as e: # noqa: BLE001 — a bad sheet must not break a pricing run
logger.warning("competitor sheet %s could not be read (%s); "
"treating competitor data as unavailable", best[1].name, e)
return None
# ---------------------------------------------------------------- coverage
@property
def covered_skus(self) -> set[str]:
return set(self.rows)
def covers(self, sku: str) -> bool:
return (sku or "").strip().upper() in self.rows
def coverage_note(self) -> str:
n = len(self.rows)
line = self.line or "unknown line"
return (f"the competitor sheet covers {n} SKU(s) of product line {line} "
f"({self.path.name})")
# ------------------------------------------------------------------- state
def state_for(self, sku: str, *, our_price: float | None,
max_age_hours: float | None = None,
now: datetime | None = None) -> CompetitiveState:
"""Competitor state for one SKU, or an explicit N/A saying why there is none."""
row = self.rows.get((sku or "").strip().upper())
if row is None:
# Two different holes, reported differently. Only the caller knows whether this
# SKU is in the covered LINE, so say what the sheet does cover and let the
# message be about coverage rather than about the market.
return unavailable(
f"N/A — {sku} is not in the competitor sheet; {self.coverage_note()}"
)
# Our own price: the sheet's is the live scraped one and is preferred, but it is
# legitimately blank on a suppressed row, where COSMOS's current price is the only
# figure available to measure a gap against.
price = row.our_price if row.our_price is not None else our_price
# `competitor_min` is the number the undercut rule prices against, so it is built from
# PRICEABLE rivals only -- buyable and exactly matched. Fuzzy and suppressed rivals are
# still reported below as context for a human; they simply cannot move a price.
rivals = row.priceable_rivals
lo = min(rivals.values()) if rivals else None
med = None
if rivals:
# A real median. `sorted(...)[len//2]` returns the UPPER of a pair, so two rivals at
# $19.99 and $24.99 would report $24.99 as their "median".
med = round(float(statistics.median(rivals.values())), 2)
if row.is_suppressed:
status = BuyBoxStatus.SUPPRESSED
elif row.our_buybox and "BUY BOX" in row.our_buybox.upper():
# A featured offer rendered on OUR listing. Treated as us holding it, which is
# what the sheet means by it -- with the caveat below, because this scrape carries
# no seller id and cannot prove a reseller is not the one holding it.
status = BuyBoxStatus.WON
else:
status = BuyBoxStatus.UNKNOWN
st = CompetitiveState(
status=status, our_price=price, buy_box_price=row.our_price,
competitor_min=lo, competitor_median=med, rivals=len(rivals),
source=SOURCE_SHEET, basis=BASIS_SHEET, as_of=self.as_of,
reason=(row.our_buybox or ""),
)
if row.notes:
st.notes.append(f"sheet notes: {row.notes}")
if row.suppressed_rivals:
st.notes.append(
f"excluded from the rival band — their own Buy Box is suppressed so their "
f"price is not buyable: {', '.join(sorted(row.suppressed_rivals))}")
# Named WITH their prices, because this is the one exclusion a human may want to
# overrule: if someone verifies that 'Purple' really is 'Dusty Purple', the number they
# need in order to act is right here rather than back in the workbook.
if row.fuzzy_rivals:
priced = ", ".join(
f"{b} ${row.rivals[b]:.2f}" if b in row.rivals else b
for b in sorted(row.fuzzy_rivals))
st.notes.append(
f"excluded from the rival band — NEAR colour match, not verified, so it may "
f"not move a price on its own ({priced}). Confirm the colours are the same "
f"product and the row becomes actionable.")
if status is BuyBoxStatus.WON:
st.notes.append(
"the sheet reads a featured offer on our listing but carries no seller id, so "
"it cannot rule out a reseller holding it; Apify is authoritative for ownership")
# Age gate. The sheet is a 25-35 minute batch run, not a live feed, so it gets its own
# (longer) limit than a single-ASIN scrape -- see competitor_sheet_max_age_hours.
if max_age_hours is not None and st.as_of is not None:
age = st.age(now)
if age is not None and age.total_seconds() / 3600 > max_age_hours:
hrs = age.total_seconds() / 3600
st.unusable_because = (
f"N/A — the competitor sheet is {hrs:.0f}h old (limit "
f"{max_age_hours:.0f}h); re-run the comparison for {self.line or 'this line'}"
)
return st
if status is BuyBoxStatus.UNKNOWN and lo is None:
st.unusable_because = (
f"N/A — {sku} is in the sheet but has neither a readable Buy Box status nor a "
f"rival price: {row.notes or 'no reason recorded'}")
return st

View File

@ -0,0 +1,12 @@
"""COSMOS API integration (cost, fees, profit, price) for the pricing agent."""
from pricing_agent.cosmos.client import CosmosClient
from pricing_agent.cosmos.errors import CosmosApiError, CosmosAuthError, CosmosError
from pricing_agent.cosmos.service import CosmosPricingService
__all__ = [
"CosmosClient",
"CosmosPricingService",
"CosmosError",
"CosmosAuthError",
"CosmosApiError",
]

View File

@ -0,0 +1,180 @@
"""Low-level COSMOS HTTP client.
Responsibilities: authentication (login JWT), token caching + proactive refresh,
retries with backoff on transient errors, timeouts, and typed error translation.
Higher-level, agent-shaped methods live in ``service.py``.
"""
from __future__ import annotations
import base64
import json
import logging
import threading
import time
from typing import Any
# Params worth showing in logs (the rest are boilerplate like marketplaces/currency).
_KEY_PARAMS = ("sku", "itemPrice", "saleUnits", "inventory", "skuPrefix", "q", "page")
import requests
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from pricing_agent.cosmos.errors import (
CosmosApiError,
CosmosAuthError,
ProductNotFoundError,
)
logger = logging.getLogger("pricing_agent.cosmos")
# Refresh the token this many seconds before its real expiry, to avoid races.
_TOKEN_SKEW_SECONDS = 60
# Upper bound on parallel COSMOS requests. The history endpoint is slow (~4-5s per
# 15-day window) but the windows are independent, so we fan them out. Kept modest to
# stay a polite client of an internal ERP.
MAX_CONCURRENCY = 8
class CosmosClient:
def __init__(
self,
base_url: str,
email: str,
password: str,
*,
timeout: float = 30.0,
session: requests.Session | None = None,
):
if not email or not password:
raise CosmosAuthError("COSMOS_EMAIL and COSMOS_PASSWORD are required.")
self.base_url = base_url.rstrip("/")
self._email = email
self._password = password
self.timeout = timeout
self._session = session or requests.Session()
self._token: str | None = None
self._token_exp: float = 0.0
# History windows are fetched concurrently, so guard the token and widen the
# connection pool past requests' default of 10 (otherwise workers serialize).
self._auth_lock = threading.Lock()
adapter = requests.adapters.HTTPAdapter(pool_connections=MAX_CONCURRENCY,
pool_maxsize=MAX_CONCURRENCY)
self._session.mount("https://", adapter)
self._session.mount("http://", adapter)
# ── Authentication ────────────────────────────────────────────────────
def _login(self) -> None:
url = f"{self.base_url}/api/auth/login"
try:
r = self._session.post(
url,
json={"email": self._email, "password": self._password},
timeout=self.timeout,
)
except requests.RequestException as e:
raise CosmosAuthError(f"login request failed: {e}") from e
if r.status_code != 200:
raise CosmosAuthError(f"login returned {r.status_code}: {r.text[:200]}")
token = r.json().get("accessToken")
if not token:
raise CosmosAuthError("login succeeded but no accessToken in response")
self._token = token
self._token_exp = self._jwt_exp(token)
logger.info("COSMOS login OK for %s (token exp in %ss)",
self._email, int(self._token_exp - time.time()))
@staticmethod
def _jwt_exp(token: str) -> float:
"""Extract the `exp` (epoch seconds) from a JWT without verifying it."""
try:
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
return float(payload.get("exp", time.time() + 3600))
except Exception:
# Fall back to a conservative 1h TTL if the token is opaque.
return time.time() + 3600
def _expired(self) -> bool:
return self._token is None or time.time() >= (self._token_exp - _TOKEN_SKEW_SECONDS)
def _ensure_token(self) -> str:
# Double-checked under the lock: concurrent workers must not each trigger a login.
if self._expired():
with self._auth_lock:
if self._expired():
self._login()
token = self._token
assert token is not None
return token
# ── Requests ──────────────────────────────────────────────────────────
@retry(
retry=retry_if_exception_type(requests.RequestException),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=8),
reraise=True,
)
def _request(self, method: str, path: str, **kwargs) -> Any:
url = f"{self.base_url}{path}"
token = self._ensure_token()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
params = kwargs.get("params") or {}
shown = {k: params[k] for k in _KEY_PARAMS if k in params and params[k] not in (None, "")}
logger.info("GET %s %s", path, shown if shown else "")
t0 = time.perf_counter()
resp = self._session.request(
method, url, headers=headers, timeout=self.timeout, **kwargs
)
logger.info(" <- %s (%dms)", resp.status_code, (time.perf_counter() - t0) * 1000)
# One transparent re-auth on 401 (expired/revoked token). Only invalidate the
# token we actually used — a concurrent worker may already have refreshed it.
if resp.status_code == 401:
logger.info("COSMOS 401 — re-authenticating and retrying once")
with self._auth_lock:
if self._token == token:
self._token = None
token = self._ensure_token()
headers["Authorization"] = f"Bearer {token}"
resp = self._session.request(
method, url, headers=headers, timeout=self.timeout, **kwargs
)
return self._parse(resp, url)
@staticmethod
def _parse(resp: requests.Response, url: str) -> Any:
try:
body = resp.json()
except ValueError:
body = None
if resp.status_code == 200:
return body
message = ""
if isinstance(body, dict):
message = str(body.get("error") or body.get("message") or body)
else:
message = resp.text[:200]
if resp.status_code == 500 and "not found" in message.lower():
raise ProductNotFoundError(resp.status_code, message, url)
if resp.status_code == 403:
raise CosmosApiError(403, f"access denied: {message}", url)
raise CosmosApiError(resp.status_code, message, url)
def get(self, path: str, params: dict | None = None) -> Any:
return self._request("GET", path, params=params or {})
def post(self, path: str, json_body: dict | None = None) -> Any:
return self._request("POST", path, json=json_body or {})

View File

@ -0,0 +1,23 @@
"""COSMOS client exception hierarchy."""
from __future__ import annotations
class CosmosError(Exception):
"""Base class for all COSMOS client errors."""
class CosmosAuthError(CosmosError):
"""Login failed or the token could not be obtained/refreshed."""
class CosmosApiError(CosmosError):
"""A COSMOS API call returned an error status."""
def __init__(self, status: int, message: str, url: str = ""):
self.status = status
self.url = url
super().__init__(f"COSMOS {status} for {url}: {message}")
class ProductNotFoundError(CosmosApiError):
"""The requested SKU/product does not exist in COSMOS."""

View File

@ -0,0 +1,260 @@
"""Typed models for the COSMOS responses the pricing agent consumes.
Field names mirror the live API (verified against real responses). `extra="ignore"`
keeps us resilient to COSMOS adding fields.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class CosmosProduct(BaseModel):
"""One row from `GET /api/products` (list projection)."""
model_config = ConfigDict(extra="ignore")
sku: str
asin: str | None = None
parent_asin: str | None = Field(default=None, alias="parentAsin")
marketplace: str | None = None
brand: str | None = None
manufacturer: str | None = None
cost: float | None = None # COGS / landed cost per unit
status: str | None = None
listing_id: int | None = Field(default=None, alias="listingId")
avg_sale_7d: float | None = Field(default=None, alias="averageSale7Days")
avg_sale_14d: float | None = Field(default=None, alias="averageSale14Days")
avg_sale_30d: float | None = Field(default=None, alias="averageSale30Days")
potential_daily_sale: float | None = Field(default=None, alias="potentialDailySale")
class CostBreakdown(BaseModel):
model_config = ConfigDict(extra="ignore")
purchase_price: float = Field(default=0.0, alias="purchasePrice")
freight: float = 0.0
duty: float = 0.0
class TakehomeQuote(BaseModel):
"""`GET /api/sales-insight/takehome-calculator` response — the fee/profit engine."""
model_config = ConfigDict(extra="ignore")
item_price: float = Field(alias="itemPrice")
referral_fee: float = Field(default=0.0, alias="referralFee")
fba_fee: float = Field(default=0.0, alias="fbaFee")
low_inventory_fee: float = Field(default=0.0, alias="lowInventoryFee")
inbound_placement_fee: float = Field(default=0.0, alias="inboundPlacementFee")
return_fee: float = Field(default=0.0, alias="returnFee")
epr_fee: float = Field(default=0.0, alias="eprFee")
vat: float = 0.0
logistics_cost: float = Field(default=0.0, alias="logisticsCost")
duty_amount: float = Field(default=0.0, alias="dutyAmount")
order_handling: float = Field(default=0.0, alias="orderHandling")
weight_handling: float = Field(default=0.0, alias="weightHandling")
warehouse_expense: float = Field(default=0.0, alias="warehouseExpense")
cost_subtotal: float | None = Field(default=None, alias="costSubtotal")
cost_per_unit: float = Field(default=0.0, alias="costPerUnit")
take_home: float = Field(default=0.0, alias="takeHome")
margin_impact: float | None = Field(default=None, alias="marginImpact")
cost_breakdown: CostBreakdown | None = Field(default=None, alias="costBreakdown")
@property
def other_fees(self) -> float:
"""Amazon fees beyond FBA + referral + returns (rolled into one bucket)."""
return (
self.low_inventory_fee
+ self.inbound_placement_fee
+ self.epr_fee
+ self.vat
+ self.order_handling
+ self.weight_handling
+ self.warehouse_expense
)
class InvpInsight(BaseModel):
"""`GET /api/invp-insight` — sales trend (incl. 6-month) + inventory + cover days."""
model_config = ConfigDict(extra="ignore")
sku: str
asin: str | None = None
parent_asin_title: str | None = Field(default=None, alias="parentAsinTitle")
inventory: float = 0.0
limbo_inventory: float = Field(default=0.0, alias="limboInventory")
avg_sale: float | None = Field(default=None, alias="averageSale")
avg_7d: float | None = Field(default=None, alias="averageSale7Days")
avg_14d: float | None = Field(default=None, alias="averageSale14Days")
avg_30d: float | None = Field(default=None, alias="averageSale30Days")
avg_6m: float | None = Field(default=None, alias="averageSale6Months")
potential_daily_sale: float | None = Field(default=None, alias="potentialDailySale")
min_po_quantity: float | None = Field(default=None, alias="minPoQuantity")
min_po_days: float | None = Field(default=None, alias="minPoDays")
date_map: dict = Field(default_factory=dict, alias="dateMap")
@property
def projections(self) -> list[dict]:
"""COSMOS's own forward inventory projection — the weekly snapshots in dateMap.
Each snapshot is a real projected inventory point (units, value, cover days,
and incoming warehouse/PO arrivals), sorted oldestnewest. This is the
authoritative INVP outlook, not a locally-computed forecast walk.
"""
out = []
for key, snap in self.date_map.items():
if not isinstance(snap, dict):
continue
out.append({
"date": snap.get("dataDate") or key,
"inventory": snap.get("inventory"),
"inventory_value": snap.get("inventoryValue"),
"cover_days": snap.get("coverDays"),
"warehouse_arrival": snap.get("warehouseArrival") or 0.0,
"warehouse_arrival_value": snap.get("warehouseArrivalValue") or 0.0,
"po_inventory": snap.get("poInventory") or 0.0,
})
return sorted(out, key=lambda s: str(s["date"]))
@property
def cover_days(self) -> int | None:
"""COSMOS's own cover for the EARLIEST snapshot — its "today".
Sorted by date rather than taken in iteration order. The API happens to return the
dateMap already sorted today, so the two agree, but that is an accident of the
response and not a promise: one reordering upstream and this would silently return a
cover from some week in the future. `CosmosPricingService.find_line` already sorts
the same map explicitly, so this brings the two readers into line.
NOTE this is COSMOS's definition, which counts INBOUND stock and divides by a 7-day
velocity it therefore reads LONGER than what is physically on the shelf. The
dashboard reports both; see `cover_days_onhand` in `dashboard/live_data.py`.
"""
def _by_date(k: str):
# 'MM/DD/YYYY' -> (YYYY, MM, DD)
return (k[6:], k[:2], k[3:5]) if len(k) == 10 else (k, "", "")
for key in sorted(self.date_map, key=_by_date):
snap = self.date_map.get(key)
if isinstance(snap, dict) and snap.get("coverDays") is not None:
return int(snap["coverDays"])
return None
class BulkQuote(BaseModel):
"""`GET /api/sales-insight/bulk-calculator` — total economics over a sales volume.
takeHome = saleUnits * takeHomePerUnit - storageCharges - marketing.
"""
model_config = ConfigDict(extra="ignore")
item_price: float = Field(alias="itemPrice")
sale_units: float = Field(default=0.0, alias="saleUnits")
inventory: float = 0.0
marketing: float = 0.0
storage_charges: float = Field(default=0.0, alias="storageCharges")
take_home: float = Field(default=0.0, alias="takeHome") # total, net of storage
take_home_per_unit: float = Field(default=0.0, alias="takeHomePerUnit")
referral_fee: float = Field(default=0.0, alias="referralFee")
fba_fee: float = Field(default=0.0, alias="fbaFee")
low_inventory_fee: float = Field(default=0.0, alias="lowInventoryFee")
inbound_placement_fee: float = Field(default=0.0, alias="inboundPlacementFee")
return_fee: float = Field(default=0.0, alias="returnFee")
vat: float = 0.0
cost_per_unit: float = Field(default=0.0, alias="costPerUnit")
class LineProduct(BaseModel):
"""One product in a line, from `invp-insight` filtered by SKU prefix.
A single call returns the WHOLE line with inventory and sales velocity
attached, which is what makes "show me everything, then let me choose" cheap:
no per-SKU round trips just to decide what is worth analyzing.
"""
model_config = ConfigDict(extra="ignore")
sku: str
asin: str | None = None
parent_asin: str | None = Field(default=None, alias="parentAsin")
title: str | None = Field(default=None, alias="parentAsinTitle")
image_url: str | None = Field(default=None, alias="imageUrl")
inventory: float = 0.0
limbo_inventory: float = Field(default=0.0, alias="limboInventory")
avg_7d: float = Field(default=0.0, alias="averageSale7Days")
avg_14d: float = Field(default=0.0, alias="averageSale14Days")
avg_30d: float = Field(default=0.0, alias="averageSale30Days")
avg_6m: float = Field(default=0.0, alias="averageSale6Months")
potential_daily_sale: float = Field(default=0.0, alias="potentialDailySale")
min_po_quantity: float = Field(default=0.0, alias="minPoQuantity")
cover_days: int | None = None # lifted out of the dateMap by the service
@property
def size(self) -> str:
"""Bedding size parsed from the SKU — the natural grouping in this catalog."""
s = self.sku.upper()
for token in ("CALKING", "KING", "QUEEN", "FULL", "TWINXL", "TWIN"):
if token in s:
return token.title()
return "Other"
class MarketingMetrics(BaseModel):
"""`GET /api/marketing/dashboard/marketing-data` grouped by SKU.
This is the Amazon Advertising data COSMOS proxies, and it carries exactly the
fields `sales-insight` cannot: **ad-attributed sales** (`ppcSales`), **ACoS**,
ROAS, CPC and the campaign budget. `sales-insight` only knows total marketing
spend, which is why TACoS was the only ratio available before this.
"""
model_config = ConfigDict(extra="ignore")
sku: str = Field(alias="id")
asin: str | None = Field(default=None, alias="subTitle")
status: str | None = None
ad_spend: float = Field(default=0.0, alias="adSpend")
ppc_sales: float = Field(default=0.0, alias="ppcSales") # ad-attributed sales
acos: float = 0.0 # fraction, not %
roas: float = 0.0
daily_budget: float = Field(default=0.0, alias="dailyBudget")
budget_utilization: float = Field(default=0.0, alias="budgetUtilization")
cpc: float = 0.0
clicks: float = 0.0
impressions: float = 0.0
conversion: float = 0.0 # fraction, not %
units: float = 0.0 # ad-attributed units
breakeven_acos: float = Field(default=0.0, alias="breakevenAcos")
breakeven_bid: float = Field(default=0.0, alias="breakevenBid")
current_bid: float = Field(default=0.0, alias="currentBid")
class SalesDay(BaseModel):
"""One day of sales history from `sales-insight` dateMap."""
model_config = ConfigDict(extra="ignore")
date: str # MM/DD/YYYY
sale_price: float | None = Field(default=None, alias="salePrice")
units: float = Field(default=0.0, alias="unitOrders")
marketing_cost: float = Field(default=0.0, alias="marketingCost")
promotion_spend: float = Field(default=0.0, alias="promotionSpend")
revenue: float = 0.0
profit: float = 0.0
product_cost: float = Field(default=0.0, alias="productCost")
referral_pct: float | None = Field(default=None, alias="amazonReferalFee")
inventory: float | None = None
class Page(BaseModel):
"""COSMOS paginated envelope (`data` + paging metadata)."""
model_config = ConfigDict(extra="ignore")
data: list = Field(default_factory=list)
total_elements: int = Field(default=0, alias="totalElements")
total_pages: int = Field(default=0, alias="totalPages")
current_page: int = Field(default=1, alias="currentPage")
has_next: bool = Field(default=False, alias="hasNext")

View File

@ -0,0 +1,678 @@
"""High-level COSMOS operations shaped for the pricing agent.
Wraps the raw client with the exact calls the agent needs and maps COSMOS
responses into the agent's normalized schemas.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from pricing_agent.cosmos.client import MAX_CONCURRENCY, CosmosClient
from pricing_agent.cosmos.errors import CosmosApiError, ProductNotFoundError
from datetime import date, timedelta
from pricing_agent.cosmos.models import (
BulkQuote,
CosmosProduct,
InvpInsight,
LineProduct,
MarketingMetrics,
Page,
SalesDay,
TakehomeQuote,
)
from pricing_agent.schemas import FeeQuote
logger = logging.getLogger("pricing_agent.cosmos")
_MARKETPLACES_FILTER = "['AMAZON_USA']"
def takehome_to_feequote(th: TakehomeQuote) -> FeeQuote:
"""Map a full take-home response to the normalized FeeQuote.
`costPerUnit` already includes freight + duty, so it maps directly to landed
cost; top-level logistics/duty are not re-added (avoids double counting).
"""
landed = th.cost_per_unit or (
th.cost_breakdown.purchase_price + th.cost_breakdown.freight + th.cost_breakdown.duty
if th.cost_breakdown else 0.0
)
return FeeQuote(
selling_price=th.item_price,
landed_cost=landed,
fba_fee=th.fba_fee,
referral_amt=th.referral_fee,
returns_reserve=th.return_fee,
other_fees=th.other_fees,
net_takehome=th.take_home,
source="cosmos",
)
class CosmosPricingService:
def __init__(self, client: CosmosClient, marketplace: str = "AMAZON_USA"):
self.client = client
self.marketplace = marketplace
# ── Product lookup ────────────────────────────────────────────────────
def get_product(self, sku: str) -> CosmosProduct | None:
"""Best-effort product enrichment (ASIN, cost, velocity). Never raises."""
try:
body = self.client.get("/api/products", {
"q": sku, "page": 1, "size": 20,
"marketplaces": _MARKETPLACES_FILTER, "targetCurrency": "USD",
})
except CosmosApiError as e:
logger.warning("product lookup failed for %s: %s", sku, e)
return None
page = Page.model_validate(body or {})
for row in page.data:
if str(row.get("sku", "")).upper() == sku.upper():
return CosmosProduct.model_validate(row)
# Fall back to the first row if COSMOS returned a fuzzy match set.
if page.data:
return CosmosProduct.model_validate(page.data[0])
return None
def list_skus(self, limit: int = 20, sku_prefix: str | None = None,
brand: str | None = None, max_scan: int = 600) -> list[str]:
"""Return up to `limit` SKUs whose code CONTAINS `sku_prefix` (case-insensitive).
The catalog is sorted by potentialDailySale, so results are the highest-demand
matches first. Filtering is client-side because the API's `q` is a fuzzy/relevance
search, not a SKU substring match.
"""
needle = (sku_prefix or "").upper().strip()
skus: list[str] = []
page, scanned = 1, 0
while len(skus) < limit and scanned < max_scan:
params = {
"page": page, "size": 100, "marketplaces": _MARKETPLACES_FILTER,
"targetCurrency": "USD", "sort": "potentialDailySale", "order": "desc",
}
if brand:
params["brand"] = brand
pg = Page.model_validate(self.client.get("/api/products", params) or {})
if not pg.data:
break
for row in pg.data:
scanned += 1
sku = str(row.get("sku", "")).strip()
if not sku or (needle and needle not in sku.upper()):
continue
skus.append(sku)
if len(skus) >= limit:
break
if not pg.has_next:
break
page += 1
logger.info("catalog search prefix=%r%d SKUs (scanned %d rows)",
sku_prefix, len(skus), scanned)
return skus[:limit]
def find_line(self, sku_prefix: str, max_products: int = 500) -> list[LineProduct]:
"""EVERY product whose SKU contains `sku_prefix`, with inventory and velocity.
`list_skus` scans the products catalog and stops at a caller-supplied
limit, so a line is silently truncated before anyone sees how big it is.
This asks invp-insight instead, which filters server-side by prefix and
returns inventory, sales averages and min-PO in the same response so the
caller can show the true size of the line and let a human choose what to
analyze, rather than guessing a number up front.
"""
needle = (sku_prefix or "").upper().strip()
if not needle:
return []
out: list[LineProduct] = []
page = 1
while len(out) < max_products:
try:
body = self.client.get("/api/invp-insight", {
"skuPrefix": needle, "marketplaces": _MARKETPLACES_FILTER,
"preferredCurrency": "USD", "page": page, "size": 100,
"order": "desc",
})
except CosmosApiError as e:
logger.warning("line lookup failed for %r: %s", sku_prefix, e)
break
pg = Page.model_validate(body or {})
if not pg.data:
break
for row in pg.data:
sku = str(row.get("sku", "")).strip()
if not sku or needle not in sku.upper():
continue
item = LineProduct.model_validate(row)
# Cover days lives inside the weekly dateMap; the earliest entry is
# today's projection.
dm = row.get("dateMap") or {}
if isinstance(dm, dict) and dm:
first = min(dm, key=lambda k: (k[6:], k[:2], k[3:5]))
cd = (dm.get(first) or {}).get("coverDays")
item.cover_days = int(cd) if cd is not None else None
out.append(item)
if len(out) >= max_products:
break
if not pg.has_next:
break
page += 1
logger.info("line %r%d products (%d with stock)", sku_prefix, len(out),
sum(1 for p in out if p.inventory > 0))
return out
def list_all_skus(self) -> list[str]:
"""Every SKU in the catalog, ordered by demand (potentialDailySale desc).
Used to report true product-line sizes and pick the top-N to analyze.
~90 pages; cache the result.
"""
skus: list[str] = []
page = 1
while True:
pg = Page.model_validate(self.client.get("/api/products", {
"page": page, "size": 100, "marketplaces": _MARKETPLACES_FILTER,
"targetCurrency": "USD", "sort": "potentialDailySale", "order": "desc",
}) or {})
if not pg.data:
break
skus += [str(r.get("sku", "")).strip() for r in pg.data if r.get("sku")]
if not pg.has_next:
break
page += 1
logger.info("loaded full catalog: %d SKUs", len(skus))
return skus
# ── Fees + profit (the core pricing calc) ─────────────────────────────
@staticmethod
def _flatten_takehome(body: dict) -> dict:
"""Normalize the takehome-calculator response to TakehomeQuote's flat aliases.
The live API nests everything: fees under ``fees.breakdown`` (display names
like "Referral Fee" / "Pick & Pack"), costs under ``cost.breakdown``, and
``otherItems`` as "$ 1.23" strings. Older/flat responses (referralFee,
fbaFee, costPerUnit at top level) pass through untouched without this
mapping every fee validates to its 0.0 default and break-even collapses to 0.
"""
if not isinstance(body, dict) or "fees" not in body:
return body or {}
def money(v) -> float:
if isinstance(v, (int, float)):
return float(v)
try: # "$ -13.09" → -13.09
return float(str(v).replace("$", "").replace(",", "").strip())
except (TypeError, ValueError):
return 0.0
fees = (body.get("fees") or {}).get("breakdown") or {}
cost = body.get("cost") or {}
cost_bd = cost.get("breakdown") or {}
other = body.get("otherItems") or {}
variable = (body.get("variableExpenses") or {}).get("breakdown") or {}
cost_total = money(cost.get("total")) or sum(money(v) for v in cost_bd.values())
return {
"itemPrice": body.get("itemPrice"),
"takeHome": body.get("takeHome", 0.0),
"referralFee": money(fees.get("Referral Fee")),
"fbaFee": money(fees.get("Pick & Pack")) or money(fees.get("FBA Fee")),
"lowInventoryFee": money(fees.get("Low Inventory Fee")),
"inboundPlacementFee": money(fees.get("Inbound Placement Fee")),
"returnFee": money(fees.get("Return Fee")),
"eprFee": money(fees.get("EPR Fee")),
"vat": money(fees.get("VAT")),
"costPerUnit": cost_total,
"costBreakdown": {
"purchasePrice": money(cost_bd.get("Purchase Price")),
"freight": money(cost_bd.get("Freight In")) or money(cost_bd.get("Freight")),
"duty": money(cost_bd.get("Duty")),
},
"costSubtotal": money(other.get("Cost Subtotal")),
"marginImpact": money(other.get("Margin Impact")),
"logisticsCost": money(other.get("Logistics Cost")),
"orderHandling": money(other.get("Order Handling")),
"weightHandling": money(other.get("Weight Handling")),
"warehouseExpense": sum(money(v) for v in variable.values()),
}
def get_takehome(self, sku: str, price: float) -> TakehomeQuote:
"""Call the take-home calculator for a SKU at a candidate price."""
body = self.client.get("/api/sales-insight/takehome-calculator", {
"sku": sku,
"marketplace": self.marketplace,
"itemPrice": f"{price:.2f}",
"marketplaces": _MARKETPLACES_FILTER,
"preferredCurrency": "USD",
"groupBy": "SKU",
"interval": "Day",
"perspective": "unit_orders",
"mergeMarkets": "true",
"excludeZeros": "false",
"page": 1,
"size": 1,
})
th = TakehomeQuote.model_validate(self._flatten_takehome(body))
logger.info("fees %s @ $%.2f → take-home $%.2f/unit (referral $%.2f, FBA $%.2f, "
"landed $%.2f)", sku, price, th.take_home, th.referral_fee, th.fba_fee,
th.cost_per_unit)
return th
def fee_quote(self, sku: str, price: float) -> FeeQuote:
"""Fetch + normalize a take-home response into the agent's FeeQuote."""
return takehome_to_feequote(self.get_takehome(sku, price))
# ── Sales trend + inventory (invp) ────────────────────────────────────
def get_invp(self, sku: str) -> InvpInsight | None:
"""Sales trend (incl. 6-month), current inventory, and cover days for a SKU."""
try:
body = self.client.get("/api/invp-insight", {
"skuPrefix": sku, "marketplaces": _MARKETPLACES_FILTER,
"preferredCurrency": "USD", "page": 1, "size": 10, "order": "desc",
})
except CosmosApiError as e:
logger.warning("invp lookup failed for %s: %s", sku, e)
return None
page = Page.model_validate(body or {})
row = next((r for r in page.data if str(r.get("sku", "")).upper() == sku.upper()),
page.data[0] if page.data else None)
if row is None:
logger.info("invp %s → no record", sku)
return None
invp = InvpInsight.model_validate(row)
logger.info("trend+inventory %s → inv %.0f, 6mo %.0f/day, 30d %.0f/day, cover %s days",
sku, invp.inventory, invp.avg_6m or 0, invp.avg_30d or 0, invp.cover_days)
return invp
# ── Bulk economics (volume + inventory + storage) ─────────────────────
def bulk_quote(
self, sku: str, price: float, sale_units: float, inventory: float,
marketing: float = 0.0,
) -> BulkQuote:
"""Total take-home for `sale_units` sold at `price`, net of storage on `inventory`."""
body = self.client.get("/api/sales-insight/bulk-calculator", {
"sku": sku,
"marketplace": self.marketplace,
"itemPrice": f"{price:.2f}",
"saleUnits": int(round(sale_units)),
"inventory": int(round(inventory)),
"marketing": marketing,
"marketplaces": _MARKETPLACES_FILTER,
"preferredCurrency": "USD",
"groupBy": "SKU",
"interval": "Day",
"perspective": "unit_orders",
"mergeMarkets": "true",
"excludeZeros": "false",
"page": 1,
"size": 1,
})
if isinstance(body, dict) and "fees" in body: # nested live shape → flat aliases
flat = self._flatten_takehome(body)
fees = (body.get("fees") or {}).get("breakdown") or {}
flat.update({
"saleUnits": body.get("saleUnits", 0.0),
"inventory": body.get("inventory", 0.0),
"marketing": body.get("marketing", 0.0),
"takeHomePerUnit": body.get("takeHomePerUnit", 0.0),
"storageCharges": float(fees.get("Storage Charges") or 0.0),
})
body = flat
bulk = BulkQuote.model_validate(body)
logger.info("bulk %s: %.0f units, %.0f in stock → total take-home $%.2f "
"(storage $%.2f)", sku, bulk.sale_units, bulk.inventory,
bulk.take_home, bulk.storage_charges)
return bulk
# ── Sales history (price + units + ad spend over time) ────────────────
@staticmethod
def _windows(days: int, window: int, today: date | None) -> list[tuple[date, date]]:
"""Split `days` into the ≤15-day windows the sales-insight endpoint allows."""
today = today or date.today()
out, d = [], today - timedelta(days=days)
while d < today:
td = min(d + timedelta(days=window - 1), today)
out.append((d, td))
d = td + timedelta(days=1)
return out
@staticmethod
def _history_params(frm: date, to: date, sku_prefix: str, page: int, size: int) -> dict:
"""`sort` must carry the window's toDate, and `marketplaces` takes no brackets."""
return {
"fromDate": frm.strftime("%m/%d/%Y"), "toDate": to.strftime("%m/%d/%Y"),
"sort": to.strftime("%m/%d/%Y"), "page": page, "size": size,
"perspective": "unit_orders", "order": "desc", "groupBy": "SKU",
"interval": "Day", "marketplaces": "AMAZON_USA",
"preferredCurrency": "USD", "skuPrefix": sku_prefix,
"excludeZeros": "false", "mergeMarkets": "true",
}
@staticmethod
def _day_key(dt: str): # MM/DD/YYYY -> (YYYY, MM, DD)
return (dt[6:], dt[:2], dt[3:5])
def _fetch_windows(self, windows, fetch_one, label: str) -> list:
"""Run one fetch per window concurrently. Windows are independent, and each costs
~4-5s server-side, so fanning them out turns ~12 × 5s into roughly one round-trip.
A failed window is logged and skipped, never fatal."""
if len(windows) <= 1:
return [fetch_one(w) for w in windows]
workers = min(MAX_CONCURRENCY, len(windows))
results = [None] * len(windows)
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cosmos") as pool:
futures = {pool.submit(fetch_one, w): i for i, w in enumerate(windows)}
for fut in as_completed(futures):
i = futures[fut]
try:
results[i] = fut.result()
except CosmosApiError as e:
logger.warning("%s window %s failed: %s", label, windows[i][0], e)
return [r for r in results if r is not None]
def get_marketing(self, sku: str, days: int = 30,
today: date | None = None) -> MarketingMetrics | None:
"""Amazon Advertising metrics for one SKU over the last `days`.
`sales-insight` only reports total marketing spend, so TACoS was the only
ratio the engine could compute. This endpoint carries the ad-attributed
side `ppcSales`, ACoS, ROAS, CPC and the campaign budget which is what
separates "we spent $X on ads" from "ads produced $Y of sales".
Dates must be MM/DD/YYYY; ISO dates return a 500 from this controller.
Returns None when the SKU has no campaigns or the call fails.
"""
today = today or date.today()
frm = today - timedelta(days=days)
try:
body = self.client.get("/api/marketing/dashboard/marketing-data", {
"marketplace": self.marketplace,
"fromDate": frm.strftime("%m/%d/%Y"),
"toDate": today.strftime("%m/%d/%Y"),
"groupBy": "SKU", "granularity": "day", "perspective": "totalSpend",
"compare": "false", "skuPrefix": sku,
"page": 1, "size": 50, "order": "desc",
})
except CosmosApiError as e:
logger.warning("marketing metrics unavailable for %s: %s", sku, e)
return None
rows = (body or {}).get("data") if isinstance(body, dict) else None
row = next((r for r in (rows or [])
if str(r.get("id", "")).upper() == sku.upper()), None)
if not row:
logger.info("no marketing row for %s (%d candidates)", sku, len(rows or []))
return None
m = MarketingMetrics.model_validate(row)
logger.info("marketing %s: ACoS %.1f%%, ad sales $%.0f, ROAS %.2f, budget $%.0f",
sku, m.acos * 100, m.ppc_sales, m.roas, m.daily_budget)
return m
def get_sales_history(self, sku: str, days: int = 180, window: int = 15,
today: date | None = None) -> list[SalesDay]:
"""Daily sales history for a SKU over `days`, via ≤15-day windows fetched in
parallel. Returns daily SalesDay points sorted oldestnewest the source for
elasticity, ad cost and actual profit."""
def fetch(w):
body = self.client.get("/api/sales-insight",
self._history_params(w[0], w[1], sku, page=1, size=100))
rows = body.get("data") if isinstance(body, dict) else None
row = next((x for x in (rows or [])
if str(x.get("sku", "")).upper() == sku.upper()), None)
return (row or {}).get("dateMap") or {}
windows = self._windows(days, window, today)
collected: dict[str, dict] = {}
for day_map in self._fetch_windows(windows, fetch, "sales-insight"):
collected.update(day_map)
history = [SalesDay(date=dt, **{k: v for k, v in rec.items() if k != "date"})
for dt, rec in collected.items()]
history.sort(key=lambda p: self._day_key(p.date))
logger.info("sales history %s: %d days over %dd (%d windows, %d-way parallel)",
sku, len(history), days, len(windows),
min(MAX_CONCURRENCY, len(windows)))
return history
def get_sales_history_bulk(self, sku_prefix: str, days: int = 180, window: int = 15,
wanted: set[str] | None = None,
today: date | None = None) -> dict[str, list[SalesDay]]:
"""Daily history for EVERY SKU matching `sku_prefix`, in one shared sweep.
`skuPrefix` matches by substring, and each call returns the whole line, so a
product line costs ~12 calls total instead of ~12 per SKU. Pass `wanted` to keep
only the SKUs you'll analyze (the rest are discarded as they stream in).
"""
def fetch(w):
"""All pages of one window, keeping only the SKUs we care about."""
found: dict[str, dict[str, dict]] = {}
page = 1
while True:
body = self.client.get(
"/api/sales-insight",
self._history_params(w[0], w[1], sku_prefix, page=page, size=1000))
pg = Page.model_validate(body or {})
if not pg.data:
break
for row in pg.data:
sku = str(row.get("sku", "")).strip()
if not sku or (wanted and sku not in wanted):
continue
found.setdefault(sku, {}).update(row.get("dateMap") or {})
# Early exit: once every wanted SKU is captured, the remaining pages are all
# SKUs we'd discard. A window returns each SKU's full dateMap in one row, so
# having them all means this window is complete. Without this, a broad prefix
# (e.g. "PILLOW") paginates the whole matching catalog before the per-SKU loop
# can even start — which the UI shows as a frozen 0% bar with a dead Stop button.
if wanted and len(found) >= len(wanted):
break
if not pg.has_next:
break
page += 1
return found
windows = self._windows(days, window, today)
collected: dict[str, dict[str, dict]] = {}
for found in self._fetch_windows(windows, fetch, "bulk history"):
for sku, day_map in found.items():
collected.setdefault(sku, {}).update(day_map)
_key = self._day_key
out: dict[str, list[SalesDay]] = {}
for sku, days_map in collected.items():
pts = [SalesDay(date=dt, **{k: v for k, v in rec.items() if k != "date"})
for dt, rec in days_map.items()]
pts.sort(key=lambda p: _key(p.date))
out[sku] = pts
logger.info("bulk history '%s': %d SKUs, %dd", sku_prefix, len(out), days)
return out
# ── Catalog-wide ACTUAL performance (money-at-risk scan) ─────────────
def get_catalog_performance(self, days: int = 30, window: int = 15,
page_size: int = 1000, today: date | None = None) -> list[dict]:
"""Per-SKU ACTUAL profit across the whole catalog for the last `days`.
`sales-insight` is a bulk endpoint (groupBy=SKU, paginated), so this costs only a
handful of calls. Uses COSMOS's own `profit` field — the real number, including
storage, refunds, promo and ads. Returned sorted most-negative-profit first.
"""
from collections import defaultdict
today = today or date.today()
start = today - timedelta(days=days)
agg: dict[str, dict] = defaultdict(
lambda: {"units": 0.0, "revenue": 0.0, "profit": 0.0, "ad": 0.0,
"price_w": 0.0, "days": 0, "asin": None})
d = start
while d < today:
td = min(d + timedelta(days=window - 1), today)
page = 1
while True:
body = self.client.get("/api/sales-insight", {
"fromDate": d.strftime("%m/%d/%Y"), "toDate": td.strftime("%m/%d/%Y"),
"sort": td.strftime("%m/%d/%Y"), "page": page, "size": page_size,
"perspective": "unit_orders", "order": "desc", "groupBy": "SKU",
"interval": "Day", "marketplaces": "AMAZON_USA",
"preferredCurrency": "USD", "excludeZeros": "true",
"mergeMarkets": "true",
})
pg = Page.model_validate(body or {})
if not pg.data:
break
for row in pg.data:
sku = str(row.get("sku", "")).strip()
if not sku:
continue
a = agg[sku]
a["asin"] = a["asin"] or row.get("asin")
for rec in (row.get("dateMap") or {}).values():
u = rec.get("unitOrders") or 0
if u <= 0:
continue
a["units"] += u
a["revenue"] += rec.get("revenue") or 0.0
a["profit"] += rec.get("profit") or 0.0
a["ad"] += rec.get("marketingCost") or 0.0
sp = rec.get("salePrice")
if sp:
a["price_w"] += sp * u
a["days"] += 1
logger.info("catalog scan %s..%s page %d/%s (%d SKUs)",
d, td, page, pg.total_pages, len(agg))
if not pg.has_next:
break
page += 1
d = td + timedelta(days=1)
out = []
for sku, a in agg.items():
if a["units"] <= 0:
continue
out.append({
"sku": sku, "asin": a["asin"],
"units": round(a["units"]),
"avg_price": round(a["price_w"] / a["units"], 2) if a["price_w"] else None,
"revenue": round(a["revenue"], 2),
"actual_profit": round(a["profit"], 2),
"profit_per_unit": round(a["profit"] / a["units"], 2),
"ad_spend": round(a["ad"], 2),
"ad_per_unit": round(a["ad"] / a["units"], 2),
"days": a["days"],
})
out.sort(key=lambda r: r["actual_profit"]) # biggest losers first
logger.info("catalog scan complete: %d SKUs with sales in last %dd", len(out), days)
return out
# ── Price write-back (guarded) ────────────────────────────────────────
def submit_price_approval(self, sku: str, price: float, *, dry_run: bool = True) -> dict:
"""Push an approved price to COSMOS price-adjustment.
NOT YET WIRED: the write endpoint behind the `price_adjustment.edit_price`
claim is not in the read-only API export. Until its path + body are
confirmed, this only logs (dry-run) and never mutates COSMOS. Fill in the
real POST/PUT here once documented.
"""
if dry_run:
logger.info("[dry-run] would submit price %.2f for %s to COSMOS", price, sku)
return {"submitted": False, "dry_run": True, "sku": sku, "price": price}
raise NotImplementedError(
"COSMOS price-approval write endpoint not yet confirmed. "
"Provide the POST/PUT path + body (behind price_adjustment.edit_price)."
)
# ── Current price (read) ──────────────────────────────────────────────
def get_list_price(self, sku: str) -> float | None:
"""The take-home calculator's default `itemPrice`.
WARNING: this is NOT what we sell at. It is a reference/list price COSMOS
pre-fills in the FBA calculator UI. Verified on
UBMICROFIBERGUSSETPILLOWWHITEQUEEN: itemPrice = $33.89 while the listing was
actually selling at $23.39 on Amazon and in COSMOS's own sales data.
Kept only as a last-resort fallback for SKUs with no sales history, and as the
`list_price` reference. Use `get_current_price` for the real one.
"""
try:
body = self.client.get("/api/sales-insight/takehome-calculator", {
"sku": sku, "marketplace": self.marketplace,
"marketplaces": _MARKETPLACES_FILTER, "preferredCurrency": "USD",
"groupBy": "SKU", "interval": "Day",
})
except (CosmosApiError, ProductNotFoundError) as e:
logger.warning("list-price lookup failed for %s: %s", sku, e)
return None
price = body.get("itemPrice") if isinstance(body, dict) else None
return float(price) if price else None
def get_price_signal(self, sku: str, days: int = 14) -> dict:
"""What we ACTUALLY sell at, where it came from, and whether it is moving.
Returns: {price, source, as_of, low, high, selling_days}
`low`/`high` are the realized-price range over `days`. They matter because a single
price is a lie when the price is oscillating: UBCFKMATTRESSPROTECTORTWIN88 swings
between $11.71 and $12.98 (coupon/deal toggling), so "our price" read $12.34 from
COSMOS while Amazon showed $11.71 live twenty minutes later. Neither number is
wrong the price genuinely moved. Surfacing the range stops someone from treating
a moving price as a fixed one and cutting against it.
"""
price, source, as_of = self._current_price_detail(sku, days=days)
low = high = None
selling_days = 0
try:
sold = [d for d in self.get_sales_history(sku, days=days)
if d.units > 0 and d.sale_price]
selling_days = len(sold)
if sold:
prices = [float(d.sale_price) for d in sold]
low, high = round(min(prices), 2), round(max(prices), 2)
except CosmosApiError:
pass
return {"price": price, "source": source, "as_of": as_of,
"low": low, "high": high, "selling_days": selling_days}
def _current_price_detail(
self, sku: str, days: int = 14
) -> tuple[float | None, str, str | None]:
"""(price, source, as_of) — what we ACTUALLY sell at, and where it came from.
`sales-insight.salePrice` is revenue ÷ units for the day: the price customers
genuinely paid, net of promotions. Verified against the live Amazon Buy Box on 6
of our highest-demand ASINs all 6 matched TO THE CENT, while the take-home
calculator's default `itemPrice` was high on 5 of them, by up to +52%.
That gap is not cosmetic. On UBMICROFIBERGUSSETPILLOWWHITEQUEEN, `itemPrice`
($33.89) shows a 30% margin and clears the target; the real price ($23.39) is 5.5%
and far below the floor. Analysing at `itemPrice` reports a money-loser as healthy.
The SOURCE is returned rather than inferred by comparing the two numbers: a SKU
that genuinely sells AT its list price would otherwise be mislabelled "no recent
sales" — and that label is exactly what tells you whether the price is real or a
fallback, so it has to be right when the two happen to coincide.
"""
try:
history = self.get_sales_history(sku, days=days)
except CosmosApiError as e:
logger.warning("sales history failed for %s: %s", sku, e)
history = []
sold = [d for d in history if d.units > 0 and d.sale_price]
if sold:
latest = sold[-1]
logger.info("current price %s = $%.2f (realized salePrice, %s)",
sku, latest.sale_price, latest.date)
return float(latest.sale_price), "realized salePrice", latest.date
fallback = self.get_list_price(sku)
logger.warning(
"current price %s: NO SALES in %dd — falling back to list itemPrice %s. "
"That is a REFERENCE price, not a selling price; any margin computed from it "
"may be optimistic. (No sales on a stocked SKU is itself worth looking at.)",
sku, days, f"${fallback:.2f}" if fallback else "none",
)
return fallback, f"list itemPrice (no sales in {days}d)", None
def get_current_price(self, sku: str, days: int = 14) -> float | None:
"""What we ACTUALLY sell at. See `get_price_signal` for source + price range."""
return self._current_price_detail(sku, days=days)[0]

View File

@ -0,0 +1,202 @@
"""Price-elasticity estimation from COSMOS sales history.
From daily (price, units) points we build monthly aggregates (normalized to
units/day so partial months are comparable), then fit a constant-elasticity
demand model by log-log OLS:
ln(units_per_day) = a + e * ln(price)
`e` is the price elasticity (% change in units per 1% change in price; negative =
normal good). tells us how much of the demand variance price explains the
basis for the confidence label. If price barely varied or the fit is weak, we say
so and fall back to held volume instead of guessing.
"""
from __future__ import annotations
import math
from collections import defaultdict
def monthly_aggregate(history) -> list[dict]:
"""Aggregate daily SalesDay points to per-month avg price, units/day, ad/unit."""
agg: dict[str, dict] = defaultdict(lambda: {"units": 0.0, "price_w": 0.0, "ad": 0.0, "days": 0})
for p in history:
if not p.sale_price or p.units <= 0:
continue
m = p.date[6:] + "-" + p.date[:2] # YYYY-MM
a = agg[m]
a["units"] += p.units
a["price_w"] += p.sale_price * p.units
a["ad"] += p.marketing_cost
a["days"] += 1
out = []
for m in sorted(agg):
a = agg[m]
if a["units"] > 0 and a["days"] > 0:
out.append({
"month": m,
"avg_price": round(a["price_w"] / a["units"], 2),
"units_per_day": round(a["units"] / a["days"], 1),
"ad_per_unit": round(a["ad"] / a["units"], 2),
"days": a["days"],
})
return out
# two-sided t at 95% by degrees of freedom (n-2); >=30 uses the normal limit
_T95 = {1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365,
8: 2.306, 9: 2.262, 10: 2.228, 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145,
15: 2.131, 16: 2.120, 17: 2.110, 18: 2.101, 19: 2.093, 20: 2.086}
def _t95(df: int) -> float:
return _T95.get(df, 1.96 if df > 20 else 12.706)
# Periods shorter than this are calendar stubs (a month that only overlaps the
# window by a few days). Weighting them like a full month visibly moves the fit.
MIN_PERIOD_DAYS = 10
def estimate_elasticity(monthly: list[dict], min_months: int = 4,
min_period_days: int = MIN_PERIOD_DAYS) -> dict | None:
"""Day-weighted log-log elasticity with a confidence interval.
ln(units_per_day) = a + e * ln(price)
Returns {elasticity, r2, n, price_spread_pct, confidence, std_err, t_stat,
ci_low, ci_high, actionable, days} or None when there isn't enough data.
``actionable`` is the field callers must respect: it is False when the 95%
confidence interval spans zero, i.e. the data cannot rule out "price has no
effect on volume". A point estimate without that check is how a statistically
meaningless slope ends up driving a 20% price move.
"""
pts = [(m["avg_price"], m["units_per_day"], float(m.get("days") or 1))
for m in monthly
if m["avg_price"] > 0 and m["units_per_day"] > 0]
# Drop calendar stubs, but never drop so many that the fit dies.
full = [p for p in pts if p[2] >= min_period_days]
if len(full) >= min_months:
pts = full
if len(pts) < min_months:
return None
prices = [p for p, _, _ in pts]
spread = (max(prices) - min(prices)) / (sum(prices) / len(prices))
if spread < 0.02: # price essentially flat → can't infer elasticity
return {"elasticity": None, "r2": None, "n": len(pts),
"price_spread_pct": round(spread * 100, 1), "confidence": "none",
"actionable": False,
"why": "price barely moved — nothing to estimate from"}
xs = [math.log(p) for p, _, _ in pts]
ys = [math.log(u) for _, u, _ in pts]
ws = [w for _, _, w in pts]
n = len(xs)
tw = sum(ws)
mx = sum(x * w for x, w in zip(xs, ws)) / tw
my = sum(y * w for y, w in zip(ys, ws)) / tw
sxx = sum(w * (x - mx) ** 2 for x, w in zip(xs, ws))
sxy = sum(w * (x - mx) * (y - my) for x, y, w in zip(xs, ys, ws))
if sxx == 0:
return None
e = sxy / sxx
b = my - e * mx
ss_res = sum(w * (y - (b + e * x)) ** 2 for x, y, w in zip(xs, ys, ws))
ss_tot = sum(w * (y - my) ** 2 for y, w in zip(ys, ws))
r2 = 1 - ss_res / ss_tot if ss_tot else 0.0
# Standard error of the slope, on the weighted fit.
df = n - 2
if df > 0 and ss_res > 0:
se = math.sqrt((ss_res / df) / sxx)
else:
se = 0.0
t_stat = e / se if se > 0 else float("inf")
half = _t95(df) * se if se > 0 else 0.0
ci_low, ci_high = e - half, e + half
# Spans zero → the sign of the effect is not established.
actionable = bool(se > 0 and ci_low * ci_high > 0 and e < 0)
if not actionable:
conf = "none"
elif n >= 6 and r2 >= 0.5:
conf = "high"
elif n >= 4 and r2 >= 0.3:
conf = "medium"
else:
conf = "low"
out = {"elasticity": round(e, 2), "r2": round(r2, 2), "n": n,
"days": int(tw), "price_spread_pct": round(spread * 100, 1),
"confidence": conf, "std_err": round(se, 3),
"t_stat": round(t_stat, 2) if se > 0 else None,
"ci_low": round(ci_low, 2), "ci_high": round(ci_high, 2),
"actionable": actionable}
if not actionable:
out["why"] = (f"95% CI [{out['ci_low']}, {out['ci_high']}] spans zero — the "
f"data cannot establish that price moves volume "
f"(t={out['t_stat']}, n={n})")
return out
def demand_at(price: float, ref_price: float, ref_units: float, elasticity: float) -> float:
"""Expected units at `price` under a constant-elasticity model."""
if ref_price <= 0 or price <= 0 or ref_units <= 0:
return ref_units
return ref_units * (price / ref_price) ** elasticity
def unconstrained_optimum(*, elasticity: float, margin_rate: float,
fixed_per_unit: float) -> float | None:
"""Closed-form profit-maximising price for q = A·p^e with a linear margin.
With net/unit = margin_rate·p fixed_per_unit, setting /dp = 0 gives
p* = fixed_per_unit · e / (margin_rate · (e + 1))
Only defined for e < 1 (elastic demand); at e 1 profit rises without bound
in the model and there is no interior optimum which is itself a finding, not
a price. Computing this is how you tell a real maximum from the edge of a
grid search.
"""
if elasticity >= -1 or margin_rate <= 0:
return None
p = fixed_per_unit * elasticity / (margin_rate * (elasticity + 1))
return round(p, 2) if p > 0 else None
def optimize_price(*, floor, ceiling, ref_price, ref_units, elasticity, net_profit_fn,
step=1.0):
"""Maximize total profit = expected_units(price) × net_profit_per_unit(price).
Sweeps prices from `floor` to `ceiling`. `net_profit_fn(price)` returns per-unit
net profit at that price (must already include ad cost). Returns (best, table).
The 3%-tiebreak rule prefers the lower price when profits are within 3%.
``best["is_corner"]`` is True when the winner sits on the edge of the sweep
then it is not a maximum at all, just where the search stopped, and callers
must not present it as "profit-optimal".
"""
table = []
p = floor
while p <= ceiling + 1e-9:
units = demand_at(p, ref_price, ref_units, elasticity)
npu = net_profit_fn(p)
table.append({"price": round(p, 2), "units_per_day": round(units, 1),
"net_per_unit": round(npu, 2),
"daily_profit": round(units * npu, 2)})
p = round(p + step, 2)
best = max(table, key=lambda r: r["daily_profit"]) if table else None
if best:
# 3% tiebreak → cheapest price within 3% of the peak. The band must be
# measured on the MAGNITUDE of the peak: for a SKU that loses money at
# every price, `peak * 0.97` moves the bar ABOVE the peak (a negative
# times 0.97 is larger), leaving no candidates at all.
peak = best["daily_profit"]
threshold = peak - abs(peak) * 0.03
near = [r for r in table if r["daily_profit"] >= threshold] or [best]
best = min(near, key=lambda r: r["price"])
best["is_corner"] = bool(
len(table) > 1 and best["price"] >= table[-1]["price"] - 1e-9)
return best, table

16
src/pricing_agent/fmt.py Normal file
View File

@ -0,0 +1,16 @@
"""Shared money formatting.
Accounting convention: the sign leads the currency symbol. `-$3.55`, never `$-3.55`.
Every layer (CLI, UI, rule reasons) formats through here so a negative profit reads
the same way wherever it surfaces.
"""
from __future__ import annotations
def usd(value, dp: int = 2, dash: str = "") -> str:
"""Format `value` as USD. Non-numeric / None renders as an em dash."""
try:
f = float(value)
except (TypeError, ValueError):
return dash
return f"{'-' if f < 0 else ''}${abs(f):,.{dp}f}"

View File

@ -0,0 +1,80 @@
"""LangGraph state machine wiring.
fetch_fees -> compute_margin -> fetch_competitive -> evaluate
-> human_review (interrupt) -> write_back -> END
A checkpointer (in-memory here; swap for SqliteSaver to persist across processes)
is required for interrupt()/resume to work.
"""
from __future__ import annotations
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.graph import END, START, StateGraph
from pricing_agent.nodes.compute import (
analyze_node,
compute_margin_node,
enrich_node,
evaluate_node,
fetch_competitive_node,
)
from pricing_agent.nodes.writeback import human_review_node, write_back_node
from pricing_agent.state import PricingState
def _schema_allowlist() -> set[tuple[str, str]]:
"""(module, name) tuples for every type we put into graph state.
LangGraph 1.x's msgpack allowlist matches exact (module, qualname) pairs, so we
enumerate the pydantic models + enums defined in pricing_agent.schemas rather than
hand-maintaining the list.
"""
import enum
import inspect
from pydantic import BaseModel
from pricing_agent import schemas
allow: set[tuple[str, str]] = set()
for _name, obj in inspect.getmembers(schemas):
if inspect.isclass(obj) and obj.__module__ == schemas.__name__:
if issubclass(obj, (BaseModel, enum.Enum)):
allow.add((schemas.__name__, obj.__name__))
return allow
def _default_checkpointer() -> MemorySaver:
"""MemorySaver whose serializer explicitly allows our schema types.
Without the allowlist LangGraph 1.x logs 'unregistered type' warnings on every
resume and will block them in a future release. Registering the types is the
forward-compatible fix.
"""
serde = JsonPlusSerializer(allowed_msgpack_modules=_schema_allowlist())
return MemorySaver(serde=serde)
def build_graph(checkpointer=None):
"""Compile and return the pricing agent graph."""
g = StateGraph(PricingState)
g.add_node("enrich", enrich_node)
g.add_node("compute_margin", compute_margin_node)
g.add_node("fetch_competitive", fetch_competitive_node)
g.add_node("evaluate", evaluate_node)
g.add_node("analyze", analyze_node)
g.add_node("human_review", human_review_node)
g.add_node("write_back", write_back_node)
g.add_edge(START, "enrich")
g.add_edge("enrich", "compute_margin")
g.add_edge("compute_margin", "fetch_competitive")
g.add_edge("fetch_competitive", "evaluate")
g.add_edge("evaluate", "analyze")
g.add_edge("analyze", "human_review")
g.add_edge("human_review", "write_back")
g.add_edge("write_back", END)
return g.compile(checkpointer=checkpointer or _default_checkpointer())

134
src/pricing_agent/llm.py Normal file
View File

@ -0,0 +1,134 @@
"""OpenAI structured-output wrapper for the rationale node.
The LLM ONLY writes narrative. It receives already-computed numbers and the
deterministic gate decision, and returns a LlmNarrative. If no API key is set
(or the call fails), we fall back to a deterministic template so the agent still
runs fully offline.
"""
from __future__ import annotations
from config.settings import get_settings
from pricing_agent.schemas import (
CompetitiveSnapshot,
Decision,
FeeStack,
LlmNarrative,
)
SYSTEM_PROMPT = (
"You are a senior Amazon Pricing & Profitability Analyst. You are given a SKU's "
"already-computed fee stack, margin figures, competitive snapshot, and a FINAL "
"deterministic decision that you must NOT change. Write a concise, professional "
"rationale explaining the decision to a launch manager. Never invent or recompute "
"numbers — only use the ones provided. Be specific about break-even, MAP, "
"contribution margin, and Buy Box/suppression status."
)
def _facts_block(
sku: str, price: float, fs: FeeStack, comp: CompetitiveSnapshot,
decision: Decision, gate_reasons: list[str],
) -> str:
offer_lines = []
for o in comp.offers[:10]:
tag = " [Buy Box]" if o.is_buy_box else ""
p = f"${o.price:.2f}" if o.price is not None else "n/a"
offer_lines.append(f" - {o.seller_name}: {p}{tag}")
offers_block = (
"Competitor offers:\n" + "\n".join(offer_lines)
if offer_lines else "Competitor offers: (none)"
)
return (
f"SKU: {sku}\n"
f"Evaluated price: ${price:.2f}\n"
f"Landed cost: ${fs.landed_cost:.2f} | FBA fee: ${fs.fba_fee:.2f} | "
f"Referral: {fs.referral_pct*100:.0f}% (${fs.referral_amt:.2f}) [{fs.referral_basis}]\n"
f"Returns reserve: ${fs.returns_reserve:.2f} | Storage: ${fs.storage_alloc:.2f}\n"
f"Total cost/unit: ${fs.total_cost:.2f} | Profit/unit: ${fs.profit:.2f} | "
f"Contribution margin: {fs.contribution_margin_pct*100:.1f}%\n"
f"Break-even: ${fs.break_even_price:.2f} | MAP floor: ${fs.map_floor:.2f}\n"
f"Buy Box: {comp.buy_box_status.value} | Suppressed: {comp.is_suppressed} | "
f"Buy Box seller: {comp.buy_box_seller_name or ''} @ "
f"{comp.buy_box_price}\n"
f"Competitive median: {comp.competitive_median} (low {comp.competitive_low}, "
f"high {comp.competitive_high})\n"
f"{offers_block}\n"
f"Competitive note: {comp.reason}\n"
f"DECISION (final, do not change): {decision.value}\n"
f"Gate reasons: {'; '.join(gate_reasons)}\n"
)
def _template_narrative(
sku: str, price: float, fs: FeeStack, comp: CompetitiveSnapshot,
decision: Decision, gate_reasons: list[str],
) -> LlmNarrative:
verdict = {
Decision.APPROVED: "Approved for pricing.",
Decision.BLOCKED: "Blocked at the pricing gate.",
Decision.NEEDS_REVIEW: "Needs a human match/hold decision.",
}[decision]
rationale = (
f"{verdict} At ${price:.2f}, contribution margin is "
f"{fs.contribution_margin_pct*100:.1f}% at the worst-case "
f"{fs.referral_pct*100:.0f}% referral. Break-even is ${fs.break_even_price:.2f} "
f"and the MAP floor is ${fs.map_floor:.2f}. " + " ".join(gate_reasons)
)
if comp.offers:
bits = []
for o in comp.offers[:6]:
p = f"${o.price:.2f}" if o.price is not None else "n/a"
tag = " (Buy Box)" if o.is_buy_box else ""
bits.append(f"{o.seller_name} {p}{tag}")
competitor = "Sellers on listing: " + "; ".join(bits) + ". " + (comp.reason or "")
elif comp.competitive_median:
competitor = (
f"Competitive band ${comp.competitive_low}${comp.competitive_high} "
f"(median ${comp.competitive_median}). {comp.reason}"
)
else:
competitor = comp.reason or "No competitive band available."
if comp.is_suppressed:
supp = ("Listing is price-suppressed — the Buy Box is hidden, so PPC cannot "
"convert. Do not launch until the price is inside the reference band.")
elif comp.buy_box_status.value == "LOST_PRICE":
supp = "Not the Featured Offer on price; match only if it stays above MAP."
else:
supp = "No suppression; Featured Offer eligibility looks healthy."
action = {
Decision.APPROVED: f"Set price to ${price:.2f} and flip status to Pricing Approved.",
Decision.BLOCKED: "Hold at Pricing Blocked; escalate to Launch Manager / Sourcing.",
Decision.NEEDS_REVIEW: "Bring to the PPC & Sales meeting for a match/hold call.",
}[decision]
return LlmNarrative(
rationale=rationale,
competitor_summary=competitor,
suppression_note=supp,
recommended_action=action,
)
def generate_narrative(
*, sku: str, price: float, fee_stack: FeeStack, competitive: CompetitiveSnapshot,
decision: Decision, gate_reasons: list[str],
) -> LlmNarrative:
settings = get_settings()
if not settings.openai_api_key:
return _template_narrative(sku, price, fee_stack, competitive, decision, gate_reasons)
try:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model=settings.openai_model,
api_key=settings.openai_api_key,
temperature=0,
).with_structured_output(LlmNarrative)
facts = _facts_block(sku, price, fee_stack, competitive, decision, gate_reasons)
result = llm.invoke(
[("system", SYSTEM_PROMPT), ("human", facts)]
)
return result # type: ignore[return-value]
except Exception:
# Any API/parse failure -> deterministic fallback, never break the run.
return _template_narrative(sku, price, fee_stack, competitive, decision, gate_reasons)

View File

@ -0,0 +1,46 @@
"""Logging setup for the agent.
Console logging shows every COSMOS call + each analysis step. Use setup_logging()
from the CLI/UI. ListHandler captures messages so the UI can show the steps inline.
"""
from __future__ import annotations
import logging
import os
import sys
_FMT = "%(asctime)s %(levelname)-5s %(name)s | %(message)s"
_DATEFMT = "%H:%M:%S"
def setup_logging(level: str | None = None) -> logging.Logger:
"""Configure the 'pricing_agent' logger to print to stderr. Idempotent."""
level = (level or os.environ.get("LOG_LEVEL") or "INFO").upper()
logger = logging.getLogger("pricing_agent")
logger.setLevel(getattr(logging, level, logging.INFO))
if not any(getattr(h, "_pa_console", False) for h in logger.handlers):
try: # UTF-8 so arrows/dashes render on Windows consoles too
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
h = logging.StreamHandler(sys.stderr)
h.setFormatter(logging.Formatter(_FMT, _DATEFMT))
h._pa_console = True # type: ignore[attr-defined]
logger.addHandler(h)
logger.propagate = False
return logger
class ListHandler(logging.Handler):
"""Collect log lines into a list — used to show the steps inside the UI."""
def __init__(self, level=logging.INFO):
super().__init__(level)
self.records: list[str] = []
self.setFormatter(logging.Formatter("%(asctime)s %(message)s", _DATEFMT))
def emit(self, record: logging.LogRecord) -> None:
try:
self.records.append(self.format(record))
except Exception:
pass

View File

View File

@ -0,0 +1,79 @@
"""Deterministic pipeline nodes: enrich -> margin -> competitive -> evaluate.
Market data comes through a MarketDataProvider (cosmos | mock), so these nodes
are backend-agnostic. The human interrupt lives in nodes/writeback.py.
"""
from __future__ import annotations
from config.settings import get_rules, get_settings
from pricing_agent.llm import generate_narrative
from pricing_agent.providers import get_provider
from pricing_agent.schemas import PricingDecision
from pricing_agent.state import PricingState
from pricing_agent.tools.gate import evaluate_gate
def enrich_node(state: PricingState) -> dict:
"""Fill in ASIN / landed cost / fee inputs from the provider."""
provider = get_provider(get_settings())
return {"sku_input": provider.enrich(state["sku_input"])}
def compute_margin_node(state: PricingState) -> dict:
"""Build the gate fee stack (fees + CM + break-even + MAP)."""
provider = get_provider(get_settings())
return {"fee_stack": provider.fee_stack(state["sku_input"])}
def fetch_competitive_node(state: PricingState) -> dict:
provider = get_provider(get_settings())
return {"competitive": provider.competitive(state["sku_input"])}
def evaluate_node(state: PricingState) -> dict:
"""Run the deterministic gate, then have the LLM write the narrative."""
rules = get_rules()
si = state["sku_input"]
gate_stack = state["fee_stack"]
competitive = state["competitive"]
decision, reasons = evaluate_gate(
gate_stack=gate_stack, competitive=competitive, rules=rules
)
narrative = generate_narrative(
sku=si.sku,
price=si.target_price,
fee_stack=gate_stack,
competitive=competitive,
decision=decision,
gate_reasons=reasons,
)
proposal = PricingDecision(
sku=si.sku,
asin=si.asin,
decision=decision,
recommended_price=si.target_price,
fee_stack=gate_stack,
competitive=competitive,
gate_reasons=reasons,
rationale=narrative.rationale,
competitor_summary=narrative.competitor_summary,
suppression_note=narrative.suppression_note,
recommended_action=narrative.recommended_action,
)
return {"decision": proposal}
def analyze_node(state: PricingState) -> dict:
"""Attach sales-trend + inventory + verdict, if the provider supports it (COSMOS)."""
provider = get_provider(get_settings())
fn = getattr(provider, "analysis", None)
if fn is None:
return {}
try:
info = fn(state["sku_input"], state["fee_stack"])
except Exception:
info = None
return {"analysis": info} if info else {}

View File

@ -0,0 +1,120 @@
"""Human review + write-back nodes.
human_review uses LangGraph's interrupt() to pause the graph and surface the
proposal to a human. The CLI resumes with Command(resume={...}). On approval
(or an edited price), write_back updates the tracker and appends an audit row.
"""
from __future__ import annotations
from config.settings import get_rules, get_settings
from langgraph.types import interrupt
from pricing_agent.providers import get_provider
from pricing_agent.schemas import Decision
from pricing_agent.state import PricingState
from pricing_agent.tools.gate import evaluate_gate
from pricing_agent.tools.tracker import get_tracker, now_iso
def human_review_node(state: PricingState) -> dict:
"""Pause for human approval. Returns the human's action to state."""
proposal = state["decision"]
# Everything the reviewer needs to decide, serialisable for the interrupt payload.
payload = {
"sku": proposal.sku,
"asin": proposal.asin,
"decision": proposal.decision.value,
"recommended_price": proposal.recommended_price,
"contribution_margin_pct": proposal.fee_stack.contribution_margin_pct,
"break_even": proposal.fee_stack.break_even_price,
"map_floor": proposal.fee_stack.map_floor,
"buy_box": proposal.competitive.buy_box_status.value,
"buy_box_price": proposal.competitive.buy_box_price,
"buy_box_seller": proposal.competitive.buy_box_seller_name,
"competitor_offers": [
{
"seller_name": o.seller_name,
"seller_id": o.seller_id,
"price": o.price,
"is_buy_box": o.is_buy_box,
"condition": o.condition,
}
for o in proposal.competitive.offers
],
"rationale": proposal.rationale,
"recommended_action": proposal.recommended_action,
"analysis": state.get("analysis"),
}
# interrupt() raises internally; the value we return on resume is a dict:
# {"action": "approve"|"edit"|"reject", "price": float|None, "note": str}
result = interrupt(payload)
action = (result or {}).get("action", "reject")
return {
"human_action": action,
"human_price": (result or {}).get("price"),
"human_note": (result or {}).get("note", ""),
}
def write_back_node(state: PricingState) -> dict:
"""Apply the human decision to the tracker + audit log."""
settings = get_settings()
tracker = get_tracker(settings)
proposal = state["decision"]
action = state.get("human_action", "reject")
si = state["sku_input"]
if action == "reject":
# Record the rejection in audit; do not touch pricing columns/status.
tracker.append_audit(
{
"timestamp": now_iso(),
"sku": proposal.sku,
"decision": "REJECTED_BY_HUMAN",
"price": proposal.recommended_price,
"approver": settings and "human",
"detail": {
"note": state.get("human_note", ""),
"proposed": proposal.decision.value,
},
}
)
return {"written": False, "audit_id": f"{proposal.sku}:rejected"}
final = proposal
# If the human edited the price, recompute the fee stack AND re-run the gate at
# the new price, so the written status reflects the edited price (not the stale
# decision from the original proposal).
if action == "edit" and state.get("human_price"):
new_price = float(state["human_price"])
provider = get_provider(settings)
new_si = si.model_copy(update={"target_price": new_price})
new_stack = provider.fee_stack(new_si)
new_decision, new_reasons = evaluate_gate(
gate_stack=new_stack, competitive=proposal.competitive, rules=get_rules()
)
final = proposal.model_copy(update={
"recommended_price": new_price,
"fee_stack": new_stack,
"decision": new_decision,
"gate_reasons": new_reasons,
})
row = final.tracker_row()
tracker.write_decision(row)
tracker.append_audit(
{
"timestamp": now_iso(),
"sku": final.sku,
"decision": final.decision.value,
"price": final.recommended_price,
"approver": "human",
"detail": {
"action": action,
"note": state.get("human_note", ""),
"cm_pct": final.fee_stack.contribution_margin_pct,
"break_even": final.fee_stack.break_even_price,
"map_floor": final.fee_stack.map_floor,
},
}
)
return {"written": True, "audit_id": f"{final.sku}:{final.decision.value}", "decision": final}

View File

@ -0,0 +1,281 @@
"""Evidence-based price performance from real COSMOS sales history.
No model, no extrapolation: we take the actual daily rows (`salePrice`, `unitOrders`,
`profit`, `marketingCost`) and answer "at which price did we ACTUALLY sell well and
make money?".
COSMOS's `profit` field is fuller than our modelled `take-home ad` — it also absorbs
storage, refunds, promotion spend and logistics. Where the two disagree, the actual
number wins, and `reconcile()` surfaces the gap as unmodeled cost.
Caveat encoded by callers: month/band comparisons confound seasonality, ad spend,
promos and stock availability. This is evidence in context, not proof of causation.
"""
from __future__ import annotations
from collections import defaultdict
from .fmt import usd
def _month_key(date_str: str) -> str:
"""'MM/DD/YYYY' -> 'YYYY-MM'."""
return date_str[6:] + "-" + date_str[:2]
def monthly_performance(history) -> list[dict]:
"""Per-month actual performance: price, units/day, ACTUAL profit/day, profit/unit."""
agg: dict[str, dict] = defaultdict(
lambda: {"units": 0.0, "price_w": 0.0, "profit": 0.0, "ad": 0.0, "days": 0})
for p in history:
if not p.sale_price or p.units <= 0:
continue
a = agg[_month_key(p.date)]
a["units"] += p.units
a["price_w"] += p.sale_price * p.units
a["profit"] += p.profit
a["ad"] += p.marketing_cost
a["days"] += 1
out = []
for m in sorted(agg):
a = agg[m]
if a["units"] <= 0 or a["days"] <= 0:
continue
out.append({
"month": m,
"avg_price": round(a["price_w"] / a["units"], 2),
"units_per_day": round(a["units"] / a["days"], 1),
"actual_profit_per_day": round(a["profit"] / a["days"], 2),
"profit_per_unit": round(a["profit"] / a["units"], 2),
"ad_per_unit": round(a["ad"] / a["units"], 2),
"days": a["days"],
})
return out
def price_band_performance(history, band: float = 0.50, min_days: int = 7) -> list[dict]:
"""Bucket days by price band (not calendar month) and aggregate actual performance.
Removes month/seasonality binning: the same price seen across different months is
pooled. Bands with fewer than `min_days` of data are returned but marked
``enough_data=False`` so callers never call them "best".
"""
if band <= 0:
raise ValueError("band must be positive")
agg: dict[float, dict] = defaultdict(
lambda: {"units": 0.0, "price_w": 0.0, "profit": 0.0, "ad": 0.0, "days": 0})
for p in history:
if not p.sale_price or p.units <= 0:
continue
key = round(round(p.sale_price / band) * band, 2)
a = agg[key]
a["units"] += p.units
a["price_w"] += p.sale_price * p.units
a["profit"] += p.profit
a["ad"] += p.marketing_cost
a["days"] += 1
out = []
for price in sorted(agg):
a = agg[price]
if a["units"] <= 0 or a["days"] <= 0:
continue
out.append({
"price_band": price,
"avg_price": round(a["price_w"] / a["units"], 2),
"units_per_day": round(a["units"] / a["days"], 1),
"actual_profit_per_day": round(a["profit"] / a["days"], 2),
"profit_per_unit": round(a["profit"] / a["units"], 2),
"ad_per_unit": round(a["ad"] / a["units"], 2),
"days": a["days"],
"enough_data": a["days"] >= min_days,
})
return out
def best_observed(bands: list[dict]) -> dict | None:
"""The price band with the highest ACTUAL profit/day, among bands with enough data.
Returns None if no band has a sufficient sample. Note the winner may still be
loss-making that itself is the finding (unprofitable at every observed price).
"""
eligible = [b for b in bands if b.get("enough_data")]
if not eligible:
return None
return max(eligible, key=lambda b: b["actual_profit_per_day"])
def unprofitable_months(monthly: list[dict]) -> int:
"""How many of the observed months actually lost money."""
return sum(1 for m in monthly if m["actual_profit_per_day"] < 0)
def reconcile(actual_profit_per_unit: float | None,
modeled_net_incl_ad: float | None) -> float | None:
"""Gap between our modelled net/unit and COSMOS's actual profit/unit.
Positive = the model OVERSTATES profit by this much (unmodeled costs: storage,
refunds, promo, logistics). Callers should trust the actual number.
"""
if actual_profit_per_unit is None or modeled_net_incl_ad is None:
return None
return round(modeled_net_incl_ad - actual_profit_per_unit, 2)
# Share of each extra $1 of price that survives referral (~15%) + returns (~2%).
_PRICE_PASSTHROUGH = 0.83
def loss_reason(profit_per_unit: float, ad_per_unit: float,
price: float | None = None) -> str:
"""Explain WHY a SKU is losing/thin, straight from its own numbers.
`profit_per_unit + ad_per_unit` is profit BEFORE advertising: if positive, ads alone
caused the loss; if negative, the unit loses money before a cent of ads is spent.
"""
before_ads = profit_per_unit + ad_per_unit
tacos = f" — ads are {ad_per_unit / price:.0%} of price" if price else ""
if profit_per_unit >= 0.50:
return "healthy"
if profit_per_unit < 0 and before_ads > 0:
return (f"ADS: earns {usd(before_ads)}/u before ads, but {usd(ad_per_unit)}/u ad spend "
f"turns it into {usd(profit_per_unit)}/u{tacos}")
if profit_per_unit < 0:
return (f"BELOW COST: loses {usd(-before_ads)}/u before any ads; {usd(ad_per_unit)}/u "
f"ads deepen it to {usd(profit_per_unit)}/u{tacos}")
return f"THIN: only {usd(profit_per_unit)}/u after {usd(ad_per_unit)}/u ads{tacos}"
def fix_suggestion(profit_per_unit: float, ad_per_unit: float,
price: float | None = None) -> str:
"""The concrete lever to pull to get this SKU back to break-even."""
if profit_per_unit >= 0.50:
return ""
before_ads = profit_per_unit + ad_per_unit
if profit_per_unit < 0 and before_ads > 0:
return (f"Cut ad/unit from ${ad_per_unit:.2f} to under ${before_ads:.2f} "
f"(no price change needed)")
gap = -profit_per_unit # profit/unit needed to reach zero
if profit_per_unit < 0:
if price:
uplift = gap / _PRICE_PASSTHROUGH
return (f"Needs +${gap:.2f}/u: raise price ≈ ${price + uplift:.2f} "
f"(+${uplift:.2f}) and/or cut ads/COGS")
return f"Needs +${gap:.2f}/u: raise price and/or cut ads/COGS"
return f"Thin: +${0.50 - profit_per_unit:.2f}/u would clear a $0.50 floor"
def root_cause_split(losers: list[dict]) -> tuple[list[dict], list[dict]]:
"""Split money-losers into (ads-caused, cost/price-caused) — the lever to pull."""
ads = [r for r in losers if (r["profit_per_unit"] + r["ad_per_unit"]) > 0]
cost = [r for r in losers if (r["profit_per_unit"] + r["ad_per_unit"]) <= 0]
return ads, cost
def recent_profit_per_unit(monthly: list[dict], months: int = 3) -> float | None:
"""Units-weighted actual profit/unit over the most recent `months`."""
tail = monthly[-months:] if monthly else []
units = sum(m["units_per_day"] * m["days"] for m in tail)
if units <= 0:
return None
profit = sum(m["actual_profit_per_day"] * m["days"] for m in tail)
return round(profit / units, 2)
# ------------------------------------------------------------------ fits on evidence
def weighted_fit(points: list[tuple[float, float, float]]) -> dict | None:
"""Day-weighted least squares of y on x. `points` are (x, y, weight).
Returns {slope, intercept, r2, n, days} or None when it cannot be fitted.
Weighting by days matters: a band observed for 2 days must not carry the same
influence as one observed for 54.
"""
pts = [(x, y, w) for x, y, w in points if w > 0]
if len(pts) < 3:
return None
tw = sum(w for _, _, w in pts)
mx = sum(x * w for x, _, w in pts) / tw
my = sum(y * w for _, y, w in pts) / tw
sxx = sum(w * (x - mx) ** 2 for x, _, w in pts)
if sxx <= 0:
return None
sxy = sum(w * (x - mx) * (y - my) for x, y, w in pts)
slope = sxy / sxx
intercept = my - slope * mx
sse = sum(w * (y - (intercept + slope * x)) ** 2 for x, y, w in pts)
sst = sum(w * (y - my) ** 2 for _, y, w in pts)
return {"slope": slope, "intercept": intercept,
"r2": (1 - sse / sst) if sst > 0 else 0.0,
"n": len(pts), "days": int(tw)}
def _eligible(bands: list[dict]) -> list[dict]:
return [b for b in (bands or []) if b.get("enough_data")]
def empirical_break_even(bands: list[dict]) -> dict | None:
"""The price at which ACTUAL booked profit/unit crosses zero.
This is the only break-even grounded in what the SKU really earns it carries
advertising, refunds, promotions, storage and every other cost COSMOS books,
none of which appear in the fee-stack break-even. Returns
{price, slope, r2, days, n} or None without enough spread/observations.
"""
ok = _eligible(bands)
fit = weighted_fit([(b["avg_price"], b["profit_per_unit"], b["days"]) for b in ok])
if not fit or fit["slope"] <= 0:
return None
return {"price": round(-fit["intercept"] / fit["slope"], 2),
"slope": round(fit["slope"], 4), "r2": round(fit["r2"], 3),
"days": fit["days"], "n": fit["n"]}
def ad_cost_model(bands: list[dict]) -> dict | None:
"""How advertising cost per unit moves with price, from observed bands.
A constant-TACoS assumption implies ad spend FALLS when price rises, because
revenue falls. Real listings behave the opposite way: a higher price converts
worse, so each sale costs more to buy. Returns {slope, intercept, r2, days, n,
at_low, at_high} where slope is $ of ad/unit per $1 of price.
"""
ok = _eligible(bands)
fit = weighted_fit([(b["avg_price"], b["ad_per_unit"], b["days"]) for b in ok])
if not fit:
return None
lo = min(ok, key=lambda b: b["avg_price"])
hi = max(ok, key=lambda b: b["avg_price"])
return {"slope": round(fit["slope"], 4), "intercept": round(fit["intercept"], 4),
"r2": round(fit["r2"], 3), "days": fit["days"], "n": fit["n"],
"at_low": {"price": lo["avg_price"], "ad_per_unit": lo["ad_per_unit"]},
"at_high": {"price": hi["avg_price"], "ad_per_unit": hi["ad_per_unit"]}}
def ad_per_unit_at(price: float, model: dict | None, fallback: float) -> float:
"""Predicted ad cost per unit at `price`, never negative.
Falls back to a flat per-unit cost (NOT flat TACoS) when there is no usable
fit holding $/unit is the conservative assumption, holding TACoS is not.
"""
if not model or model.get("r2", 0) < 0.25:
return max(fallback, 0.0)
return max(model["intercept"] + model["slope"] * price, 0.0)
def observed_price_range(bands: list[dict]) -> tuple[float, float] | None:
"""(lowest, highest) price with a real sample behind it — the tested range."""
ok = _eligible(bands)
if not ok:
return None
return (min(b["avg_price"] for b in ok), max(b["avg_price"] for b in ok))
def observed_profit_at(price: float, bands: list[dict],
tolerance: float = 0.02) -> dict | None:
"""The observed band matching `price` within `tolerance`, if one exists.
Used to answer "have we actually run this price?" before recommending it.
"""
ok = _eligible(bands)
near = [b for b in ok if abs(b["avg_price"] - price) <= tolerance * price]
return max(near, key=lambda b: b["days"]) if near else None

View File

@ -0,0 +1,205 @@
"""Market-data providers.
A provider supplies the three things the pricing graph needs about a SKU:
1. enrichment (ASIN, landed cost, velocity)
2. a fee stack at a candidate price (fees + margin + break-even + MAP)
3. a competitive snapshot (Buy Box / offers via Apify when configured)
Two implementations behind one Protocol, selected by DATA_BACKEND:
- cosmos : live COSMOS API (cost, fees, profit) + optional Apify competitive
- mock : offline fixtures, for tests and local dev
"""
from __future__ import annotations
import logging
from typing import Protocol, runtime_checkable
from config.settings import Settings, get_rules
from pricing_agent.schemas import (
BuyBoxStatus,
CompetitiveSnapshot,
FeeStack,
SkuInput,
)
from pricing_agent.tools.margin_engine import stack_from_quote, worst_case_stack
logger = logging.getLogger("pricing_agent.providers")
@runtime_checkable
class MarketDataProvider(Protocol):
def enrich(self, sku: SkuInput) -> SkuInput: ...
def fee_stack(self, sku: SkuInput) -> FeeStack: ...
def competitive(self, sku: SkuInput) -> CompetitiveSnapshot: ...
class CosmosProvider:
"""Live provider: COSMOS for cost/fees; Apify for Buy Box when APIFY_TOKEN is set."""
def __init__(self, settings: Settings):
from pricing_agent.cosmos.client import CosmosClient
from pricing_agent.cosmos.service import CosmosPricingService
client = CosmosClient(
base_url=settings.cosmos_base_url,
email=settings.cosmos_email or "",
password=settings.cosmos_password or "",
timeout=settings.cosmos_timeout_seconds,
)
self._svc = CosmosPricingService(client, marketplace=settings.cosmos_marketplace)
self._apify = None
if settings.apify_token:
from pricing_agent.tools.amazon.apify import ApifyAmazonClient, RawItemCache
cache = RawItemCache(
settings.apify_cache_path, settings.apify_cache_ttl_seconds
)
self._apify = ApifyAmazonClient(
token=settings.apify_token,
actor_id=settings.apify_actor_id,
seller_id=settings.apify_seller_id,
max_offers=settings.apify_max_offers,
zip_code=settings.apify_zip_code,
timeout_seconds=settings.apify_timeout_seconds,
batch_size=settings.apify_batch_size,
cache=cache,
)
logger.info(
"Apify competitive client enabled (actor=%s, batch=%d, cache_ttl=%.0fs)",
settings.apify_actor_id,
settings.apify_batch_size,
settings.apify_cache_ttl_seconds,
)
def enrich(self, sku: SkuInput) -> SkuInput:
product = self._svc.get_product(sku.sku)
if product is None:
return sku
return sku.model_copy(update={
"asin": sku.asin or product.asin,
"product_name": sku.product_name or (product.brand or ""),
"category": sku.category or (product.brand or ""),
"landed_cost": sku.landed_cost or (product.cost or 0.0),
})
def fee_stack(self, sku: SkuInput) -> FeeStack:
quote = self._svc.fee_quote(sku.sku, sku.target_price)
return stack_from_quote(quote, get_rules())
def competitive(self, sku: SkuInput) -> CompetitiveSnapshot:
if self._apify is None:
return CompetitiveSnapshot(
asin=sku.asin,
buy_box_status=BuyBoxStatus.UNKNOWN,
is_suppressed=False,
reason=(
"Competitive/Buy Box check skipped — set APIFY_TOKEN to scrape "
"the ASIN's Amazon PDP (COSMOS has no Buy Box data)."
),
)
return self._apify.get_competitive(sku.asin, our_price=sku.target_price)
def competitive_many(self, skus: list[SkuInput]) -> dict[str, CompetitiveSnapshot]:
"""Competitive snapshots for many SKUs, keyed by SKU.
Batches every ASIN into as few Apify actor runs as possible. Cost is dominated by
the actor run, not the ASIN count, so this is dramatically faster than calling
``competitive()`` in a loop which is why product-line analysis used to skip
Apify entirely.
"""
if self._apify is None:
return {
s.sku: CompetitiveSnapshot(
asin=s.asin,
buy_box_status=BuyBoxStatus.UNKNOWN,
is_suppressed=False,
reason=(
"Competitive/Buy Box check skipped — set APIFY_TOKEN to scrape "
"the ASIN's Amazon PDP (COSMOS has no Buy Box data)."
),
)
for s in skus
}
with_asin = [s for s in skus if s.asin]
snaps = self._apify.get_competitive_many(
[s.asin for s in with_asin],
our_prices={s.asin: s.target_price for s in with_asin},
)
out: dict[str, CompetitiveSnapshot] = {}
for s in skus:
if s.asin and s.asin in snaps:
out[s.sku] = snaps[s.asin]
else:
out[s.sku] = CompetitiveSnapshot(
asin=s.asin,
buy_box_status=BuyBoxStatus.UNKNOWN,
reason="No ASIN on SKU — enrich from COSMOS before Apify scrape.",
)
return out
def analysis(self, sku: SkuInput, fee_stack: FeeStack) -> dict | None:
"""Sales-trend + inventory + bulk economics + verdict for the decision card."""
from pricing_agent.analyze import classify_trend, price_verdict, suggested_price
invp = self._svc.get_invp(sku.sku)
if invp is None:
return None
sug = suggested_price(fee_stack, get_rules().margin_target)
trend = classify_trend(invp.avg_30d, invp.avg_6m)
selling = bool((invp.avg_6m or invp.avg_30d or 0) > 0)
monthly_units = round((invp.avg_30d or invp.avg_6m or 0) * 30)
monthly_th = storage = None
if monthly_units > 0:
bulk = self._svc.bulk_quote(sku.sku, sku.target_price, monthly_units, invp.inventory)
monthly_th, storage = bulk.take_home, bulk.storage_charges
rating, headline, reasons = price_verdict(
margin_pct=fee_stack.contribution_margin_pct, trend=trend, selling=selling,
cover_days=invp.cover_days, monthly_take_home=monthly_th, rules=get_rules(),
)
return {
"avg_7d": invp.avg_7d, "avg_30d": invp.avg_30d, "avg_6m": invp.avg_6m,
"trend": trend, "inventory": invp.inventory, "cover_days": invp.cover_days,
"monthly_take_home": monthly_th, "storage": storage,
"suggested_price": sug,
"rating": rating.value, "headline": headline, "reasons": reasons,
}
class MockProvider:
"""Offline provider backed by JSON fixtures — used by tests and local dev."""
def __init__(self, settings: Settings):
from pricing_agent.tools.amazon.mock import MockAmazonClient
self._client = MockAmazonClient(settings.mock_fixtures_path)
def enrich(self, sku: SkuInput) -> SkuInput:
if sku.fba_fee is None or sku.referral_pct is None:
fba, referral = self._client.get_fees(sku.asin, sku.target_price)
return sku.model_copy(update={
"fba_fee": sku.fba_fee if sku.fba_fee is not None else fba,
"referral_pct": sku.referral_pct if sku.referral_pct is not None else referral,
})
return sku
def fee_stack(self, sku: SkuInput) -> FeeStack:
# Gate always evaluates at the worst-case referral (playbook rule).
return worst_case_stack(
selling_price=sku.target_price,
landed_cost=sku.landed_cost,
fba_fee=sku.fba_fee or 0.0,
rules=get_rules(),
)
def competitive(self, sku: SkuInput) -> CompetitiveSnapshot:
return self._client.get_competitive(sku.asin)
def get_provider(settings: Settings) -> MarketDataProvider:
backend = (settings.data_backend or "cosmos").lower()
if backend == "cosmos":
return CosmosProvider(settings)
if backend == "mock":
return MockProvider(settings)
raise ValueError(f"Unknown DATA_BACKEND: {settings.data_backend!r}")

View File

@ -0,0 +1,172 @@
"""Typed data contracts for the pricing pipeline.
These mirror the master-tracker columns the Pricing Analyst owns (playbook §11)
and the Definition of Done (playbook §13). Keeping them as pydantic models means
every hop in the LangGraph state machine is validated.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class Decision(str, Enum):
APPROVED = "APPROVED" # -> tracker Status "Pricing Approved"
BLOCKED = "BLOCKED" # -> tracker Status "Pricing Blocked"
NEEDS_REVIEW = "NEEDS_REVIEW"
class BuyBoxStatus(str, Enum):
WON = "WON" # we are the Featured Offer
LOST_PRICE = "LOST_PRICE" # lost Featured Offer on price
LOST_ELIGIBILITY = "LOST_ELIGIBILITY" # lost on account/listing eligibility, not price
SUPPRESSED = "SUPPRESSED" # Buy Box hidden entirely (price significantly high)
UNKNOWN = "UNKNOWN"
class SkuInput(BaseModel):
"""One row read from the master tracker before pricing."""
sku: str
asin: str | None = None
product_name: str = ""
marketplace: str = "US"
category: str = ""
landed_cost: float = Field(..., description="COGS from Inventory Planner, per unit")
target_price: float = Field(..., description="Proposed / current selling price to evaluate")
# Optional inputs; if absent we fall back to the fee API / mock.
fba_fee: float | None = None
referral_pct: float | None = Field(
default=None, description="Modeled/negotiated referral fraction, e.g. 0.12"
)
dimensions: str | None = None
weight: str | None = None
class FeeQuote(BaseModel):
"""Normalized per-unit fee/cost breakdown from a data provider at a given price.
COSMOS fills this from `takehome-calculator`; the mock fills it synthetically.
Dollar amounts are per unit at `selling_price`.
"""
selling_price: float
landed_cost: float # COGS (purchase + freight + duty)
fba_fee: float = 0.0
referral_amt: float = 0.0 # Amazon referral fee, in dollars
returns_reserve: float = 0.0
other_fees: float = 0.0 # inbound placement, low-inventory, EPR, VAT, handling…
net_takehome: float | None = None # provider's authoritative net profit/unit, if given
source: str = "unknown"
class FeeStack(BaseModel):
"""Full per-unit fee stack + derived margin figures (playbook SOP A)."""
selling_price: float
landed_cost: float
fba_fee: float
referral_pct: float
referral_amt: float
returns_reserve: float
storage_alloc: float
total_cost: float
profit: float
contribution_margin_pct: float
break_even_price: float
map_floor: float
# The referral basis used to derive this stack ("modeled" or "worst_case").
referral_basis: str = "worst_case"
class CompetitorOffer(BaseModel):
"""One seller offer on the ASIN's Amazon listing (from Apify offers / Buy Box)."""
seller_name: str = "Unknown seller"
seller_id: str | None = None
price: float | None = None
is_buy_box: bool = False
condition: str | None = None
# True when seller_id matches APIFY_SELLER_ID — this is our own offer, not a rival.
# Our own offers are excluded from the competitive band.
is_ours: bool = False
class CompetitiveSnapshot(BaseModel):
"""Result of the competitive / Buy Box scan (playbook SOP B)."""
asin: str | None = None
buy_box_status: BuyBoxStatus = BuyBoxStatus.UNKNOWN
buy_box_price: float | None = None
buy_box_seller_name: str | None = None
buy_box_seller_id: str | None = None
competitive_low: float | None = None
competitive_median: float | None = None
competitive_high: float | None = None
is_suppressed: bool = False
reason: str = ""
# Named sellers + prices on this ASIN (featured first when present).
offers: list[CompetitorOffer] = Field(default_factory=list)
class PricingDecision(BaseModel):
"""The proposal presented to the human and (on approval) written to the tracker."""
sku: str
asin: str | None = None
decision: Decision
recommended_price: float
fee_stack: FeeStack
competitive: CompetitiveSnapshot
# Deterministic gate reasons (from evaluate node).
gate_reasons: list[str] = Field(default_factory=list)
# LLM-written, human-readable narrative (rationale node).
rationale: str = ""
competitor_summary: str = ""
suppression_note: str = ""
recommended_action: str = ""
def tracker_row(self) -> dict:
"""Map to the master-tracker columns this role owns (playbook §11)."""
fs = self.fee_stack
status = {
Decision.APPROVED: "Pricing Approved",
Decision.BLOCKED: "Pricing Blocked",
Decision.NEEDS_REVIEW: "Pricing Review",
}[self.decision]
return {
"SKU": self.sku,
"ASIN": self.asin or "",
"Landed Cost": round(fs.landed_cost, 2),
"FBA Fee": round(fs.fba_fee, 2),
"Referral Fee %": round(fs.referral_pct * 100, 2),
"Referral Fee $": round(fs.referral_amt, 2),
"Break-even Price": round(fs.break_even_price, 2),
"MAP Floor": round(fs.map_floor, 2),
"Target Selling Price": round(self.recommended_price, 2),
"Contribution Margin %": round(fs.contribution_margin_pct * 100, 2),
"Buy Box Status": self.competitive.buy_box_status.value,
"Buy Box Seller": self.competitive.buy_box_seller_name or "",
"Buy Box Price": (
round(self.competitive.buy_box_price, 2)
if self.competitive.buy_box_price is not None else ""
),
"Competitor Offers": "; ".join(
f"{o.seller_name} @ "
f"{('$' + format(o.price, '.2f')) if o.price is not None else 'n/a'}"
+ (" [BB]" if o.is_buy_box else "")
for o in self.competitive.offers[:8]
),
"Suppression Risk": "YES" if self.competitive.is_suppressed else "NO",
"Status": status,
}
class LlmNarrative(BaseModel):
"""Structured output schema the LLM must return (no numbers invented here)."""
rationale: str
competitor_summary: str
suppression_note: str
recommended_action: str

View File

@ -0,0 +1,31 @@
"""LangGraph state object passed between nodes for a single SKU run."""
from __future__ import annotations
from typing import Optional, TypedDict
from pricing_agent.schemas import (
CompetitiveSnapshot,
FeeStack,
PricingDecision,
SkuInput,
)
class PricingState(TypedDict, total=False):
# Input
sku_input: SkuInput
# Populated as the graph runs
fee_stack: FeeStack
competitive: CompetitiveSnapshot
decision: PricingDecision
analysis: dict # sales-trend + inventory + verdict (COSMOS only)
# Human-in-the-loop outcome
human_action: str # "approve" | "edit" | "reject"
human_price: Optional[float]
human_note: str
# Write-back result
written: bool
audit_id: str

View File

View File

@ -0,0 +1,597 @@
"""Apify Amazon Product Scraper → CompetitiveSnapshot.
Uses junglee/Amazon-crawler to scrape the public PDP for an ASIN (not a COSMOS
SKU). Callers must resolve SKU ASIN via COSMOS first.
Flow:
ASIN https://www.amazon.com/dp/{ASIN} actor run featured price / seller /
named offers BuyBoxStatus + competitive low/median/high + competitor list.
"""
from __future__ import annotations
import json
import logging
import statistics
import time
from pathlib import Path
from typing import Any
from pricing_agent.schemas import BuyBoxStatus, CompetitorOffer, CompetitiveSnapshot
logger = logging.getLogger("pricing_agent.apify")
def _price_value(raw: Any) -> float | None:
"""Normalize Apify price shapes: number, {value, currency}, or '$6.98'."""
if raw is None:
return None
if isinstance(raw, bool):
return None
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, dict):
v = raw.get("value")
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
if isinstance(raw, str):
cleaned = raw.replace(",", "").strip()
for sym in ("$", "", "£", "USD", "EUR", "GBP"):
cleaned = cleaned.replace(sym, "")
cleaned = cleaned.strip()
try:
return float(cleaned)
except ValueError:
return None
return None
def _seller_fields(seller: Any) -> tuple[str | None, str | None]:
if not isinstance(seller, dict):
return None, None
name = seller.get("name") or seller.get("sellerName") or seller.get("businessName")
sid = seller.get("id") or seller.get("sellerId")
return (str(name) if name else None), (str(sid) if sid else None)
def is_new_condition(cond: str | None) -> bool:
"""True for a new-condition offer.
Amazon labels used stock "Used - Like New", "Used - Very Good", etc. Those are
not a competing price for a new listing, so they must not enter the band. A
missing condition (the featured offer often has none) is treated as new.
"""
if not cond:
return True
return str(cond).strip().lower().startswith("new")
def _offer_rows(item: dict, our_seller_id: str | None) -> list[CompetitorOffer]:
"""Named seller offers from the Apify `offers` array, flagged ours vs rival."""
rows: list[CompetitorOffer] = []
offers = item.get("offers") or []
if not isinstance(offers, list):
return rows
for offer in offers:
if not isinstance(offer, dict):
continue
price = None
for key in ("price", "amount", "value"):
price = _price_value(offer.get(key))
if price is not None and price > 0:
break
name, sid = _seller_fields(offer.get("seller"))
if not name:
name = (
offer.get("sellerName")
or offer.get("seller_name")
or offer.get("shipsFrom")
)
if name:
name = str(name)
if not sid:
raw_id = offer.get("sellerId") or offer.get("seller_id")
sid = str(raw_id) if raw_id else None
cond = offer.get("condition")
if price is None and not name:
continue
rows.append(
CompetitorOffer(
seller_name=name or "Unknown seller",
seller_id=sid,
price=price,
is_buy_box=False,
condition=str(cond) if cond else None,
is_ours=bool(our_seller_id and sid and sid == our_seller_id),
)
)
return rows
def _seller_id(item: dict) -> str | None:
_, sid = _seller_fields(item.get("seller"))
return sid
def _seller_name(item: dict) -> str | None:
name, _ = _seller_fields(item.get("seller"))
return name
def _build_offers(
item: dict,
*,
featured: float | None,
featured_name: str | None,
featured_id: str | None,
our_seller_id: str | None,
) -> list[CompetitorOffer]:
"""Featured Buy Box seller first, then other offers (deduped by seller_id/name+price)."""
rows = _offer_rows(item, our_seller_id)
out: list[CompetitorOffer] = []
seen: set[tuple[str | None, float | None]] = set()
if featured is not None or featured_name:
key = (featured_id or featured_name, featured)
out.append(
CompetitorOffer(
seller_name=featured_name or "Featured seller",
seller_id=featured_id,
price=featured,
is_buy_box=True,
condition="New",
is_ours=bool(
our_seller_id and featured_id and featured_id == our_seller_id
),
)
)
seen.add(key)
for row in rows:
key = (row.seller_id or row.seller_name, row.price)
if key in seen:
# Same seller/price already listed as Buy Box — keep the flagged one.
continue
# Mark as buy box if same seller as featured.
if featured_id and row.seller_id == featured_id and row.price == featured:
row = row.model_copy(update={"is_buy_box": True})
if out and out[0].is_buy_box:
continue
seen.add(key)
out.append(row)
# Sort non-buy-box by price ascending after the featured row.
featured_rows = [o for o in out if o.is_buy_box]
others = sorted(
[o for o in out if not o.is_buy_box],
key=lambda o: (o.price is None, o.price if o.price is not None else 0.0),
)
return featured_rows + others
def competitor_offers(offers: list[CompetitorOffer]) -> list[CompetitorOffer]:
"""Rival, new-condition, priced offers — the only ones that define the band.
Excludes our own offers (``is_ours``) and used/refurbished stock. On a listing we
sell alone, this is empty, and the band is correctly ``None`` rather than a
reflection of our own prices.
"""
return [
o
for o in offers
if not o.is_ours and o.price is not None and is_new_condition(o.condition)
]
def _offers_summary(offers: list[CompetitorOffer]) -> str:
"""Report rivals and our own offers separately — never label ourselves a competitor."""
if not offers:
return ""
def _fmt(o: CompetitorOffer) -> str:
tag = " [Buy Box]" if o.is_buy_box else ""
cond = "" if is_new_condition(o.condition) else f" ({o.condition})"
price = f"${o.price:.2f}" if o.price is not None else "n/a"
return f"{o.seller_name} @ {price}{cond}{tag}"
rivals = [o for o in offers if not o.is_ours]
ours = [o for o in offers if o.is_ours]
parts: list[str] = []
if rivals:
shown = rivals[:8]
extra = f" (+{len(rivals) - 8} more)" if len(rivals) > 8 else ""
parts.append("Competitors: " + "; ".join(_fmt(o) for o in shown) + extra)
else:
parts.append("Competitors: none — we are the only seller on this ASIN.")
if ours:
parts.append("Our own offers: " + "; ".join(_fmt(o) for o in ours[:8]))
return " ".join(parts)
def item_to_competitive(
item: dict,
*,
asin: str | None,
our_price: float | None = None,
our_seller_id: str | None = None,
) -> CompetitiveSnapshot:
"""Map one junglee/Amazon-crawler dataset item → CompetitiveSnapshot."""
featured = _price_value(item.get("price"))
sid = _seller_id(item)
sname = _seller_name(item)
offers = _build_offers(
item,
featured=featured,
featured_name=sname,
featured_id=sid,
our_seller_id=our_seller_id,
)
# The band describes RIVALS only. Our own offers and used stock are excluded, so a
# listing we sell alone yields no band at all — which is the honest answer, and very
# different from "our own price is also the lowest competitor price".
band_src = [o.price for o in competitor_offers(offers) if o.price is not None]
low = med = high = None
if band_src:
low = min(band_src)
high = max(band_src)
med = float(statistics.median(band_src))
we_hold_featured = bool(our_seller_id and sid and sid == our_seller_id)
in_stock = item.get("inStock")
offer_note = _offers_summary(offers)
# No featured price → treat as suppressed (Buy Box hidden / unavailable).
if featured is None:
reason = item.get("inStockText") or "No featured/Buy Box price on the PDP."
if offer_note:
reason = f"{reason} {offer_note}"
return CompetitiveSnapshot(
asin=asin or item.get("asin"),
buy_box_status=BuyBoxStatus.SUPPRESSED,
buy_box_price=None,
buy_box_seller_name=sname,
buy_box_seller_id=sid,
competitive_low=low,
competitive_median=med,
competitive_high=high,
is_suppressed=True,
reason=str(reason),
offers=offers,
)
status = BuyBoxStatus.UNKNOWN
bits: list[str] = [f"Featured offer ${featured:.2f}"]
if sname:
bits.append(f"seller={sname}")
if sid:
bits.append(f"seller_id={sid}")
if our_seller_id:
if we_hold_featured:
status = BuyBoxStatus.WON
bits.append("we hold the Featured Offer")
elif our_price is not None and featured < our_price:
# A rival holds it AND is cheaper — this is a price loss we can act on.
status = BuyBoxStatus.LOST_PRICE
bits.append(f"rival undercuts our ${our_price:.2f} at ${featured:.2f}")
else:
# A rival holds it while at or above our price, so price is not the lever —
# calling this LOST_PRICE would send a human off to cut a price that is
# already competitive. The real cause is eligibility/fulfilment.
status = BuyBoxStatus.LOST_ELIGIBILITY
bits.append(
"another seller holds the Featured Offer at or above our price "
"— not a price loss; check eligibility/fulfilment"
)
elif our_price is not None and featured < our_price * 0.98:
# No merchant id configured — still flag a clear undercut for review.
status = BuyBoxStatus.LOST_PRICE
bits.append(
f"featured ${featured:.2f} is below our ${our_price:.2f} "
f"(set APIFY_SELLER_ID to confirm whether we own the Buy Box)"
)
elif in_stock is False:
bits.append("PDP reports out of stock")
reason = "; ".join(bits) + "."
if offer_note:
reason = f"{reason} {offer_note}"
return CompetitiveSnapshot(
asin=asin or item.get("asin"),
buy_box_status=status,
buy_box_price=featured,
buy_box_seller_name=sname,
buy_box_seller_id=sid,
competitive_low=low,
competitive_median=med,
competitive_high=high,
is_suppressed=False,
reason=reason,
offers=offers,
)
class RawItemCache:
"""Disk-backed TTL cache of RAW Apify items, keyed by ASIN.
We deliberately cache the raw payload rather than the mapped CompetitiveSnapshot:
a mapper fix (self-offer filtering, condition filtering) then applies to cached data
on the next read, instead of being masked until the entry expires.
Competitor prices do not move minute-to-minute, and the actor is pay-per-event, so a
multi-hour TTL cuts both wall-clock and spend on repeat/overlapping analyses.
"""
def __init__(self, path: str | Path, ttl_seconds: float):
self._path = Path(path)
self._ttl = float(ttl_seconds or 0.0)
self._data: dict[str, dict] = {}
if self.enabled:
self._load()
@property
def enabled(self) -> bool:
return self._ttl > 0
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
self._data = raw
except (OSError, json.JSONDecodeError) as e:
# A corrupt cache must never break pricing — start empty.
logger.warning("Apify cache unreadable (%s); starting empty", e)
self._data = {}
def _save(self) -> None:
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, default=str), encoding="utf-8"
)
except OSError as e:
logger.warning("Could not persist Apify cache: %s", e)
def get(self, asin: str) -> dict | None:
if not self.enabled:
return None
entry = self._data.get(asin)
if not isinstance(entry, dict):
return None
age = time.time() - float(entry.get("ts", 0))
if age > self._ttl:
return None
item = entry.get("item")
return item if isinstance(item, dict) else None
def put_many(self, items: dict[str, dict]) -> None:
if not self.enabled or not items:
return
now = time.time()
for asin, item in items.items():
self._data[asin] = {"ts": now, "item": item}
self._save()
class ApifyAmazonClient:
"""Thin wrapper around apify-client for junglee/Amazon-crawler.
Timing note: the dominant cost is the actor RUN (container cold start + Amazon
anti-bot retries), not the number of ASINs in it. Measured on this actor:
3 ASINs, one run -> 46.8s (15.6s effective per ASIN)
3 ASINs, three runs-> ~145s
So always prefer ``get_competitive_many`` over a loop of ``get_competitive``.
"""
def __init__(
self,
*,
token: str,
actor_id: str = "junglee/Amazon-crawler",
seller_id: str | None = None,
max_offers: int = 10,
zip_code: str = "10001",
timeout_seconds: float = 300.0,
batch_size: int = 20,
cache: RawItemCache | None = None,
):
self._token = token
self._actor_id = actor_id
self._seller_id = seller_id
self._max_offers = max_offers
self._zip_code = zip_code
self._timeout = timeout_seconds
self._batch_size = max(1, int(batch_size))
self._cache = cache
# ── scraping ──────────────────────────────────────────────────────────
def _run_actor(self, asins: list[str]) -> dict[str, dict]:
"""One actor run for N ASINs. Returns {asin: raw_item} for whatever came back."""
from datetime import timedelta
from apify_client import ApifyClient
run_input: dict[str, Any] = {
"categoryOrProductUrls": [
{"url": f"https://www.amazon.com/dp/{a}"} for a in asins
],
"maxItemsPerStartUrl": 1,
"maxOffers": self._max_offers,
"scrapeSellers": True,
"zipCode": self._zip_code,
"proxyCountry": "US",
"language": "en",
}
logger.info(
"Apify run: %d ASIN(s) in ONE actor call (%s)", len(asins), self._actor_id
)
# Actor run streams Amazon 503 / retry noise to the console; keep our logs clean.
for name in ("apify", "apify_client", "apify_client.client"):
logging.getLogger(name).setLevel(logging.WARNING)
client = ApifyClient(self._token)
run = client.actor(self._actor_id).call(
run_input=run_input,
run_timeout=timedelta(seconds=int(self._timeout)),
wait_duration=timedelta(seconds=int(self._timeout)),
logger=None, # suppress Amazon 503 / crawl spam in Streamlit terminal
)
dataset_id = _run_dataset_id(run)
if not dataset_id:
logger.warning("Apify run returned no dataset for %s", asins)
return {}
requested = set(asins)
out: dict[str, dict] = {}
for item in client.dataset(dataset_id).iterate_items():
if not isinstance(item, dict):
continue
key = _match_requested_asin(item, requested)
if key:
out[key] = item
missing = requested - set(out)
if missing:
logger.warning("Apify returned no item for: %s", ", ".join(sorted(missing)))
return out
def scrape_asin(self, asin: str) -> dict | None:
"""Single-ASIN scrape (cache-aware). Prefer scrape_asins for more than one."""
return self.scrape_asins([asin]).get(asin)
def scrape_asins(self, asins: list[str]) -> dict[str, dict]:
"""Cache-aware batch scrape. Only cache-misses hit Apify, chunked per run."""
wanted = [a for a in dict.fromkeys(a for a in asins if a)]
found: dict[str, dict] = {}
misses: list[str] = []
for asin in wanted:
cached = self._cache.get(asin) if self._cache else None
if cached is not None:
found[asin] = cached
else:
misses.append(asin)
if found:
logger.info("Apify cache hit for %d/%d ASIN(s)", len(found), len(wanted))
if not misses:
return found
fresh: dict[str, dict] = {}
for i in range(0, len(misses), self._batch_size):
chunk = misses[i : i + self._batch_size]
fresh.update(self._run_actor(chunk))
if self._cache:
self._cache.put_many(fresh)
found.update(fresh)
return found
# ── mapping ───────────────────────────────────────────────────────────
def _snapshot(
self, asin: str, item: dict | None, our_price: float | None
) -> CompetitiveSnapshot:
if item is None:
return CompetitiveSnapshot(
asin=asin,
buy_box_status=BuyBoxStatus.UNKNOWN,
reason="Apify returned no product data for this ASIN.",
)
return item_to_competitive(
item,
asin=asin,
our_price=our_price,
our_seller_id=self._seller_id,
)
def get_competitive(
self,
asin: str | None,
*,
our_price: float | None = None,
) -> CompetitiveSnapshot:
if not asin:
return CompetitiveSnapshot(
asin=None,
buy_box_status=BuyBoxStatus.UNKNOWN,
reason="No ASIN on SKU — enrich from COSMOS before Apify scrape.",
)
return self.get_competitive_many([asin], our_prices={asin: our_price})[asin]
def get_competitive_many(
self,
asins: list[str],
*,
our_prices: dict[str, float | None] | None = None,
) -> dict[str, CompetitiveSnapshot]:
"""Competitive snapshots for many ASINs in as few actor runs as possible.
This is the call that makes product-line / catalog competitive data practical:
20 ASINs cost ~1 actor run instead of 20 cold starts.
"""
prices = our_prices or {}
wanted = [a for a in dict.fromkeys(a for a in asins if a)]
if not wanted:
return {}
try:
items = self.scrape_asins(wanted)
except Exception as e: # network / actor failures must not crash the graph
logger.exception("Apify batch scrape failed: %s", e)
return {
a: CompetitiveSnapshot(
asin=a,
buy_box_status=BuyBoxStatus.UNKNOWN,
reason=f"Apify scrape failed: {e}",
)
for a in wanted
}
return {
a: self._snapshot(a, items.get(a), prices.get(a)) for a in wanted
}
def _match_requested_asin(item: dict, requested: set[str]) -> str | None:
"""Map a returned item back to the ASIN we asked for.
Amazon can redirect a /dp/<asin> URL to a parent/variant listing, so ``item['asin']``
is not guaranteed to equal the requested ASIN. Fall back to originalAsin, then to the
input URL, before giving up otherwise a redirected SKU silently loses its data.
"""
for key in ("asin", "originalAsin"):
val = item.get(key)
if isinstance(val, str) and val in requested:
return val
for key in ("url", "unNormalizedProductUrl"):
url = item.get(key)
if isinstance(url, str):
for asin in requested:
if asin in url:
return asin
inp = item.get("input")
if isinstance(inp, dict):
url = inp.get("url")
if isinstance(url, str):
for asin in requested:
if asin in url:
return asin
return None
def _run_dataset_id(run: Any) -> str | None:
"""apify-client v3 returns a pydantic Run; older clients returned a dict."""
if run is None:
return None
if isinstance(run, dict):
return run.get("defaultDatasetId") or run.get("default_dataset_id")
return getattr(run, "default_dataset_id", None) or getattr(run, "defaultDatasetId", None)

View File

@ -0,0 +1,24 @@
"""Amazon data-layer interface (used by the offline mock + Apify clients).
The live agent uses COSMOS via pricing_agent.providers for cost/fees, and
Apify (junglee/Amazon-crawler) for competitive/Buy-Box when APIFY_TOKEN is set.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from pricing_agent.schemas import CompetitiveSnapshot
@runtime_checkable
class AmazonFeesClient(Protocol):
def get_fees(self, asin: str | None, price: float) -> tuple[float, float]:
"""Return (fba_fee, referral_pct) for the ASIN at the given price."""
...
@runtime_checkable
class AmazonPricingClient(Protocol):
def get_competitive(self, asin: str | None) -> CompetitiveSnapshot:
"""Return the competitive/Buy Box snapshot for the ASIN."""
...

View File

@ -0,0 +1,44 @@
"""Mock Amazon client — reads fixtures/amazon_mock.json.
Lets the entire agent run offline with zero SP-API credentials. Implements both
the fees and pricing Protocols. Swap for SpApiClient by setting AMAZON_BACKEND=sp_api.
"""
from __future__ import annotations
import json
from pathlib import Path
from pricing_agent.schemas import BuyBoxStatus, CompetitiveSnapshot
class MockAmazonClient:
def __init__(self, fixtures_path: str):
path = Path(fixtures_path)
data = json.loads(path.read_text(encoding="utf-8"))
self._default = data.get("default", {})
self._asins = data.get("asins", {})
def _record(self, asin: str | None) -> dict:
rec = dict(self._default)
if asin and asin in self._asins:
rec.update(self._asins[asin])
return rec
# --- AmazonFeesClient ---
def get_fees(self, asin: str | None, price: float) -> tuple[float, float]:
rec = self._record(asin)
return float(rec["fba_fee"]), float(rec["referral_pct"])
# --- AmazonPricingClient ---
def get_competitive(self, asin: str | None) -> CompetitiveSnapshot:
rec = self._record(asin)
return CompetitiveSnapshot(
asin=asin,
buy_box_status=BuyBoxStatus(rec.get("buy_box_status", "UNKNOWN")),
buy_box_price=rec.get("buy_box_price"),
competitive_low=rec.get("competitive_low"),
competitive_median=rec.get("competitive_median"),
competitive_high=rec.get("competitive_high"),
is_suppressed=bool(rec.get("is_suppressed", False)),
reason=rec.get("reason", ""),
)

View File

@ -0,0 +1,78 @@
"""Real Amazon SP-API client (Phase 2).
Stubbed with the exact call map so it can be filled in once LWA credentials and
the Pricing/Listing roles are approved. Implements the same Protocols as the mock,
so flipping AMAZON_BACKEND=sp_api requires NO changes to the graph nodes.
Dependencies (install extra): pip install "pricing-agent[sp_api]"
-> python-amazon-sp-api (community SDK)
Endpoints used:
- Product Fees API getMyFeesEstimateForASIN -> FBA fee + referral fee
- Product Pricing getItemOffers / v2022-05-01 getFeaturedOfferExpectedPrice
getCompetitiveSummary -> Buy Box + competitive band
- Catalog Items getCatalogItem -> dims/weight/category (fee sizing)
Rate limits are strict; wrap calls with tenacity backoff and prefer the Reports
API path for full-catalog (1000+ SKU) runs.
"""
from __future__ import annotations
from pricing_agent.schemas import BuyBoxStatus, CompetitiveSnapshot
class SpApiClient:
def __init__(self, settings):
self.settings = settings
missing = [
k
for k in (
"sp_api_refresh_token",
"sp_api_lwa_client_id",
"sp_api_lwa_client_secret",
)
if not getattr(settings, k, None)
]
if missing:
raise RuntimeError(
"SP-API backend selected but credentials are missing: "
+ ", ".join(missing)
+ ". Set them in .env or use AMAZON_BACKEND=mock."
)
# Lazy import so the package works without the optional dependency installed.
try:
from sp_api.api import CatalogItems, Products # noqa: F401
from sp_api.base import Marketplaces # noqa: F401
except ImportError as e: # pragma: no cover
raise RuntimeError(
'python-amazon-sp-api is not installed. Run: pip install "pricing-agent[sp_api]"'
) from e
# Store credentials dict for the SDK.
self._creds = dict(
refresh_token=settings.sp_api_refresh_token,
lwa_app_id=settings.sp_api_lwa_client_id,
lwa_client_secret=settings.sp_api_lwa_client_secret,
)
self._marketplace_id = settings.sp_api_marketplace_id
# --- AmazonFeesClient ---
def get_fees(self, asin: str | None, price: float) -> tuple[float, float]: # pragma: no cover
"""Call Product Fees API getMyFeesEstimateForASIN.
TODO(phase2): parse FeesEstimate -> (fba_fulfillment_fee, referral_fee_pct).
Referral pct = referral_fee_amount / price.
"""
raise NotImplementedError(
"SP-API get_fees not yet implemented — pending credentials (Phase 2)."
)
# --- AmazonPricingClient ---
def get_competitive(self, asin: str | None) -> CompetitiveSnapshot: # pragma: no cover
"""Call Product Pricing getItemOffers / getFeaturedOfferExpectedPrice.
TODO(phase2): derive buy_box_status (WON/LOST_PRICE/LOST_ELIGIBILITY/SUPPRESSED),
buy_box_price, and the competitive low/median/high from the offers list.
"""
raise NotImplementedError(
"SP-API get_competitive not yet implemented — pending credentials (Phase 2)."
)

View File

@ -0,0 +1,104 @@
"""The deterministic pricing gate — encodes the playbook Definition of Done (§13).
This is where APPROVED / BLOCKED / NEEDS_REVIEW is decided. It is pure and
testable; the LLM never makes this call, it only explains it.
"""
from __future__ import annotations
from config.settings import PricingRules
from pricing_agent.schemas import (
BuyBoxStatus,
CompetitiveSnapshot,
Decision,
FeeStack,
)
def evaluate_gate(
*,
gate_stack: FeeStack,
competitive: CompetitiveSnapshot,
rules: PricingRules,
) -> tuple[Decision, list[str]]:
"""Return (decision, reasons).
``gate_stack`` MUST be the worst-case-referral stack. Rules, in order:
1. Price below break-even -> BLOCKED (loss-making)
2. CM below floor at worst-case ref -> BLOCKED (margin floor the approval gate)
3. Listing suppressed on price -> BLOCKED (do-not-launch; PPC would waste)
4. Lost Buy Box on price -> NEEDS_REVIEW (match vs hold decision)
5. Otherwise -> APPROVED
Note: the margin-floor rule (2) is strictly stronger than "price >= MAP", so MAP is
NOT a separate block here. MAP is still computed and published as a downstream
guardrail for Promotions/PPC (playbook: no coupon/deal below MAP) and written to the
tracker + narrative.
"""
reasons: list[str] = []
price = gate_stack.selling_price
# 1. Loss-making.
if price < gate_stack.break_even_price:
reasons.append(
f"Price ${price:.2f} is below break-even ${gate_stack.break_even_price:.2f} "
f"— loss-making after fees."
)
return Decision.BLOCKED, reasons
# 2. Margin floor at worst-case referral.
if gate_stack.contribution_margin_pct < rules.margin_floor:
reasons.append(
f"Contribution margin {gate_stack.contribution_margin_pct * 100:.1f}% "
f"is below the {rules.margin_floor * 100:.0f}% floor at worst-case "
f"{rules.worst_case_referral_pct * 100:.0f}% referral."
)
return Decision.BLOCKED, reasons
# 3. Suppression = do-not-launch.
if competitive.is_suppressed or competitive.buy_box_status == BuyBoxStatus.SUPPRESSED:
reasons.append(
"Listing is price-suppressed (Buy Box hidden) — PPC cannot convert. "
"Do not launch until resolved."
)
return Decision.BLOCKED, reasons
# 4. Lost Buy Box on price -> needs a human match/hold call.
if competitive.buy_box_status == BuyBoxStatus.LOST_PRICE:
reasons.append(
"Not the Featured Offer due to price. Decide: match (if still above MAP) "
"or hold and let content/PPC carry conversion."
)
return Decision.NEEDS_REVIEW, reasons
# 4b. Lost the Buy Box while priced at or below the rival — price is not the lever,
# so a price cut would burn margin without winning it back. Escalate instead.
if competitive.buy_box_status == BuyBoxStatus.LOST_ELIGIBILITY:
reasons.append(
"Not the Featured Offer despite being at or below the rival's price — "
"cutting price will not win it back. Investigate eligibility "
"(fulfilment, stock, account health, listing condition)."
)
return Decision.NEEDS_REVIEW, reasons
# 5. Approved. Add positive context.
reasons.append(
f"Clears floor at worst-case referral (CM "
f"{gate_stack.contribution_margin_pct * 100:.1f}% ≥ "
f"{rules.margin_floor * 100:.0f}%), price ${price:.2f} ≥ MAP "
f"${gate_stack.map_floor:.2f}."
)
if competitive.buy_box_status == BuyBoxStatus.WON:
reasons.append("Featured Offer (Buy Box) held.")
return Decision.APPROVED, reasons
def within_competitive_band(
price: float, competitive: CompetitiveSnapshot, rules: PricingRules
) -> bool:
"""True if price is within tolerance of the competitive median (playbook KPI)."""
if competitive.competitive_median is None:
return True
tol = rules.price_competitiveness_tolerance
lo = competitive.competitive_median * (1 - tol)
hi = competitive.competitive_median * (1 + tol)
return lo <= price <= hi

View File

@ -0,0 +1,172 @@
"""Pure money math — the deterministic core of the pricing gate.
NO I/O, NO LLM, NO side effects. Every formula here comes straight from the
Pricing & Profitability Analyst playbook, SOP A:
Break-even = (landed + fba + returns + storage) / (1 - referral%)
MAP = (landed + fba + returns + storage) / (1 - CM_floor)
Golden values (playbook worked example, "Microfiber 4 Piece Queen Sheet Set - Grey"
at target $24.99, landed $8.00, FBA $5.50, returns 2%, storage $0.25, referral 15%):
break_even = $16.76
MAP = $19.00
contribution margin = ~28%
These are asserted in tests/test_margin_engine.py.
"""
from __future__ import annotations
from config.settings import PricingRules
from pricing_agent.schemas import FeeQuote, FeeStack
def _cost_base(
landed_cost: float, fba_fee: float, returns_reserve: float, storage_alloc: float
) -> float:
"""Fixed (non-referral) per-unit cost — the numerator of break-even & MAP."""
return landed_cost + fba_fee + returns_reserve + storage_alloc
def compute_fee_stack(
*,
selling_price: float,
landed_cost: float,
fba_fee: float,
referral_pct: float,
rules: PricingRules,
referral_basis: str = "worst_case",
) -> FeeStack:
"""Build the full fee stack and derived margin figures for one price point.
``referral_pct`` is the fraction used for THIS stack. The caller decides
whether that is the optimistic modeled referral or the worst-case (15%)
used for the gate. Returns/storage come from the pricing rules.
"""
if selling_price <= 0:
raise ValueError("selling_price must be positive")
if not (0 <= referral_pct < 1):
raise ValueError("referral_pct must be in [0, 1)")
returns_reserve = rules.returns_reserve_pct * selling_price
storage_alloc = rules.storage_alloc
referral_amt = referral_pct * selling_price
cost_base = _cost_base(landed_cost, fba_fee, returns_reserve, storage_alloc)
total_cost = cost_base + referral_amt
profit = selling_price - total_cost
cm_pct = profit / selling_price
# Break-even: price where contribution = 0, at this referral %.
break_even = cost_base / (1.0 - referral_pct)
# MAP: floor price protecting the margin_floor (playbook simplified form).
map_floor = cost_base / (1.0 - rules.margin_floor)
return FeeStack(
selling_price=round(selling_price, 4),
landed_cost=round(landed_cost, 4),
fba_fee=round(fba_fee, 4),
referral_pct=referral_pct,
referral_amt=round(referral_amt, 4),
returns_reserve=round(returns_reserve, 4),
storage_alloc=round(storage_alloc, 4),
total_cost=round(total_cost, 4),
profit=round(profit, 4),
contribution_margin_pct=round(cm_pct, 6),
break_even_price=round(break_even, 4),
map_floor=round(map_floor, 4),
referral_basis=referral_basis,
)
def worst_case_stack(
*,
selling_price: float,
landed_cost: float,
fba_fee: float,
rules: PricingRules,
) -> FeeStack:
"""The stack the GATE evaluates: always at the worst-case referral %.
Per playbook 'If the SKU still clears floor at the worst-case referral,
it's safe to approve.'
"""
return compute_fee_stack(
selling_price=selling_price,
landed_cost=landed_cost,
fba_fee=fba_fee,
referral_pct=rules.worst_case_referral_pct,
rules=rules,
referral_basis="worst_case",
)
def stack_from_quote(quote: FeeQuote, rules: PricingRules) -> FeeStack:
"""Build a FeeStack from a provider's real dollar-denominated fee quote.
Used for the COSMOS path, where fees come from the API rather than from
percentage assumptions. Break-even and MAP use the fixed (non-referral) cost
base and treat the referral as a percentage (referral scales with price).
Contribution margin uses the provider's authoritative ``net_takehome`` when
present, else it is derived.
"""
price = quote.selling_price
if price <= 0:
raise ValueError("selling_price must be positive")
referral_pct = quote.referral_amt / price if price else 0.0
if not (0 <= referral_pct < 1):
raise ValueError("derived referral_pct out of range")
# Fixed cost base = everything that does NOT scale with price (referral does).
cost_base = quote.landed_cost + quote.fba_fee + quote.returns_reserve + quote.other_fees
total_cost = cost_base + quote.referral_amt
profit = quote.net_takehome if quote.net_takehome is not None else (price - total_cost)
cm_pct = profit / price
break_even = cost_base / (1.0 - referral_pct) if referral_pct < 1 else float("inf")
map_floor = cost_base / (1.0 - rules.margin_floor)
return FeeStack(
selling_price=round(price, 4),
landed_cost=round(quote.landed_cost, 4),
fba_fee=round(quote.fba_fee, 4),
referral_pct=round(referral_pct, 6),
referral_amt=round(quote.referral_amt, 4),
returns_reserve=round(quote.returns_reserve, 4),
storage_alloc=round(quote.other_fees, 4), # reuse column for "other Amazon fees"
total_cost=round(total_cost, 4),
profit=round(profit, 4),
contribution_margin_pct=round(cm_pct, 6),
break_even_price=round(break_even, 4),
map_floor=round(map_floor, 4),
referral_basis=quote.source or "provider",
)
def suggest_price_for_floor(
*,
landed_cost: float,
fba_fee: float,
rules: PricingRules,
charm: bool = True,
) -> float:
"""Smallest selling price that clears the margin floor at worst-case referral.
Solves CM(price) = margin_floor for price, accounting for referral% and the
returns reserve both scaling with price:
price - (landed + fba + storage + returns%*price + referral%*price) = floor*price
price * (1 - returns% - referral% - floor) = landed + fba + storage
"""
r = rules
denom = 1.0 - r.returns_reserve_pct - r.worst_case_referral_pct - r.margin_floor
if denom <= 0:
raise ValueError("margin floor + fees exceed 100%; no viable price")
fixed = landed_cost + fba_fee + r.storage_alloc
price = fixed / denom
if charm and r.charm_ending is not None:
# Round UP to the next .99 so we never dip under the floor.
base = int(price) + (0 if price <= int(price) + r.charm_ending else 1)
price = base + r.charm_ending
return round(price, 2)

View File

@ -0,0 +1,156 @@
"""Master-tracker read/write layer.
Two interchangeable backends behind one interface (TRACKER_BACKEND in .env):
- csv : read data/sample_skus.csv, write *_out.csv (offline pilot default)
- gsheets : read/write the real Google Sheet via gspread (service account)
Both expose read_skus(), write_decision(row_dict), and append_audit(record).
"""
from __future__ import annotations
import csv
import json
from datetime import datetime, timezone
from pathlib import Path
from pricing_agent.schemas import SkuInput
def _to_float(v, default=None):
try:
return float(v)
except (TypeError, ValueError):
return default
def _row_to_sku_input(row: dict) -> SkuInput:
referral_raw = _to_float(row.get("Referral Fee %"))
referral_pct = referral_raw / 100 if referral_raw is not None else None
return SkuInput(
sku=(row.get("SKU") or "").strip(),
asin=(row.get("ASIN") or "").strip() or None,
product_name=(row.get("Product Name") or "").strip(),
marketplace=(row.get("Marketplace") or "US").strip(),
category=(row.get("Category") or "").strip(),
landed_cost=_to_float(row.get("Landed Cost"), 0.0) or 0.0,
target_price=_to_float(row.get("Target Selling Price"), 0.0) or 0.0,
referral_pct=referral_pct,
)
class CsvTracker:
def __init__(self, settings):
self.in_path = Path(settings.tracker_csv_path)
self.out_path = Path(settings.tracker_csv_out)
self.audit_path = Path(settings.audit_log_path)
def read_skus(self, sku: str | None = None) -> list[SkuInput]:
with self.in_path.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
items = [_row_to_sku_input(r) for r in rows if (r.get("SKU") or "").strip()]
if sku:
items = [i for i in items if i.sku == sku]
return items
def write_decision(self, row: dict) -> None:
"""Upsert the decision row (keyed by SKU) into the output CSV."""
existing: dict[str, dict] = {}
if self.out_path.exists():
with self.out_path.open(newline="", encoding="utf-8") as f:
for r in csv.DictReader(f):
existing[r["SKU"]] = r
existing[row["SKU"]] = {k: str(v) for k, v in row.items()}
fieldnames = list(row.keys())
self.out_path.parent.mkdir(parents=True, exist_ok=True)
with self.out_path.open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
for r in existing.values():
w.writerow({k: r.get(k, "") for k in fieldnames})
def append_audit(self, record: dict) -> None:
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
is_new = not self.audit_path.exists()
with self.audit_path.open("a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if is_new:
w.writerow(["timestamp", "sku", "decision", "price", "approver", "detail"])
w.writerow(
[
record.get("timestamp"),
record.get("sku"),
record.get("decision"),
record.get("price"),
record.get("approver"),
json.dumps(record.get("detail", {})),
]
)
class GSheetsTracker: # pragma: no cover (needs live Google creds)
"""Google Sheets backend via gspread + service account."""
def __init__(self, settings):
import gspread
from google.oauth2.service_account import Credentials
if not settings.tracker_sheet_id:
raise RuntimeError("TRACKER_SHEET_ID is required for gsheets backend.")
scopes = ["https://www.googleapis.com/auth/spreadsheets"]
creds = Credentials.from_service_account_file(
settings.google_application_credentials, scopes=scopes
)
gc = gspread.authorize(creds)
self.sh = gc.open_by_key(settings.tracker_sheet_id)
self.ws = self.sh.worksheet(settings.tracker_worksheet)
self.audit_ws_name = "audit_log"
def read_skus(self, sku: str | None = None) -> list[SkuInput]:
rows = self.ws.get_all_records()
items = [_row_to_sku_input(r) for r in rows if str(r.get("SKU", "")).strip()]
if sku:
items = [i for i in items if i.sku == sku]
return items
def write_decision(self, row: dict) -> None:
header = self.ws.row_values(1)
col = {name: idx + 1 for idx, name in enumerate(header)}
skus = self.ws.col_values(col.get("SKU", 1))
try:
r_idx = skus.index(row["SKU"]) + 1
except ValueError:
r_idx = len(skus) + 1
self.ws.update_cell(r_idx, col.get("SKU", 1), row["SKU"])
for name, value in row.items():
if name in col:
self.ws.update_cell(r_idx, col[name], value)
def append_audit(self, record: dict) -> None:
try:
aw = self.sh.worksheet(self.audit_ws_name)
except Exception:
aw = self.sh.add_worksheet(self.audit_ws_name, rows=1000, cols=6)
aw.append_row(["timestamp", "sku", "decision", "price", "approver", "detail"])
aw.append_row(
[
record.get("timestamp"),
record.get("sku"),
record.get("decision"),
str(record.get("price")),
record.get("approver"),
json.dumps(record.get("detail", {})),
]
)
def get_tracker(settings):
backend = (settings.tracker_backend or "csv").lower()
if backend == "csv":
return CsvTracker(settings)
if backend == "gsheets":
return GSheetsTracker(settings)
raise ValueError(f"Unknown TRACKER_BACKEND: {settings.tracker_backend!r}")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")

59
tests/fixtures/amazon_mock.json vendored Normal file
View File

@ -0,0 +1,59 @@
{
"_comment": "Mock Amazon fee + competitive data keyed by ASIN. Used when AMAZON_BACKEND=mock so the whole agent runs offline. 'fba_fee' and 'referral_pct' feed the fee stack; the competitive block feeds the Buy Box gate.",
"default": {
"fba_fee": 5.50,
"referral_pct": 0.15,
"buy_box_status": "WON",
"buy_box_price": null,
"competitive_low": null,
"competitive_median": null,
"competitive_high": null,
"is_suppressed": false,
"reason": ""
},
"asins": {
"B0MICROFIBERQ": {
"fba_fee": 5.50,
"referral_pct": 0.15,
"buy_box_status": "WON",
"buy_box_price": 24.99,
"competitive_low": 22.49,
"competitive_median": 24.99,
"competitive_high": 29.99,
"is_suppressed": false,
"reason": "We hold the Featured Offer at the market median."
},
"B0THINMARGIN": {
"fba_fee": 6.90,
"referral_pct": 0.15,
"buy_box_status": "WON",
"competitive_low": 15.99,
"competitive_median": 17.49,
"competitive_high": 19.99,
"is_suppressed": false,
"reason": "Crowded low-price segment."
},
"B0SUPPRESSED": {
"fba_fee": 5.50,
"referral_pct": 0.15,
"buy_box_status": "SUPPRESSED",
"buy_box_price": null,
"competitive_low": 18.99,
"competitive_median": 21.99,
"competitive_high": 24.99,
"is_suppressed": true,
"reason": "Your price is significantly higher than recent prices; Buy Box hidden."
},
"B0LOSTPRICE": {
"fba_fee": 5.50,
"referral_pct": 0.15,
"buy_box_status": "LOST_PRICE",
"buy_box_price": 22.49,
"competitive_low": 21.99,
"competitive_median": 22.99,
"competitive_high": 26.99,
"is_suppressed": false,
"reason": "A competitor undercut us; we are not the Featured Offer on price."
}
}
}

128
tests/fixtures/apify_amazon_item.json vendored Normal file
View File

@ -0,0 +1,128 @@
{
"_comment": "Trimmed junglee/Amazon-crawler item shapes for offline Apify mapper tests.",
"won": {
"asin": "B01ND3Y00M",
"title": "Utopia Kitchen 2 Pack Bib Apron",
"price": { "value": 6.98, "currency": "$" },
"listPrice": { "value": 12.99, "currency": "$" },
"inStock": true,
"inStockText": "In Stock",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" },
"offers": [],
"stars": 4.5,
"reviewsCount": 12783,
"monthlyPurchaseVolume": "8K+ bought in past month",
"bestsellerRanks": [
{ "rank": 353, "category": "Kitchen & Dining" },
{ "rank": 1, "category": "Aprons" }
]
},
"_comment_self_offers": "Captured from a live junglee/Amazon-crawler run on B01ND3Y00M (2026-07-14). Every offer is OUR OWN seller id, and one is used stock. There are no competitors on this ASIN.",
"won_self_offers_only": {
"asin": "B01ND3Y00M",
"title": "Utopia Kitchen 2 Pack Bib Apron",
"price": { "value": 6.99, "currency": "$" },
"listPrice": { "value": 12.99, "currency": "$" },
"inStock": true,
"inStockText": "In Stock",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" },
"offers": [
{
"price": { "value": 6.99, "currency": "$" },
"condition": "New",
"shipsFrom": "Amazon.com",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" }
},
{
"price": { "value": 9.99, "currency": "$" },
"condition": "New",
"shipsFrom": "Amazon.com",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" }
},
{
"price": { "value": 12.99, "currency": "$" },
"condition": "Used - Like New",
"shipsFrom": "Amazon.com",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" }
}
]
},
"won_with_used_rival": {
"asin": "B0MIXED001",
"price": { "value": 19.99, "currency": "$" },
"inStock": true,
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" },
"offers": [
{
"price": { "value": 19.99, "currency": "$" },
"condition": "New",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" }
},
{
"price": { "value": 22.50, "currency": "$" },
"condition": "New",
"seller": { "name": "Real Rival", "id": "A9REALRIVAL9" }
},
{
"price": { "value": 8.00, "currency": "$" },
"condition": "Used - Acceptable",
"seller": { "name": "Bargain Bin", "id": "A8BARGAINBIN" }
}
]
},
"lost_eligibility": {
"asin": "B0ELIG0001",
"price": { "value": 26.00, "currency": "$" },
"inStock": true,
"seller": { "name": "Rival Seller", "id": "A1RIVALSELLER" },
"offers": [
{
"price": { "value": 26.00, "currency": "$" },
"condition": "New",
"seller": { "name": "Rival Seller", "id": "A1RIVALSELLER" }
},
{
"price": { "value": 24.99, "currency": "$" },
"condition": "New",
"seller": { "name": "Utopia Brands", "id": "A3AQP8TDYVYCGL" }
}
]
},
"lost_with_offers": {
"asin": "B0COMPETE01",
"price": { "value": 21.49, "currency": "$" },
"inStock": true,
"seller": { "name": "Rival Seller", "id": "A1RIVALSELLER" },
"offers": [
{
"price": { "value": 21.49, "currency": "$" },
"condition": "New",
"seller": { "name": "Rival Seller", "id": "A1RIVALSELLER" }
},
{
"price": { "value": 22.00, "currency": "$" },
"condition": "New",
"seller": { "name": "Other Mart", "id": "A2OTHERMART01" }
},
{
"price": { "value": 24.99, "currency": "$" },
"condition": "New",
"seller": { "name": "Budget Goods", "id": "A3BUDGETGOODS" }
}
]
},
"suppressed": {
"asin": "B0NOSHOW",
"price": null,
"inStock": true,
"inStockText": "See all buying options",
"seller": null,
"offers": [
{
"price": { "value": 19.99, "currency": "$" },
"condition": "New",
"seller": { "name": "Third Party Co", "id": "A3THIRDPARTY" }
}
]
}
}

69
tests/test_analyze.py Normal file
View File

@ -0,0 +1,69 @@
"""Unit tests for the price-verdict logic (pure, no network)."""
from __future__ import annotations
from config.settings import PricingRules
from pricing_agent.analyze import Rating, classify_trend, price_verdict, suggested_price
from pricing_agent.tools.margin_engine import stack_from_quote
from pricing_agent.schemas import FeeQuote
RULES = PricingRules(margin_floor=0.25, margin_target=0.30)
def test_suggested_price_clears_target_margin():
# Real pillow fee model: landed 8.59, fba 8.97, referral 15%, returns 2%, other 0.11.
quote = FeeQuote(selling_price=24.99, landed_cost=8.59, fba_fee=8.97,
referral_amt=3.75, returns_reserve=0.50, other_fees=0.11,
net_takehome=3.07, source="cosmos")
stack = stack_from_quote(quote, RULES)
sug = suggested_price(stack, RULES.margin_target)
assert sug is not None
# ~$33.34 rounded up to a .99 ending.
assert 33.0 <= sug <= 34.5
assert round(sug - int(sug), 2) == 0.99
# Re-price at the suggestion and confirm it clears the 30% target.
requote = FeeQuote(selling_price=sug, landed_cost=8.59, fba_fee=8.97,
referral_amt=0.15 * sug, returns_reserve=0.02 * sug,
other_fees=0.11, source="cosmos")
restack = stack_from_quote(requote, RULES)
assert restack.contribution_margin_pct >= RULES.margin_target
def _verdict(**kw):
base = dict(trend="stable", selling=True, cover_days=60, monthly_take_home=10000.0, rules=RULES)
base.update(kw)
return price_verdict(**base)[0]
def test_trend_classification():
assert classify_trend(1714, 1655) == "stable" # ~+3.6%
assert classify_trend(2000, 1655) == "rising" # +21%
assert classify_trend(1300, 1655) == "declining" # -21%
assert classify_trend(None, 1655) == "unknown"
def test_good_price_healthy_margin_and_demand():
assert _verdict(margin_pct=0.31) == Rating.GOOD
def test_thin_margin_but_strong_demand_is_caution_raise_price():
assert _verdict(margin_pct=0.12, trend="stable", selling=True) == Rating.CAUTION
def test_thin_margin_and_weak_demand_is_poor():
assert _verdict(margin_pct=0.12, trend="declining", selling=False) == Rating.POOR
def test_negative_margin_is_poor_even_with_strong_demand():
assert _verdict(margin_pct=-0.059, trend="stable", selling=True) == Rating.POOR
def test_overstock_storage_drain_is_caution():
assert _verdict(margin_pct=0.31, cover_days=200) == Rating.CAUTION
def test_negative_monthly_takehome_is_poor():
assert _verdict(margin_pct=0.31, monthly_take_home=-2813.0) == Rating.POOR
def test_declining_demand_with_good_margin_is_caution():
assert _verdict(margin_pct=0.31, trend="declining") == Rating.CAUTION

159
tests/test_apify_batch.py Normal file
View File

@ -0,0 +1,159 @@
"""Batching + raw-item cache for the Apify client (no network).
These lock in the two things that make competitive data affordable:
1. N ASINs cost ONE actor run, not N runs.
2. A cache hit costs zero actor runs.
"""
from __future__ import annotations
import json
import pytest
from pricing_agent.schemas import BuyBoxStatus
from pricing_agent.tools.amazon.apify import (
ApifyAmazonClient,
RawItemCache,
_match_requested_asin,
)
OUR_SELLER = "A3AQP8TDYVYCGL"
def _item(asin: str, price: float, seller_id: str = "A1RIVAL") -> dict:
return {
"asin": asin,
"price": {"value": price, "currency": "$"},
"seller": {"name": "Rival", "id": seller_id},
"offers": [
{
"price": {"value": price, "currency": "$"},
"condition": "New",
"seller": {"name": "Rival", "id": seller_id},
}
],
}
class _SpyClient(ApifyAmazonClient):
"""Counts actor runs instead of hitting the network."""
def __init__(self, **kw):
super().__init__(token="x", **kw)
self.runs: list[list[str]] = []
def _run_actor(self, asins): # type: ignore[override]
self.runs.append(list(asins))
return {a: _item(a, 10.0 + i) for i, a in enumerate(asins)}
def test_many_asins_cost_one_actor_run():
c = _SpyClient(seller_id=OUR_SELLER, batch_size=20)
snaps = c.get_competitive_many(["B001", "B002", "B003"])
assert len(c.runs) == 1, "3 ASINs must not cost 3 actor runs"
assert c.runs[0] == ["B001", "B002", "B003"]
assert set(snaps) == {"B001", "B002", "B003"}
assert snaps["B001"].buy_box_price == 10.0
def test_batch_size_chunks_runs():
c = _SpyClient(seller_id=OUR_SELLER, batch_size=2)
c.get_competitive_many(["B001", "B002", "B003", "B004", "B005"])
assert [len(r) for r in c.runs] == [2, 2, 1]
def test_duplicate_asins_scraped_once():
c = _SpyClient(seller_id=OUR_SELLER)
c.get_competitive_many(["B001", "B001", "B002"])
assert c.runs == [["B001", "B002"]]
def test_cache_hit_costs_no_actor_run(tmp_path):
cache = RawItemCache(tmp_path / "c.json", ttl_seconds=3600)
c1 = _SpyClient(seller_id=OUR_SELLER, cache=cache)
c1.get_competitive_many(["B001", "B002"])
assert len(c1.runs) == 1
# Fresh client, same cache file -> served entirely from disk.
c2 = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(tmp_path / "c.json", 3600))
snaps = c2.get_competitive_many(["B001", "B002"])
assert c2.runs == [], "cached ASINs must not trigger an actor run"
assert snaps["B001"].buy_box_price == 10.0
def test_cache_only_scrapes_the_misses(tmp_path):
path = tmp_path / "c.json"
c1 = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(path, 3600))
c1.get_competitive_many(["B001"])
c2 = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(path, 3600))
c2.get_competitive_many(["B001", "B002"])
assert c2.runs == [["B002"]], "must scrape only the cache miss"
def test_expired_cache_entry_is_refetched(tmp_path):
path = tmp_path / "c.json"
c1 = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(path, 3600))
c1.get_competitive_many(["B001"])
# ttl=0 disables the cache entirely; a negative-age TTL would be nonsense.
c2 = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(path, 0))
c2.get_competitive_many(["B001"])
assert c2.runs == [["B001"]]
def test_corrupt_cache_file_does_not_break_pricing(tmp_path):
path = tmp_path / "c.json"
path.write_text("{not json", encoding="utf-8")
cache = RawItemCache(path, 3600)
assert cache.get("B001") is None
c = _SpyClient(seller_id=OUR_SELLER, cache=cache)
snaps = c.get_competitive_many(["B001"])
assert snaps["B001"].buy_box_price == 10.0
def test_cache_stores_raw_payload_so_mapper_fixes_apply(tmp_path):
"""Caching the raw item (not the snapshot) means a mapper fix applies immediately.
Here the cached payload is one of OUR offers; a client configured with our seller id
must map it to WON with no band, without re-scraping.
"""
path = tmp_path / "c.json"
cache = RawItemCache(path, 3600)
cache.put_many({"B001": _item("B001", 6.99, seller_id=OUR_SELLER)})
c = _SpyClient(seller_id=OUR_SELLER, cache=RawItemCache(path, 3600))
snap = c.get_competitive_many(["B001"])["B001"]
assert c.runs == []
assert snap.buy_box_status == BuyBoxStatus.WON
assert snap.competitive_low is None # our own offer is not a competitor
def test_actor_failure_degrades_to_unknown_for_every_asin():
class _Boom(ApifyAmazonClient):
def _run_actor(self, asins): # type: ignore[override]
raise RuntimeError("actor exploded")
c = _Boom(token="x", seller_id=OUR_SELLER)
snaps = c.get_competitive_many(["B001", "B002"])
assert all(s.buy_box_status == BuyBoxStatus.UNKNOWN for s in snaps.values())
assert "actor exploded" in snaps["B001"].reason
@pytest.mark.parametrize(
"item",
[
{"asin": "B001"},
{"asin": "BXXXX", "originalAsin": "B001"},
{"asin": "BXXXX", "url": "https://www.amazon.com/dp/B001"},
{"asin": "BXXXX", "input": {"url": "https://www.amazon.com/dp/B001"}},
],
)
def test_redirected_variant_still_maps_back_to_requested_asin(item):
"""Amazon can redirect /dp/<asin> to a parent listing — don't lose the SKU."""
assert _match_requested_asin(item, {"B001", "B002"}) == "B001"
def test_unrelated_item_is_not_force_matched():
assert _match_requested_asin({"asin": "BZZZZ"}, {"B001"}) is None

132
tests/test_apify_mapper.py Normal file
View File

@ -0,0 +1,132 @@
"""Unit tests for Apify → CompetitiveSnapshot mapping (no network)."""
from __future__ import annotations
import json
from pathlib import Path
from pricing_agent.schemas import BuyBoxStatus
from pricing_agent.tools.amazon.apify import item_to_competitive
FIX = Path(__file__).parent / "fixtures" / "apify_amazon_item.json"
ITEMS = json.loads(FIX.read_text(encoding="utf-8"))
OUR_SELLER = "A3AQP8TDYVYCGL"
def test_won_when_seller_matches():
snap = item_to_competitive(
ITEMS["won"],
asin="B01ND3Y00M",
our_price=6.98,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.WON
assert snap.buy_box_price == 6.98
assert snap.is_suppressed is False
assert "Featured Offer" in snap.reason or "hold" in snap.reason.lower()
# Sole seller, no rival offers -> there is no competitive band to report.
assert snap.competitive_low is None
assert snap.competitive_median is None
assert snap.competitive_high is None
def test_our_own_offers_never_become_the_competitive_band():
"""Regression: B01ND3Y00M returns three offers, all ours, one of them used.
The old mapper reported low/median/high = 6.99/9.99/12.99 and told the user
"Competitors: Utopia Brands" it was quoting us back at ourselves.
"""
snap = item_to_competitive(
ITEMS["won_self_offers_only"],
asin="B01ND3Y00M",
our_price=6.99,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.WON
assert snap.buy_box_price == 6.99
# No rivals on this listing -> no band at all.
assert snap.competitive_low is None
assert snap.competitive_median is None
assert snap.competitive_high is None
assert all(o.is_ours for o in snap.offers)
assert "Competitors: none" in snap.reason
assert "Utopia Brands @ $9.99" not in snap.reason.split("Our own offers:")[0]
def test_used_offers_excluded_from_band():
"""A used rival offer must not set competitive_low; only new stock competes."""
snap = item_to_competitive(
ITEMS["won_with_used_rival"],
asin="B0MIXED001",
our_price=19.99,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.WON
# Only the New rival at 22.50 counts. The used $8.00 offer is ignored, and our
# own $19.99 featured offer is not a competitor either.
assert snap.competitive_low == 22.50
assert snap.competitive_median == 22.50
assert snap.competitive_high == 22.50
def test_rival_holds_buy_box_at_higher_price_is_not_a_price_loss():
"""We are cheaper but still lost the Buy Box -> eligibility, not price."""
snap = item_to_competitive(
ITEMS["lost_eligibility"],
asin="B0ELIG0001",
our_price=24.99,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.LOST_ELIGIBILITY
assert snap.buy_box_price == 26.00
# Band is the rival only; our own 24.99 offer is excluded.
assert snap.competitive_low == 26.00
assert snap.competitive_high == 26.00
def test_lost_price_and_offer_band():
snap = item_to_competitive(
ITEMS["lost_with_offers"],
asin="B0COMPETE01",
our_price=24.99,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.LOST_PRICE
assert snap.buy_box_price == 21.49
assert snap.buy_box_seller_name == "Rival Seller"
assert snap.competitive_low == 21.49
assert snap.competitive_high == 24.99
assert snap.competitive_median == 22.00
assert snap.is_suppressed is False
assert len(snap.offers) >= 3
names = {o.seller_name for o in snap.offers}
assert "Rival Seller" in names
assert "Other Mart" in names
assert "Budget Goods" in names
buy_box = next(o for o in snap.offers if o.is_buy_box)
assert buy_box.price == 21.49
assert "Competitors:" in snap.reason
def test_suppressed_when_no_featured_price():
snap = item_to_competitive(
ITEMS["suppressed"],
asin="B0NOSHOW",
our_price=24.99,
our_seller_id=OUR_SELLER,
)
assert snap.buy_box_status == BuyBoxStatus.SUPPRESSED
assert snap.is_suppressed is True
assert snap.buy_box_price is None
assert snap.competitive_low == 19.99
assert any(o.seller_name == "Third Party Co" and o.price == 19.99 for o in snap.offers)
def test_undercut_without_seller_id_flags_lost_price():
snap = item_to_competitive(
ITEMS["lost_with_offers"],
asin="B0COMPETE01",
our_price=24.99,
our_seller_id=None,
)
assert snap.buy_box_status == BuyBoxStatus.LOST_PRICE
assert "APIFY_SELLER_ID" in snap.reason

183
tests/test_backtest.py Normal file
View File

@ -0,0 +1,183 @@
"""Walk-forward backtest + trust scoring (pure, no network)."""
from __future__ import annotations
import pytest
from pricing_agent.backtest import METHODS, trust_score, walk_forward
from pricing_agent.cosmos.models import SalesDay
FP = {"referral_pct": 0.15, "returns_pct": 0.02, "cost": 9.28, "fba": 8.97,
"variable": 0.11}
def _hist(days, price_fn, units_fn, profit_fn, ad_fn=lambda i: 100.0):
"""Synthetic daily history, oldest first, over `days` days of 2026."""
out = []
for i in range(days):
month, day = divmod(i, 28)
out.append(SalesDay(
date=f"{month + 1:02d}/{day + 1:02d}/2026",
salePrice=price_fn(i), unitOrders=units_fn(i),
profit=profit_fn(i), marketingCost=ad_fn(i),
revenue=price_fn(i) * units_fn(i)))
return out
def _flat(days=180, price=26.0, units=100.0, profit=200.0):
return _hist(days, lambda i: price, lambda i: units, lambda i: profit)
# ---------------------------------------------------------------- shape
def test_returns_none_without_enough_history():
assert walk_forward(_flat(days=60), FP) is None
def test_produces_folds_and_scores_for_every_method():
bt = walk_forward(_flat(), FP, list_price=26.0)
assert bt is not None
assert bt["n_folds"] >= 1
assert set(bt["scores"]) == set(METHODS)
for m in METHODS:
assert bt["scores"][m]["n"] == bt["n_folds"]
assert bt["scores"][m]["mae"] >= 0
def test_folds_never_train_on_the_window_they_predict():
bt = walk_forward(_flat(), FP, list_price=26.0)
for f in bt["folds"]:
assert f["train_days"] == f["cut_day"]
assert f["test_days"] <= bt["holdout_days"]
def test_persistence_is_exact_on_a_perfectly_stable_sku():
"""Constant profit every day → 'assume last month repeats' cannot be wrong.
The gap-corrected model is also exact here (it is fitted to reproduce the
training level), so the winner just has to be one of the zero-error methods."""
bt = walk_forward(_flat(profit=200.0), FP, list_price=26.0)
assert bt["scores"]["persistence"]["mae"] == 0
assert bt["scores"][bt["best"]]["mae"] == 0
def test_gap_correction_fixes_the_level_on_a_stable_sku():
"""A SKU booking far less than the fee model implies: the constant $/unit
gap should close almost all of it, where the raw model cannot."""
hist = _hist(180, lambda i: 26.0, lambda i: 100.0, lambda i: 1.0)
bt = walk_forward(hist, FP, list_price=26.0)
assert bt["scores"]["anchored_gap"]["mae"] < bt["scores"]["anchored"]["mae"]
def test_selection_never_picks_a_non_pricing_model():
"""Persistence can score best, but it has no price response, so it must never
become the model the engine prices with."""
bt = walk_forward(_flat(profit=200.0), FP, list_price=26.0)
assert bt["scores"]["persistence"]["mae"] == 0
assert bt["selected"] in ("anchored", "anchored_gap")
def test_selection_keeps_the_default_without_a_material_margin():
"""A rival must be clearly better, not marginally, before the engine switches."""
from pricing_agent.backtest import DEFAULT_METHOD
bt = walk_forward(_flat(), FP, list_price=26.0)
s = bt["scores"]
if s["anchored"]["mae"] >= s[DEFAULT_METHOD]["mae"] * 0.85:
assert bt["selected"] == DEFAULT_METHOD
def test_beats_baseline_flag_tracks_the_selected_method():
bt = walk_forward(_flat(), FP, list_price=26.0)
sel, base = bt["scores"][bt["selected"]]["mae"], bt["scores"]["persistence"]["mae"]
assert bt["beats_baseline"] == (sel < base)
def test_predicting_at_the_charged_price_scores_the_profit_model():
"""The held-out price is the one really charged, not one the engine picked."""
prices = lambda i: 24.0 if i < 120 else 28.0
bt = walk_forward(
_hist(180, prices, lambda i: 100.0, lambda i: 200.0), FP, list_price=26.0)
last = bt["folds"][-1]
assert last["test_price"] == pytest.approx(28.0)
assert last["train_price"] < last["test_price"]
def test_bias_sign_reports_direction_of_the_miss():
"""A SKU whose booked profit is far below the fee model → model over-states."""
bt = walk_forward(
_hist(180, lambda i: 26.0, lambda i: 100.0, lambda i: 1.0), FP,
list_price=26.0)
assert bt["scores"]["anchored"]["bias"] > 0 # predicted higher than actual
# ---------------------------------------------------------------- trust
def _bands(n, days=30):
return [{"avg_price": 20.0 + i, "price_band": 20.0 + i, "units_per_day": 100,
"actual_profit_per_day": 100.0, "profit_per_unit": 1.0,
"ad_per_unit": 1.0, "days": days, "enough_data": True}
for i in range(n)]
GOOD_EL = {"actionable": True, "elasticity": -1.5}
BAD_EL = {"actionable": False, "elasticity": -2.2}
def _bt(mape, mae=1000, persistence_mae=5000, selected="anchored_gap"):
scores = {m: {"mae": mae, "mape": mape, "bias": 0, "n": 3}
for m in ("anchored", "anchored_gap")}
scores["persistence"] = {"mae": persistence_mae, "mape": 50, "bias": 0, "n": 3}
scores["legacy"] = {"mae": 9000, "mape": 90, "bias": 0, "n": 3}
return {"scores": scores, "best": selected, "selected": selected,
"beats_baseline": mae < persistence_mae,
"n_folds": 3, "folds": [], "holdout_days": 30}
def test_high_trust_requires_everything_to_pass():
t = trust_score(_bt(12), GOOD_EL, _bands(5), storage_verified=True)
assert t["tier"] == "High" and t["auto_approve"] is True
def test_missing_costs_blocks_all_pricing_action():
t = trust_score(_bt(5), GOOD_EL, _bands(5), has_costs=False,
storage_verified=True)
assert t["tier"] == "None" and t["auto_approve"] is False
def test_unusable_elasticity_demotes_below_auto_approve():
t = trust_score(_bt(10), BAD_EL, _bands(5), storage_verified=True)
assert t["auto_approve"] is False
assert any("elasticity" in w for w in t["reasons"])
def test_thin_evidence_base_demotes():
t = trust_score(_bt(10), GOOD_EL, _bands(1), storage_verified=True)
assert t["tier"] == "Low"
def test_large_backtest_error_demotes_to_low():
t = trust_score(_bt(80), GOOD_EL, _bands(5), storage_verified=True)
assert t["tier"] == "Low"
assert any("does not predict" in w for w in t["reasons"])
def test_losing_to_persistence_demotes_even_when_error_looks_small():
t = trust_score(_bt(10, mae=6000, persistence_mae=1000), GOOD_EL, _bands(5),
storage_verified=True)
assert t["tier"] == "Low"
assert any("repeats" in w for w in t["reasons"])
def test_unverified_storage_caps_trust_at_medium():
t = trust_score(_bt(5), GOOD_EL, _bands(5), storage_verified=False)
assert t["tier"] == "Medium"
assert any("storage" in w for w in t["reasons"])
def test_unscored_sku_is_never_auto_approved():
t = trust_score(None, GOOD_EL, _bands(5), storage_verified=True)
assert t["auto_approve"] is False
assert any("unscored" in w for w in t["reasons"])
def test_tier_is_the_worst_signal_not_an_average():
"""One broken input is enough to make a recommendation wrong."""
t = trust_score(_bt(5), GOOD_EL, _bands(1), has_costs=False,
storage_verified=True)
assert t["tier"] == "None"

View File

@ -0,0 +1,862 @@
"""Competitor-driven pricing rules, and the guarantees that make them safe to ship.
The whole risk of wiring competitor data into a deterministic cascade is that a best-effort
scrape starts deciding prices. So the invariants tested here are, in order of importance:
1. **Absent / stale / failed / unknown competitor data changes NOTHING.** The verdict must be
byte-identical to the competitor-blind one. This is the test that lets the feature ship.
2. **No competitor rule can price below break-even**, however cheap a rival is.
3. **A rival above our price never triggers a cut** that is LOST_ELIGIBILITY, where cutting
donates margin and fixes nothing.
4. **Inventory risk still outranks competitor position**, which still outranks profit-optimal.
5. Every fired rule is traceable to ONE named reason code no blended scores.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import json
import pandas as pd
import pytest
from pricing_agent.competitive_state import (
CompetitiveState, from_apify, from_scraper_listing, reconcile, unavailable,
)
from pricing_agent.schemas import (
BuyBoxStatus, CompetitiveSnapshot, CompetitorOffer,
)
from dashboard.live_data import _decide, _decide_raw, _scenarios
NOW = datetime(2026, 7, 30, 12, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------- fixtures
@dataclass
class FakeResult:
"""The subset of AnalysisResult the cascade actually reads."""
break_even: float = 15.0
is_losing_money: bool = False
cover_days: int | None = 60 # between the 35/90 thresholds: no inventory rule
avg_30d: float = 10.0
avg_6m: float = 10.0
profit_optimal_price: float | None = None
profit_optimal_is_corner: bool = False
elasticity_actionable: bool = True
elasticity: dict = field(default_factory=lambda: {
"elasticity": -1.3, "r2": 0.8, "confidence": "high"})
ad_curve_unrecoverable: bool = False
contribution_per_dollar: float | None = None
break_even_ad_curve: float | None = None
observed_price_max: float | None = None
best_observed_price: float | None = None
best_observed_profit_day: float | None = None
price_bands: list = field(default_factory=list)
ad_cost_per_unit: float | None = None
inventory: float = 5000.0
# Same shape _fee_params() produces: break-even here is ~$15.06, so $25 clears it and a
# rival at $9 does not.
FP = {"cost": 8.0, "fba": 4.0, "referral_pct": 0.15, "returns_pct": 0.02, "variable": 0.5}
CUR = 25.00
def hist(days: int = 180, price: float = CUR, units: float = 10.0):
return pd.DataFrame({
"date": pd.date_range("2026-02-01", periods=days),
"price": [price] * days,
"units": [units] * days,
"ad_spend": [5.0] * days,
"revenue": [price * units] * days,
"profit": [20.0] * days,
"inventory": [5000.0] * days,
})
def scen(comp_match: float | None = None):
extra = {"min_safe": 16.0}
if comp_match:
extra["comp_match"] = comp_match
return _scenarios(CUR, 10.0, 5000.0, FP, -1.3, None, extra)
def state(status: BuyBoxStatus, *, rival: float | None = None, our: float = CUR,
age_h: float = 0.0, rivals: int = 1, max_age_hours: float = 6.0
) -> CompetitiveState:
"""A state built through the REAL adapter + gate, not hand-assembled.
Hand-setting the fields would leave `unusable_because` None on a state that is hours past
its age limit, so a staleness test would pass while never exercising the gate that makes
staleness safe. Build it the way production does.
"""
snap = CompetitiveSnapshot(
buy_box_status=status, buy_box_price=rival, competitive_low=rival,
competitive_median=rival, reason="test",
offers=([CompetitorOffer(seller_name="them", price=rival, is_ours=False)]
* rivals if rival else []),
)
return from_apify(snap, our_price=our, as_of=NOW - timedelta(hours=age_h),
max_age_hours=max_age_hours, now=NOW)
def verdict(comp=None, r=None, comp_match=None):
"""(action, rec_key, reasons) — what the cascade decided."""
r = r or FakeResult()
a, rec, _cons, _aggr, reasons, _root, _obj = _decide(
r, CUR, hist(), FP, scen(comp_match), None, comp)
return a, rec, reasons
# ============================================================ 1. FAIL-SAFE
# The invariant the whole feature rests on: competitor data may only ADD a verdict.
@pytest.mark.parametrize("comp,label", [
(None, "no competitor object at all"),
(unavailable("scrape not run"), "explicitly unavailable"),
(unavailable("scrape failed"), "scrape failed"),
(state(BuyBoxStatus.UNKNOWN, rival=20.0), "ownership unknown"),
(state(BuyBoxStatus.LOST_PRICE, rival=20.0, age_h=9.0), "9h old, past the 6h limit"),
])
def test_unusable_competitor_data_changes_nothing(comp, label):
"""Every one of these must reproduce the competitor-blind verdict exactly."""
blind = verdict(None)
assert verdict(comp) == blind, f"{label} changed the verdict"
def test_stale_data_is_reported_not_silently_dropped():
"""'No data' and '9h-old data' look identical in a blank cell; only one is re-runnable."""
stale = state(BuyBoxStatus.LOST_PRICE, rival=20.0, age_h=9.0)
assert not stale.usable
assert "9.0h old" in stale.unusable_because
# A 20% undercut, which WOULD fire the rule if it were fresh — proving the gate, not the
# absence of a signal, is what stops it.
fresh = state(BuyBoxStatus.LOST_PRICE, rival=20.0, age_h=0.0)
assert verdict(fresh, comp_match=20.0)[2] == ["COMPETITOR_UNDERCUT"]
assert verdict(stale, comp_match=20.0)[2] == ["NO_SIGNALS"]
# ...and the reason it was ignored is on the record, not a blank.
root = _decide_raw(FakeResult(), CUR, hist(), FP, scen(20.0), None, stale)[5]
assert any("not used" in str(v) for _k, v in root)
# ============================================================ 1b. SCOPED KILL SWITCH
@pytest.fixture()
def rules_off(monkeypatch):
"""Flip competitor_rules_enabled off the way config does, cache included."""
from config.settings import get_rules
get_rules.cache_clear()
real = get_rules()
patched = real.model_copy(update={"competitor_rules_enabled": False})
monkeypatch.setattr("config.settings.get_rules", lambda: patched)
yield
get_rules.cache_clear()
def test_the_flag_is_on_by_default():
from config.settings import get_rules
assert get_rules().competitor_rules_enabled is True
@pytest.mark.parametrize("comp_state,comp_match,rule", [
(BuyBoxStatus.SUPPRESSED, None, "BUYBOX_SUPPRESSED"),
(BuyBoxStatus.LOST_PRICE, 22.0, "COMPETITOR_UNDERCUT"),
])
def test_switching_the_flag_off_disables_exactly_the_two_competitor_rules(
comp_state, comp_match, rule, monkeypatch):
"""Same SKU, same competitor state, flag on vs off — both arms in one test.
Asserting only the OFF arm would pass vacuously if the rule never fired in the first place,
so the ON arm is measured here rather than assumed from a sibling test.
"""
from config.settings import get_rules
comp = state(comp_state, rival=(comp_match or 22.0))
# ---- flag ON: the rule fires
get_rules.cache_clear()
on = verdict(comp, comp_match=comp_match)
assert on[2] == [rule], f"ON arm did not fire {rule}; the OFF arm would be vacuous"
# ---- flag OFF: identical to the competitor-blind verdict
patched = get_rules().model_copy(update={"competitor_rules_enabled": False})
monkeypatch.setattr("config.settings.get_rules", lambda: patched)
off = verdict(comp, comp_match=comp_match)
assert off == verdict(None, comp_match=comp_match)
assert rule not in off[2]
assert off != on # the flag demonstrably did something
def test_flag_off_leaves_every_other_cascade_rule_working(rules_off):
"""The switch is scoped: only the two competitor rules go quiet."""
assert verdict(None, r=FakeResult(cover_days=20))[2][0] == "LOW_STOCK"
assert verdict(None, r=FakeResult(cover_days=120))[2][0] == "EXCESS_STOCK"
assert verdict(None, r=FakeResult(break_even=26.0))[2] == ["BELOW_BREAK_EVEN"]
assert verdict(None, r=FakeResult(is_losing_money=True))[2] == ["LOSING_MONEY"]
assert verdict(None, r=FakeResult(profit_optimal_price=30.0))[2] == [
"PROFIT_OPTIMAL", "PREMIUM_HEADROOM"]
no_cost = {**FP, "cost": 0.0, "fba": 0.0}
assert _decide(FakeResult(), CUR, hist(), no_cost, scen(), None, None)[4] == ["NO_COST_DATA"]
def test_flag_off_says_WHY_rather_than_going_quiet(rules_off):
"""A silently competitor-blind verdict is indistinguishable from a missing scrape."""
comp = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
root = _decide_raw(FakeResult(), CUR, hist(), FP, scen(22.0), None, comp)[5]
note = next(v for k, v in root if k == "Competitor Buy Box")
assert "switched off in pricing_rules.yaml" in note
def test_the_flag_uses_the_same_fail_safe_path_as_stale_data(rules_off):
"""Flag-off and stale-data must produce the IDENTICAL verdict, not merely similar ones.
If these ever diverge there are two 'pretend competitor data isn't there' code paths, which
is the bug risk the single-path requirement exists to prevent.
"""
fresh = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
stale = state(BuyBoxStatus.LOST_PRICE, rival=22.0, age_h=9.0)
assert verdict(fresh, comp_match=22.0) == verdict(stale, comp_match=22.0)
# ============================================================ 2. SUPPRESSION
def test_suppressed_buybox_returns_investigate():
action, rec, reasons = verdict(state(BuyBoxStatus.SUPPRESSED))
assert action == "Investigate"
assert reasons == ["BUYBOX_SUPPRESSED"] # ONE named reason, no blend
assert rec == "current" # holds; no price is moved
def test_suppression_outranks_profit_optimal():
"""A listing nobody can buy from makes its modelled optimum meaningless."""
r = FakeResult(profit_optimal_price=30.0) # would otherwise fire PROFIT_OPTIMAL
assert verdict(None, r=r)[2] == ["PROFIT_OPTIMAL", "PREMIUM_HEADROOM"]
assert verdict(state(BuyBoxStatus.SUPPRESSED), r=r)[2] == ["BUYBOX_SUPPRESSED"]
def test_missing_cost_data_still_outranks_suppression():
"""With no COGS nothing is computable at all — that stays the first gate."""
r = FakeResult()
assert verdict(state(BuyBoxStatus.SUPPRESSED), r=r,
)[2] == ["BUYBOX_SUPPRESSED"]
no_cost = {**FP, "cost": 0.0, "fba": 0.0}
reasons = _decide(r, CUR, hist(), no_cost, scen(), None,
state(BuyBoxStatus.SUPPRESSED))[4]
assert reasons == ["NO_COST_DATA"]
def test_suppression_is_not_relabelled_as_an_ad_problem():
"""Both hold, but the Buy Box is the thing to go and fix."""
r = FakeResult(ad_curve_unrecoverable=True, contribution_per_dollar=0.05)
assert verdict(None, r=r)[2] == ["AD_SPIRAL"]
assert verdict(state(BuyBoxStatus.SUPPRESSED), r=r)[2] == ["BUYBOX_SUPPRESSED"]
# ============================================================ 3. UNDERCUT
def test_material_undercut_recommends_a_decrease():
action, rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0),
comp_match=22.0)
assert action == "Decrease"
assert reasons == ["COMPETITOR_UNDERCUT"]
assert rec == "comp_match"
def test_immaterial_undercut_does_nothing():
"""1.2% below us is inside the noise our own realized price already swings through."""
action, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=24.70),
comp_match=24.70)
assert reasons == ["NO_SIGNALS"]
assert action == "Maintain"
def test_rival_above_us_never_triggers_a_cut():
"""LOST_ELIGIBILITY: they hold the Buy Box at a HIGHER price. Cutting fixes nothing."""
action, _rec, reasons = verdict(state(BuyBoxStatus.LOST_ELIGIBILITY, rival=28.0),
comp_match=28.0)
assert "COMPETITOR_UNDERCUT" not in reasons
assert action != "Decrease"
def test_winning_the_buybox_never_triggers_a_cut():
action, _rec, reasons = verdict(state(BuyBoxStatus.WON, rival=22.0), comp_match=22.0)
assert "COMPETITOR_UNDERCUT" not in reasons
# ======================= 3b. COSMOS cover_days == 0 must not silence the inventory rules
# Found while validating a real suggested price. COSMOS returns `cover_days: 0` on some
# low-velocity SKUs that are sitting on months of stock, and 0 passes NEITHER inventory test
# (`0 < cover <= 35` is false; `cover >= 90` is false), so both rules go quiet and the SKU falls
# through to the next rung. On UBMICROFIBERBS4PCFULLGREY — 167 units on hand, 15 sales in six
# months, 334 days of real cover — that produced COMPETITOR_UNDERCUT instead of EXCESS_STOCK,
# bypassing the "inventory outranks competitor position" guarantee.
def test_cosmos_cover_days_zero_does_not_silence_both_inventory_rules():
r = FakeResult(cover_days=0, inventory=167.0) # exactly what COSMOS returned
comp = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
# The raw COSMOS value alone: neither rule can fire, and the competitor rule wins by
# default. This is the behaviour being fixed, asserted so the bug cannot creep back.
assert _decide(r, CUR, hist(), FP, scen(22.0), None, comp)[4] == ["COMPETITOR_UNDERCUT"]
# With the reconciled cover passed in — 167 units / 0.5 a day = 334 days — the inventory
# rule fires and correctly outranks the competitor rule.
reasons = _decide(r, CUR, hist(), FP, scen(22.0), None, comp, 334)[4]
assert reasons[0] == "EXCESS_STOCK"
assert "COMPETITOR_UNDERCUT" not in reasons
def test_reconciled_cover_also_restores_the_low_stock_rule():
"""The same zero hides an UNDERSTOCKED SKU, which is the more expensive direction."""
r = FakeResult(cover_days=0, inventory=10.0)
assert _decide(r, CUR, hist(), FP, scen(), None, None)[4] == ["NO_SIGNALS"]
assert _decide(r, CUR, hist(), FP, scen(), None, None, 20)[4][0] == "LOW_STOCK"
def test_an_explicit_cover_of_zero_is_still_honoured_when_that_is_the_truth():
"""A genuinely empty shelf (no stock, no velocity) must not be invented into cover."""
r = FakeResult(cover_days=0, inventory=0.0)
# 0 units on hand and 0 velocity -> the reconciled figure is also 0, and neither inventory
# rule should fire off a fabricated number.
reasons = _decide(r, CUR, hist(), FP, scen(), None, None, 0)[4]
assert "EXCESS_STOCK" not in reasons and "LOW_STOCK" not in reasons
# ============================================================ 4. ORDERING
def test_low_stock_outranks_a_competitor_undercut():
"""Chasing a rival down while the shelf empties pays margin to sell out faster."""
r = FakeResult(cover_days=20) # <= 35d
_a, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0), r=r,
comp_match=22.0)
assert reasons[0] == "LOW_STOCK"
assert "COMPETITOR_UNDERCUT" not in reasons
def test_excess_stock_outranks_a_competitor_undercut():
r = FakeResult(cover_days=120) # >= 90d
_a, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0), r=r,
comp_match=22.0)
assert reasons[0] == "EXCESS_STOCK"
def test_below_break_even_outranks_a_competitor_undercut():
"""Margin protection first: we do not chase a rival while already under water."""
r = FakeResult(break_even=26.0) # cur 25.00 < 26.00
_a, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0), r=r,
comp_match=22.0)
assert reasons == ["BELOW_BREAK_EVEN"]
def test_undercut_outranks_profit_optimal():
"""A price the market is actively beating us on beats a modelled optimum."""
r = FakeResult(profit_optimal_price=30.0)
_a, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0), r=r,
comp_match=22.0)
assert reasons == ["COMPETITOR_UNDERCUT"]
# ============================================================ 5. NEVER BELOW BREAK-EVEN
def test_comp_match_candidate_is_never_recommended_below_break_even():
"""A rival at $9 against a $15 break-even must not produce a $9 recommendation.
The comp_match KEY is allowed to hold $9 it is a candidate to evaluate. The floor
clamp downstream is what guarantees the number that ships is not below break-even.
"""
s = scen(9.0)
assert float(s.set_index("scenario").loc["comp_match", "price"]) == 9.0
_a, rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=9.0), comp_match=9.0)
assert reasons == ["COMPETITOR_UNDERCUT"]
assert rec == "comp_match"
# The floor (min_safe) is a candidate in the same grid and sits above the rival price,
# which is what the guardrail in build_live_sku lifts the target to.
assert float(s.set_index("scenario").loc["min_safe", "price"]) > 9.0
def test_no_comp_match_key_means_no_undercut_rule():
"""The rule cannot fire without a priced candidate to move toward."""
_a, _rec, reasons = verdict(state(BuyBoxStatus.LOST_PRICE, rival=22.0),
comp_match=None)
assert "COMPETITOR_UNDERCUT" not in reasons
# ============================================================ 6. PREMIUM = NOTE ONLY
def test_premium_while_winning_is_a_note_and_never_an_action():
r = FakeResult()
comp = state(BuyBoxStatus.WON, rival=20.0) # we are 25% above the field
action, rec, reasons = verdict(comp, r=r)
blind_action, blind_rec, blind_reasons = verdict(None, r=r)
assert (action, rec, reasons) == (blind_action, blind_rec, blind_reasons)
root = _decide_raw(r, CUR, hist(), FP, scen(), None, comp)[5]
note = next(v for k, v in root if k == "Competitor premium")
assert "not a reason to raise" in note
def test_premium_does_not_override_the_elasticity_optimum():
"""Where the model wants a LOWER price, a premium must not flip it upward."""
r = FakeResult(profit_optimal_price=23.0) # model says come down
comp = state(BuyBoxStatus.WON, rival=20.0) # and we are above the field
action, _rec, reasons = verdict(comp, r=r)
assert action == "Decrease"
assert reasons == ["PROFIT_OPTIMAL"]
# ============================================================ 7. STATE ADAPTERS
def test_apify_snapshot_excludes_our_own_offers_from_the_band():
"""Our own offer must not become 'the cheapest rival' — that reports a 0% undercut."""
snap = CompetitiveSnapshot(
buy_box_status=BuyBoxStatus.LOST_PRICE, buy_box_price=21.0,
offers=[CompetitorOffer(seller_name="us", price=25.0, is_ours=True),
CompetitorOffer(seller_name="them", price=21.0, is_ours=False)])
st = from_apify(snap, our_price=25.0, as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.competitor_min == 21.0
assert st.rivals == 1
assert st.undercut_pct == pytest.approx(0.16)
def test_used_stock_never_enters_the_rival_band():
"""Second-hand stock is not a competing price for a new listing.
Found on LIVE data, not in review. Two real ASINs from the item-3 validation run:
* B06X421WJ6 cheapest offer Amazon Resale $22.53 *Used - Very Good* vs our $22.99 new,
which scored as us being 2% dearer. The only NEW rival was $30.99: we were $8 CHEAPER.
* B00U6HREPQ Amazon Resale $21.37 *Used - Like New* vs our $22.49 scored a 4.98%
undercut, clearing the 3% action threshold outright.
"""
snap = CompetitiveSnapshot(
buy_box_status=BuyBoxStatus.LOST_PRICE, buy_box_price=22.99,
offers=[
CompetitorOffer(seller_name="Utopia Brands", price=22.99, is_ours=True,
is_buy_box=True, condition="New"),
CompetitorOffer(seller_name="Amazon Resale", price=22.53, is_ours=False,
condition="Used - Very Good"),
CompetitorOffer(seller_name="Amazon Resale", price=21.37, is_ours=False,
condition="Used - Like New"),
CompetitorOffer(seller_name="Amazon.com", price=30.99, is_ours=False,
condition="New"),
])
st = from_apify(snap, our_price=22.99, as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.competitor_min == 30.99 # the NEW rival, not the $21.37 used one
assert st.rivals == 1
assert st.undercut_pct < 0 # we are cheaper, not undercut
# The whole point: a used offer must not be able to cut our price.
assert "COMPETITOR_UNDERCUT" not in verdict(st, comp_match=21.37)[2]
def test_a_new_condition_rival_still_counts():
"""The used filter must not silently swallow real competition."""
snap = CompetitiveSnapshot(
buy_box_status=BuyBoxStatus.LOST_PRICE, buy_box_price=18.00,
offers=[
CompetitorOffer(seller_name="us", price=25.00, is_ours=True, condition="New"),
CompetitorOffer(seller_name="Rival", price=18.00, is_ours=False, condition="New"),
])
st = from_apify(snap, our_price=25.00, as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.competitor_min == 18.00
assert verdict(st, comp_match=18.00)[2] == ["COMPETITOR_UNDERCUT"]
def test_playwright_listing_cannot_claim_ownership_without_a_seller_name():
"""It carries no seller id, so 'a featured price rendered' is not 'we hold it'."""
class L:
buybox, price, error = "buybox", 21.0, None
fields: dict = {}
st = from_scraper_listing(L(), our_price=25.0, as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.status is BuyBoxStatus.UNKNOWN
assert not st.usable # and therefore cannot drive any rule
assert "authoritative" in st.notes[-1]
def test_playwright_suppression_is_a_fact_not_an_error():
"""This scraper sets `error` on a suppressed listing. That must not read as a failure."""
class L:
buybox, price = "suppressed", None
error = "Buy Box suppressed; Amazon reason: High price"
fields = {"Buy Box Status": "SUPPRESSED — High price"}
st = from_scraper_listing(L(), our_price=25.0, as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.status is BuyBoxStatus.SUPPRESSED
assert st.usable
def test_playwright_reads_ownership_when_the_pdp_names_us():
class L:
buybox, price, error = "buybox", 25.0, None
fields = {"Sold By": "Utopia Deals"}
st = from_scraper_listing(L(), our_price=25.0, our_seller_name="Utopia Deals",
as_of=NOW, max_age_hours=6.0, now=NOW)
assert st.status is BuyBoxStatus.WON
# ============================================================ 8. RECONCILIATION
def test_disagreement_on_suppression_is_logged_and_recorded():
apify = state(BuyBoxStatus.WON, rival=22.0)
play = CompetitiveState(status=BuyBoxStatus.SUPPRESSED, source="playwright",
as_of=NOW, our_price=CUR)
merged = reconcile(apify, play, sku="UBTEST")
assert merged.source == "apify" # primary always wins the field
assert any("disagree on Buy Box suppression" in n for n in merged.notes)
def test_disagreement_on_price_is_recorded_with_both_figures():
apify = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
play = CompetitiveState(status=BuyBoxStatus.LOST_PRICE, buy_box_price=27.0,
competitor_min=27.0, source="playwright", as_of=NOW,
our_price=CUR)
merged = reconcile(apify, play, sku="UBTEST")
assert merged.buy_box_price == 22.0 # authoritative source's number ships
note = next(n for n in merged.notes if "featured price" in n)
assert "$22.00" in note and "$27.00" in note
def test_unusable_primary_promotes_a_usable_secondary():
"""A Playwright SUPPRESSED must not be discarded to preserve a tidy hierarchy.
Suppression means the variant sells nothing at any price. If Apify did not run, throwing
that away would be choosing the source hierarchy over the fact.
"""
apify = unavailable("scrape not run")
play = CompetitiveState(status=BuyBoxStatus.SUPPRESSED, source="playwright",
as_of=NOW, our_price=CUR)
merged = reconcile(apify, play, sku="UBTEST")
assert merged.source == "playwright"
assert merged.usable
assert any("rests on playwright" in n for n in merged.notes)
# ...and it drives the rule, exactly as an Apify suppression would.
assert verdict(merged)[2] == ["BUYBOX_SUPPRESSED"]
def test_playwright_cache_reader_is_best_effort(tmp_path):
"""Any problem returns None. It is a cross-check, not a dependency."""
from pricing_agent.competitive_state import from_playwright_cache
missing = tmp_path / "nope.json"
assert from_playwright_cache("B08DTH86Q2", our_price=CUR,
cache_path=str(missing)) is None
bad = tmp_path / "bad.json"
bad.write_text("{not json", encoding="utf-8")
assert from_playwright_cache("B08DTH86Q2", our_price=CUR,
cache_path=str(bad)) is None
good = tmp_path / "c.json"
good.write_text(json.dumps({
"B08DTH86Q2@10001": {"ts": NOW.timestamp(), "row": {
"buybox": "suppressed", "price": None,
"error": "Buy Box suppressed; Amazon reason: High price",
"fields": {"Buy Box Status": "SUPPRESSED — High price"}}}}), encoding="utf-8")
st = from_playwright_cache("B08DTH86Q2", our_price=CUR, cache_path=str(good),
max_age_hours=6.0, now=NOW)
assert st is not None and st.status is BuyBoxStatus.SUPPRESSED
assert st.source == "playwright"
# A cache written for a DIFFERENT ZIP describes a different marketplace. Ignored, not
# reinterpreted — the ZIP is what decides the price.
assert from_playwright_cache("B08DTH86Q2", our_price=CUR, zip_code="90210",
cache_path=str(good), now=NOW) is None
assert from_playwright_cache("B0NOTTHERE", our_price=CUR, cache_path=str(good),
now=NOW) is None
# ============================================================ 9. THE SHEET + COVERAGE GATE
def _write_sheet(tmp_path, rows, *, name="Competitor_Price_Comparison_UBTESTLINE_2026-07-29_1923.xlsx",
brands=("Bedsure", "CGK")):
"""A workbook shaped like the real one — including the footnotes that trip a naive parse."""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Price Comparison"
header = ["Our SKU", "Our ASIN", "Size", "Colour", "Config", "Our Price",
"Our List Price", "Our Buy Box", "Our BSR", "Our Bought/Mo", "Our Stock"]
for b in brands:
header += [f"{b} ASIN", f"{b} Price", f"vs {b} ($)", f"vs {b} (%)", f"{b} BSR",
f"BSR vs {b}", f"{b} Bought/Mo"]
header += ["Cheapest", "Notes"]
ws.append(header)
for r in rows:
line = [r.get("sku"), r.get("asin"), r.get("size"), r.get("colour"), "3-pc",
r.get("our_price"), None, r.get("buybox"), None, None, None]
for b in brands:
line += [f"B0RIV{b[:2].upper()}01", r.get(b), None, None, None, None, None]
line += [r.get("cheapest"), r.get("notes")]
ws.append(line)
# The real workbook writes its footnotes into column A under the table. 6 of them.
ws.append([])
for note in ("Our Price is scraped LIVE from Amazon on every row…",
"Our Buy Box is scraped live: COSMOS knows what we charge…",
"Our Stock is UNITS ON HAND from COSMOS…",
"A 'NEAR colour match' note means the colours are not identically labelled…",
"BSR is each listing's OWN sub-category rank…",
"'BSR vs <brand>' is left BLANK when their listing ranks in a DIFFERENT…"):
ws.append([note])
p = tmp_path / name
wb.save(p)
wb.close()
return p
SHEET_ROWS = [
{"sku": "UBTESTLINEQUEENWHITE", "asin": "B0OURS0001", "size": "QUEEN",
"colour": "White", "our_price": 28.99, "buybox": "Buy Box",
"Bedsure": 24.99, "CGK": 31.00, "cheapest": "Bedsure"},
{"sku": "UBTESTLINEKINGWHITE", "asin": "B0OURS0002", "size": "KING",
"colour": "White", "our_price": 30.00, "buybox": "Buy Box",
"Bedsure": 29.70, "CGK": None, "cheapest": "Bedsure"},
{"sku": "UBTESTLINETWINPINK", "asin": "B0OURS0003", "size": "TWIN",
"colour": "Pink", "our_price": None, "buybox": "SUPPRESSED — High price",
"Bedsure": 25.00, "CGK": None, "cheapest": None},
{"sku": "UBTESTLINEFULLSAGE", "asin": "B0OURS0004", "size": "FULL",
"colour": "Sage", "our_price": 26.00, "buybox": "Buy Box",
"Bedsure": 20.00, "CGK": None, "cheapest": "Bedsure",
"notes": "Bedsure: Buy Box SUPPRESSED — High price; their price is not currently "
"buyable, do not price against it"},
# Match quality. These two rows are IDENTICAL apart from the NEAR-colour note, so any
# difference in verdict is attributable to match quality alone.
{"sku": "UBTESTLINEKINGFUZZY", "asin": "B0OURS0005", "size": "KING",
"colour": "Purple", "our_price": 30.00, "buybox": "Buy Box",
"Bedsure": 24.00, "CGK": None, "cheapest": "Bedsure",
"notes": "Bedsure: our 'Purple' ≈ their '06 - Dusty Purple (No Comforter)' — NEAR "
"colour match, verify"},
{"sku": "UBTESTLINEKINGEXACT", "asin": "B0OURS0006", "size": "KING",
"colour": "Purple", "our_price": 30.00, "buybox": "Buy Box",
"Bedsure": 24.00, "CGK": None, "cheapest": "Bedsure"},
# One fuzzy rival AND one exact rival on the same row. The flag is written per rival, so
# the exact one must survive while the fuzzy one is dropped.
{"sku": "UBTESTLINEQUEENMIXED", "asin": "B0OURS0007", "size": "QUEEN",
"colour": "Grey", "our_price": 30.00, "buybox": "Buy Box",
"Bedsure": 20.00, "CGK": 27.00, "cheapest": "Bedsure",
"notes": "Bedsure: our 'Grey' ≈ their '08 - Dark Grey (No Comforter)' — NEAR colour "
"match, verify"},
]
def _sheet(tmp_path):
from pricing_agent.competitor_sheet import CompetitorSheet
return CompetitorSheet.load(_write_sheet(tmp_path, SHEET_ROWS))
# ---- match quality: a FUZZY row may inform a human, never move a price -------------------
# Real-data motivation: 18 of 56 rows in the live workbook carry a NEAR-colour rival, and 2 of
# the 6 material undercuts were being driven by one. UBMICROFIBERDUVETKINGPURPLE would have cut
# 15.6% off an unverified 'Purple' ~ 'Dusty Purple' pairing — and once the fuzzy match is
# excluded the EXACT rival turns out to be 5.6% more expensive than us, i.e. the undercut was
# entirely an artifact of the guess.
def test_a_fuzzy_match_cannot_trigger_the_undercut_rule(tmp_path):
s = _sheet(tmp_path)
row = s.rows["UBTESTLINEKINGFUZZY"]
assert row.fuzzy_rivals == {"Bedsure"}
assert row.rivals == {"Bedsure": 24.00} # still READ, for display
assert row.priceable_rivals == {} # but not priceable
st = s.state_for("UBTESTLINEKINGFUZZY", our_price=None)
assert st.competitor_min is None # a 20% gap that must not reach the cascade
assert st.undercut_pct is None
action, _rec, reasons = verdict(st, comp_match=24.00)
assert "COMPETITOR_UNDERCUT" not in reasons
assert (action, reasons) == verdict(None)[0::2]
def test_an_otherwise_identical_exact_match_does_trigger_it(tmp_path):
"""Same prices, same 20% gap — the ONLY difference is the NEAR-colour note."""
s = _sheet(tmp_path)
assert s.rows["UBTESTLINEKINGEXACT"].fuzzy_rivals == set()
st = s.state_for("UBTESTLINEKINGEXACT", our_price=None)
assert st.competitor_min == 24.00
assert st.undercut_pct == pytest.approx(0.20)
action, rec, reasons = verdict(st, comp_match=24.00)
assert (action, rec, reasons) == ("Decrease", "comp_match", ["COMPETITOR_UNDERCUT"])
def test_fuzzy_exclusion_is_per_rival_not_per_row(tmp_path):
"""One row, one fuzzy rival, one exact rival: the exact one must survive.
The workbook writes the flag per rival ('Bedsure: our X ≈ their Y'), so dropping the whole
row would discard a perfectly good exact comparison and keeping the whole row would price
us against the guess.
"""
s = _sheet(tmp_path)
row = s.rows["UBTESTLINEQUEENMIXED"]
assert row.fuzzy_rivals == {"Bedsure"}
assert row.priceable_rivals == {"CGK": 27.00} # Bedsure $20 dropped, CGK $27 kept
st = s.state_for("UBTESTLINEQUEENMIXED", our_price=None)
assert st.competitor_min == 27.00 # NOT 20.00
assert st.undercut_pct == pytest.approx(0.10)
# It still fires — off the exact rival, at the exact rival's price.
assert verdict(st, comp_match=27.00)[2] == ["COMPETITOR_UNDERCUT"]
def test_an_excluded_fuzzy_rival_is_still_reported_with_its_price(tmp_path):
"""The one exclusion a human may want to overrule, so give them the number to act on."""
s = _sheet(tmp_path)
st = s.state_for("UBTESTLINEKINGFUZZY", our_price=None)
note = next(n for n in st.notes if "NEAR colour match" in n and "excluded" in n)
assert "Bedsure $24.00" in note
assert "may not move a price on its own" in note
def test_sheet_parsing_rejects_the_footnote_rows(tmp_path):
"""The footnotes live in column A, so 'Our SKU' alone is not trustworthy.
Measured on the real workbook: a naive read yields 62 'SKUs', 6 of which are footnote
prose ('Our Price is scra…'). A real row carries a real ASIN.
"""
s = _sheet(tmp_path)
# Derived from the fixture, not hardcoded: the point is that the 6 footnote rows written
# under the table are rejected, whatever the data-row count happens to be.
assert len(s.rows) == len(SHEET_ROWS), sorted(s.rows)
assert all(r.asin and r.asin.startswith("B0") for r in s.rows.values())
assert not any("Our Price is" in k for k in s.rows)
def test_sheet_reads_rivals_from_the_header_not_a_hardcoded_list(tmp_path):
s = _sheet(tmp_path)
assert s.brands == ["Bedsure", "CGK"]
assert s.line == "UBTESTLINE"
def test_sheet_blank_our_price_stays_none(tmp_path):
"""A suppressed row legitimately has no price. That is a fact, not a parse failure."""
s = _sheet(tmp_path)
assert s.rows["UBTESTLINETWINPINK"].our_price is None
assert s.rows["UBTESTLINEQUEENWHITE"].our_price == 28.99
def test_coverage_gate_returns_na_for_an_uncovered_sku(tmp_path):
"""The point of the gate: 'not in our sheet' must not read as 'has no competitors'."""
s = _sheet(tmp_path)
assert s.covers("UBTESTLINEQUEENWHITE")
assert not s.covers("UBMICROFIBERGUSSETPILLOWWHITEQUEEN")
st = s.state_for("UBMICROFIBERGUSSETPILLOWWHITEQUEEN", our_price=26.07)
assert not st.usable
assert st.unusable_because.startswith("N/A")
# The message has to say what the sheet DOES cover, or a reader cannot tell a coverage
# hole from a market fact.
assert "UBTESTLINE" in st.unusable_because
assert f"{len(SHEET_ROWS)} SKU(s)" in st.unusable_because
# ...and it changes no verdict.
assert verdict(st) == verdict(None)
def test_covered_sku_yields_a_like_for_like_sheet_state(tmp_path):
from pricing_agent.competitive_state import BASIS_SHEET, SOURCE_SHEET
s = _sheet(tmp_path)
st = s.state_for("UBTESTLINEQUEENWHITE", our_price=None)
assert st.usable and st.source == SOURCE_SHEET and st.basis == BASIS_SHEET
assert st.our_price == 28.99
assert st.competitor_min == 24.99 # the cheaper of Bedsure 24.99 / CGK 31.00
assert st.rivals == 2
assert st.undercut_pct == pytest.approx(0.1379, abs=1e-3)
def test_sheet_excludes_a_rival_whose_own_buybox_is_suppressed(tmp_path):
"""Their price is not buyable, so undercutting it donates margin for nothing."""
s = _sheet(tmp_path)
row = s.rows["UBTESTLINEFULLSAGE"]
assert row.suppressed_rivals == {"Bedsure"}
st = s.state_for("UBTESTLINEFULLSAGE", our_price=None)
assert st.competitor_min is None # the only rival was excluded
assert st.rivals == 0
assert any("not buyable" in n for n in st.notes)
# The state stays USABLE — 'we hold the Buy Box' is a real, known fact worth reporting.
# What must not happen is a price move: with no buyable rival there is nothing to undercut,
# so the rule cannot fire even though a $20.00 figure sits in the sheet.
assert st.usable and st.status is BuyBoxStatus.WON
assert st.undercut_pct is None
assert verdict(st, comp_match=20.00) == verdict(None)
def test_sheet_suppression_drives_the_suppressed_rule(tmp_path):
s = _sheet(tmp_path)
st = s.state_for("UBTESTLINETWINPINK", our_price=25.00)
assert st.status is BuyBoxStatus.SUPPRESSED and st.usable
assert verdict(st)[2] == ["BUYBOX_SUPPRESSED"]
def test_stale_sheet_is_na_and_names_the_line_to_re_run(tmp_path):
s = _sheet(tmp_path)
s.as_of = NOW - timedelta(hours=400)
st = s.state_for("UBTESTLINEQUEENWHITE", our_price=None, max_age_hours=168.0, now=NOW)
assert not st.usable
assert "400h old" in st.unusable_because
assert "UBTESTLINE" in st.unusable_because
def test_sheet_basis_undercut_fires_without_a_buybox_loss(tmp_path):
"""A rival BRAND's cheaper product is a real signal even while we hold our own Buy Box.
This is the difference the `basis` field exists to record: it is NOT a Buy Box loss, and
must never be reported as one.
"""
from pricing_agent.competitive_state import BASIS_SHEET
s = _sheet(tmp_path)
st = s.state_for("UBTESTLINEQUEENWHITE", our_price=None)
assert st.status is BuyBoxStatus.WON # we hold it, and it still fires
assert st.basis == BASIS_SHEET
action, rec, reasons = verdict(st, comp_match=24.99)
assert (action, rec, reasons) == ("Decrease", "comp_match", ["COMPETITOR_UNDERCUT"])
def test_sheet_basis_respects_the_same_material_threshold(tmp_path):
"""KING row: rival 29.70 vs our 30.00 = 1% — inside coupon noise, so nothing fires."""
s = _sheet(tmp_path)
st = s.state_for("UBTESTLINEKINGWHITE", our_price=None)
assert st.undercut_pct == pytest.approx(0.01)
assert "COMPETITOR_UNDERCUT" not in verdict(st, comp_match=29.70)[2]
def test_a_same_asin_state_still_needs_a_buybox_loss():
"""The two bases are not interchangeable — WON on the SAME ASIN must not fire."""
won = state(BuyBoxStatus.WON, rival=22.0)
assert "COMPETITOR_UNDERCUT" not in verdict(won, comp_match=22.0)[2]
# ==================================== 10. A DROP WITH A COMPETITOR EXPLANATION
def _dropping():
"""A SKU whose velocity halved, with no price change and no ad collapse of its own."""
return FakeResult(avg_30d=4.0, avg_6m=10.0)
def test_a_drop_with_no_competitor_cause_is_still_unexplained():
assert verdict(None, r=_dropping())[2] == ["UNEXPLAINED_DROP", "VELOCITY_DROP"]
def test_a_material_undercut_explains_a_drop_instead_of_filing_it_as_a_mystery():
"""Observed on real SKUs: a rival 15.6% / 35.2% below us, filed UNEXPLAINED_DROP.
Calling a drop unexplained while holding the explanation sends someone off to rediscover
a rival price already printed in the root cause. Same precedent as `stock_explained`.
"""
r = _dropping()
comp = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
_a, _rec, reasons = verdict(comp, r=r, comp_match=22.0)
assert "UNEXPLAINED_DROP" not in reasons
assert reasons == ["COMPETITOR_UNDERCUT", "VELOCITY_DROP"]
def test_an_immaterial_rival_does_not_explain_a_drop():
"""Only a MATERIAL undercut is an explanation; 1% is not."""
r = _dropping()
comp = state(BuyBoxStatus.LOST_PRICE, rival=24.70)
assert verdict(comp, r=r, comp_match=24.70)[2] == ["UNEXPLAINED_DROP", "VELOCITY_DROP"]
def test_a_stockout_still_explains_a_drop_before_a_competitor_does():
"""The existing stockout branch keeps priority — it is the cheaper explanation to act on."""
r = _dropping()
hist_oos = hist()
hist_oos.loc[hist_oos.index[-25:], "units"] = 0.0
hist_oos.loc[hist_oos.index[-25:], "inventory"] = 0.0
comp = state(BuyBoxStatus.LOST_PRICE, rival=22.0)
reasons = _decide(r, CUR, hist_oos, FP, scen(22.0), None, comp)[4]
assert reasons[0] == "STOCKOUT_BEFORE_ARRIVAL"
def test_one_source_missing_is_not_a_disagreement():
apify = state(BuyBoxStatus.WON, rival=22.0)
assert reconcile(apify, None, sku="X").notes == []
assert reconcile(None, apify, sku="X").source == "apify"
assert not reconcile(None, None, sku="X").usable

56
tests/test_cosmos.py Normal file
View File

@ -0,0 +1,56 @@
"""Unit tests for the COSMOS mapping layer (no network).
Uses a fake client that returns a captured real take-home response, so we verify
the FeeQuote mapping and the stack assembly without hitting the API.
"""
from __future__ import annotations
from config.settings import PricingRules
from pricing_agent.cosmos.service import CosmosPricingService
from pricing_agent.tools.margin_engine import stack_from_quote
# A real captured take-home response for UBMICROFIBERGUSSETPILLOWWHITEQUEEN @ $24.99.
REAL_TAKEHOME = {
"itemPrice": 24.99, "referralFee": 3.75, "fbaFee": 8.97, "lowInventoryFee": 0.0,
"inboundPlacementFee": 0.11, "returnFee": 0.5, "eprFee": 0, "vat": 0.0,
"costSubtotal": -12.72, "marginImpact": 37.71, "logisticsCost": 0.0,
"dutyAmount": 0.0, "costPerUnit": 8.59, "orderHandling": 0, "weightHandling": 0,
"warehouseExpense": 0,
"costBreakdown": {"purchasePrice": 6.28, "freight": 1.27, "duty": 1.03},
"takeHome": 3.07,
}
RULES = PricingRules(margin_floor=0.25, worst_case_referral_pct=0.15)
class FakeClient:
def get(self, path, params=None):
assert "takehome-calculator" in path
return REAL_TAKEHOME
def test_fee_quote_maps_takehome():
svc = CosmosPricingService(FakeClient())
q = svc.fee_quote("UBTEST", 24.99)
assert q.selling_price == 24.99
assert q.landed_cost == 8.59
assert q.fba_fee == 8.97
assert q.referral_amt == 3.75
assert q.returns_reserve == 0.5
assert q.other_fees == 0.11 # inbound placement only
assert q.net_takehome == 3.07
assert q.source == "cosmos"
def test_stack_from_quote_matches_cosmos_net():
svc = CosmosPricingService(FakeClient())
q = svc.fee_quote("UBTEST", 24.99)
stack = stack_from_quote(q, RULES)
# Net profit must equal COSMOS take-home.
assert round(stack.profit, 2) == 3.07
# CM% = takeHome / price.
assert round(stack.contribution_margin_pct, 4) == round(3.07 / 24.99, 4)
# Break-even uses the fixed cost base and the derived referral %.
cost_base = 8.59 + 8.97 + 0.5 + 0.11
assert round(stack.break_even_price, 2) == round(cost_base / (1 - 0.15), 2)
assert round(stack.map_floor, 2) == round(cost_base / (1 - 0.25), 2)

173
tests/test_cover_bands.py Normal file
View File

@ -0,0 +1,173 @@
"""COSMOS display bands vs the engine's pricing triggers — two things, one definition each.
They are NOT the same thing and the difference is deliberate:
* `theme.COVER_BANDS` COSMOS Inventory Planning's own Alpha/Beta shading. Pink at 70 days
is a REPLENISHMENT warning: don't reorder yet.
* `low/high_cover_days` in pricing_rules.yaml where this engine spends margin, raising 5%
to slow a burn or cutting 5% to clear stock.
Measured on 47 SKUs of the covered line: moving the triggers to the band edges (40/70) would
put six more SKUs on a discount, all of them in the 70-90 day window. So the bands match COSMOS
for display parity, the triggers stay put, and the UI says which is which.
What these tests protect is that each has exactly ONE definition, and that a change to either
is visible rather than silent.
"""
from __future__ import annotations
import pytest
from config.settings import get_rules
from dashboard import theme
from dashboard.live_data import HIGH_COVER_DAYS, LOW_COVER_DAYS
# ------------------------------------------------------- one definition each
def test_the_engine_reads_its_thresholds_from_config_not_from_code():
r = get_rules()
assert (LOW_COVER_DAYS, HIGH_COVER_DAYS) == (r.low_cover_days, r.high_cover_days)
def test_the_thresholds_are_still_the_backtested_ones():
"""35/90 is what the impact table in pricing_rules.yaml was measured against.
Changing them is allowed it is a config line but it invalidates that table, so this
fails loudly rather than letting the documented evidence quietly describe another policy.
"""
assert (LOW_COVER_DAYS, HIGH_COVER_DAYS) == (35, 90), (
"cover triggers changed — re-measure the impact table in pricing_rules.yaml")
def test_bands_match_the_cosmos_legend_exactly():
"""Alpha 0-20-40-70-100, Beta 0-15-30-60-90 — straight off the INVP legend."""
assert [b[0] for b in theme.COVER_BANDS["alpha"]] == [20, 40, 70, 100, None]
assert [b[0] for b in theme.COVER_BANDS["beta"]] == [15, 30, 60, 90, None]
@pytest.mark.parametrize("days,word", [
(0, "very low"), (19, "very low"), (20, "low"), (39, "low"),
(40, "healthy"), (69, "healthy"), (70, "high"), (99, "high"),
(100, "excess"), (5000, "excess"),
])
def test_alpha_band_boundaries(days, word):
assert theme.cover_band(days, "alpha")[1] == word
@pytest.mark.parametrize("days,word", [
(14, "very low"), (15, "low"), (29, "low"), (30, "healthy"),
(59, "healthy"), (60, "high"), (89, "high"), (90, "excess"),
])
def test_beta_bands_are_tighter_than_alpha(days, word):
assert theme.cover_band(days, "beta")[1] == word
def test_an_unknown_cover_gets_no_colour_verdict():
"""A missing number must not be shaded as though we had judged it."""
colour, word = theme.cover_band(None)
assert word == "unknown" and colour == theme.BORDER
def test_an_unknown_class_falls_back_to_alpha_rather_than_raising():
assert theme.cover_band(50, "gamma") == theme.cover_band(50, "alpha")
# ------------------------------------------------- matching COSMOS's own figure
def _cover(cosmos, onhand):
"""The reconciliation `build_live_sku` performs, in one line."""
return int(cosmos) if cosmos else onhand
def test_cover_matches_cosmos_when_cosmos_has_an_answer():
"""The dashboard must not quote a different number from the INVP grid for one SKU.
Real values for UBMICROFIBERDUVETTWINWHITE: 3,999 on hand + 1,030 inbound = 5,029, over a
64/day 7-day velocity = 78 days. On-hand only is 63.
"""
assert _cover(78, 63) == 78
def test_a_zero_from_cosmos_with_stock_on_hand_falls_back():
"""`coverDays: 0` on a stocked SKU is a missing answer, not a full shelf.
UBMICROFIBERBS4PCFULLGREY: 167 units, 334 real days of cover, COSMOS returned 0. Taken
literally it satisfies neither inventory rule and silences both.
"""
assert _cover(0, 334) == 334
assert _cover(None, 334) == 334
def test_a_genuine_empty_shelf_stays_zero():
"""No stock and no velocity must not be inflated into cover by the fallback."""
assert _cover(0, 0) == 0
def test_matching_cosmos_can_mask_a_thin_shelf_and_that_is_recorded():
"""The accepted trade-off, pinned so it is a decision rather than a surprise.
UBMICROFIBERBS4PCKINGWHITE holds 12 days on the shelf and 84 counting inbound, so matching
COSMOS stops it triggering LOW_STOCK. If the shipment slips, nothing protects it.
"""
onhand, cosmos = 12, 84
assert 0 < onhand <= LOW_COVER_DAYS # would have raised on shelf stock alone
matched = _cover(cosmos, onhand)
assert not (0 < matched <= LOW_COVER_DAYS) # ...and does not, once inbound counts
# ------------------------------------------- the gap between band and trigger
def test_there_is_a_window_where_the_band_warns_and_the_engine_does_not_act():
"""The 70-90 day window on Alpha. Six real SKUs sit in it.
This is the state that looked like a bug on screen, so it is pinned: the tile must be able
to detect it in order to explain it.
"""
band_warns = theme.cover_band(82, "alpha")[1] == "high"
engine_acts = 0 < 82 <= LOW_COVER_DAYS or 82 >= HIGH_COVER_DAYS
assert band_warns and not engine_acts
@pytest.mark.parametrize("days", [12, 30, 95, 140])
def test_where_the_engine_acts_the_band_is_never_green(days):
"""The colour may be more cautious than the engine; it must never be LESS.
A green tile beside a SKU the engine is discounting would be the genuinely dangerous
direction of disagreement.
"""
acts = 0 < days <= LOW_COVER_DAYS or days >= HIGH_COVER_DAYS
assert acts
assert theme.cover_band(days, "alpha")[1] != "healthy"
def test_every_acting_cover_is_outside_the_green_band_for_alpha():
"""Swept, not sampled — no Alpha cover may read green while a price move fires.
Alpha is the only class in use: `inv_class` is hardcoded to "alpha" for every SKU in
`live_data.build_live_sku`, so this is the invariant that actually holds today.
"""
for days in range(0, 400):
if 0 < days <= LOW_COVER_DAYS or days >= HIGH_COVER_DAYS:
assert theme.cover_band(days, "alpha")[1] != "healthy", days
def test_beta_would_break_that_invariant_if_it_were_ever_populated():
"""A latent conflict, recorded rather than hidden.
Beta's healthy band starts at 30 days, but the raise trigger is a single global 35 — so a
Beta SKU at 30-35 days would be repriced while its own tile showed green. Nothing does
that today because `inv_class` is hardcoded to "alpha"; the moment COSMOS's real
classification is plumbed through, the cover triggers have to become per-class and Beta's
raise threshold must drop below 30.
This test passes on the CURRENT behaviour and is written to fail the day Beta appears with
the global thresholds still in force.
"""
conflict = [d for d in range(0, 400)
if (0 < d <= LOW_COVER_DAYS or d >= HIGH_COVER_DAYS)
and theme.cover_band(d, "beta")[1] == "healthy"]
assert conflict == list(range(30, LOW_COVER_DAYS + 1)), conflict
# The guard that keeps it harmless: no SKU is Beta.
from dashboard import live_data
import inspect
src = inspect.getsource(live_data.build_live_sku)
assert 'inv_class="alpha"' in src, (
"a SKU can now be Beta — make the cover triggers per-class before shipping this")

View File

@ -0,0 +1,111 @@
"""Every third-party import must be declared in requirements.txt or pyproject.toml.
`requests` the HTTP client behind EVERY COSMOS call, imported at module level in
`cosmos/client.py` was in neither file. A clean `pip install -r requirements.txt` then
failed on import, not at the first request. `openpyxl`, which reads the competitor workbook on
the default path, was missing too. Meanwhile `openai` was declared and never imported: the
real import is `langchain-openai`, so declaring `openai` alone did not enable the LLM path.
These tests parse the actual imports instead of trusting a hand-maintained list, so adding a
dependency without declaring it fails here rather than on someone else's machine.
"""
from __future__ import annotations
import ast
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
SKIP_DIRS = {"__pycache__", ".venv", "new_archtetcure_and frontend", ".mojibake_backup"}
LOCAL = {"pricing_agent", "dashboard", "config", "app", "legacy_app", "backend", "tests"}
# import name -> distribution name, where they differ.
DIST = {
"yaml": "pyyaml",
"dotenv": "python-dotenv",
"apify_client": "apify-client",
"langchain_openai": "langchain-openai",
"pydantic_settings": "pydantic-settings",
"google": "google-auth",
"sp_api": "python-amazon-sp-api",
"PIL": "pillow",
}
# Test-only, and declared under [dev].
TEST_ONLY = {"pytest"}
def _sources():
for f in ROOT.rglob("*.py"):
if not SKIP_DIRS.intersection(f.parts):
yield f
def _third_party_imports() -> dict[str, set[str]]:
found: dict[str, set[str]] = {}
for f in _sources():
try:
tree = ast.parse(f.read_text(encoding="utf-8"))
except SyntaxError: # not our problem here
continue
for node in ast.walk(tree):
mods: list[str] = []
if isinstance(node, ast.Import):
mods = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
mods = [node.module.split(".")[0]]
for m in mods:
if m and m not in sys.stdlib_module_names and m not in LOCAL:
found.setdefault(m, set()).add(str(f.relative_to(ROOT)))
return found
def _declared() -> set[str]:
text = (ROOT / "requirements.txt").read_text(encoding="utf-8")
text += (ROOT / "pyproject.toml").read_text(encoding="utf-8")
names = re.findall(r"^\s*[\"']?([A-Za-z][A-Za-z0-9._-]*)\s*(?:[><=!~]|[\"']|$)",
text, re.M)
return {n.lower().replace("_", "-") for n in names}
def test_every_import_is_declared_somewhere():
declared = _declared()
missing = {}
for mod, files in _third_party_imports().items():
if mod in TEST_ONLY:
continue
dist = DIST.get(mod, mod).lower().replace("_", "-")
if dist not in declared and mod.lower().replace("_", "-") not in declared:
missing[dist] = sorted(files)[:3]
assert not missing, f"undeclared dependencies: {missing}"
def test_requests_is_in_requirements_txt():
"""Module-level in cosmos/client.py — the dashboard cannot import without it."""
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
assert re.search(r"^requests\b", req, re.M), "requests missing from requirements.txt"
def test_openpyxl_is_in_requirements_txt():
"""The default competitor path reads a workbook; without this it raises at runtime."""
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
assert re.search(r"^openpyxl\b", req, re.M), "openpyxl missing from requirements.txt"
def test_the_llm_extra_names_the_package_actually_imported():
"""`openai` alone does not enable the narrative — the import is `langchain_openai`."""
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
assert re.search(r"^langchain-openai\b", req, re.M)
def test_dashboard_requirements_stay_a_subset_of_the_package():
"""Anything the dashboard needs must also be installable via `pip install -e .`."""
req_names = {
m.group(1).lower()
for m in re.finditer(r"^([A-Za-z][A-Za-z0-9._-]*)",
(ROOT / "requirements.txt").read_text(encoding="utf-8"), re.M)
}
proj = (ROOT / "pyproject.toml").read_text(encoding="utf-8").lower()
# streamlit/plotly/pandas/numpy live under the [ui] extra; everything else is a core dep.
for name in req_names:
assert name in proj, f"{name} is in requirements.txt but not in pyproject.toml"

View File

@ -0,0 +1,69 @@
"""Tests for the deterministic pricing gate (playbook Definition of Done, §13)."""
from __future__ import annotations
import pytest
from config.settings import PricingRules
from pricing_agent.schemas import BuyBoxStatus, CompetitiveSnapshot, Decision
from pricing_agent.tools.gate import evaluate_gate
from pricing_agent.tools.margin_engine import worst_case_stack
RULES = PricingRules(
margin_floor=0.25,
returns_reserve_pct=0.02,
storage_alloc=0.25,
worst_case_referral_pct=0.15,
)
def _stack(price):
return worst_case_stack(selling_price=price, landed_cost=8.00, fba_fee=5.50, rules=RULES)
def test_healthy_sku_won_buybox_is_approved():
comp = CompetitiveSnapshot(buy_box_status=BuyBoxStatus.WON, competitive_median=24.99)
decision, reasons = evaluate_gate(gate_stack=_stack(24.99), competitive=comp, rules=RULES)
assert decision == Decision.APPROVED
assert reasons
def test_below_break_even_is_blocked():
comp = CompetitiveSnapshot(buy_box_status=BuyBoxStatus.WON)
decision, reasons = evaluate_gate(gate_stack=_stack(16.00), competitive=comp, rules=RULES)
assert decision == Decision.BLOCKED
assert "break-even" in " ".join(reasons).lower()
def test_below_margin_floor_is_blocked():
# $18.00 is above break-even ($16.76) but its CM (~6.6%) is under the 25% floor,
# so it blocks on the margin-floor rule (which is stricter than the MAP guardrail).
comp = CompetitiveSnapshot(buy_box_status=BuyBoxStatus.WON)
stack = _stack(18.00)
decision, reasons = evaluate_gate(gate_stack=stack, competitive=comp, rules=RULES)
assert decision == Decision.BLOCKED
assert "floor" in " ".join(reasons).lower()
# MAP is still computed and available as a downstream guardrail, ~$19.
assert round(stack.map_floor, 2) == pytest.approx(19.00, abs=0.25)
def test_suppressed_listing_is_blocked_even_if_priced_well():
comp = CompetitiveSnapshot(buy_box_status=BuyBoxStatus.SUPPRESSED, is_suppressed=True)
decision, reasons = evaluate_gate(gate_stack=_stack(24.99), competitive=comp, rules=RULES)
assert decision == Decision.BLOCKED
assert "suppress" in " ".join(reasons).lower()
def test_lost_buybox_on_price_needs_review():
comp = CompetitiveSnapshot(buy_box_status=BuyBoxStatus.LOST_PRICE, competitive_median=22.00)
decision, reasons = evaluate_gate(gate_stack=_stack(24.99), competitive=comp, rules=RULES)
assert decision == Decision.NEEDS_REVIEW
def test_lost_buybox_on_eligibility_needs_review_not_silent_approval():
"""Previously fell through every branch and was APPROVED with no mention of the loss."""
comp = CompetitiveSnapshot(
buy_box_status=BuyBoxStatus.LOST_ELIGIBILITY, competitive_median=26.00
)
decision, reasons = evaluate_gate(gate_stack=_stack(24.99), competitive=comp, rules=RULES)
assert decision == Decision.NEEDS_REVIEW
assert "eligibility" in " ".join(reasons).lower()

96
tests/test_graph_e2e.py Normal file
View File

@ -0,0 +1,96 @@
"""End-to-end graph tests on the mock backend (fully offline).
Drives the LangGraph state machine through the human interrupt with an auto
resolver, and asserts the gate outcome + tracker write-back behaviour.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from langgraph.types import Command
from config.settings import get_settings
from pricing_agent.graph import build_graph
from pricing_agent.schemas import Decision, SkuInput
@pytest.fixture()
def settings(tmp_path):
s = get_settings()
# Redirect all writes into the temp dir; use the offline mock provider.
s.data_backend = "mock"
s.tracker_backend = "csv"
s.tracker_csv_out = str(tmp_path / "out.csv")
s.audit_log_path = str(tmp_path / "audit.csv")
fixtures = Path(__file__).parent / "fixtures" / "amazon_mock.json"
s.mock_fixtures_path = str(fixtures)
return s
def _run(graph, sku_input, auto):
cfg = {"configurable": {"thread_id": f"sku:{sku_input.sku}"}}
state = graph.invoke({"sku_input": sku_input}, config=cfg)
interrupts = state.get("__interrupt__")
assert interrupts, "graph should pause at human_review"
payload = interrupts[0].value
if auto == "smart":
action = "approve" if payload["decision"] == Decision.APPROVED.value else "reject"
else:
action = auto
resume = {"action": action, "price": None, "note": "test"}
return graph.invoke(Command(resume=resume), config=cfg)
def test_microfiber_sku_approved_and_written(settings, tmp_path):
graph = build_graph()
si = SkuInput(
sku="UBMICROFIBER4PCQUEENGREY",
asin="B0MICROFIBERQ",
product_name="Microfiber 4 Piece Queen Sheet Set - Grey",
landed_cost=8.00,
target_price=24.99,
referral_pct=0.12, # modeled; gate still uses worst-case 15%
)
state = _run(graph, si, "smart")
d = state["decision"]
assert d.decision == Decision.APPROVED
assert state["written"] is True
# Golden margin figures survive end to end.
assert round(d.fee_stack.break_even_price, 2) == 16.76
assert round(d.fee_stack.map_floor, 2) == 19.00
# Tracker row written with the right status.
out = Path(settings.tracker_csv_out).read_text(encoding="utf-8")
assert "Pricing Approved" in out
assert "UBMICROFIBER4PCQUEENGREY" in out
def test_suppressed_sku_blocked_and_not_written(settings, tmp_path):
graph = build_graph()
si = SkuInput(
sku="UBSUPPRESSEDTOWEL",
asin="B0SUPPRESSED",
landed_cost=9.00,
target_price=29.99,
)
state = _run(graph, si, "smart")
d = state["decision"]
assert d.decision == Decision.BLOCKED
# smart-mode rejects non-approved => nothing written to the pricing columns.
assert state["written"] is False
out_path = Path(settings.tracker_csv_out)
assert (not out_path.exists()) or ("Pricing Approved" not in out_path.read_text())
def test_human_edit_reprices_and_recomputes(settings, tmp_path):
graph = build_graph()
si = SkuInput(sku="UBEDIT", asin="B0MICROFIBERQ", landed_cost=8.00, target_price=24.99)
cfg = {"configurable": {"thread_id": "sku:UBEDIT"}}
state = graph.invoke({"sku_input": si}, config=cfg)
resume = {"action": "edit", "price": 26.99, "note": "bump"}
state = graph.invoke(Command(resume=resume), config=cfg)
d = state["decision"]
assert d.recommended_price == 26.99
# Margin recomputed at the edited price (higher price => higher CM).
assert d.fee_stack.selling_price == 26.99
assert state["written"] is True

View File

@ -0,0 +1,164 @@
"""The profit-impact baseline: a price delta must subtract like from like.
The `current` scenario row carries TWO readings on purpose:
* ``net_30d`` COSMOS's BOOKED profit, priced at the average customers actually
paid over the window (revenue / units).
* ``modelled_net_30d`` the same model every other row uses, evaluated at TODAY's price.
Subtracting a projection from the booked figure mixes those bases and measures a move nobody
proposed. Observed on UBMICROFIBERDUVETTWINWHITE:
today $17.09
recommendation $17.94 (+5%, the step cap)
booked row $18.18 <- the average actually sold over 90 days
rec - booked = $2,357 - $2,435 = -$78 -> "Profit opportunity $0" beside a RAISE
rec - modelled = $2,357 - $1,150 = +$1,207 -> the real effect of $17.09 -> $17.94
The headline tile, the queue sort and the per-SKU impact all read that number.
"""
from __future__ import annotations
import pytest
from dashboard.live_data import (
_TWINNED, _scenario_economics, booked, modelled, modelled_net_30d,
)
FP = {"cost": 8.0, "fba": 4.0, "referral_pct": 0.15, "returns_pct": 0.02, "variable": 0.5}
# ---------------------------------------------------------------- the helper
def test_prefers_the_modelled_twin_when_a_row_has_one():
row = {"net_30d": 2435, "modelled_net_30d": 1150}
assert modelled_net_30d(row) == 1150
def test_falls_back_to_net_for_rows_with_no_twin():
"""Every row except `current` is already modelled — nothing to prefer."""
assert modelled_net_30d({"net_30d": 2357}) == 2357
def test_missing_row_returns_the_fallback():
assert modelled_net_30d(None, 42.0) == 42.0
assert modelled_net_30d({}, 42.0) == 42.0
# ------------------------------------------------------- against the real shape
def _econ(cur_price=17.09, realized=18.18):
"""Scenario economics shaped like the live SKU: today's price BELOW the window average."""
grid = {"current": cur_price, "up_5": round(cur_price * 1.05, 2)}
rows, _meta = _scenario_economics(
cur_price, 66.0, FP, -1.3, 0.14, 500.0, grid,
actual_30d=2435.0, actual_rev=36000.0, actual_ad=5000.0, actual_units=1980.0,
realized_price=realized, ad_model=None, bands=None, gap_per_unit=0.0,
)
return {x["key"]: x for x in rows}
def test_the_current_row_really_does_carry_two_different_numbers():
"""If these ever coincide the test below proves nothing, so assert the premise."""
e = _econ()
cur = e["current"]
assert cur["price"] == pytest.approx(18.18) # what buyers paid
assert cur["list_price"] == pytest.approx(17.09) # today's price
assert cur["net_30d"] == 2435 # booked
assert cur["modelled_net_30d"] != cur["net_30d"] # ...and its modelled twin differs
def test_the_booked_baseline_prices_the_wrong_move():
"""Reproduces the defect: a +5% raise scored as a LOSS because $17.94 < $18.18."""
e = _econ()
booked_impact = e["up_5"]["net_30d"] - e["current"]["net_30d"]
assert booked_impact < 0, "the premise of the bug no longer holds; rewrite this test"
def test_the_modelled_baseline_prices_the_move_that_was_actually_proposed():
e = _econ()
impact = modelled_net_30d(e["up_5"]) - modelled_net_30d(e["current"])
assert impact > 0
# ...and it is exactly the difference between the model at the two prices.
assert impact == pytest.approx(
e["up_5"]["net_30d"] - e["current"]["modelled_net_30d"])
def test_a_maintain_recommendation_reports_exactly_zero():
"""The recommendation lands on the current row; subtracting it from itself must be 0.
Using the booked value on one side and the modelled value on the other would report the
gap between the two readings as if holding the price earned money.
"""
e = _econ()
impact = modelled_net_30d(e["current"]) - modelled_net_30d(e["current"])
assert impact == 0
# ------------------------------------------------- the accessor, as one place to be right
def test_every_twinned_field_is_swapped_not_just_net():
"""A partial swap is the bug. Whatever `_TWINNED` lists, the current row must twin ALL of it.
Three wrong numbers shipped because each site hand-wrote its own
`row.get("modelled_x", row["x"])` and covered a different subset of fields.
"""
e = _econ()
cur = e["current"]
for field in _TWINNED:
assert f"modelled_{field}" in cur, f"current row has no modelled twin for {field!r}"
m = modelled(cur)
for field in _TWINNED:
assert m[field] == cur[f"modelled_{field}"], field
def test_the_accessor_swaps_price_too_not_only_profit():
"""The field whose absence caused the strategy table to mix bases."""
e = _econ()
cur = e["current"]
assert cur["price"] == pytest.approx(18.18) # booked: what buyers paid
assert modelled(cur)["price"] == pytest.approx(17.09) # modelled: today's price
def test_margin_is_twinned_so_now_and_after_share_a_basis():
e = _econ()
cur = e["current"]
# Booked margin and modelled margin genuinely differ here — that is the whole hazard.
assert cur["net_margin_pct"] != modelled(cur)["net_margin_pct"]
# Comparing modelled-to-modelled is the only pairing that describes the move.
assert modelled(e["up_5"])["net_margin_pct"] > modelled(cur)["net_margin_pct"]
def test_a_projected_row_passes_through_untouched():
"""Only `current` is twinned; everything else must be returned exactly as it is."""
e = _econ()
assert modelled(e["up_5"]) == e["up_5"]
assert modelled(None) == {}
def test_booked_is_available_only_for_the_row_that_is_a_fact():
e = _econ()
b = booked(e["current"])
assert b is not None and b["net_30d"] == 2435 and b["price"] == pytest.approx(18.18)
assert booked(e["up_5"]) is None # a projection is not a fact
assert booked(None) is None
def test_modelled_is_a_copy_and_does_not_mutate_the_row():
e = _econ()
cur = e["current"]
before = dict(cur)
m = modelled(cur)
m["net_30d"] = -999
assert cur == before
def test_no_spurious_impact_when_today_equals_the_window_average():
"""With no promo gap the two readings coincide and both baselines agree."""
e = _econ(cur_price=18.18, realized=18.18)
cur = e["current"]
assert cur["price"] == pytest.approx(cur["list_price"])
booked = e["up_5"]["net_30d"] - cur["net_30d"]
modelled = modelled_net_30d(e["up_5"]) - modelled_net_30d(cur)
# The booked figure is still an actual, so they need not be identical — but the modelled
# delta must not flip sign relative to the price move, which is the failure being guarded.
assert modelled > 0
assert (booked > 0) or (cur["net_30d"] != cur["modelled_net_30d"])

View File

@ -0,0 +1,166 @@
"""Dated inventory outlook, stockout detection, and the two reason codes.
Before this, the engine knew "cover is 26 days" but not "you run out on the 24th
and the next container lands on the 7th". The weekly projection carrying those
dates was fetched and rendered in a chart, and no decision ever read it.
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from dashboard.live_data import (
STOCKOUT_COVER_DAYS, _hist_frame, _inventory_outlook, _proj_date,
_stockout_days,
)
TODAY = date(2026, 7, 29)
def _snap(day, inventory, arrival=0.0):
return {"date": f"2026-{day[:2]}-{day[3:]}", "inventory": inventory,
"warehouse_arrival": arrival, "cover_days": None,
"inventory_value": None, "po_inventory": 0.0}
# ---------------------------------------------------------------- date parsing
@pytest.mark.parametrize("raw,expected", [
("2026-08-12", date(2026, 8, 12)), # ISO dataDate
("08/12/2026", date(2026, 8, 12)), # MM/DD/YYYY dateMap key
("2026-08-12T00:00:00", date(2026, 8, 12)), # timestamp
])
def test_parses_both_date_shapes_cosmos_mixes(raw, expected):
assert _proj_date(raw) == expected
@pytest.mark.parametrize("raw", ["", None, "not a date", "13/45/2026"])
def test_unparseable_dates_are_dropped_not_guessed(raw):
assert _proj_date(raw) is None
# ---------------------------------------------------------------- stockout date
def test_stockout_date_comes_from_the_projection_when_it_reaches_zero():
proj = [_snap("07-29", 900), _snap("08-05", 400), _snap("08-12", 0),
_snap("08-19", 0)]
out = _inventory_outlook(proj, units_day=70, today=TODAY)
assert out["stockout_date"] == date(2026, 8, 12)
assert out["stockout_source"] == "projection"
assert out["days_to_stockout"] == 14
def test_falls_back_to_velocity_when_the_curve_never_empties_and_says_so():
"""A projected date and a straight-line guess must not look alike."""
proj = [_snap("07-29", 700), _snap("08-05", 650), _snap("08-12", 600)]
out = _inventory_outlook(proj, units_day=70, today=TODAY)
assert out["stockout_source"] == "velocity"
assert out["stockout_date"] == date(2026, 8, 8) # 700 / 70 = 10 days
def test_no_velocity_fallback_when_stock_is_inbound():
"""A straight line from on-hand cannot see arrivals. Observed live: a SKU whose
projected inventory RISES 52k->63k on a 10,830-unit arrival was being reported
as running out in 30 days. With stock inbound the honest answer is "not within
the horizon", not an invented date the projection disproves."""
proj = [_snap("07-29", 52_448, arrival=10_830), _snap("08-05", 55_628),
_snap("08-11", 63_176)]
out = _inventory_outlook(proj, units_day=1_700, today=TODAY)
assert out["stockout_date"] is None
assert out["stockout_source"] is None
assert out["next_arrival_date"] == date(2026, 7, 29) # relief is still reported
def test_no_velocity_means_no_invented_date():
proj = [_snap("07-29", 700)]
assert _inventory_outlook(proj, units_day=0, today=TODAY)["stockout_date"] is None
def test_horizon_is_reported_so_no_stockout_is_not_read_as_never():
proj = [_snap("07-29", 700), _snap("09-30", 600)]
out = _inventory_outlook(proj, units_day=0, today=TODAY)
assert out["horizon_end"] == date(2026, 9, 30)
assert out["horizon_days"] == 63
def test_empty_projection_returns_all_none_rather_than_raising():
out = _inventory_outlook([], units_day=100, today=TODAY)
assert out["stockout_date"] is None and out["next_arrival_date"] is None
def test_out_of_order_snapshots_are_sorted_before_reading():
proj = [_snap("08-12", 0), _snap("07-29", 900), _snap("08-05", 400)]
out = _inventory_outlook(proj, units_day=70, today=TODAY)
assert out["stockout_date"] == date(2026, 8, 12)
# ---------------------------------------------------------------- arrivals
def test_next_arrival_is_the_first_material_one():
proj = [_snap("07-29", 900), _snap("08-05", 400, arrival=5),
_snap("08-12", 0, arrival=5_000)]
out = _inventory_outlook(proj, units_day=70, today=TODAY)
assert out["next_arrival_date"] == date(2026, 8, 12) # 5 units is not relief
assert out["next_arrival_units"] == 5_000
def test_a_trivial_arrival_does_not_count_as_relief():
proj = [_snap("07-29", 900), _snap("08-05", 400, arrival=10)]
assert _inventory_outlook(proj, units_day=70, today=TODAY)["next_arrival_date"] is None
def test_a_small_arrival_is_material_for_a_slow_mover():
"""Materiality is relative to demand — 10 units matters at 1 unit/day."""
proj = [_snap("07-29", 90), _snap("08-05", 40, arrival=10)]
out = _inventory_outlook(proj, units_day=1, today=TODAY)
assert out["next_arrival_date"] == date(2026, 8, 5)
# ---------------------------------------------------------------- stockout days
def _hist(inv_units):
return pd.DataFrame({"inventory": [i for i, _ in inv_units],
"units": [u for _, u in inv_units]})
def test_counts_days_below_one_day_of_cover_not_literal_zero():
"""Amazon's feed never reports a clean 0 — the audited SKU had 90/90 readings
and not one at zero, so an `inventory <= 0` test would never fire."""
oos, known = _stockout_days(_hist([(5, 100), (3, 100), (900, 100), (800, 100)]))
assert (oos, known) == (2, 4)
assert STOCKOUT_COVER_DAYS == 1.0
def test_healthy_stock_registers_no_stockout_days():
assert _stockout_days(_hist([(900, 100)] * 10)) == (0, 10)
def test_missing_readings_are_not_treated_as_empty_shelves():
"""0 of 0 and 0 of 30 are very different claims."""
assert _stockout_days(_hist([(None, 100)] * 10)) == (0, 0)
def test_a_frame_without_the_column_degrades_quietly():
assert _stockout_days(pd.DataFrame({"units": [1, 2]})) == (0, 0)
# ---------------------------------------------------------------- hist frame
class _Day:
def __init__(self, d, price, units, inventory):
self.date, self.sale_price, self.units = d, price, units
self.inventory = inventory
self.marketing_cost = self.promotion_spend = 0.0
self.revenue = self.profit = 0.0
def test_hist_frame_keeps_the_inventory_series():
"""It was silently dropped — the only historical stock data we get."""
hist = _hist_frame([_Day("07/28/2026", 26.0, 100, 8_833.0)], 26.0, TODAY)
assert "inventory" in hist.columns
assert hist.set_index("date").loc["2026-07-28", "inventory"] == 8_833.0
def test_missing_inventory_stays_nan_and_is_not_filled_with_zero():
"""Filling with 0 would manufacture phantom stockouts on every gap day."""
hist = _hist_frame([_Day("07/28/2026", 26.0, 100, None)], 26.0, TODAY)
assert hist["inventory"].isna().all()
# ...while the genuinely-zero-able columns still fill
assert hist["units"].notna().all()

View File

@ -0,0 +1,171 @@
"""The two inventory reason codes, and a guard that behaviour did not change.
`STOCKOUT_BEFORE_ARRIVAL` and `INBOUND_PO` had labels in REASON_LABELS and no
condition anywhere dead codes. They are ADDITIVE by design: the low-stock
branch already recommends +5%, which is the step cap, so there is nothing to
escalate to. These tests pin that intent, so a later reader does not "fix" the
missing escalation and silently start moving prices further.
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from dashboard.live_data import (
HIGH_COVER_DAYS, LOW_COVER_DAYS, REASON_LABELS, _decide_raw,
)
TODAY = date(2026, 7, 29)
SCEN = pd.DataFrame([
{"scenario": "current", "price": 26.07}, {"scenario": "up_2", "price": 26.59},
{"scenario": "up_5", "price": 27.37}, {"scenario": "down_2", "price": 25.55},
{"scenario": "down_5", "price": 24.77},
])
FP = {"referral_pct": 0.15, "returns_pct": 0.02, "cost": 9.28, "fba": 8.97,
"variable": 0.11}
class _R:
"""Minimal AnalysisResult stand-in."""
def __init__(self, cover, avg_30d=100.0, avg_6m=100.0):
self.cover_days = cover
self.break_even = 22.19
self.is_losing_money = False
self.avg_30d, self.avg_6m = avg_30d, avg_6m
self.ad_cost_per_unit = 1.58
self.elasticity = None
self.profit_optimal_price = None
self.ad_curve_unrecoverable = False
self.contribution_per_dollar = 0.64
self.break_even_ad_curve = None
self.observed_price_max = 28.87
self.price_bands = []
self.best_observed_price = None
self.best_observed_profit_day = None
self.elasticity_actionable = False
self.profit_optimal_is_corner = False
def _hist(days=40, inventory=9_000.0, units=100.0):
return pd.DataFrame({
"price": [26.07] * days, "units": [units] * days,
"ad_spend": [150.0] * days, "revenue": [2_600.0] * days,
"profit": [50.0] * days, "inventory": [inventory] * days,
})
def _decide(r, outlook=None, hist=None):
action, rec, cons, aggr, reasons, root, obj = _decide_raw(
r, 26.07, hist if hist is not None else _hist(), FP, SCEN, outlook)
return {"action": action, "rec": rec, "reasons": reasons, "objective": obj,
"root": dict(root)}
def _outlook(stockout=None, arrival=None, horizon_days=63):
return {"stockout_date": stockout, "next_arrival_date": arrival,
"stockout_source": "projection" if stockout else None,
"days_to_stockout": (stockout - TODAY).days if stockout else None,
"next_arrival_units": 5_000.0 if arrival else 0.0,
"horizon_days": horizon_days, "horizon_end": None}
# ---------------------------------------------------------------- labels exist
def test_both_codes_have_labels_so_the_ui_can_render_them():
assert REASON_LABELS["STOCKOUT_BEFORE_ARRIVAL"]
assert REASON_LABELS["INBOUND_PO"]
# ---------------------------------------------------------------- the codes fire
def test_stockout_before_arrival_fires_when_the_dates_cross():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 12), arrival=date(2026, 8, 26)))
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "14 days uncovered" in got["root"]["Stockout outlook"]
def test_it_does_not_fire_when_the_arrival_beats_the_stockout():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
assert "STOCKOUT_BEFORE_ARRIVAL" not in got["reasons"]
assert "arrives in time" in got["root"]["Stockout outlook"]
def test_it_fires_when_no_arrival_is_scheduled_at_all():
got = _decide(_R(cover=26), _outlook(stockout=date(2026, 8, 12), arrival=None))
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "no arrival scheduled" in got["root"]["Stockout outlook"]
def test_inbound_po_fires_on_a_scheduled_arrival():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
assert "INBOUND_PO" in got["reasons"]
def test_inbound_po_also_reported_on_overstock():
got = _decide(_R(cover=120), _outlook(arrival=date(2026, 8, 12)))
assert got["action"] == "Decrease"
assert {"EXCESS_STOCK", "INBOUND_PO"} <= set(got["reasons"])
# ------------------------------------------------- additive, NOT escalating
def test_the_codes_do_not_change_the_action_or_the_rung():
"""+5% is already MAX_STEP_PCT — there is nothing to escalate to. If this
test fails, someone added escalation; that needs an explicit policy decision,
not a quiet code change."""
plain = _decide(_R(cover=26), _outlook())
urgent = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 1), arrival=date(2026, 9, 30)))
assert plain["action"] == urgent["action"] == "Increase"
assert plain["rec"] == urgent["rec"] == "up_5"
assert plain["objective"] == urgent["objective"] == "low_stock_protection"
# ---------------------------------------------- stockout vs demand drop
def test_a_stockout_explained_drop_is_no_longer_an_unexplained_mystery():
"""Cutting price cannot refill a shelf. This used to land on Investigate."""
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0) # a real velocity drop
starved = _hist(days=30, inventory=5.0, units=100.0) # ...while out of stock
got = _decide(r, _outlook(), hist=starved)
assert got["action"] == "Maintain"
assert "UNEXPLAINED_DROP" not in got["reasons"]
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "enough to explain" in got["root"]["Days effectively out of stock (30d)"]
def test_a_drop_with_healthy_stock_is_still_investigated():
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
got = _decide(r, _outlook(), hist=_hist(days=30, inventory=9_000.0))
assert got["action"] == "Investigate"
assert "UNEXPLAINED_DROP" in got["reasons"]
def test_a_drop_with_no_inventory_readings_stays_investigated():
"""Absence of data is not evidence of a stockout."""
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
blind = _hist(days=30)
blind["inventory"] = None
got = _decide(r, _outlook(), hist=blind)
assert got["action"] == "Investigate"
# ---------------------------------------------- unchanged-behaviour guard
@pytest.mark.parametrize("cover", [LOW_COVER_DAYS + 1, 60, HIGH_COVER_DAYS - 1])
def test_healthy_cover_is_untouched_by_any_of_this(cover):
"""A SKU between the thresholds with no stockout must behave exactly as before."""
got = _decide(_R(cover=cover), _outlook())
assert got["action"] == "Maintain"
assert got["reasons"] == ["NO_SIGNALS"]
def test_thresholds_are_unchanged():
"""These were explicitly out of scope; pin them so a refactor cannot drift."""
assert (LOW_COVER_DAYS, HIGH_COVER_DAYS) == (35, 90)
def test_outlook_is_optional_so_older_callers_keep_working():
got = _decide(_R(cover=26), outlook=None)
assert got["action"] == "Increase" and "LOW_STOCK" in got["reasons"]

View File

@ -0,0 +1,98 @@
"""The Inventory Planning header must quote COSMOS's own figures, not ours re-derived.
The weekly cells always matched, because they come straight from the `invp-insight` dateMap.
The HEADER did not: it was recomputed from our sales-insight history, so one SKU read
56 / 62 / 65 here against 64 / 130 / 67 in COSMOS Inventory Planning -- same product, same
screen position, two different numbers.
Fixed by carrying COSMOS's fields through verbatim:
daily sale ours 7-day mean 56 -> COSMOS `averageSale` 64
middle cell ours 30-day mean 62 -> COSMOS `potentialDailySale` 130 (a DIFFERENT
quantity: a demand estimate, not a trailing mean)
third cell ours 90-day mean 65 -> COSMOS `averageSale6Months` 67
on hand first projected week 5,029 -> COSMOS `inventory` 3,999
inbound every arrival 1,770 -> the IMMEDIATE arrival 1,030
"""
from __future__ import annotations
import pathlib
SRC = pathlib.Path(__file__).resolve().parents[1] / "app.py"
TEXT = SRC.read_text(encoding="utf-8")
def _table_body() -> str:
return TEXT.split("def inventory_table_html")[1].split("\ndef ")[0]
def test_header_reads_cosmos_fields_not_history_means():
body = _table_body()
assert "invp_stats" in body, "header no longer reads COSMOS's own figures"
assert '_st.get("avg_sale")' in body
assert '_st.get("potential_daily_sale")' in body
assert '_st.get("avg_6m")' in body
def test_the_old_history_derived_cells_are_gone():
body = _table_body()
assert "{d30:,.0f}" not in body, "30-day history mean is back in the header"
assert "{d90:,.0f}" not in body, "90-day history mean is back in the header"
def test_on_hand_is_cosmos_inventory_not_the_first_projected_week():
"""5,029 is week one WITH its arrival folded in; 3,999 is the shelf."""
body = _table_body()
assert 'get("invp_stats") or {}).get("inventory")' in body
assert "on hand" in body
def test_inbound_is_the_immediate_arrival_with_later_ones_reported_separately():
body = _table_body()
assert 'total_inbound = float(proj[0]["warehouse_arrival"] or 0)' in body
assert "later_inbound" in body, "later arrivals must still be reported, not dropped"
# ---------------------------------------------------------------- encoding hygiene
# app.py and dashboard/live_data.py were once written back through a cp1252 round trip, so
# every em dash, middot and emoji in the UI rendered as garbage. It showed in the BROWSER, not
# just an editor, which makes it a user-visible defect rather than a tidiness one.
#
# The signatures are built with chr() rather than spelled out, so this file cannot match its
# own check.
_ROOT = pathlib.Path(__file__).resolve().parents[1]
_MOJIBAKE = (
chr(0xE2) + chr(0x20AC), # em/en dash, curly quotes, ellipsis
chr(0xC2) + chr(0xB7), # middot
chr(0xF0) + chr(0x178), # any 4-byte emoji
chr(0xC3) + chr(0xA2), # doubly encoded
)
_SKIP = {".venv", "__pycache__", "new_archtetcure_and frontend", ".mojibake_backup"}
def _py_sources():
for f in _ROOT.rglob("*.py"):
if not _SKIP.intersection(f.parts):
yield f
def test_no_source_file_is_mojibake():
bad = {}
for f in _py_sources():
text = f.read_bytes().decode("utf-8-sig")
hits = sum(text.count(m) for m in _MOJIBAKE)
if hits:
bad[str(f.relative_to(_ROOT))] = hits
assert not bad, f"mojibake in: {bad}"
def test_no_source_file_has_a_bom():
"""A BOM breaks `ast.parse` on a utf-8 read and shows as a stray char in some editors."""
bom = bytes([0xEF, 0xBB, 0xBF])
bad = [str(f.relative_to(_ROOT)) for f in _py_sources() if f.read_bytes()[:3] == bom]
assert not bad, f"UTF-8 BOM in: {bad}"
def test_every_source_file_is_valid_utf8():
for f in _py_sources():
f.read_bytes().decode("utf-8")

90
tests/test_line_picker.py Normal file
View File

@ -0,0 +1,90 @@
"""Product-line discovery and ranking (pure, no network)."""
from __future__ import annotations
import pytest
from dashboard.line import LINE_SORTS, rank
from pricing_agent.cosmos.models import LineProduct
def _make(sku, inv=0.0, avg30=0.0, cover=None, minpo=0.0) -> LineProduct:
p = LineProduct.model_validate({
"sku": sku, "inventory": inv, "averageSale30Days": avg30,
"minPoQuantity": minpo})
p.cover_days = cover
return p
# ---------------------------------------------------------------- size parsing
@pytest.mark.parametrize("sku,expected", [
("UBMICROFIBERDUVETQUEENWHITE", "Queen"),
("UBMICROFIBERDUVETKINGWHITE2", "King"),
("UBMICROFIBERDUVETTWINGREY", "Twin"),
("UBMICROFIBERDUVETFULLNAVY", "Full"),
("UBMICROFIBERDUVETCALKINGWHITE", "Calking"),
("UBSOMETHINGELSE", "Other"),
])
def test_size_is_read_from_the_sku(sku, expected):
assert _make(sku).size == expected
def test_calking_is_not_mistaken_for_king():
"""CALKING contains KING, so the order tokens are matched in matters."""
assert _make("UBMICROFIBERDUVETCALKINGGREY").size == "Calking"
def test_line_product_maps_the_cosmos_aliases():
p = LineProduct.model_validate({
"sku": "UBMICROFIBERDUVETQUEENWHITE", "asin": "B00NWPUKMS",
"inventory": 6623, "averageSale30Days": 188, "averageSale6Months": 193,
"minPoQuantity": 11280, "parentAsin": "B0DMGS61XY"})
assert (p.inventory, p.avg_30d, p.avg_6m, p.min_po_quantity) == (
6623, 188, 193, 11280)
assert p.asin == "B00NWPUKMS" and p.parent_asin == "B0DMGS61XY"
# ---------------------------------------------------------------- ranking
ROWS = [
{"sku": "B_MID", "inventory": 500.0, "avg_30d": 20.0, "cover_days": 90,
"min_po_quantity": 0.0},
{"sku": "A_TOP", "inventory": 100.0, "avg_30d": 50.0, "cover_days": 5,
"min_po_quantity": 900.0},
{"sku": "C_DEAD", "inventory": 0.0, "avg_30d": 0.0, "cover_days": None,
"min_po_quantity": 0.0},
]
def _order(label):
return [r["sku"] for r in rank(ROWS, label)]
def test_rank_by_velocity_puts_the_biggest_seller_first():
assert _order("Sales velocity (30d)")[0] == "A_TOP"
def test_rank_by_inventory_puts_the_deepest_stock_first():
assert _order("Inventory on hand")[0] == "B_MID"
def test_rank_by_cover_puts_the_most_urgent_first():
assert _order("Cover days — lowest first")[0] == "A_TOP"
def test_missing_values_sort_last_not_first():
"""A SKU with no cover reading must not be presented as the most urgent."""
assert _order("Cover days — lowest first")[-1] == "C_DEAD"
def test_alphabetical_sorts_text_without_blowing_up():
"""A purely numeric sort key compares str against int and raises here."""
assert _order("AZ") == ["A_TOP", "B_MID", "C_DEAD"]
def test_every_offered_sort_is_usable():
"""Each option in the picker must order the pool without raising."""
for label in LINE_SORTS:
assert len(_order(label)) == len(ROWS)
def test_unknown_sort_label_falls_back_instead_of_raising():
assert len(rank(ROWS, "not a real sort")) == len(ROWS)

View File

@ -0,0 +1,91 @@
"""Golden-value tests for the margin engine.
These assert the exact worked example from the Pricing Analyst playbook (SOP A):
break-even $16.76, MAP $19.00, contribution margin ~28% on the microfiber sheet set.
If these ever drift, the pricing gate's math has changed — fail loudly.
"""
from __future__ import annotations
import pytest
from config.settings import PricingRules
from pricing_agent.tools.margin_engine import (
compute_fee_stack,
suggest_price_for_floor,
worst_case_stack,
)
RULES = PricingRules(
margin_floor=0.25,
margin_target=0.30,
returns_reserve_pct=0.02,
storage_alloc=0.25,
worst_case_referral_pct=0.15,
charm_ending=0.99,
)
# Playbook worked example inputs.
SHEET_SET = dict(selling_price=24.99, landed_cost=8.00, fba_fee=5.50)
def test_break_even_matches_playbook():
stack = worst_case_stack(rules=RULES, **SHEET_SET)
assert round(stack.break_even_price, 2) == 16.76
def test_map_matches_playbook():
stack = worst_case_stack(rules=RULES, **SHEET_SET)
assert round(stack.map_floor, 2) == 19.00
def test_contribution_margin_is_about_28pct():
stack = worst_case_stack(rules=RULES, **SHEET_SET)
assert stack.contribution_margin_pct == pytest.approx(0.28, abs=0.005)
def test_fee_components_sum_to_total_cost():
stack = worst_case_stack(rules=RULES, **SHEET_SET)
recomputed = (
stack.landed_cost
+ stack.fba_fee
+ stack.referral_amt
+ stack.returns_reserve
+ stack.storage_alloc
)
assert stack.total_cost == pytest.approx(recomputed, abs=1e-6)
assert stack.profit == pytest.approx(stack.selling_price - stack.total_cost, abs=1e-6)
def test_price_below_break_even_is_loss_making():
# At $16.00 (< $16.76 break-even) contribution must be negative.
stack = worst_case_stack(selling_price=16.00, landed_cost=8.00, fba_fee=5.50, rules=RULES)
assert stack.profit < 0
assert stack.contribution_margin_pct < 0
def test_modeled_referral_beats_worst_case():
# Modeled 12% referral should yield higher margin than worst-case 15%.
worst = worst_case_stack(rules=RULES, **SHEET_SET)
modeled = compute_fee_stack(
referral_pct=0.12, rules=RULES, referral_basis="modeled", **SHEET_SET
)
assert modeled.contribution_margin_pct > worst.contribution_margin_pct
def test_suggest_price_for_floor_clears_floor():
price = suggest_price_for_floor(landed_cost=8.00, fba_fee=5.50, rules=RULES)
stack = worst_case_stack(selling_price=price, landed_cost=8.00, fba_fee=5.50, rules=RULES)
assert stack.contribution_margin_pct >= RULES.margin_floor
# charm pricing => ends in .99
assert round(price - int(price), 2) == 0.99
def test_invalid_inputs_raise():
with pytest.raises(ValueError):
compute_fee_stack(
selling_price=0, landed_cost=8, fba_fee=5.5, referral_pct=0.15, rules=RULES
)
with pytest.raises(ValueError):
compute_fee_stack(
selling_price=24.99, landed_cost=8, fba_fee=5.5, referral_pct=1.5, rules=RULES
)

130
tests/test_performance.py Normal file
View File

@ -0,0 +1,130 @@
"""Tests for the evidence-based price-performance functions (pure, no network)."""
from __future__ import annotations
from pricing_agent.cosmos.models import SalesDay
from pricing_agent.performance import (
best_observed,
fix_suggestion,
loss_reason,
monthly_performance,
price_band_performance,
recent_profit_per_unit,
reconcile,
root_cause_split,
unprofitable_months,
)
def _day(date, price, units, profit, ad=0.0):
return SalesDay(date=date, salePrice=price, unitOrders=units, profit=profit,
marketingCost=ad)
# Two months: June profitable at $20, July loss-making at $18.
HISTORY = (
[_day(f"06/{d:02d}/2026", 20.0, 100, 500.0, 100.0) for d in range(1, 11)]
+ [_day(f"07/{d:02d}/2026", 18.0, 120, -240.0, 150.0) for d in range(1, 11)]
)
def test_monthly_performance_uses_actual_profit():
m = monthly_performance(HISTORY)
assert [x["month"] for x in m] == ["2026-06", "2026-07"]
jun, jul = m
assert jun["avg_price"] == 20.0 and jun["units_per_day"] == 100
assert jun["actual_profit_per_day"] == 500.0
assert jun["profit_per_unit"] == 5.0 # 500 / 100
assert jul["actual_profit_per_day"] == -240.0
assert jul["profit_per_unit"] == -2.0
def test_unprofitable_months_counts_losses():
assert unprofitable_months(monthly_performance(HISTORY)) == 1
def test_price_bands_pool_by_price_not_month():
bands = price_band_performance(HISTORY, band=0.50, min_days=5)
prices = [b["price_band"] for b in bands]
assert prices == [18.0, 20.0]
hi = next(b for b in bands if b["price_band"] == 20.0)
assert hi["days"] == 10 and hi["enough_data"] is True
assert hi["actual_profit_per_day"] == 500.0
def test_best_observed_picks_max_actual_profit():
bands = price_band_performance(HISTORY, band=0.50, min_days=5)
best = best_observed(bands)
assert best["price_band"] == 20.0 # not the cheaper, higher-volume $18
assert best["actual_profit_per_day"] == 500.0
def test_best_observed_respects_min_days():
# Only 3 days at $20 -> not enough sample; $18 has 10 days.
hist = ([_day(f"06/{d:02d}/2026", 20.0, 100, 900.0) for d in range(1, 4)]
+ [_day(f"07/{d:02d}/2026", 18.0, 120, 100.0) for d in range(1, 11)])
bands = price_band_performance(hist, band=0.50, min_days=7)
best = best_observed(bands)
assert best["price_band"] == 18.0 # the richer band is ineligible
def test_best_observed_none_when_no_sample():
hist = [_day("06/01/2026", 20.0, 10, 5.0)]
assert best_observed(price_band_performance(hist, min_days=7)) is None
def test_best_observed_can_still_be_loss_making():
"""A SKU unprofitable at every observed price still returns its least-bad band."""
hist = ([_day(f"06/{d:02d}/2026", 20.0, 100, -100.0) for d in range(1, 11)]
+ [_day(f"07/{d:02d}/2026", 18.0, 120, -400.0) for d in range(1, 11)])
best = best_observed(price_band_performance(hist, min_days=5))
assert best["price_band"] == 20.0
assert best["actual_profit_per_day"] < 0
def test_reconcile_reports_model_overstatement():
# Model says $2.00/unit, reality is $0.50 -> model overstates by $1.50.
assert reconcile(0.50, 2.00) == 1.50
assert reconcile(None, 2.00) is None
def test_recent_profit_per_unit_is_units_weighted():
m = monthly_performance(HISTORY)
# last 1 month = July: -240/day over 10 days / (120*10 units) = -2.00
assert recent_profit_per_unit(m, months=1) == -2.0
# ── Diagnosis: WHY it loses, and the fix ─────────────────────────────────
def test_reason_blames_ads_when_profitable_before_ads():
# -2.50/u after 3.01/u ads => +0.51/u before ads -> ads are the cause.
r = loss_reason(-2.50, 3.01, price=15.28)
assert r.startswith("ADS:") and "$0.51" in r and "20% of price" in r
def test_reason_blames_cost_when_underwater_before_ads():
# -4.56/u after 2.19/u ads => -2.37/u before ads -> below cost.
r = loss_reason(-4.56, 2.19, price=14.19)
assert r.startswith("BELOW COST:") and "$2.37" in r
def test_fix_for_ads_cause_is_an_ad_cut_no_price_change():
f = fix_suggestion(-2.50, 3.01, price=15.28)
assert "Cut ad/unit" in f and "$0.51" in f and "no price change" in f
def test_fix_for_below_cost_is_a_price_raise_sized_to_the_gap():
# gap = 4.56/u; uplift = 4.56/0.83 = 5.49 -> target ~ 14.19 + 5.49 = 19.68
f = fix_suggestion(-4.56, 2.19, price=14.19)
assert "+$4.56/u" in f and "$19.6" in f
def test_healthy_sku_needs_no_fix():
assert loss_reason(1.20, 0.30) == "healthy"
assert fix_suggestion(1.20, 0.30) == ""
def test_root_cause_split_uses_profit_before_ads():
ads, cost = root_cause_split([
{"profit_per_unit": -2.50, "ad_per_unit": 3.01}, # +0.51 before ads -> ads
{"profit_per_unit": -4.56, "ad_per_unit": 2.19}, # -2.37 before ads -> cost
])
assert len(ads) == 1 and len(cost) == 1

View File

@ -0,0 +1,290 @@
"""Safety and statistical-honesty invariants for the pricing engine.
Each test here corresponds to a way the engine was previously able to publish a
number it could not justify.
"""
from __future__ import annotations
import math
import pandas as pd
import pytest
from pricing_agent.elasticity import (
estimate_elasticity, optimize_price, unconstrained_optimum,
)
from pricing_agent.performance import (
ad_cost_model, ad_per_unit_at, empirical_break_even, observed_price_range,
weighted_fit,
)
from dashboard.live_data import _order_ladder, _scenario_economics
# ---------------------------------------------------------------- fixtures
def _band(price, units, ppu, ad, days, enough=True):
return {"price_band": price, "avg_price": price, "units_per_day": units,
"actual_profit_per_day": round(ppu * units, 2), "profit_per_unit": ppu,
"ad_per_unit": ad, "days": days, "enough_data": enough}
# Shape taken from a real SKU: losing money low, profitable high, ad/unit rising.
BANDS = [
_band(23.38, 2224, -0.66, 1.27, 56),
_band(26.05, 1758, 0.64, 2.00, 13),
_band(26.61, 1198, 1.41, 1.89, 19),
_band(27.43, 1618, 1.68, 2.07, 20),
_band(28.01, 1342, 2.94, 1.64, 12),
_band(28.87, 1448, 2.15, 2.37, 54),
_band(29.41, 1398, 4.07, 1.67, 2, enough=False), # thin — must be ignored
]
def _months(pairs):
return [{"month": f"2026-{i+1:02d}", "avg_price": p, "units_per_day": u,
"ad_per_unit": 1.5, "days": d} for i, (p, u, d) in enumerate(pairs)]
# ---------------------------------------------------------------- weighted fit
def test_weighted_fit_respects_days():
"""A 2-day observation must not sway the line like a 50-day one."""
pts_equal = [(1.0, 1.0, 1), (2.0, 2.0, 1), (3.0, 30.0, 1)]
pts_weighted = [(1.0, 1.0, 50), (2.0, 2.0, 50), (3.0, 30.0, 1)]
assert weighted_fit(pts_weighted)["slope"] < weighted_fit(pts_equal)["slope"]
def test_weighted_fit_needs_three_points():
assert weighted_fit([(1.0, 1.0, 5), (2.0, 2.0, 5)]) is None
# ---------------------------------------------------------------- break-even
def test_empirical_break_even_sits_between_loss_and_profit_bands():
be = empirical_break_even(BANDS)
assert be is not None
assert 23.38 < be["price"] < 26.61, be
assert be["r2"] > 0.5
def test_empirical_break_even_excludes_thin_bands():
"""The 2-day band is the most profitable one; it must not move the fit."""
with_thin = empirical_break_even(BANDS)
without = empirical_break_even([b for b in BANDS if b["enough_data"]])
assert with_thin == without
def test_empirical_break_even_none_without_spread():
flat = [_band(20.0, 100, 1.0, 1.0, 30) for _ in range(3)]
assert empirical_break_even(flat) is None
# ---------------------------------------------------------------- ad model
def test_ad_cost_rises_with_price():
m = ad_cost_model(BANDS)
assert m is not None and m["slope"] > 0, m
def test_ad_prediction_never_negative_and_falls_back_when_fit_is_weak():
noisy = [_band(20 + i, 100, 1.0, 2.0 if i % 2 else 0.2, 30) for i in range(5)]
weak = ad_cost_model(noisy)
assert ad_per_unit_at(25.0, weak, fallback=1.75) == 1.75
assert ad_per_unit_at(0.01, ad_cost_model(BANDS), fallback=1.0) >= 0.0
def test_observed_range_uses_only_sampled_bands():
lo, hi = observed_price_range(BANDS)
assert (lo, hi) == (23.38, 28.87) # the 2-day $29.41 band is excluded
# ---------------------------------------------------------------- elasticity
def test_elasticity_reports_a_confidence_interval():
el = estimate_elasticity(_months([(24.94, 1704, 28), (27.89, 1174, 31),
(25.26, 1845, 30), (25.52, 2013, 31),
(28.04, 1821, 30), (24.96, 1715, 27)]))
assert el is not None
assert {"std_err", "t_stat", "ci_low", "ci_high", "actionable"} <= set(el)
assert el["ci_low"] <= el["elasticity"] <= el["ci_high"]
def test_noisy_elasticity_is_not_actionable():
"""The real SKU's fit: a slope whose CI spans zero must not drive price."""
el = estimate_elasticity(_months([(24.94, 1704, 28), (27.89, 1174, 31),
(25.26, 1845, 30), (25.52, 2013, 31),
(28.04, 1821, 30), (24.96, 1715, 27)]))
assert el["actionable"] is False
assert el["ci_low"] < 0 < el["ci_high"]
assert "why" in el
def test_clean_elasticity_is_actionable():
clean = _months([(20.0, 2000, 30), (22.0, 1500, 30), (24.0, 1180, 30),
(26.0, 950, 30), (28.0, 790, 30), (30.0, 670, 30)])
el = estimate_elasticity(clean)
assert el["actionable"] is True
assert el["ci_high"] < 0 # the whole interval is negative
assert el["elasticity"] < -1
def test_short_stub_periods_are_dropped():
"""A 3-day calendar stub must not be weighted like a full month."""
full = [(24.94, 1704, 28), (27.89, 1174, 31), (25.26, 1845, 30),
(25.52, 2013, 31), (28.04, 1821, 30), (24.96, 1715, 27)]
with_stub = estimate_elasticity(_months([(26.63, 1092, 3)] + full))
assert with_stub["n"] == 6 # the 3-day point is gone
assert with_stub["days"] == sum(d for _, _, d in full)
# ---------------------------------------------------------------- optimizer
def test_corner_solution_is_flagged():
"""Profit rising to the last grid point is not a maximum."""
best, _ = optimize_price(floor=22.45, ceiling=31.45, ref_price=24.96,
ref_units=1715, elasticity=-2.22,
net_profit_fn=lambda p: p * 0.8308 - 20.01, step=1.0)
assert best["price"] == 31.45
assert best["is_corner"] is True
def test_interior_maximum_is_not_flagged_as_corner():
"""Given room to find a real peak, the sweep stops short of its ceiling."""
best, table = optimize_price(floor=20.0, ceiling=60.0, ref_price=24.96,
ref_units=1715, elasticity=-2.22,
net_profit_fn=lambda p: p * 0.8308 - 20.01, step=1.0)
assert best["is_corner"] is False
assert best["price"] < table[-1]["price"]
def test_closed_form_agrees_with_the_sweeps_argmax():
"""The sweep returns a 3%-tiebreak price (cheapest within 3% of the peak), so
compare against its raw argmax that is what the closed form solves for."""
p_star = unconstrained_optimum(elasticity=-2.22, margin_rate=0.8308,
fixed_per_unit=20.01)
_, table = optimize_price(floor=20.0, ceiling=80.0, ref_price=24.96,
ref_units=1715, elasticity=-2.22,
net_profit_fn=lambda p: p * 0.8308 - 20.01, step=0.25)
argmax = max(table, key=lambda r: r["daily_profit"])
assert abs(p_star - argmax["price"]) < 0.5
def test_tiebreak_price_stays_within_3pct_of_peak_profit():
best, table = optimize_price(floor=20.0, ceiling=80.0, ref_price=24.96,
ref_units=1715, elasticity=-2.22,
net_profit_fn=lambda p: p * 0.8308 - 20.01, step=0.25)
peak = max(r["daily_profit"] for r in table)
assert best["daily_profit"] >= peak * 0.97
assert best["price"] <= unconstrained_optimum(
elasticity=-2.22, margin_rate=0.8308, fixed_per_unit=20.01)
def test_no_interior_optimum_when_demand_is_inelastic():
assert unconstrained_optimum(elasticity=-0.9, margin_rate=0.83,
fixed_per_unit=20.0) is None
def test_optimizer_survives_a_sku_that_loses_money_at_every_price():
"""`peak * 0.97` raises the bar ABOVE a negative peak, emptying the tiebreak
candidate list. That crash took the whole evidence block down with it."""
best, table = optimize_price(floor=14.0, ceiling=17.0, ref_price=15.99,
ref_units=624, elasticity=-1.3,
net_profit_fn=lambda p: p * 0.83 - 20.0, step=0.5)
assert all(r["daily_profit"] < 0 for r in table)
assert best is not None
assert best["daily_profit"] == max(r["daily_profit"] for r in table)
def test_tiebreak_still_prefers_the_cheaper_price_on_a_genuine_tie():
"""Flat profit per unit and flat demand → every price ties, so take the lowest."""
best, table = optimize_price(floor=20.0, ceiling=24.0, ref_price=20.0,
ref_units=100, elasticity=0.0,
net_profit_fn=lambda p: 1.0, step=1.0)
assert len({r["daily_profit"] for r in table}) == 1 # a real tie
assert best["price"] == 20.0
assert best["is_corner"] is False
# ---------------------------------------------------------------- scenarios
FP = {"referral_pct": 0.15, "returns_pct": 0.0192, "cost": 9.28, "fba": 8.97,
"variable": 0.11}
def _econ(**kw):
grid = {"current": 26.07, "up_2": 26.59, "up_5": 27.37, "down_5": 24.77}
defaults = dict(cur_price=26.07, units_day=1720.6, fp=FP, elasticity=-2.22,
tacos_frac=0.0631, storage_30d=1003.0, grid=grid,
actual_30d=14300.0, actual_rev=1293993.0, actual_ad=81698.0,
actual_units=51618.0, realized_price=25.07)
defaults.update(kw)
return _scenario_economics(**defaults)
def test_every_row_reconciles_revenue_to_units_times_price():
rows, _ = _econ()
for x in rows:
if x["is_actual"]:
continue
assert x["revenue_30d"] == pytest.approx(x["units_30d"] * x["price"], rel=0.01), x
def test_modelled_revenue_is_monotone_in_price_when_elastic():
"""With e < -1, revenue must FALL as price rises. Mixing an actual current row
with list-anchored projections used to make this flip."""
rows, _ = _econ()
proj = sorted((x for x in rows if not x["is_actual"]), key=lambda x: x["price"])
revs = [x["revenue_30d"] for x in proj]
assert revs == sorted(revs, reverse=True), proj
def test_current_row_keeps_both_actual_and_modelled_readings():
rows, meta = _econ()
cur = next(x for x in rows if x["key"] == "current")
assert cur["net_30d"] == 14300 # COSMOS fact
assert "modelled_net_30d" in cur # and its like-for-like twin
assert cur["price"] == pytest.approx(25.07) # what buyers actually paid
assert cur["list_price"] == pytest.approx(26.07)
assert meta["anchor_price"] == pytest.approx(25.07)
def test_no_realization_factor_is_applied():
_, meta = _econ()
assert meta["factor"] == 1.0 and meta["calibrated"] is False
def test_ad_spend_rises_with_price_when_the_model_says_so():
rows, _ = _econ(ad_model=ad_cost_model(BANDS))
proj = sorted((x for x in rows if not x["is_actual"]), key=lambda x: x["price"])
per_unit = [x["ad_per_unit"] for x in proj]
assert per_unit == sorted(per_unit), proj
def test_observed_prices_are_marked_as_evidence():
grid = {"current": 26.07, "tested": 28.87, "untested": 31.45}
rows, _ = _econ(grid=grid, bands=BANDS)
by = {x["key"]: x for x in rows}
assert by["tested"]["observed_days"] == 54
assert by["tested"]["observed_net_30d"] == round(2.15 * 1448 * 30)
assert by["untested"]["observed_days"] == 0
assert by["untested"]["observed_net_30d"] is None
# ---------------------------------------------------------------- ladder
LADDER_SCEN = pd.DataFrame([
{"scenario": "current", "price": 26.07}, {"scenario": "up_2", "price": 26.59},
{"scenario": "up_5", "price": 27.37}, {"scenario": "down_2", "price": 25.55},
{"scenario": "down_5", "price": 24.77}, {"scenario": "max_profit", "price": 31.45},
])
@pytest.mark.parametrize("action,rec,cons,aggr", [
("Increase", "max_profit", "up_2", "up_5"), # the real regression
("Increase", "up_5", "up_2", "up_5"),
("Decrease", "down_5", "down_2", "down_5"),
])
def test_ladder_is_always_ordered(action, rec, cons, aggr):
p = dict(zip(LADDER_SCEN.scenario, LADDER_SCEN.price))
c, a = _order_ladder(action, rec, cons, aggr, LADDER_SCEN, 26.07)
order = [p[c], p[rec], p[a]]
assert order == (sorted(order) if action == "Increase"
else sorted(order, reverse=True)), order
def test_ladder_untouched_for_hold_actions():
assert _order_ladder("Maintain", "current", "current", "up_2",
LADDER_SCEN, 26.07) == ("current", "up_2")

View File

@ -0,0 +1,126 @@
"""Re-projecting the shelf at a different price.
The Inventory tab shows COSMOS's forecast at TODAY's price, so a recommendation to raise or
cut is otherwise displayed with no stock consequence at all. On a SKU near a stockout that is
the difference between a sound price move and an expensive one.
The invariants that make this safe to show:
* a RAISE leaves more stock, a CUT leaves less -- the sign must never invert;
* week 0 is today's shelf and is untouched by price;
* arrivals are carried through exactly (a placed PO does not move because we re-priced);
* stock never goes negative;
* both cover columns use OUR velocity, so their difference is the price effect alone;
* bad inputs return [] rather than a fabricated table.
"""
from __future__ import annotations
import pytest
from dashboard.live_data import reproject_inventory
def proj(units, arrivals=None, per_unit=4.45, cover=None):
"""A COSMOS-shaped weekly projection."""
arrivals = arrivals or [0] * len(units)
return [{
"date": f"2026-08-{i + 1:02d}",
"inventory": u,
"inventory_value": u * per_unit,
"cover_days": (cover[i] if cover else None),
"warehouse_arrival": arrivals[i],
} for i, u in enumerate(units)]
# A shelf drawing down 100 units a week from 1,000, on 10 units/day.
STEADY = proj([1000, 930, 860, 790, 720])
def test_a_raise_leaves_more_stock_on_the_shelf():
rows = reproject_inventory(STEADY, units_day_now=10.0, units_day_new=8.0)
assert rows[-1]["units_new"] > rows[-1]["units_now"]
assert rows[-1]["delta_units"] > 0
# ...and monotonically so: every week after the first is at least as good.
assert all(r["delta_units"] >= 0 for r in rows)
def test_a_cut_leaves_less_stock_on_the_shelf():
rows = reproject_inventory(STEADY, units_day_now=10.0, units_day_new=12.0)
assert rows[-1]["units_new"] < rows[-1]["units_now"]
assert all(r["delta_units"] <= 0 for r in rows)
def test_week_zero_is_todays_shelf_and_price_cannot_change_it():
"""We have not sold anything yet at the new price."""
for new in (5.0, 10.0, 25.0):
rows = reproject_inventory(STEADY, 10.0, new)
assert rows[0]["units_new"] == rows[0]["units_now"] == 1000
def test_no_change_in_rate_reproduces_cosmos_exactly():
"""The identity case. If this drifts, every other number here is suspect."""
rows = reproject_inventory(STEADY, 10.0, 10.0)
assert [r["units_new"] for r in rows] == [1000, 930, 860, 790, 720]
assert all(r["delta_units"] == 0 for r in rows)
def test_arrivals_are_carried_through_untouched():
"""A purchase order already placed does not move because we re-priced."""
p = proj([1000, 950, 1400, 1350], arrivals=[0, 0, 500, 0])
rows = reproject_inventory(p, 10.0, 7.0)
assert [r["arrival"] for r in rows] == [0, 0, 500, 0]
# The arrival still lands in full even though the draw around it shrank.
assert rows[2]["units_new"] > rows[1]["units_new"]
def test_stock_is_floored_at_zero_and_the_week_is_flagged():
"""A negative shelf is not a forecast."""
p = proj([300, 200, 100, 0])
rows = reproject_inventory(p, 10.0, 40.0) # 4x the burn
assert all(r["units_new"] >= 0 for r in rows)
assert any(r["stockout"] for r in rows)
def test_a_flat_cosmos_projection_has_no_draw_to_rescale():
"""Real case: COSMOS returns the same 167 units for all 11 weeks on a slow SKU.
There is no depletion to scale, so units cannot move at any price. The UI says so rather
than showing an unexplained column of zeros.
"""
rows = reproject_inventory(proj([167] * 5), 0.5, 2.0)
assert all(r["delta_units"] == 0 for r in rows)
# Cover still responds, because it is computed from our velocity.
assert rows[0]["cover_new"] < rows[0]["cover_now"]
def test_both_cover_columns_use_our_velocity_not_cosmos_own():
"""Mixing bases made a 5% RAISE look like it SHRANK cover, 78 -> 76.
COSMOS's `coverDays` divides by its own 7-day average; the new column divides by the
modelled rate. Side by side that difference is the divisor, not the price.
"""
p = proj([1000, 930], cover=[78, 72]) # COSMOS's own figures
rows = reproject_inventory(p, 10.0, 8.0) # a raise: cover must STRETCH
assert rows[0]["cover_now"] == 100 # 1000 / 10, ours
assert rows[0]["cover_new"] == 125 # 1000 / 8, ours
assert rows[0]["cover_new"] > rows[0]["cover_now"]
# COSMOS's figure is kept alongside, never mixed into the comparison.
assert rows[0]["cover_cosmos"] == 78
@pytest.mark.parametrize("projections,now,new", [
([], 10.0, 8.0), # no projection from COSMOS
(STEADY, 0.0, 8.0), # no current velocity -> ratio undefined
(STEADY, 10.0, 0.0), # no projected velocity
(STEADY, -1.0, 8.0), # nonsense input
])
def test_unusable_inputs_return_nothing_rather_than_a_guess(projections, now, new):
assert reproject_inventory(projections, now, new) == []
def test_value_stays_on_cosmos_cost_basis():
"""Scaled with units at COSMOS's own $/unit, not re-derived from our COGS."""
rows = reproject_inventory(proj([1000, 900], per_unit=4.45), 10.0, 5.0)
assert rows[0]["value_new"] == pytest.approx(4450, abs=1)
# week 1 keeps half the draw, so more units and proportionally more value
assert rows[1]["value_new"] > 900 * 4.45

View File

@ -0,0 +1,74 @@
"""The bulk calculator reports storage PER DAY, not per month.
Consuming it as a monthly figure understated storage 30x $971 vs $29,127 per
30 days on one SKU, roughly $0.55/unit against a $1.36/unit unexplained profit
gap. It is an invisible error (the number looks plausible, just small), so it is
pinned here rather than left to a comment.
How it was established, so a future reader can re-derive it:
* `storageCharges` scales linearly with `inventory` and is completely
independent of `saleUnits` identical at 100 / 1,000 / 10,000 / 43,007.
* Backing the rate out of item volume gives $0.7800/cu ft/month on two
independent SKUs to four decimal places, which is Amazon's exact
standard-size Jan-Sep FBA MONTHLY storage rate.
"""
from __future__ import annotations
import pytest
from dashboard.live_data import _storage_30d
class _Quote:
def __init__(self, storage):
self.storage_charges = storage
class _Svc:
"""Records the call and returns a fixed per-day storage charge."""
def __init__(self, storage=32.36, boom=False):
self.storage, self.boom, self.calls = storage, boom, []
def bulk_quote(self, sku, price, sale_units, inventory, marketing=0.0):
if self.boom:
raise RuntimeError("bulk calculator down")
self.calls.append({"sku": sku, "price": price, "sale_units": sale_units,
"inventory": inventory})
return _Quote(self.storage)
def test_daily_charge_is_scaled_to_the_window():
"""32.36/day over 30 days is 970.80 — not 32.36."""
svc = _Svc(storage=32.36)
assert _storage_30d(svc, "SKU", 26.07, 51_618, 43_007) == pytest.approx(970.80)
def test_the_real_world_case_that_exposed_the_bug():
"""$970.91/day of storage is $29,127 per 30 days, not $971."""
svc = _Svc(storage=970.91)
got = _storage_30d(svc, "UBMICROFIBERGUSSETPILLOWWHITEQUEEN", 26.07, 51_618, 43_007)
assert got == pytest.approx(29_127.30)
assert got - 970.91 == pytest.approx(28_156.39) # what the bug was hiding
@pytest.mark.parametrize("days", [7, 14, 30, 90, 180])
def test_scales_linearly_with_the_window(days):
svc = _Svc(storage=10.0)
assert _storage_30d(svc, "SKU", 20.0, 1000, 5000, days=days) == pytest.approx(10.0 * days)
def test_zero_storage_stays_zero():
assert _storage_30d(_Svc(storage=0.0), "SKU", 20.0, 1000, 5000) == 0.0
def test_a_failed_lookup_degrades_to_zero_rather_than_raising():
"""A storage lookup failure must not take down the whole analysis."""
assert _storage_30d(_Svc(boom=True), "SKU", 20.0, 1000, 5000) == 0.0
def test_inventory_and_units_are_floored_at_one():
"""The calculator rejects zero, and a new SKU legitimately has neither."""
svc = _Svc()
_storage_30d(svc, "SKU", 20.0, units_30d=0, inventory=0)
assert svc.calls[0]["sale_units"] == 1 and svc.calls[0]["inventory"] == 1