first commit
commit
7644202e77
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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 25–35 min batch run, not a live feed, so
|
||||
it gets a longer limit than the 6 h single-ASIN one).
|
||||
|
||||
**Two bases, never conflated.** `competitor_min` can be a price of two different things, and the
|
||||
`basis` field records which:
|
||||
|
||||
- `same-asin-buybox` (Apify) — another seller's offer on **our own listing**. Only `LOST_PRICE`
|
||||
fires the undercut rule; a rival holding the Buy Box *above* us is `LOST_ELIGIBILITY`, where
|
||||
cutting donates margin.
|
||||
- `like-for-like-sheet` — a **rival brand's** equivalent variant, matched on size + colour. A
|
||||
cheaper one fires the rule **regardless of who owns our Buy Box**: we can hold ours perfectly
|
||||
well while a different product undercuts us. This is what the comparison tool exists to
|
||||
report. It is never labelled a Buy Box loss.
|
||||
|
||||
Two sheet-driven refinements, both from real rows:
|
||||
|
||||
- **A rival whose own Buy Box is suppressed is excluded from the band.** Their price is not
|
||||
buyable, so undercutting it donates margin for nothing.
|
||||
- **A material undercut now explains a velocity drop.** Previously a drop with no *own* price
|
||||
change and no ad collapse was filed `UNEXPLAINED_DROP` even when the sheet held the
|
||||
explanation — observed on `UBMICROFIBERDUVETKINGPURPLE` (rival 15.6% below) and
|
||||
`UBMICROFIBERBS4PCFULLGREY` (35.2% below). A named cause now converts the Investigate into an
|
||||
actionable verdict, exactly as the existing stockout branch already did. A stockout still
|
||||
explains a drop first; an immaterial rival explains nothing.
|
||||
|
||||
Finally, a **suppressed listing has no selling price** (it sells nothing, so COSMOS records no
|
||||
sales), which used to fail with a bare "no current selling price found in COSMOS". That error now
|
||||
names the cause, so the most actionable rows in the sheet stop looking like a data problem.
|
||||
|
||||
### Two scrapers, one state
|
||||
|
||||
| Source | Authoritative for | Why |
|
||||
|---|---|---|
|
||||
| **Apify** (`tools/amazon/apify.py`) | **Buy Box state read by the engine** | The only source carrying a seller id, so the only one that can tell `WON` from `LOST_PRICE` from `LOST_ELIGIBILITY`. Those lead to opposite actions (cut price vs. fix fulfilment eligibility). |
|
||||
| **Playwright** (`../scraper/`) | The workbook: like-for-like size/colour matching, BSR, demand buckets, SKU gaps | Apify cannot produce any of it. Its Buy Box field knows only whether a price *rendered*, not whose it was. |
|
||||
|
||||
The split is by **question**, not preference, so neither source is redundant. `reconcile()`
|
||||
cross-checks the authoritative state against the Playwright run's own cache
|
||||
(`scraper/.scrape_cache.json`, keyed `ASIN@ZIP`) and **logs any disagreement** rather than
|
||||
letting a workbook and a recommendation contradict each other in front of a stakeholder. A
|
||||
disagreement never changes the verdict. Where the authoritative source has nothing usable but
|
||||
the other has a `SUPPRESSED`, that fact is promoted and the provenance recorded — discarding
|
||||
it to preserve a hierarchy would be choosing the hierarchy over the fact.
|
||||
|
||||
Backtest (`scripts/backtest_competitor_rules.py`, 5 real SKUs, real COSMOS data):
|
||||
|
||||
| Arm | Verdicts changed |
|
||||
|---|---|
|
||||
| Real competitor state (both cached entries stale: 66h / 146h) | **0 / 5** — the fail-safe working |
|
||||
| Counterfactual 8% undercut | 3 / 5 — the other 2 blocked by `LOW_STOCK` / `LOSING_MONEY` |
|
||||
| Counterfactual suppression | 5 / 5 → Investigate |
|
||||
| Prices shipped below break-even, any arm | **0** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Key formulas
|
||||
|
||||
| Quantity | Formula |
|
||||
|---|---|
|
||||
| Take-home / unit | `p·(1 − referral% − returns%) − landed − FBA − other` |
|
||||
| Break-even | `(landed + FBA + returns + other) / (1 − referral%)` |
|
||||
| Elasticity | OLS on `ln(units/day) = a + e·ln(price)` over 6 months |
|
||||
| Scenario demand | `units × (p / p₀)^e` |
|
||||
| Realization factor | `actual booked profit (window) ÷ modeled net at current price` |
|
||||
| TACoS | `ad spend ÷ total revenue` (window) |
|
||||
| Avg sold price | `revenue ÷ units` (window) — reconciles the Current row |
|
||||
|
||||
---
|
||||
|
||||
## 8. Repository map
|
||||
|
||||
```
|
||||
pricing_agent/
|
||||
├── app.py # dashboard (presentation only)
|
||||
├── legacy_app.py # previous analyst UI (still runnable)
|
||||
├── dashboard/
|
||||
│ ├── theme.py # CRAI design tokens + plotly template
|
||||
│ └── live_data.py # COSMOS adapter, decision + scenario engine
|
||||
├── src/pricing_agent/
|
||||
│ ├── analyze.py # per-SKU orchestration → AnalysisResult
|
||||
│ ├── competitive_state.py # canonical Buy Box state + two-scraper reconciliation
|
||||
│ ├── elasticity.py # demand model + profit optimizer
|
||||
│ ├── performance.py # actual-profit evidence
|
||||
│ ├── tools/margin_engine.py # pure fee/break-even math (golden-tested)
|
||||
│ └── cosmos/
|
||||
│ ├── client.py # auth + retry HTTP
|
||||
│ ├── service.py # endpoints, _flatten_takehome, INVP projections
|
||||
│ └── models.py # typed COSMOS responses (pydantic)
|
||||
├── config/ # settings + pricing_rules.yaml (incl. competitor thresholds)
|
||||
├── scripts/
|
||||
│ └── backtest_competitor_rules.py # verdict delta, competitor rules blind vs live
|
||||
├── .streamlit/config.toml # CRAI theme
|
||||
└── tests/ # incl. margin-engine golden values and
|
||||
# test_competitor_rules.py (fail-safe + ordering invariants)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Principles
|
||||
|
||||
1. **Deterministic core, narrative shell** — every number is a formula over COSMOS data; language models only phrase explanations.
|
||||
2. **Read-only** — the agent proposes; a human approves; nothing writes back.
|
||||
`submit_price_approval` remains a stub with no callers.
|
||||
3. **Honest gaps** — missing upstream data shows "—" or an explicit investigation, never a fabricated number.
|
||||
4. **Facts vs projections are labeled** — the Current row is real booked history; other prices are clearly modeled.
|
||||
5. **Everything reconciles** — one averaging window drives units, revenue, ads and profit so `price × units = revenue` always holds.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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`.
|
||||
|
|
@ -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 28–52% 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).
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Dashboard package: theme, demo data, and the live COSMOS data adapter."""
|
||||
|
|
@ -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),
|
||||
"A–Z": ("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 A–Z, 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)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 p10–p90 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"
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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("$", "$"), 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} · " 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 ~30–90s)")
|
||||
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 > 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 & 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")
|
||||
|
|
@ -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?*
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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 {})
|
||||
|
|
@ -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."""
|
||||
|
|
@ -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 oldest→newest. 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")
|
||||
|
|
@ -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 oldest→newest — 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]
|
||||
|
|
@ -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). r² 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 dπ/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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."""
|
||||
...
|
||||
|
|
@ -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", ""),
|
||||
)
|
||||
|
|
@ -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)."
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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")
|
||||
|
|
@ -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("A–Z") == ["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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue