From 7644202e77522fccf2baa43a34c14ab95725ceb4 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Fri, 31 Jul 2026 19:42:26 +0500 Subject: [PATCH] first commit --- .env.example | 70 + .gitignore | 22 + .streamlit/config.toml | 16 + ARCHITECTURE.md | 378 + COSMOS_API.postman_collection.json | 7355 +++++++++++++++++ README.md | 119 + app.py | 2024 +++++ claude.md | 143 + ...son_UBMICROFIBERDUVET_2026-07-29_1923.xlsx | Bin 0 -> 97599 bytes config/__init__.py | 0 config/pricing_rules.yaml | 90 + config/settings.py | 129 + dashboard/__init__.py | 1 + dashboard/line.py | 40 + dashboard/live_data.py | 1917 +++++ dashboard/theme.py | 154 + data/apify_cache.json | 1 + data/live_validation_item3.json | 205 + data/sample_skus.csv | 5 + legacy_app.py | 843 ++ ppt.md | 289 + pyproject.toml | 44 + requirements.txt | 37 + scripts/backtest_competitor_rules.py | 282 + scripts/e2e_pipeline_validate.py | 357 + scripts/serve_lan.ps1 | 80 + src/pricing_agent/__init__.py | 16 + src/pricing_agent/analyze.py | 859 ++ src/pricing_agent/backtest.py | 301 + src/pricing_agent/cli.py | 383 + src/pricing_agent/competitive_state.py | 392 + src/pricing_agent/competitor_sheet.py | 351 + src/pricing_agent/cosmos/__init__.py | 12 + src/pricing_agent/cosmos/client.py | 180 + src/pricing_agent/cosmos/errors.py | 23 + src/pricing_agent/cosmos/models.py | 260 + src/pricing_agent/cosmos/service.py | 678 ++ src/pricing_agent/elasticity.py | 202 + src/pricing_agent/fmt.py | 16 + src/pricing_agent/graph.py | 80 + src/pricing_agent/llm.py | 134 + src/pricing_agent/logging_config.py | 46 + src/pricing_agent/nodes/__init__.py | 0 src/pricing_agent/nodes/compute.py | 79 + src/pricing_agent/nodes/writeback.py | 120 + src/pricing_agent/performance.py | 281 + src/pricing_agent/providers.py | 205 + src/pricing_agent/schemas.py | 172 + src/pricing_agent/state.py | 31 + src/pricing_agent/tools/__init__.py | 0 src/pricing_agent/tools/amazon/__init__.py | 0 src/pricing_agent/tools/amazon/apify.py | 597 ++ src/pricing_agent/tools/amazon/base.py | 24 + src/pricing_agent/tools/amazon/mock.py | 44 + src/pricing_agent/tools/amazon/sp_api.py | 78 + src/pricing_agent/tools/gate.py | 104 + src/pricing_agent/tools/margin_engine.py | 172 + src/pricing_agent/tools/tracker.py | 156 + tests/fixtures/amazon_mock.json | 59 + tests/fixtures/apify_amazon_item.json | 128 + tests/test_analyze.py | 69 + tests/test_apify_batch.py | 159 + tests/test_apify_mapper.py | 132 + tests/test_backtest.py | 183 + tests/test_competitor_rules.py | 862 ++ tests/test_cosmos.py | 56 + tests/test_cover_bands.py | 173 + tests/test_dependencies_declared.py | 111 + tests/test_evaluate_gate.py | 69 + tests/test_graph_e2e.py | 96 + tests/test_impact_baseline.py | 164 + tests/test_inventory_outlook.py | 166 + tests/test_inventory_reasons.py | 171 + tests/test_invp_header_matches_cosmos.py | 98 + tests/test_line_picker.py | 90 + tests/test_margin_engine.py | 91 + tests/test_performance.py | 130 + tests/test_pricing_safety.py | 290 + tests/test_reproject_inventory.py | 126 + tests/test_storage_units.py | 74 + 80 files changed, 24094 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .streamlit/config.toml create mode 100644 ARCHITECTURE.md create mode 100644 COSMOS_API.postman_collection.json create mode 100644 README.md create mode 100644 app.py create mode 100644 claude.md create mode 100644 competetor/Competitor_Price_Comparison_UBMICROFIBERDUVET_2026-07-29_1923.xlsx create mode 100644 config/__init__.py create mode 100644 config/pricing_rules.yaml create mode 100644 config/settings.py create mode 100644 dashboard/__init__.py create mode 100644 dashboard/line.py create mode 100644 dashboard/live_data.py create mode 100644 dashboard/theme.py create mode 100644 data/apify_cache.json create mode 100644 data/live_validation_item3.json create mode 100644 data/sample_skus.csv create mode 100644 legacy_app.py create mode 100644 ppt.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/backtest_competitor_rules.py create mode 100644 scripts/e2e_pipeline_validate.py create mode 100644 scripts/serve_lan.ps1 create mode 100644 src/pricing_agent/__init__.py create mode 100644 src/pricing_agent/analyze.py create mode 100644 src/pricing_agent/backtest.py create mode 100644 src/pricing_agent/cli.py create mode 100644 src/pricing_agent/competitive_state.py create mode 100644 src/pricing_agent/competitor_sheet.py create mode 100644 src/pricing_agent/cosmos/__init__.py create mode 100644 src/pricing_agent/cosmos/client.py create mode 100644 src/pricing_agent/cosmos/errors.py create mode 100644 src/pricing_agent/cosmos/models.py create mode 100644 src/pricing_agent/cosmos/service.py create mode 100644 src/pricing_agent/elasticity.py create mode 100644 src/pricing_agent/fmt.py create mode 100644 src/pricing_agent/graph.py create mode 100644 src/pricing_agent/llm.py create mode 100644 src/pricing_agent/logging_config.py create mode 100644 src/pricing_agent/nodes/__init__.py create mode 100644 src/pricing_agent/nodes/compute.py create mode 100644 src/pricing_agent/nodes/writeback.py create mode 100644 src/pricing_agent/performance.py create mode 100644 src/pricing_agent/providers.py create mode 100644 src/pricing_agent/schemas.py create mode 100644 src/pricing_agent/state.py create mode 100644 src/pricing_agent/tools/__init__.py create mode 100644 src/pricing_agent/tools/amazon/__init__.py create mode 100644 src/pricing_agent/tools/amazon/apify.py create mode 100644 src/pricing_agent/tools/amazon/base.py create mode 100644 src/pricing_agent/tools/amazon/mock.py create mode 100644 src/pricing_agent/tools/amazon/sp_api.py create mode 100644 src/pricing_agent/tools/gate.py create mode 100644 src/pricing_agent/tools/margin_engine.py create mode 100644 src/pricing_agent/tools/tracker.py create mode 100644 tests/fixtures/amazon_mock.json create mode 100644 tests/fixtures/apify_amazon_item.json create mode 100644 tests/test_analyze.py create mode 100644 tests/test_apify_batch.py create mode 100644 tests/test_apify_mapper.py create mode 100644 tests/test_backtest.py create mode 100644 tests/test_competitor_rules.py create mode 100644 tests/test_cosmos.py create mode 100644 tests/test_cover_bands.py create mode 100644 tests/test_dependencies_declared.py create mode 100644 tests/test_evaluate_gate.py create mode 100644 tests/test_graph_e2e.py create mode 100644 tests/test_impact_baseline.py create mode 100644 tests/test_inventory_outlook.py create mode 100644 tests/test_inventory_reasons.py create mode 100644 tests/test_invp_header_matches_cosmos.py create mode 100644 tests/test_line_picker.py create mode 100644 tests/test_margin_engine.py create mode 100644 tests/test_performance.py create mode 100644 tests/test_pricing_safety.py create mode 100644 tests/test_reproject_inventory.py create mode 100644 tests/test_storage_units.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9aa2614 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b87f42 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..756eded --- /dev/null +++ b/.streamlit/config.toml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6d7bdaf --- /dev/null +++ b/ARCHITECTURE.md @@ -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
(same-network browser)"] --> APP + + subgraph APP["app.py — presentation (Streamlit, CRAI theme)"] + SIDE["Sidebar
Single product / Product line
+ filters, kill switch"] + QUEUE["Recommendation queue
tiles · pills · rows"] + SECT["Per-SKU sections
price · inventory · scenarios
competitors · PPC · costs · AI"] + end + + subgraph DASH["dashboard/ package"] + THEME["theme.py
CRAI tokens + plotly template"] + LIVE["live_data.py
adapter + decision engine
+ scenario economics"] + end + + subgraph CORE["src/pricing_agent — analysis core"] + AN["analyze.py"] + MARGIN["margin_engine.py"] + ELAST["elasticity.py"] + PERF["performance.py"] + SVC["cosmos/service.py"] + CLIENT["cosmos/client.py"] + end + + COSMOS[("COSMOS API")] + APIFY[("Apify — optional")] + + APP --> THEME + APP --> LIVE + LIVE --> AN + AN --> MARGIN & ELAST & PERF + AN --> SVC --> CLIENT --> COSMOS + LIVE --> SVC + AN -.optional.-> APIFY +``` + +--- + +## 2. Layers + +| Layer | Files | Responsibility | +|---|---|---| +| **Presentation** | `app.py` | All rendering, zero pricing logic. Session state (approve/modify/reject, filters, per-SKU section + window), staged progress loader, session-state cache. | +| **Design system** | `dashboard/theme.py`, `.streamlit/config.toml` | CRAI palette (cream `#f4f0e8`, teal `#0c8276`, coral `#df4f33`, navy `#22304e`), Inter font, plotly template. | +| **Adapter + engine** | `dashboard/live_data.py` | Builds the per-SKU dict; **decides** the action; computes **scenario economics** (elasticity projection → bulk reconciliation → calibration); exposes `scenarios_for_window()`. | +| **Competitive state** | `src/pricing_agent/competitive_state.py` | The one competitive fact the cascade may read. Adapts either scraper into a typed `WON`/`LOST_PRICE`/`LOST_ELIGIBILITY`/`SUPPRESSED` state with a source and a timestamp, gates it on age, and logs disagreement between sources. | +| **Analysis core** | `src/pricing_agent/analyze.py` | Orchestrates one SKU: fees → trend → bulk → ad cost → elasticity → actual-profit evidence. | +| **Money math** | `tools/margin_engine.py` | Pure: break-even, MAP, contribution margin, suggested price. | +| **Statistics** | `elasticity.py`, `performance.py` | Log-log elasticity fit, profit-optimal sweep, actual-profit aggregation. | +| **Data access** | `cosmos/{client,service,models}.py` | Auth + retry client; endpoint calls + response flattening; typed pydantic models. | + +--- + +## 3. Data sources — what each COSMOS endpoint feeds + +```mermaid +flowchart TB + subgraph COSMOS["COSMOS API"] + TH["/sales-insight/takehome-calculator
nested fees.breakdown · cost.breakdown"] + INVP["/invp-insight
trend + inventory + dateMap PROJECTIONS"] + BULK["/sales-insight/bulk-calculator
storage + total take-home"] + SI["/sales-insight (daily, 6-month)
price · units · revenue · profit · ad spend"] + PROD["/products
brand · marketplace"] + CAMP["/api/campaigns · /adsApi
budget · ACoS · ad sales (not yet wired)"] + end + + TH -->|"_flatten_takehome()"| FEES["Fee model
referral% · FBA · landed · returns"] + INVP --> TREND["Velocity + cover days"] + INVP --> INVPROJ["Inventory Outlook tab
real weekly units/value/cover/arrivals"] + BULK --> STORAGE["Storage + take-home (scenarios)"] + SI --> HIST["180-day daily series
(window filter + calibration)"] + SI --> ADS["Ad spend / TACoS (PPC tab)"] + PROD --> META["Brand / marketplace"] +``` + +**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
7/14/30/90d · 6mo"] --> BASE["Baseline velocity
= avg units/day in window"] + BASE --> DEMAND["Units(p) = units × (p/cur)^elasticity"] + DEMAND --> REV["Revenue = units × p × 30"] + REV --> AD["Ad spend = TACoS × revenue"] + DEMAND --> TH["Take-home (bulk calculator fee model)"] + TH --> GROSS["Gross = take-home − storage − ad"] + GROSS --> CAL["× realization factor
(actual booked ÷ modeled at current)"] + CAL --> NET["Net profit / 30d"] +``` + +Key rules: +- **Current row = FACT**, not a projection: real units, real revenue, real ad spend, real + booked profit. Its price is the **average price sold** (revenue ÷ units) so + `price × units × 30 = revenue` reconciles — this is *below* list when promos ran, and + changes with the window because the avg selling price differed period to period. The + **list price is fixed**. +- **Calibration:** raw bulk-calculator profit over-states reality (prices at list, ignores + real returns/promos). A **realization factor** = actual booked profit ÷ modeled profit at + the current price scales every projected row, anchoring net profit to what the SKU truly + earns. +- **Suggested price** (teal callout) is computed from the **full 6-month** window always — + stable — independent of the display-window filter. +- **⭐** marks the highest-net-profit price in the current view. + +--- + +## 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. diff --git a/COSMOS_API.postman_collection.json b/COSMOS_API.postman_collection.json new file mode 100644 index 0000000..96a993d --- /dev/null +++ b/COSMOS_API.postman_collection.json @@ -0,0 +1,7355 @@ +{ + "info": { + "_postman_id": "00000000-0000-4000-8000-000000000001", + "name": "COSMOS API", + "description": "COSMOS API collection generated from API_Permission_Checks report.\n\nBase URL: https://cosmos-api.utopiadeals.com\n\nAuth: Bearer token. Set the `token` collection variable (obtain via POST /api/auth/login).\nAll requests here are GET endpoints. Path variables and known query params are pre-filled (query params are disabled by default — enable the ones you need).", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://cosmos-api.utopiadeals.com", + "type": "string" + }, + { + "key": "token", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "1. variable-expense-controller", + "description": "Result: Has permission (GET access confirmed)\n\nAccess granted at the API/role level. The list endpoint returned HTTP 200 with actual data; the by-ID endpoint reached business logic (not a 401/403). Note: no dedicated variable-expense string appears in the JWT permission claim, so access is granted via role (APM) or default rather than a named permission.", + "item": [ + { + "name": "GET /api/variable-expenses", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/variable-expenses?page=&size=&order=&sort=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "variable-expenses" + ], + "query": [ + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned real data (1 record: \"Warehouse Expense\")\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/variable-expenses/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/variable-expenses/:id?page=&size=&order=&sort=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "variable-expenses", + ":id" + ], + "query": [ + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"VariableExpense not found: 1\" — request processed; failed only because ID 1 doesn't exist, not an auth block\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "2. unit-cost-distribution-controller", + "description": "Result: Has permission (GET access confirmed)\n\nAccess granted at the API/role level (no 401/403). Empty results simply mean those product-listing-cost IDs have no unit-cost-distribution records yet.", + "item": [ + { + "name": "GET /api/unit-cost-distribution/{productListingCostId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/unit-cost-distribution/:productListingCostId?page=&size=&sort=&id=&order=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "unit-cost-distribution", + ":productListingCostId" + ], + "query": [ + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "productListingCostId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Access granted. Tested with IDs 1, 16, 100 — all returned HTTP 200 with an empty data array (no distribution records exist for those IDs, but the request was authorized and processed)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "3. product-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: product_management.product.view", + "item": [ + { + "name": "GET /api/products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ] + }, + "description": "Note: Returned real product list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/products/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/:id?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + ":id" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Product not found\" (id 1 absent) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/products/{marketplace}/{sku}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/:marketplace/:sku?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + ":marketplace", + ":sku" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ], + "variable": [ + { + "key": "marketplace", + "value": "", + "description": "required path variable" + }, + { + "key": "sku", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Authorized\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/products/{id}/cogs-batches", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/:id/cogs-batches?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + ":id", + "cogs-batches" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Product listing not found\" — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/products/{id}/cogs-batches/{batchId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/:id/cogs-batches/:batchId?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + ":id", + "cogs-batches", + ":batchId" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + }, + { + "key": "batchId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Product listing not found\" — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/products/warehouses", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/warehouses?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + "warehouses" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ] + }, + "description": "Note: Returned warehouse list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/products/skus", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/skus?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + "skus" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ] + }, + "description": "Note: Returned product(s) by SKU\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/products/search", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/search?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + "search" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ] + }, + "description": "Note: Returned search results\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/products/new", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/products/new?brand=&page=1&size=10&sort=potentialDailySale&order=desc&tag=&skus=&q=&marketplaces=['AMAZON_USA']&targetCurrency=USD&isTaggingRequired=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "products", + "new" + ], + "query": [ + { + "key": "brand", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "potentialDailySale", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + }, + { + "key": "q", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "isTaggingRequired", + "value": "true", + "disabled": true + } + ] + }, + "description": "Note: Returned product list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "4. product-listing-cost-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/product-listing-costs/{listingId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product-listing-costs/:listingId?page=1&size=10&sort=receiptDate&order=desc&marketplace=ATVPDKIKX0DER", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product-listing-costs", + ":listingId" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "receiptDate", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "marketplace", + "value": "ATVPDKIKX0DER", + "disabled": true + } + ], + "variable": [ + { + "key": "listingId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Paginated wrapper; empty for listing id 1 (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/product-listing-costs/download-template", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product-listing-costs/download-template?page=1&size=10&sort=receiptDate&order=desc&marketplace=ATVPDKIKX0DER", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product-listing-costs", + "download-template" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "receiptDate", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "marketplace", + "value": "ATVPDKIKX0DER", + "disabled": true + } + ] + }, + "description": "Note: Returned an Excel (.xlsx) template file\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "5. optimization-goal-controller", + "description": "Result: NO permission\n\nAccess explicitly denied (HTTP 403) for all GET endpoints. No data retrievable.", + "item": [ + { + "name": "GET /api/marketing/optimization/goals/enabled", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/goals/enabled", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "goals", + "enabled" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/marketing/optimization/goals/campaign/{campaignId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/goals/campaign/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "goals", + "campaign", + ":campaignId" + ], + "variable": [ + { + "key": "campaignId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "6. optimization-campaign-setting-controller", + "description": "Result: NO permission\n\nAccess explicitly denied (HTTP 403) for all GET endpoints. No data retrievable.", + "item": [ + { + "name": "GET /api/marketing/optimization/campaign-settings/defaults", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/campaign-settings/defaults", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "campaign-settings", + "defaults" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/marketing/optimization/campaign-settings/overrides", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/campaign-settings/overrides", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "campaign-settings", + "overrides" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/marketing/optimization/campaign-settings/campaign/{campaignId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/campaign-settings/campaign/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "campaign-settings", + "campaign", + ":campaignId" + ], + "variable": [ + { + "key": "campaignId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "7. tariff-controller (HS Codes)", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: product_management.hs_code.view", + "item": [ + { + "name": "GET /api/hs-codes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/hs-codes?marketplace=&hscode=&page=1&size=10&sort=hscode&order=desc", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "hs-codes" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hscode", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "hscode", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + } + ] + }, + "description": "Note: Returned HS code list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/hs-codes/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/hs-codes/:id?marketplace=&hscode=&page=1&size=10&sort=hscode&order=desc", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "hs-codes", + ":id" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hscode", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "hscode", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Product not found\" (id 1) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/hs-codes/new", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/hs-codes/new?marketplace=&hscode=&page=1&size=10&sort=hscode&order=desc", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "hs-codes", + "new" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hscode", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "hscode", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + } + ] + }, + "description": "Note: Returned paginated HS codes\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/hs-codes/{hsCodeId}/get-hs-code-items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/hs-codes/:hsCodeId/get-hs-code-items?marketplace=&hscode=&page=1&size=10&sort=hscode&order=desc", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "hs-codes", + ":hsCodeId", + "get-hs-code-items" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hscode", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "hscode", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + } + ], + "variable": [ + { + "key": "hsCodeId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/hs-codes/get-item/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/hs-codes/get-item/:id?marketplace=&hscode=&page=1&size=10&sort=hscode&order=desc", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "hs-codes", + "get-item", + ":id" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hscode", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "hscode", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Item not found\" (id 1) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "8. collection-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/collections", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ] + }, + "description": "Note: Returned collections list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/types", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/types?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + "types" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ] + }, + "description": "Note: Returned collection types\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/{collectionId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/:collectionId?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + ":collectionId" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ], + "variable": [ + { + "key": "collectionId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Collection not found with ID: 1\" — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/collections/{collectionId}/products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/:collectionId/products?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + ":collectionId", + "products" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ], + "variable": [ + { + "key": "collectionId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned {total:0,data:[]} (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/{collectionId}/get-collection-products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/:collectionId/get-collection-products?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + ":collectionId", + "get-collection-products" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ], + "variable": [ + { + "key": "collectionId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/get-types-and-collections", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/get-types-and-collections?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + "get-types-and-collections" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ] + }, + "description": "Note: Returned types + collections map\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/get-all-types", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/get-all-types?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + "get-all-types" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ] + }, + "description": "Note: Returned all types\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/by-types", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/by-types?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + "by-types" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ] + }, + "description": "Note: Returned collections grouped by type\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/collections/by-type/{typeId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/collections/by-type/:typeId?page=1&size=20&order=desc&sort=id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "collections", + "by-type", + ":typeId" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + } + ], + "variable": [ + { + "key": "typeId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "9. campaign-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/campaigns", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/campaigns?name=&status=&type=&page=1&size=20&sort=dailyBudget&order=desc&skuPrefix=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "campaigns" + ], + "query": [ + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "type", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "sort", + "value": "dailyBudget", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned campaign list with real data\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "10. global-config-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/admin/global-config/{key}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/admin/global-config/:key", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "admin", + "global-config", + ":key" + ], + "variable": [ + { + "key": "key", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\" (admin endpoint)\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "11. targeting-integration-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /adsApi/v1/target/{targetId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/adsApi/v1/target/:targetId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "adsApi", + "v1", + "target", + ":targetId" + ], + "variable": [ + { + "key": "targetId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /adsApi/v1/campaign/{campaignId}/targets", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/adsApi/v1/campaign/:campaignId/targets", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "adsApi", + "v1", + "campaign", + ":campaignId", + "targets" + ], + "variable": [ + { + "key": "campaignId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "12. keyword-integration-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /adsApi/v1/keyword/{keywordId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/adsApi/v1/keyword/:keywordId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "adsApi", + "v1", + "keyword", + ":keywordId" + ], + "variable": [ + { + "key": "keywordId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\" (tested with Amazon-Ads-Profile-Id header)\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "13. warehouse-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.warehouse.view", + "item": [ + { + "name": "GET /api/warehouse/all", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse/all?page=1&size=10&order=desc&sort=id&marketplace=AMAZON_USA&search=&status=ACTIVE&type=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse", + "all" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "ACTIVE", + "disabled": true + }, + { + "key": "type", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned warehouse list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/warehouse/dd", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse/dd?page=1&size=10&order=desc&sort=id&marketplace=AMAZON_USA&search=&status=ACTIVE&type=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse", + "dd" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "ACTIVE", + "disabled": true + }, + { + "key": "type", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned warehouse dropdown list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/warehouse/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse/:id?page=1&size=10&order=desc&sort=id&marketplace=AMAZON_USA&search=&status=ACTIVE&type=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse", + ":id" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "ACTIVE", + "disabled": true + }, + { + "key": "type", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned warehouse details\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "14. inbound-shipment-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/shipment/fetch/all", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/shipment/fetch/all", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "shipment", + "fetch", + "all" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/shipment/fetch/items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/shipment/fetch/items", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "shipment", + "fetch", + "items" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/shipment/fetch-by-id", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/shipment/fetch-by-id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "shipment", + "fetch-by-id" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "15. seller-controller", + "description": "Result: N/A — no GET endpoints\n\nThis controller exposes no GET endpoints in the API spec (only non-GET methods exist). Nothing to test under the GET-only scope.", + "item": [] + }, + { + "name": "16. sale-insight-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: sales_insight.sales_trend.view", + "item": [ + { + "name": "GET /api/sales-insight", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Validation errors (\"Date range cannot exceed 15 days\" / date parse) — authorized, not blocked\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/sales-insight/user-metrics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight/user-metrics?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight", + "user-metrics" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned user metric preferences (userId 192)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/sales-insight/metrics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight/metrics?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight", + "metrics" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/sales-insight/metrics-with-user-preference", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight/metrics-with-user-preference?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight", + "metrics-with-user-preference" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/sales-insight/takehome-calculator", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight/takehome-calculator?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight", + "takehome-calculator" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Product not found: TEST\" — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/sales-insight/bulk-calculator", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sales-insight/bulk-calculator?toDate=&page=1&size=10&perspective=unit_orders&order=desc&groupBy=SKU&interval=Day&marketplaces=['AMAZON_USA']&preferredCurrency=USD&excludeZeros=true&mergeMarkets=true&category=&marketplace=&listingId=0&itemPrice=&fromDate=&sort=&tag=&skuPrefix=&id=&idType=&releasedSince=&saleUnits=&marketing=&inventory=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sales-insight", + "bulk-calculator" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "unit_orders", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "interval", + "value": "Day", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "preferredCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "excludeZeros", + "value": "true", + "disabled": true + }, + { + "key": "mergeMarkets", + "value": "true", + "disabled": true + }, + { + "key": "category", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "0", + "disabled": true + }, + { + "key": "itemPrice", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "releasedSince", + "value": "", + "disabled": true + }, + { + "key": "saleUnits", + "value": "", + "disabled": true + }, + { + "key": "marketing", + "value": "", + "disabled": true + }, + { + "key": "inventory", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Product not found: TEST\" — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "17. purchase-order-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.purchase_order.view", + "item": [ + { + "name": "GET /api/purchase-orders", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/purchase-orders?page=1&size=20&order=desc&sort=etaDate&marketplace=AMAZON_USA&status=&approval=&downloadType=DOWNLOAD_WITHOUT_PRICE&supplier=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "purchase-orders" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etaDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "approval", + "value": "", + "disabled": true + }, + { + "key": "downloadType", + "value": "DOWNLOAD_WITHOUT_PRICE", + "disabled": true + }, + { + "key": "supplier", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned PO list with real data\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/purchase-orders/{purchaseOrderId}/items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/purchase-orders/:purchaseOrderId/items?page=1&size=20&order=desc&sort=etaDate&marketplace=AMAZON_USA&status=&approval=&downloadType=DOWNLOAD_WITHOUT_PRICE&supplier=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "purchase-orders", + ":purchaseOrderId", + "items" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etaDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "approval", + "value": "", + "disabled": true + }, + { + "key": "downloadType", + "value": "DOWNLOAD_WITHOUT_PRICE", + "disabled": true + }, + { + "key": "supplier", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "purchaseOrderId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned [] (authorized)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/purchase-orders/{poNumber}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/purchase-orders/:poNumber?page=1&size=20&order=desc&sort=etaDate&marketplace=AMAZON_USA&status=&approval=&downloadType=DOWNLOAD_WITHOUT_PRICE&supplier=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "purchase-orders", + ":poNumber" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etaDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "approval", + "value": "", + "disabled": true + }, + { + "key": "downloadType", + "value": "DOWNLOAD_WITHOUT_PRICE", + "disabled": true + }, + { + "key": "supplier", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "poNumber", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Authorized\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/purchase-orders/ordered", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/purchase-orders/ordered?page=1&size=20&order=desc&sort=etaDate&marketplace=AMAZON_USA&status=&approval=&downloadType=DOWNLOAD_WITHOUT_PRICE&supplier=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "purchase-orders", + "ordered" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etaDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "approval", + "value": "", + "disabled": true + }, + { + "key": "downloadType", + "value": "DOWNLOAD_WITHOUT_PRICE", + "disabled": true + }, + { + "key": "supplier", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Authorized\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/purchase-orders/download-sample-items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/purchase-orders/download-sample-items?page=1&size=20&order=desc&sort=etaDate&marketplace=AMAZON_USA&status=&approval=&downloadType=DOWNLOAD_WITHOUT_PRICE&supplier=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "purchase-orders", + "download-sample-items" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etaDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "approval", + "value": "", + "disabled": true + }, + { + "key": "downloadType", + "value": "DOWNLOAD_WITHOUT_PRICE", + "disabled": true + }, + { + "key": "supplier", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned an Excel (.xlsx) sample file\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "18. profile-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/profile/sku-requests", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/profile/sku-requests?sku=&page=1&size=10&marketplaces=['AMAZON_USA']", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "profile", + "sku-requests" + ], + "query": [ + { + "key": "sku", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + } + ] + }, + "description": "Note: Returned []\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/profile/users/{userId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/profile/users/:userId?sku=&page=1&size=10&marketplaces=['AMAZON_USA']", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "profile", + "users", + ":userId" + ], + "query": [ + { + "key": "sku", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + } + ], + "variable": [ + { + "key": "userId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned own user profile (userId 192)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/profile/products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/profile/products?sku=&page=1&size=10&marketplaces=['AMAZON_USA']", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "profile", + "products" + ], + "query": [ + { + "key": "sku", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + } + ] + }, + "description": "Note: Returned product list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "19. campaign-schedule-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/marketing/campaign/schedule/all", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/campaign/schedule/all", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "campaign", + "schedule", + "all" + ] + }, + "description": "Note: Returned []\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/marketing/campaign/schedule/{campaignId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/campaign/schedule/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "campaign", + "schedule", + ":campaignId" + ], + "variable": [ + { + "key": "campaignId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned schedule object (scheduleData bitmap)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "20. marketing-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: marketing.campaign_performance2.view", + "item": [ + { + "name": "GET /api/marketing/dashboard/aggregates", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/dashboard/aggregates?marketplace=AMAZON_USA&compare=false&page=1&size=10&perspective=totalSpend&order=desc&groupBy=SKU&granularity=day&sort=adSpend&fromDate=&toDate=&compareFromDate=&compareToDate=&skuPrefix=&dropdownValue=&drillDown=&tag=&sponsorType=&groupedCampaigns=&id=&idType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "dashboard", + "aggregates" + ], + "query": [ + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "compare", + "value": "false", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "totalSpend", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "granularity", + "value": "day", + "disabled": true + }, + { + "key": "sort", + "value": "adSpend", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "compareFromDate", + "value": "", + "disabled": true + }, + { + "key": "compareToDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "dropdownValue", + "value": "", + "disabled": true + }, + { + "key": "drillDown", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "sponsorType", + "value": "", + "disabled": true + }, + { + "key": "groupedCampaigns", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned KPI aggregates (e.g. Marketing Spend)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/marketing/campaign-performance2", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/campaign-performance2?marketplace=AMAZON_USA&compare=false&page=1&size=10&perspective=totalSpend&order=desc&groupBy=SKU&granularity=day&sort=adSpend&fromDate=&toDate=&compareFromDate=&compareToDate=&skuPrefix=&dropdownValue=&drillDown=&tag=&sponsorType=&groupedCampaigns=&id=&idType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "campaign-performance2" + ], + "query": [ + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "compare", + "value": "false", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "totalSpend", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "granularity", + "value": "day", + "disabled": true + }, + { + "key": "sort", + "value": "adSpend", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "compareFromDate", + "value": "", + "disabled": true + }, + { + "key": "compareToDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "dropdownValue", + "value": "", + "disabled": true + }, + { + "key": "drillDown", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "sponsorType", + "value": "", + "disabled": true + }, + { + "key": "groupedCampaigns", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Date-format parse error — authorized, not blocked\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/marketing/campaign-performance", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/campaign-performance?marketplace=AMAZON_USA&compare=false&page=1&size=10&perspective=totalSpend&order=desc&groupBy=SKU&granularity=day&sort=adSpend&fromDate=&toDate=&compareFromDate=&compareToDate=&skuPrefix=&dropdownValue=&drillDown=&tag=&sponsorType=&groupedCampaigns=&id=&idType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "campaign-performance" + ], + "query": [ + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "compare", + "value": "false", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "totalSpend", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "granularity", + "value": "day", + "disabled": true + }, + { + "key": "sort", + "value": "adSpend", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "compareFromDate", + "value": "", + "disabled": true + }, + { + "key": "compareToDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "dropdownValue", + "value": "", + "disabled": true + }, + { + "key": "drillDown", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "sponsorType", + "value": "", + "disabled": true + }, + { + "key": "groupedCampaigns", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Date-format parse error — authorized, not blocked\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/marketing/dashboard/marketing-data", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/dashboard/marketing-data?marketplace=AMAZON_USA&compare=false&page=1&size=10&perspective=totalSpend&order=desc&groupBy=SKU&granularity=day&sort=adSpend&fromDate=&toDate=&compareFromDate=&compareToDate=&skuPrefix=&dropdownValue=&drillDown=&tag=&sponsorType=&groupedCampaigns=&id=&idType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "dashboard", + "marketing-data" + ], + "query": [ + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "compare", + "value": "false", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "totalSpend", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "granularity", + "value": "day", + "disabled": true + }, + { + "key": "sort", + "value": "adSpend", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "compareFromDate", + "value": "", + "disabled": true + }, + { + "key": "compareToDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "dropdownValue", + "value": "", + "disabled": true + }, + { + "key": "drillDown", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "sponsorType", + "value": "", + "disabled": true + }, + { + "key": "groupedCampaigns", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: (not exercised) Same controller/access\n\nObserved HTTP status during permission check: not exercised" + }, + "response": [] + }, + { + "name": "GET /api/marketing/campaign-performance-product-campaigns", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/campaign-performance-product-campaigns?marketplace=AMAZON_USA&compare=false&page=1&size=10&perspective=totalSpend&order=desc&groupBy=SKU&granularity=day&sort=adSpend&fromDate=&toDate=&compareFromDate=&compareToDate=&skuPrefix=&dropdownValue=&drillDown=&tag=&sponsorType=&groupedCampaigns=&id=&idType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "campaign-performance-product-campaigns" + ], + "query": [ + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "compare", + "value": "false", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "perspective", + "value": "totalSpend", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "SKU", + "disabled": true + }, + { + "key": "granularity", + "value": "day", + "disabled": true + }, + { + "key": "sort", + "value": "adSpend", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "compareFromDate", + "value": "", + "disabled": true + }, + { + "key": "compareToDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "dropdownValue", + "value": "", + "disabled": true + }, + { + "key": "drillDown", + "value": "", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "sponsorType", + "value": "", + "disabled": true + }, + { + "key": "groupedCampaigns", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: (not exercised) Same controller/access\n\nObserved HTTP status during permission check: not exercised" + }, + "response": [] + } + ] + }, + { + "name": "21. inventory-transaction-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/inventory-transactions/fetch", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/inventory-transactions/fetch?productId=&warehouseId=&fetch=&page=&size=&order=&sort=&marketplace=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "inventory-transactions", + "fetch" + ], + "query": [ + { + "key": "productId", + "value": "", + "disabled": true + }, + { + "key": "warehouseId", + "value": "", + "disabled": true + }, + { + "key": "fetch", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/inventory-transactions/fetch/top", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/inventory-transactions/fetch/top?productId=&warehouseId=&fetch=&page=&size=&order=&sort=&marketplace=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "inventory-transactions", + "fetch", + "top" + ], + "query": [ + { + "key": "productId", + "value": "", + "disabled": true + }, + { + "key": "warehouseId", + "value": "", + "disabled": true + }, + { + "key": "fetch", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "22. delivery-order-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/delivery-orders/fetch", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/delivery-orders/fetch", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "delivery-orders", + "fetch" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/delivery-orders/items/{deliveryOrderId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/delivery-orders/items/:deliveryOrderId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "delivery-orders", + "items", + ":deliveryOrderId" + ], + "variable": [ + { + "key": "deliveryOrderId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/delivery-orders/fetch-by-id/{id}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/delivery-orders/fetch-by-id/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "delivery-orders", + "fetch-by-id", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "23. delivery-appointment-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/delivery-appointment/fetch-all", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/delivery-appointment/fetch-all", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "delivery-appointment", + "fetch-all" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/delivery-appointment/view-insights", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/delivery-appointment/view-insights", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "delivery-appointment", + "view-insights" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "24. container-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.container.view", + "item": [ + { + "name": "GET /api/containers", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/containers?page=1&size=20&order=desc&sort=etdDate&marketplace=AMAZON_USA&status=&search=&startDate=&endDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "containers" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etdDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "startDate", + "value": "", + "disabled": true + }, + { + "key": "endDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned container list with real data\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/containers/{containerId}/items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/containers/:containerId/items?page=1&size=20&order=desc&sort=etdDate&marketplace=AMAZON_USA&status=&search=&startDate=&endDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "containers", + ":containerId", + "items" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etdDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "startDate", + "value": "", + "disabled": true + }, + { + "key": "endDate", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "containerId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Container not found\" (id 1) — authorized\n\nObserved HTTP status during permission check: 404" + }, + "response": [] + }, + { + "name": "GET /api/containers/transportation-costs", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/containers/transportation-costs?page=1&size=20&order=desc&sort=etdDate&marketplace=AMAZON_USA&status=&search=&startDate=&endDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "containers", + "transportation-costs" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "etdDate", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "startDate", + "value": "", + "disabled": true + }, + { + "key": "endDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Container transportation costs not found\" — authorized\n\nObserved HTTP status during permission check: 404" + }, + "response": [] + } + ] + }, + { + "name": "25. container-workflow-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/container-workflow/fetch/all", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/container-workflow/fetch/all?page=1&size=20&order=desc&sort=id&marketplace=AMAZON_USA&containerStatus=IN_TRANSIT&containerId=&containerNo=&startDate=&endDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "container-workflow", + "fetch", + "all" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "containerStatus", + "value": "IN_TRANSIT", + "disabled": true + }, + { + "key": "containerId", + "value": "", + "disabled": true + }, + { + "key": "containerNo", + "value": "", + "disabled": true + }, + { + "key": "startDate", + "value": "", + "disabled": true + }, + { + "key": "endDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned workflow list with real data\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/container-workflow/fetch-etd", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/container-workflow/fetch-etd?page=1&size=20&order=desc&sort=id&marketplace=AMAZON_USA&containerStatus=IN_TRANSIT&containerId=&containerNo=&startDate=&endDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "container-workflow", + "fetch-etd" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "20", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "id", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "containerStatus", + "value": "IN_TRANSIT", + "disabled": true + }, + { + "key": "containerId", + "value": "", + "disabled": true + }, + { + "key": "containerNo", + "value": "", + "disabled": true + }, + { + "key": "startDate", + "value": "", + "disabled": true + }, + { + "key": "endDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Authorized\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "26. auth-controller", + "description": "Result: N/A — no GET endpoints\n\nThis controller exposes no GET endpoints in the API spec (login/auth actions are POST). Nothing to test under the GET-only scope.", + "item": [] + }, + { + "name": "27. impersonation-controller", + "description": "Result: N/A — no GET endpoints\n\nThis controller exposes no GET endpoints in the API spec. Nothing to test under the GET-only scope.", + "item": [] + }, + { + "name": "28. access-management-controller", + "description": "Result: NO permission\n\nController-wide auth block (403). No data retrievable. Token has no access-management / user-admin permission claim.", + "item": [ + { + "name": "GET /api/access-management/roles", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/roles?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "roles" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/access-management/users", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/users?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "users" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/access-management/users/{userId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/users/:userId?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "users", + ":userId" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "userId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/access-management/users/{userId}/product-access", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/users/:userId/product-access?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "users", + ":userId", + "product-access" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "userId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/access-management/roles/{roleId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/roles/:roleId?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "roles", + ":roleId" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "roleId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/access-management/users/import-cosmos-user", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/access-management/users/import-cosmos-user?page=1&size=10&sort=createdAt&order=desc&username=&users=&searchQuery=&roles=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "access-management", + "users", + "import-cosmos-user" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "createdAt", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "username", + "value": "", + "disabled": true + }, + { + "key": "users", + "value": "", + "disabled": true + }, + { + "key": "searchQuery", + "value": "", + "disabled": true + }, + { + "key": "roles", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "29. campaign-integration-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable. No ads/campaign-integration claim in token.", + "item": [ + { + "name": "GET /adsApi/v1/campaign/{campaignId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/adsApi/v1/campaign/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "adsApi", + "v1", + "campaign", + ":campaignId" + ], + "variable": [ + { + "key": "campaignId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "30. price-adjustment-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: product_management.price_adjustment.view / .edit_price", + "item": [ + { + "name": "GET /api/price-adjustment-products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/price-adjustment-products?page=1&size=50&order=desc&sort=2&marketplaces=['AMAZON_USA']&isActiveOnly=true&isPriceApproved=all&productListingId=&listingId=&businessPrice=&marketplaceId=&skuPrefix=&standardPrice=&productListingIds=&feedId=&marketplace=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "price-adjustment-products" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "2", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "isActiveOnly", + "value": "true", + "disabled": true + }, + { + "key": "isPriceApproved", + "value": "all", + "disabled": true + }, + { + "key": "productListingId", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "", + "disabled": true + }, + { + "key": "businessPrice", + "value": "", + "disabled": true + }, + { + "key": "marketplaceId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "standardPrice", + "value": "", + "disabled": true + }, + { + "key": "productListingIds", + "value": "", + "disabled": true + }, + { + "key": "feedId", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned real product rows\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/price-adjustment-products/feed-history", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/price-adjustment-products/feed-history?page=1&size=50&order=desc&sort=2&marketplaces=['AMAZON_USA']&isActiveOnly=true&isPriceApproved=all&productListingId=&listingId=&businessPrice=&marketplaceId=&skuPrefix=&standardPrice=&productListingIds=&feedId=&marketplace=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "price-adjustment-products", + "feed-history" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "2", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "isActiveOnly", + "value": "true", + "disabled": true + }, + { + "key": "isPriceApproved", + "value": "all", + "disabled": true + }, + { + "key": "productListingId", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "", + "disabled": true + }, + { + "key": "businessPrice", + "value": "", + "disabled": true + }, + { + "key": "marketplaceId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "standardPrice", + "value": "", + "disabled": true + }, + { + "key": "productListingIds", + "value": "", + "disabled": true + }, + { + "key": "feedId", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Paginated envelope (empty for id 1)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/price-adjustment-products/detail", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/price-adjustment-products/detail?page=1&size=50&order=desc&sort=2&marketplaces=['AMAZON_USA']&isActiveOnly=true&isPriceApproved=all&productListingId=&listingId=&businessPrice=&marketplaceId=&skuPrefix=&standardPrice=&productListingIds=&feedId=&marketplace=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "price-adjustment-products", + "detail" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "sort", + "value": "2", + "disabled": true + }, + { + "key": "marketplaces", + "value": "['AMAZON_USA']", + "disabled": true + }, + { + "key": "isActiveOnly", + "value": "true", + "disabled": true + }, + { + "key": "isPriceApproved", + "value": "all", + "disabled": true + }, + { + "key": "productListingId", + "value": "", + "disabled": true + }, + { + "key": "listingId", + "value": "", + "disabled": true + }, + { + "key": "businessPrice", + "value": "", + "disabled": true + }, + { + "key": "marketplaceId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "standardPrice", + "value": "", + "disabled": true + }, + { + "key": "productListingIds", + "value": "", + "disabled": true + }, + { + "key": "feedId", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Detail object (nulls for sample id — business logic reached)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "31. warehouse-inv-level-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/warehouse-inv-levels/fetch", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse-inv-levels/fetch?marketplace=&hasInventory=false&sort=warehouseBoxes&id=&productIds=&fetch=&page=&size=&order=&search=&collectionId=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse-inv-levels", + "fetch" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hasInventory", + "value": "false", + "disabled": true + }, + { + "key": "sort", + "value": "warehouseBoxes", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "productIds", + "value": "", + "disabled": true + }, + { + "key": "fetch", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/warehouse-inv-levels/fetch-by-warehouse", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse-inv-levels/fetch-by-warehouse?marketplace=&hasInventory=false&sort=warehouseBoxes&id=&productIds=&fetch=&page=&size=&order=&search=&collectionId=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse-inv-levels", + "fetch-by-warehouse" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hasInventory", + "value": "false", + "disabled": true + }, + { + "key": "sort", + "value": "warehouseBoxes", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "productIds", + "value": "", + "disabled": true + }, + { + "key": "fetch", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/warehouse-inv-levels/allocated-inv", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/warehouse-inv-levels/allocated-inv?marketplace=&hasInventory=false&sort=warehouseBoxes&id=&productIds=&fetch=&page=&size=&order=&search=&collectionId=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "warehouse-inv-levels", + "allocated-inv" + ], + "query": [ + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "hasInventory", + "value": "false", + "disabled": true + }, + { + "key": "sort", + "value": "warehouseBoxes", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "productIds", + "value": "", + "disabled": true + }, + { + "key": "fetch", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "", + "disabled": true + }, + { + "key": "size", + "value": "", + "disabled": true + }, + { + "key": "order", + "value": "", + "disabled": true + }, + { + "key": "search", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "32. invp-controller-v-3", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.invp.view", + "item": [ + { + "name": "GET /api/v3/invp-insight", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v3/invp-insight?coverDays=60&saleType=1&sortDate=&marketplace=&sortMetric=predictedInventoryValue&targetCurrency=USD&page=1&size=50&order=desc&groupBy=Parent&activeOnly=true&collectionId=&skuPrefix=&id=&idType=&excludePOs=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v3", + "invp-insight" + ], + "query": [ + { + "key": "coverDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "sortDate", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "sortMetric", + "value": "predictedInventoryValue", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "Parent", + "disabled": true + }, + { + "key": "activeOnly", + "value": "true", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "excludePOs", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Date-parse error on empty sortDate (validation, not auth) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/v3/invp-insight/aggregate", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v3/invp-insight/aggregate?coverDays=60&saleType=1&sortDate=&marketplace=&sortMetric=predictedInventoryValue&targetCurrency=USD&page=1&size=50&order=desc&groupBy=Parent&activeOnly=true&collectionId=&skuPrefix=&id=&idType=&excludePOs=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v3", + "invp-insight", + "aggregate" + ], + "query": [ + { + "key": "coverDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "sortDate", + "value": "", + "disabled": true + }, + { + "key": "marketplace", + "value": "", + "disabled": true + }, + { + "key": "sortMetric", + "value": "predictedInventoryValue", + "disabled": true + }, + { + "key": "targetCurrency", + "value": "USD", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "Parent", + "disabled": true + }, + { + "key": "activeOnly", + "value": "true", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "excludePOs", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned inventory-value time series\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "33. invp-controller-v-2", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.invp.view", + "item": [ + { + "name": "GET /api/v2/invp-insight", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v2/invp-insight?coverDays=60&saleType=1&marketplace=AMAZON_USA&sortMetric=predictedInventoryValue&page=1&size=50&order=desc&groupBy=Parent&sortDate=&collectionId=&skuPrefix=&id=&idType=&excludePOs=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v2", + "invp-insight" + ], + "query": [ + { + "key": "coverDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "sortMetric", + "value": "predictedInventoryValue", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "Parent", + "disabled": true + }, + { + "key": "sortDate", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "excludePOs", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Date-parse error on empty date input (validation, not auth) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + }, + { + "name": "GET /api/v2/invp-insight/aggregate", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v2/invp-insight/aggregate?coverDays=60&saleType=1&marketplace=AMAZON_USA&sortMetric=predictedInventoryValue&page=1&size=50&order=desc&groupBy=Parent&sortDate=&collectionId=&skuPrefix=&id=&idType=&excludePOs=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v2", + "invp-insight", + "aggregate" + ], + "query": [ + { + "key": "coverDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "sortMetric", + "value": "predictedInventoryValue", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "Parent", + "disabled": true + }, + { + "key": "sortDate", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "id", + "value": "", + "disabled": true + }, + { + "key": "idType", + "value": "", + "disabled": true + }, + { + "key": "excludePOs", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: SQL exception from missing bound params (business logic reached, not auth) — authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "34. region-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/regions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/regions?sellerId=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "regions" + ], + "query": [ + { + "key": "sellerId", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned all regions + marketplaces\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/regions/seller", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/regions/seller?sellerId=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "regions", + "seller" + ], + "query": [ + { + "key": "sellerId", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned seller's regions (scoped by token's sellerId)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "35. menu-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/menu/{userId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/menu/:userId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "menu", + ":userId" + ], + "variable": [ + { + "key": "userId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: Returned the navigation menu, filtered to the token's permissions\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + }, + { + "name": "36. optimization-dashboard-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable. Consistent with all other marketing/optimization/* controllers being denied for this token.", + "item": [ + { + "name": "GET /api/marketing/optimization/dashboard/campaigns-under-optimization", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/dashboard/campaigns-under-optimization?to=&optimizations=&from=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "dashboard", + "campaigns-under-optimization" + ], + "query": [ + { + "key": "to", + "value": "", + "disabled": true + }, + { + "key": "optimizations", + "value": "", + "disabled": true + }, + { + "key": "from", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/marketing/optimization/dashboard/optimizations", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/dashboard/optimizations?to=&optimizations=&from=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "dashboard", + "optimizations" + ], + "query": [ + { + "key": "to", + "value": "", + "disabled": true + }, + { + "key": "optimizations", + "value": "", + "disabled": true + }, + { + "key": "from", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "37. optimization-bid-history-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable.", + "item": [ + { + "name": "GET /api/marketing/optimization/bid-history", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/bid-history?ruleId=&cursor=&limit=50&campaignId=&status=&fromDate=&toDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "bid-history" + ], + "query": [ + { + "key": "ruleId", + "value": "", + "disabled": true + }, + { + "key": "cursor", + "value": "", + "disabled": true + }, + { + "key": "limit", + "value": "50", + "disabled": true + }, + { + "key": "campaignId", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + }, + { + "name": "GET /api/marketing/optimization/bid-history/last-change/{keywordId}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/marketing/optimization/bid-history/last-change/:keywordId?ruleId=&cursor=&limit=50&campaignId=&status=&fromDate=&toDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "marketing", + "optimization", + "bid-history", + "last-change", + ":keywordId" + ], + "query": [ + { + "key": "ruleId", + "value": "", + "disabled": true + }, + { + "key": "cursor", + "value": "", + "disabled": true + }, + { + "key": "limit", + "value": "50", + "disabled": true + }, + { + "key": "campaignId", + "value": "", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "keywordId", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Observed HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "38. manufacturer-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/manufacturers", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/manufacturers?order=asc&sort=code&status=&name=&fullName=&ABFA=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "manufacturers" + ], + "query": [ + { + "key": "order", + "value": "asc", + "disabled": true + }, + { + "key": "sort", + "value": "code", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "fullName", + "value": "", + "disabled": true + }, + { + "key": "ABFA", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned manufacturer list\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/manufacturers/{manufacturerCode}", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/manufacturers/:manufacturerCode?order=asc&sort=code&status=&name=&fullName=&ABFA=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "manufacturers", + ":manufacturerCode" + ], + "query": [ + { + "key": "order", + "value": "asc", + "disabled": true + }, + { + "key": "sort", + "value": "code", + "disabled": true + }, + { + "key": "status", + "value": "", + "disabled": true + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "fullName", + "value": "", + "disabled": true + }, + { + "key": "ABFA", + "value": "", + "disabled": true + } + ], + "variable": [ + { + "key": "manufacturerCode", + "value": "", + "description": "required path variable" + } + ] + }, + "description": "Note: NullPointerException for non-existent code TEST — business logic reached, authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "39. invp-controller", + "description": "Result: Has permission (GET access confirmed)\n\nJWT claim: inventory_planning.invp.view", + "item": [ + { + "name": "GET /api/invp-insight", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/invp-insight?coverageDays=60&saleType=1&excludePOs=true&marketplace=AMAZON_USA&groupBy=sku&page=1&size=50&order=desc&customLeadDaysDate=&skuPrefix=&manufacturer=&collectionId=&marketplaces=&sort=&skus=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "invp-insight" + ], + "query": [ + { + "key": "coverageDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "excludePOs", + "value": "true", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "groupBy", + "value": "sku", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "customLeadDaysDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "manufacturer", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned SKU-level inventory insight rows\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/invp-insight/aggregates-new", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/invp-insight/aggregates-new?coverageDays=60&saleType=1&excludePOs=true&marketplace=AMAZON_USA&groupBy=sku&page=1&size=50&order=desc&customLeadDaysDate=&skuPrefix=&manufacturer=&collectionId=&marketplaces=&sort=&skus=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "invp-insight", + "aggregates-new" + ], + "query": [ + { + "key": "coverageDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "excludePOs", + "value": "true", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "groupBy", + "value": "sku", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "customLeadDaysDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "manufacturer", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned inventory + value time series\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + }, + { + "name": "GET /api/invp-insight/min-po-quantity", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/invp-insight/min-po-quantity?coverageDays=60&saleType=1&excludePOs=true&marketplace=AMAZON_USA&groupBy=sku&page=1&size=50&order=desc&customLeadDaysDate=&skuPrefix=&manufacturer=&collectionId=&marketplaces=&sort=&skus=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "invp-insight", + "min-po-quantity" + ], + "query": [ + { + "key": "coverageDays", + "value": "60", + "disabled": true + }, + { + "key": "saleType", + "value": "1", + "disabled": true + }, + { + "key": "excludePOs", + "value": "true", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "groupBy", + "value": "sku", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "50", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "customLeadDaysDate", + "value": "", + "disabled": true + }, + { + "key": "skuPrefix", + "value": "", + "disabled": true + }, + { + "key": "manufacturer", + "value": "", + "disabled": true + }, + { + "key": "collectionId", + "value": "", + "disabled": true + }, + { + "key": "marketplaces", + "value": "", + "disabled": true + }, + { + "key": "sort", + "value": "", + "disabled": true + }, + { + "key": "skus", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Date-validation (\"customLeadDaysDate should be ≥ 2026-08-30\") — business logic reached, authorized\n\nObserved HTTP status during permission check: 500" + }, + "response": [] + } + ] + }, + { + "name": "40. import-report-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable. No import-report/reporting scope in token.", + "item": [ + { + "name": "GET /api/import-report", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/import-report?toDate=&page=1&size=10&sort=Total&perspective=amount&order=desc&groupBy=Supplier&interval=Month&marketplace=AMAZON_USA&fromDate=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "import-report" + ], + "query": [ + { + "key": "toDate", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "Total", + "disabled": true + }, + { + "key": "perspective", + "value": "amount", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "groupBy", + "value": "Supplier", + "disabled": true + }, + { + "key": "interval", + "value": "Month", + "disabled": true + }, + { + "key": "marketplace", + "value": "AMAZON_USA", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "41. veeqo-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable. No fbu-inventory/Veeqo integration scope in token.", + "item": [ + { + "name": "GET /api/fbu-inventory-transactions/sync-veeqo", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/fbu-inventory-transactions/sync-veeqo", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "fbu-inventory-transactions", + "sync-veeqo" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "42. ship-station-controller", + "description": "Result: NO permission\n\nAccess denied (403). No data retrievable. No fbu-inventory/ShipStation integration scope in token.", + "item": [ + { + "name": "GET /api/fbu-inventory-transactions/sync-shipstation", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/fbu-inventory-transactions/sync-shipstation", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "fbu-inventory-transactions", + "sync-shipstation" + ] + }, + "description": "Note: \"Access Denied\"\n\nObserved HTTP status during permission check: 403" + }, + "response": [] + } + ] + }, + { + "name": "43. artifact-change-log-controller", + "description": "Result: Has permission (GET access confirmed)", + "item": [ + { + "name": "GET /api/artifact-change-logs/by-artifact", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/artifact-change-logs/by-artifact?artifactIdentifier=&page=1&size=10&sort=changeDateTime&order=desc&artifactType=", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "artifact-change-logs", + "by-artifact" + ], + "query": [ + { + "key": "artifactIdentifier", + "value": "", + "disabled": true + }, + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "size", + "value": "10", + "disabled": true + }, + { + "key": "sort", + "value": "changeDateTime", + "disabled": true + }, + { + "key": "order", + "value": "desc", + "disabled": true + }, + { + "key": "artifactType", + "value": "", + "disabled": true + } + ] + }, + "description": "Note: Returned paginated envelope (empty for sample artifact PRODUCT/1)\n\nObserved HTTP status during permission check: 200" + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..09ef639 --- /dev/null +++ b/README.md @@ -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 --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 --price 24.99 + +# Non-interactive (approve healthy, reject the rest): +python -m pricing_agent.cli --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 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`. diff --git a/app.py b/app.py new file mode 100644 index 0000000..bc7f58c --- /dev/null +++ b/app.py @@ -0,0 +1,2024 @@ +"""Utopia Pricing Agent — one-page dashboard (CRAI design system). + +Utopia Brands look (crai.utopiabrands.com): off-white cards on warm cream paper, +teal primary, coral accent, navy chrome. Sidebar with filters, stat tiles, action +pills, and a recommendation queue where clicking a SKU row drops down the advanced +analysis (price & demand, inventory, scenarios, competitors, PPC, costs, AI +reasoning). All numbers come from the live COSMOS pipeline — fees, 6-month +history, elasticity, actual profit. + +Run: streamlit run app.py +""" +from __future__ import annotations + +import hmac +import os +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) + +from datetime import date, datetime, timedelta + +import pandas as pd +import streamlit as st +from plotly.subplots import make_subplots +import plotly.graph_objects as go + +from dashboard import theme +# Ranking rules live in the data layer so they can be tested without booting +# Streamlit — importing app.py would execute the whole page. +from dashboard.line import LINE_SORTS, line_sort_key as _line_sort_key +from dashboard.live_data import ( + FALLBACK_ELASTICITY, REASON_LABELS, REFERRAL_PCT, VARIABLE, modelled, + reproject_inventory, + scenarios_for_window, +) + +TODAY = date.today() + +st.set_page_config(page_title="Utopia Pricing Agent", page_icon="🧭", + layout="wide", initial_sidebar_state="auto") +theme.register_template() + + +def _require_password() -> None: + """Shared-password gate, active only when APP_PASSWORD is set. + + The app authenticates to COSMOS with the credentials in .env and shows live + cost, margin and sales data, so on a shared network the URL alone is enough + for anyone to read it. Set APP_PASSWORD before binding to 0.0.0.0. Left unset + (the default, and the local-only case) nothing changes. + + This is a doorlock, not an identity system: one shared secret, no accounts, + no audit of who looked. For anything beyond an internal LAN, put it behind a + real reverse proxy with SSO. + """ + expected = os.environ.get("APP_PASSWORD") + if not expected or st.session_state.get("_authed"): + return + st.markdown( + '
🔒 Utopia Pricing Agent
' + '
This dashboard shows live cost and margin data. ' + 'Enter the shared password to continue.
', + unsafe_allow_html=True) + _, mid, _ = st.columns([1, 2, 1]) + with mid: + pw = st.text_input("Password", type="password", label_visibility="collapsed", + placeholder="Shared password") + if pw: + # compare_digest so a wrong guess takes the same time as a right one + if hmac.compare_digest(pw, expected): + st.session_state["_authed"] = True + st.rerun() + else: + st.error("Incorrect password.") + st.stop() + +st.markdown(""" + +""", unsafe_allow_html=True) + +# Gate runs after the stylesheet so the lock screen is styled like the rest. +_require_password() + +ACTION_GLYPH = {"Increase": "↑", "Decrease": "↓", "Maintain": "→", "Investigate": "🔍"} + +# The per-SKU section switcher (replaces st.tabs so "View more" can jump to a section). +# Ordered by the questions a reviewer actually asks, in order: what's the shape of +# this product, can I trust the model, what are my options, why this, then reference. +# "Track record" was previously buried at the bottom of the last tab — it is the one +# thing that says how wrong the engine has been, so it sits second. +VIEW_KEYS = ["price", "track", "scenarios", "why", "inventory", "repricing", + "competitors", "ppc", "costs"] +VIEW_LABELS = { + "price": "📈 Price & demand", "track": "🎯 Track record", + "scenarios": "🧮 Scenarios", "why": "🔍 Why this", + "inventory": "📦 Inventory outlook", + "repricing": "🔁 Stock at the new price", + "competitors": "🥊 Competitors", + "ppc": "📣 PPC", "costs": "💰 Costs", +} +VIEW_SCENARIOS = "scenarios" + +# Internal objective codes → language a human uses. +OBJECTIVE_LABELS = { + "max_profit": "maximise profit", "margin_protection": "protect margin", + "low_stock_protection": "protect low stock", "overstock_reduction": "clear overstock", + "fix_data_first": "fix the data first", "observed_best": "match the best price we've run", + "fix_ad_efficiency": "fix ad efficiency", +} +# Inventory band codes → what they mean. +INV_CLASS_LABELS = {"alpha": "Alpha (fast-moving)", "beta": "Beta (slower-moving)"} + +MARKETS = { # marketplace code → (flag, short label) + "AMAZON_USA": ("🇺🇸", "US"), "AMAZON_CA": ("🇨🇦", "CA"), "AMAZON_UK": ("🇬🇧", "UK"), + "AMAZON_DE": ("🇩🇪", "DE"), "AMAZON_FR": ("🇫🇷", "FR"), "AMAZON_IT": ("🇮🇹", "IT"), + "AMAZON_ES": ("🇪🇸", "ES"), "AMAZON_NL": ("🇳🇱", "NL"), "AMAZON_SE": ("🇸🇪", "SE"), + "AMAZON_PL": ("🇵🇱", "PL"), "AMAZON_BE": ("🇧🇪", "BE"), "AMAZON_TR": ("🇹🇷", "TR"), + "AMAZON_IE": ("🇮🇪", "IE"), "AMAZON_AU": ("🇦🇺", "AU"), +} + + +def mkt_label(m: str) -> str: + flag, short = MARKETS.get(m, ("🌐", m)) + return f"{flag} {short}" + + +# ---------------------------------------------------------------- data loading +def _load_live(skus: tuple, with_comp: bool, progress=None) -> dict: + """Session-cached loader. Not @st.cache_data — the progress callback writes to a + live Streamlit element, which the cache's element-replay can't handle. We cache + the pure result in session_state instead, so progress stays live on cold loads.""" + cache = st.session_state.setdefault("_live_cache", {}) + key = (skus, with_comp) + if key in cache: + return cache[key] + from dashboard.live_data import get_live_data + data = get_live_data(skus, with_competitive=with_comp, progress_cb=progress) + cache[key] = data + return data + + +def _clear_live_cache(): + st.session_state["_live_cache"] = {} + + +def _amazon_url(asin: str | None, marketplace: str = "AMAZON_USA") -> str | None: + """Amazon product-detail URL for an ASIN, per marketplace domain.""" + if not asin or asin == "—": + return None + domains = { + "AMAZON_USA": "com", "AMAZON_CA": "ca", "AMAZON_UK": "co.uk", + "AMAZON_DE": "de", "AMAZON_FR": "fr", "AMAZON_IT": "it", "AMAZON_ES": "es", + "AMAZON_NL": "nl", "AMAZON_SE": "se", "AMAZON_PL": "pl", "AMAZON_BE": "com.be", + "AMAZON_TR": "com.tr", "AMAZON_IE": "ie", "AMAZON_AU": "com.au", + } + return f"https://www.amazon.{domains.get(marketplace, 'com')}/dp/{asin}" + + +@st.cache_data(ttl=3600, show_spinner=False) +def _find_skus(prefix: str, limit: int) -> tuple: + from config.settings import get_settings + from pricing_agent.analyze import _build_service + svc = _build_service(get_settings()) + return tuple(svc.list_skus(limit=limit, sku_prefix=prefix or None)) + + +@st.cache_data(ttl=3600, show_spinner=False) +def _find_line(prefix: str) -> list[dict]: + """Every product in a line, with stock and velocity — one COSMOS call.""" + from config.settings import get_settings + from pricing_agent.analyze import _build_service + svc = _build_service(get_settings()) + return [{"sku": p.sku, "asin": p.asin, "size": p.size, + "inventory": p.inventory, "avg_7d": p.avg_7d, "avg_30d": p.avg_30d, + "avg_6m": p.avg_6m, "cover_days": p.cover_days, + "min_po_quantity": p.min_po_quantity, + "potential_daily_sale": p.potential_daily_sale} + for p in svc.find_line(prefix)] + + + + +def _parse_skus(raw: str) -> tuple: + parts = [p.strip() for chunk in raw.replace(",", "\n").splitlines() + for p in [chunk] if p.strip()] + seen, out = set(), [] + for p in parts: + if p.upper() not in seen: + seen.add(p.upper()) + out.append(p) + return tuple(out) + + +# ---------------------------------------------------------------- helpers +def esc(s: str) -> str: + """Escape $ so Streamlit markdown never enters LaTeX math mode.""" + return s.replace("$", "\\$") + + +def dash(v, fmt: str = "{}") -> str: + """Format a value, or an em-dash when it is missing.""" + if v is None or (isinstance(v, float) and pd.isna(v)): + return "—" + return fmt.format(v) + + +def compact_money(v: float) -> str: + """$33k rather than $32,974 — false precision from a model with a known error + band reads as certainty the number does not have.""" + a = abs(v) + sign = "-" if v < 0 else "" + if a >= 1_000_000: + return f"{sign}${a / 1_000_000:.1f}M" + if a >= 1_000: + return f"{sign}${a / 1_000:,.0f}k" + return f"{sign}${a:,.0f}" + + +def model_error_pct(d: dict) -> float | None: + """This SKU's backtested error, as a fraction — the width of any honest range.""" + bt = d.get("backtest") or {} + sel = bt.get("selected") + mape = (bt.get("scores", {}).get(sel, {}) or {}).get("mape") if sel else None + return (mape / 100.0) if mape else None + + +def money_range(v: float, err: float | None) -> str: + """A point estimate widened by the model's own measured error.""" + if not err: + return compact_money(v) + lo, hi = v * (1 - err), v * (1 + err) + return f"{compact_money(min(lo, hi))}–{compact_money(max(lo, hi))}" + + +def unit_take_home(price: float, d: dict) -> float: + """Per-unit take-home under this SKU's COSMOS fee model.""" + rp = d.get("referral_pct", REFERRAL_PCT) + ret = d.get("returns_pct", 0.0) + var = d.get("variable", VARIABLE) + return price * (1 - rp - ret) - d["cfg"]["fba"] - d["cfg"]["cost"] - var + + +def _price_move_html(delta_pct: float) -> str: + """Colored arrow + percentage chip for a price move vs current.""" + if abs(delta_pct) < 0.05: + return '→ 0.0%' + if delta_pct > 0: + return (f'▲ +{delta_pct:.1f}%') + return (f'▼ {delta_pct:.1f}%') + + +WINDOW_OPTS = {"7 days": 7, "14 days": 14, "30 days": 30, "90 days": 90, + "6 months": 180} +DEFAULT_WINDOW = "90 days" # one month is mostly noise; 90d spans real price variation + + +def render_scenarios(d: dict, key_prefix: str, compact: bool = False): + """Scenario view on ONE price basis: revenue + ad-inclusive net profit, + coloured price moves, and per-row marketing advice. + + A window filter drives the averaging: units/day, revenue, ad spend and profit + all come from the SAME window, so revenue ÷ units/day == the actual average + selling price.""" + base_econ = d.get("scen_econ") or [] + if not base_econ: + st.info("No scenario economics available for this product.") + return + + # ── The recommendation itself. This is the engine's decision, not a separate + # re-derivation from a different window — showing one number here and another + # in the table below is how the two used to disagree by 3.5x. ── + tgt = d.get("target_price", d["rec_price"]) + if d["action"] == "Maintain": + body = (f"Hold at ${d['current_price']:.2f} — nothing in the evidence " + f"beats the current price.") + else: + arrow = "Raise" if d["rec_price"] > d["current_price"] else "Lower" + body = (f"{arrow} from ${d['current_price']:.2f} to " + f"${d['rec_price']:.2f} ({d['delta_pct']:+.1f}%)") + if d.get("stepped") and abs(tgt - d["rec_price"]) > 0.01: + body += (f", stepping toward ${tgt:.2f} — one move at a time so the " + f"demand response can be read before the next one") + body += "." + if d.get("impact_30d"): + body += f" Projected {d['impact_30d']:+,.0f}/30d net profit." + ob = d.get("observed_price_max") + if ob: + body += (f" Highest price with a real sample behind it: ${ob:.2f}.") + st.markdown( + f'
' + f'💡 Recommendation: ' + f'{body}
', + unsafe_allow_html=True, + ) + + # Averaging-window filter — everything below derives from this one window. + wl = st.segmented_control( + "Averaging window", list(WINDOW_OPTS), key=f"win_{key_prefix}", + default=DEFAULT_WINDOW, help="Units/day, revenue, ad spend and profit are all " + "averaged over this window, so they always reconcile.") + days = WINDOW_OPTS.get(wl or DEFAULT_WINDOW, 90) + + if days == d.get("scen_window", 90): + econ = base_econ + summary = d.get("scen_summary") + best_key = d.get("scen_best_key") + facts = None + else: + econ, meta, facts = scenarios_for_window(d, days) + summary = meta.get("summary") + best_key = meta.get("best_key") + + cur = next((x for x in econ if x["key"] == "current"), econ[0]) + price_check = (cur["revenue_30d"] / cur["units_30d"]) if cur["units_30d"] else 0.0 + best = next((x for x in econ if x["key"] == best_key), None) + + # ONE caveat block, not four scattered ones. Every projection on this tab + # carries the same two qualifications, so they are stated once, together. + el = d.get("elasticity_detail") or {} + caveats = [] + if not d.get("elasticity_actionable"): + caveats.append("**Elasticity is not usable for pricing on this SKU** — " + + (el.get("why") or "the fit does not establish a price/volume link") + + ". The recommendation comes from prices this product has " + "actually run, not from the curve below.") + if d.get("profit_optimal_blocked_reason"): + line = f"**No profit-optimal price published** — {d['profit_optimal_blocked_reason']}." + if d.get("profit_optimal_unconstrained"): + line += (f" Taken literally the fit implies pricing at " + f"${d['profit_optimal_unconstrained']:.2f}, which is itself evidence " + f"the fit is unusable.") + caveats.append(line) + if caveats: + st.warning(esc("⚠️ " + "\n\n".join(caveats))) + + a1, a2, a3 = st.columns(3) + a1.metric(f"Units/day (last {days}d)", f"{cur['units_day']:,}", + f"avg sold ${price_check:,.2f}/unit", delta_color="off") + a2.metric("Net profit /30d", f"${cur['net_30d']:,.0f}", + f"on ${cur['revenue_30d']:,.0f} revenue", delta_color="off") + if best: + lbl = "Best price = hold" if best["key"] == "current" else "Best price (projected)" + a3.metric(lbl, f"${best['price']:.2f}", + f"{best['net_vs_current']:+,.0f}/30d vs now", delta_color="off") + + rows_html = [] + for x in econ: + is_best = x["key"] == best_key + is_cur = x["key"] == "current" + emoji = "🟰" if is_cur else ("📈" if x["price"] > cur["price"] else "📉") + row_bg = "#eef6f3" if is_best else ("#fbf6ee" if is_cur else "transparent") + star = " ⭐" if is_best else "" + net_color = "#0a6f65" if x["net_30d"] >= 0 else "#c9442b" + # Current row: show the AVG PRICE SOLD (revenue ÷ units) so price × units × 30 = + # revenue reconciles. It sits below list when there are promos, and varies by + # window because the avg selling price differed period to period. + if is_cur and x["units_30d"]: + eff = x["revenue_30d"] / x["units_30d"] + price_cell = (f'${eff:,.2f}' + f'
avg sold · ' + f'{days}d
' + f'
list ' + f'${x.get("list_price", x["price"]):.2f}
') + move_cell = 'actual' + else: + # Provenance: a price this SKU has really run is evidence; one it has + # never run is extrapolation, and the table must not look the same. + obs = x.get("observed_days") or 0 + tag = (f'
' + f'✓ ran {obs}d
' if obs else + '
never tested
') + price_cell = f'${x["price"]:.2f}{tag}' + move_cell = _price_move_html(x["delta_pct"]) + rows_html.append( + f'' + f'{emoji} {esc(x["label"])}{star}' + f'{price_cell}' + f'{move_cell}' + f'{x["units_day"]:,}' + f'${x["revenue_30d"]:,.0f}' + f'${x["ad_30d"]:,.0f}' + f'${x["net_30d"]:,.0f}' + f'{x["net_margin_pct"]:.1f}%' + # No esc() inside the raw HTML table — Streamlit does not run LaTeX on + # these cells, so escaping would render a literal backslash. + + ("" if compact else + f'{x["advice"]}') + + '' + ) + # Column headers with hover tooltips explaining how each number is computed. + elas = d["cfg"].get("elasticity", -1.3) + eld = d.get("elasticity_detail") or {} + el_note = (f"elasticity {elas:.2f}" + + (f" (95% CI {eld['ci_low']} to {eld['ci_high']}, " + f"{'usable' if d.get('elasticity_actionable') else 'NOT statistically usable'})" + if eld.get("ci_low") is not None else "")) + anchor_p = cur.get("price", d["current_price"]) + tacos_now = cur["ad_30d"] / cur["revenue_30d"] * 100 if cur["revenue_30d"] else 0.0 + + def th(label, tip, left=False): + align = "left" if left else "center" + return (f'' + f'{label}' + f' ') + + headers = ( + th("Scenario", f"Current = your actual last-{days}-day facts. Every other row is " + f"a what-if. '✓ ran Nd' means this price really has been live for " + f"N days — that row is evidence, not a projection.", left=True) + + th("Price", "Current = your AVERAGE price sold over the window (revenue ÷ " + "units); your list price is shown beneath it. Other rows = the " + "price you would set.") + + th("Move", "Percentage change vs your current list price.") + + th("Units/day", f"Projected demand = current units × (new price ÷ the price " + f"customers actually paid, ${anchor_p:,.2f}) ^ elasticity. " + f"Anchoring on the paid price — not list — is what keeps every " + f"row on one basis. {el_note}.") + + th("Revenue/30d", "Units/day × 30 × price.") + + th("Ad spend/30d", f"Ad cost PER UNIT × units, with per-unit cost fitted " + f"against price from your own history (a higher price " + f"converts worse, so each sale costs more). Current TACoS " + f"{tacos_now:.1f}%.") + + th("Net profit/30d", "Take-home − storage − ad spend. No realization factor: " + "a single multiplier fitted at one price also distorts the " + "difference between prices, which is the whole question.") + + th("Net margin", "Net profit ÷ revenue.") + + ("" if compact else th("Marketing advice", + "Plain-language read of the price / volume / ad-spend " + "trade-off for this row.", left=True)) + ) + st.markdown( + '
' + '' + headers + '' + + "".join(rows_html) + '
', + unsafe_allow_html=True, + ) + st.caption("✓ = this price has really been run · ⭐ = highest projected net profit") + + # Methodology lives behind a disclosure. It answers "how is this computed?", + # which is a question asked once — not on every read of the table. The same + # detail is on each column header, but hover is dead on touch and keyboard, + # so this is the accessible copy of it. + with st.expander("How these numbers are calculated"): + anchor = cur.get("price", d["current_price"]) + adm = d.get("ad_cost_model") or {} + ad_note = "held flat per unit" + if adm and adm.get("r2", 0) >= 0.25: + ad_note = (f"rising ${adm['slope']:+.2f}/unit per $1 of price — fitted on " + f"{adm['days']} days of your own data (r²={adm['r2']:.2f}), " + f"because a higher price converts worse and costs more per sale") + st.markdown(esc( + f"- **Window** — every figure is averaged over the last **{days} days**, so " + f"units, revenue, ad spend and profit always reconcile.\n" + f"- **Current row** — COSMOS fact: actual booked revenue, ad spend and " + f"profit. Its price is your **average sold** (${price_check:,.2f}), not your " + f"list price (${d['current_price']:.2f}); the two differ whenever there are " + f"promotions or coupons.\n" + f"- **Projected rows** — anchored at the price customers actually paid " + f"(${anchor:,.2f}), not list, so every row sits on one basis.\n" + f"- **Units/day** — current units × (new price ÷ ${anchor:,.2f}) ^ " + f"elasticity, where {el_note}.\n" + f"- **Ad spend** — ad cost per unit × units, with per-unit cost {ad_note}.\n" + f"- **Net profit** — take-home − storage (${d.get('storage_30d', 0):,.0f}/30d) " + f"− ad spend" + + (f", less a ${d['gap_per_unit']:.2f}/unit unmodeled cost measured against " + f"actual booked profit" if d.get("gap_per_unit") else "") + + ".\n" + f"- Reconciliation check: ${price_check:,.2f} × {cur['units_day']:,} units " + f"× 30 ≈ ${cur['revenue_30d']:,.0f} revenue.")) + + +def set_status(sku: str, status: str): + st.session_state.status[sku] = status + if status == "Pending": + st.session_state.modified.pop(sku, None) + icon = {"Approved": "✅", "Rejected": "🚫", "Pending": "🔄"}[status] + st.toast(f"{sku} → {status}", icon=icon) + + +def apply_modify(sku: str, details: dict): + new_price = float(st.session_state[f"mod_{sku}"]) + d = details[sku] + st.session_state.modified[sku] = new_price + st.session_state.status[sku] = "Approved" + over_cap = abs(new_price - d["current_price"]) / d["current_price"] > 0.05 + note = " · exceeds 5% step cap → elevated approval tier" if over_cap else "" + st.toast(f"{sku} approved at modified ${new_price:.2f}{note}", icon="✏️") + + +def bulk_approve(skus: list): + for s in skus: + st.session_state.status[s] = "Approved" + st.toast(f"Bulk-approved {len(skus)} high-confidence recommendations", icon="✅") + + +def reset_workspace(): + st.session_state.status = {} + st.session_state.modified = {} + st.session_state.action_sel = "All" + # Also drop the analysis cache. Without this, 'Reset workspace' cleared the + # approve/reject marks but kept every previously computed SKU in + # st.session_state['_live_cache'], so re-analysing returned the SAME numbers + # from before — including after a code change — and the button looked broken. + st.session_state.pop("_live_cache", None) + st.toast("Workspace reset", icon="🔄") + + +def set_action(a: str): + st.session_state.action_sel = a + + +def goto_view(sku: str, view: str): + """Jump a SKU's section switcher to `view` (used by the View more button).""" + st.session_state[f"view_{sku}"] = view + + +# ---------------------------------------------------------------- charts +def price_demand_chart(d) -> go.Figure: + """Two stacked panels, one shared x-axis — never a dual-axis chart.""" + hist, fc = d["hist"], d["forecast"] + fig = make_subplots(rows=2, cols=1, shared_xaxes=True, row_heights=[0.5, 0.5], + vertical_spacing=0.12, subplot_titles=("Price ($)", "Units / day")) + fig.add_trace(go.Scatter(x=hist["date"], y=hist["price"], name="Our price", + line=dict(color=theme.SERIES["us"], width=2, shape="hv")), row=1, col=1) + has_comp_hist = hist["comp_median"].notna().any() + if has_comp_hist: + fig.add_trace(go.Scatter(x=hist["date"], y=hist["comp_median"], name="Competitor median", + line=dict(color=theme.SERIES["competitor"], width=2)), row=1, col=1) + if d["change_dates"]: + ys = [float(hist.loc[hist["date"] == dt, "price"].iloc[0]) for dt in d["change_dates"]] + fig.add_trace(go.Scatter(x=d["change_dates"], y=ys, mode="markers", name="Price change", + marker=dict(symbol="triangle-down", size=10, + color=theme.SERIES["us"])), row=1, col=1) + lo = float(min(hist["price"].min(), hist["comp_median"].min()) + if has_comp_hist else hist["price"].min()) + hi = float(max(hist["price"].max(), hist["comp_median"].max()) + if has_comp_hist else hist["price"].max()) + span = hi - lo if hi > lo else max(hi * 0.1, 0.1) + guides = [(d["min_price"], f"min ${d['min_price']:.2f}", theme.STATUS["critical"]), + (d["max_price"], f"max ${d['max_price']:.2f}", theme.STATUS["serious"]), + (d["break_even"], f"break-even ${d['break_even']:.2f}", theme.MUTED)] + if d.get("comp_median_now"): + guides.append((d["comp_median_now"], + f"competitor median ${d['comp_median_now']:.2f}", + theme.SERIES["competitor"])) + for y, txt, color in guides: + if y and lo - span * 0.6 <= y <= hi + span * 0.6: + fig.add_hline(y=y, line_dash="dot", line_color=color, line_width=1, + annotation_text=txt, annotation_font_size=10, + annotation_font_color=theme.MUTED, row=1, col=1) + fig.add_trace(go.Scatter(x=hist["date"], y=hist["units"], name="Units", + line=dict(color=theme.SERIES["us"], width=1.5)), row=2, col=1) + fig.add_trace(go.Scatter(x=fc["date"], y=fc["p90"], line=dict(width=0), + showlegend=False, hoverinfo="skip"), row=2, col=1) + fig.add_trace(go.Scatter(x=fc["date"], y=fc["p10"], line=dict(width=0), fill="tonexty", + fillcolor=theme.US_BAND, name="Forecast p10–p90", + hoverinfo="skip"), row=2, col=1) + fig.add_trace(go.Scatter(x=fc["date"], y=fc["p50"], name="Forecast p50", + line=dict(color=theme.SERIES["us"], width=2, dash="dash")), row=2, col=1) + today = pd.Timestamp(TODAY) + fig.add_shape(type="line", x0=today, x1=today, y0=0, y1=1, xref="x", yref="paper", + line=dict(color=theme.AXIS, width=1, dash="dot")) + fig.add_annotation(x=today, y=1.02, xref="x", yref="paper", text="today", showarrow=False, + font=dict(size=10, color=theme.MUTED), xanchor="left") + fig.update_yaxes(rangemode="tozero", row=2, col=1) + fig.update_layout(height=440) + return fig + + +def inventory_chart(d) -> go.Figure: + """Projected inventory over COSMOS's own INVP weekly snapshots, with arrivals.""" + proj = d.get("inv_projection") or [] + fig = go.Figure() + if not proj: + return fig + dates = [pd.Timestamp(p["date"]) for p in proj] + units = [float(p["inventory"] or 0) for p in proj] + fig.add_trace(go.Scatter(x=dates, y=units, name="Projected inventory", + mode="lines+markers", + line=dict(color=theme.SERIES["us"], width=2))) + fig.add_hline(y=0, line_color=theme.STATUS["critical"], line_dash="dot", line_width=1) + for p, dt, u in zip(proj, dates, units): + arr = float(p["warehouse_arrival"] or 0) + if arr > 0: + fig.add_annotation(x=dt, y=u, text=f"🚚 +{arr:,.0f}", showarrow=True, + arrowhead=2, font=dict(size=10, color=theme.INK2)) + fig.update_yaxes(rangemode="tozero", title_text="units") + fig.update_layout(height=280, showlegend=False) + return fig + + +# Cover-day color bands (Alpha/Beta scheme, matched to the planning sheet) +def _bucket_bg(cover: float, inv_class: str) -> str: + """Cell shading by projected cover days, per the SKU's Alpha/Beta band scheme. + + Thresholds and colours both live in `theme.COVER_BANDS` — one definition, so the + inventory grid, the cover tile and anything added later cannot drift into shading the + same number three different ways. + """ + return theme.cover_band(cover, inv_class)[0] + + +def ppc_chart(d) -> go.Figure: + """Daily PPC spend (thin) with 7-day moving average (bold), last 90 days.""" + h = d["hist"].tail(90) + ma = h["ad_spend"].rolling(7, min_periods=1).mean() + fig = go.Figure() + fig.add_trace(go.Scatter(x=h["date"], y=h["ad_spend"], name="Daily spend", + line=dict(color=theme.MUTED, width=1))) + fig.add_trace(go.Scatter(x=h["date"], y=ma, name="7-day average", + line=dict(color=theme.SERIES["us"], width=2))) + fig.update_yaxes(rangemode="tozero", title_text="spend ($/day)") + fig.update_layout(height=280) + return fig + + +def inventory_table_html(d) -> str: + """COSMOS INVP weekly projection table — REAL data from invp-insight dateMap: + projected units, inventory value, cover days (blue), and incoming arrivals.""" + proj = d.get("inv_projection") or [] + cfg, hist = d["cfg"], d["hist"] + asin = d.get("asin") or f"B0{abs(hash(cfg['sku'])) % 10**8:08d}" + asin_url = _amazon_url(asin, cfg.get("marketplace", "AMAZON_USA")) + asin_html = (f'{asin}' if asin_url else asin) + # COSMOS's OWN daily-sale figures, not ours re-derived from sales-insight history. The + # weekly cells below come straight from the INVP dateMap and always matched; the header did + # not, so one SKU showed 56 / 62 / 65 here against 64 / 130 / 67 in Inventory Planning. + # + # Falls back to the history means only where COSMOS returns nothing, so a gap still shows a + # number rather than a dash — but the two tools agree wherever COSMOS has an answer. + _st = d.get("invp_stats") or {} + d7 = _st.get("avg_sale") or _st.get("avg_7d") or hist["units"].tail(7).mean() + # The middle figure in COSMOS's header is POTENTIAL daily sale — its own demand estimate, + # not a trailing average. It is a different quantity from the 30-day mean that used to sit + # here, so it is labelled for what it is. + d_pot = _st.get("potential_daily_sale") + d6m = _st.get("avg_6m") or hist["units"].tail(90).mean() + reviews = f" ({cfg['reviews']:,})" if cfg.get("reviews") else "" + + if not proj: + return ('
' + 'No INVP projection available for this product from COSMOS.
') + + # Match the COSMOS Inventory Planning header exactly. + # + # `proj[0].inventory` is the FIRST PROJECTED WEEK, which already has that + # week's arrival folded in (5,029 = 3,999 on hand + 1,030 landing), so showing + # it as stock overstated the shelf by the inbound load. COSMOS shows them apart. + # + # Inbound is likewise the IMMEDIATE arrival, not every arrival on the horizon: + # summing the lot gave 1,770 against COSMOS's 1,030 by sweeping in +90 and +650 + # that land weeks later. Those are real, so they are reported separately. + on_hand = float((d.get("invp_stats") or {}).get("inventory") + or proj[0]["inventory"] or 0) + total_inbound = float(proj[0]["warehouse_arrival"] or 0) + later_inbound = sum(float(p["warehouse_arrival"] or 0) for p in proj[1:]) + + heads = "".join( + f'{pd.Timestamp(p["date"]):%m/%d/%Y}' for p in proj + ) + cells = [] + for p in proj: + units = float(p["inventory"] or 0) + value = float(p["inventory_value"] or (units * cfg["cost"])) + cover = float(p["cover_days"]) if p["cover_days"] is not None else 0.0 + arr = float(p["warehouse_arrival"] or 0) + cells.append( + f'' + f'
{units:,.0f}
' + f'
${value:,.0f}
' + f'
{cover:,.0f}' + f'{arr:,.0f}
' + ) + return ( + '
' + f'{heads}' + "" + f'' + f'' + f'{"".join(cells)}' + "
Product DescriptionDaily Sale
{cfg["title"]}
' + f'
{cfg["sku"]} · {asin_html}{reviews}
' + f'
📦 {on_hand:,.0f} on hand · 🚚 {total_inbound:,.0f} inbound' + f'{f" (+{later_inbound:,.0f} later)" if later_inbound else ""} · ' + f'class {cfg["inv_class"].capitalize()}
{d7:,.0f}
' + + (f'
{d_pot:,.0f} · potential
' if d_pot else '') + + f'
{d6m:,.0f} · 6mo avg
" + ) + + +# ---------------------------------------------------------------- sidebar +if "action_sel" not in st.session_state: + st.session_state.action_sel = "All" +if "status" not in st.session_state: + st.session_state.status = {} +if "modified" not in st.session_state: + st.session_state.modified = {} +if "live_skus" not in st.session_state: + st.session_state.live_skus = () + +with st.sidebar: + st.markdown( + '', + unsafe_allow_html=True, + ) + st.markdown("") + st.caption("ANALYZE") + with st.expander("Load products", expanded=not st.session_state.live_skus): + mode = st.radio("Mode", ["Single product", "Product line"], horizontal=True, + label_visibility="collapsed") + with_comp = st.checkbox("Include competitor prices", value=False, + help="Adds Buy Box + rival offers per product. Slower; " + "needs the competitor-data token configured in .env.") + + if mode == "Single product": + one = st.text_input("Product SKU", placeholder="e.g. UB-PILLOW-QUEEN", + help="Enter one Amazon SKU to analyze.") + if st.button("🔎 Analyze product", use_container_width=True, type="primary"): + skus = _parse_skus(one) + if not skus: + st.warning("Enter a SKU first.") + else: + _clear_live_cache() + st.session_state.live_skus = skus[:1] + st.session_state.live_comp = with_comp + else: + # Two steps on purpose: find the WHOLE line first, then decide what + # to analyze. Asking for a count up front means choosing blind — and + # silently truncating a 99-product line to the first 15. + prefix = st.text_input("Product line / SKU prefix", + placeholder="e.g. UBMICROFIBERDUVET", + help="Finds every product whose SKU contains this text.") + if st.button("🔎 Find products", use_container_width=True): + if not prefix.strip(): + st.warning("Enter a product-line prefix first.") + else: + with st.spinner("Finding every product in this line…"): + st.session_state.line = _find_line(prefix.strip()) + st.session_state.line_prefix = prefix.strip() + paste = st.text_area("…or paste an explicit SKU list", + placeholder="SKU-001, SKU-002, SKU-003", height=68) + if paste.strip() and st.button("🔎 Analyze pasted list", + use_container_width=True, type="primary"): + skus = _parse_skus(paste) + if not skus: + st.warning("Could not read any SKUs from that list.") + else: + _clear_live_cache() + st.session_state.live_skus = skus + st.session_state.live_comp = with_comp + st.divider() + st.caption("FILTERS") + q = st.text_input("Search", placeholder="Search SKU or product…") + +# ------------------------------------------------- product-line picker (main area) +line = st.session_state.get("line") or [] +if line and not st.session_state.live_skus: + st.markdown(f"## {len(line)} products in `{st.session_state.get('line_prefix', '')}`") + in_stock = [p for p in line if p["inventory"] > 0] + selling = [p for p in line if p["avg_30d"] > 0] + st.caption(f"{len(in_stock)} hold stock · {len(selling)} sold in the last 30 days · " + f"{sum(p['inventory'] for p in line):,.0f} units on hand") + + f1, f2, f3 = st.columns([1.4, 1.4, 1.2]) + with f1: + order = st.selectbox( + "Rank by", list(LINE_SORTS), + help="Which products matter most to look at first.") + with f2: + sizes = sorted({p["size"] for p in line}) + pick_sizes = st.multiselect("Size", sizes, default=[], + help="Bedding size, read from the SKU. " + "Empty = all sizes.") + with f3: + n_no_stock = len(line) - len(in_stock) + only_stock = st.toggle( + f"Only products with stock ({len(in_stock)})", value=True, + help=f"{n_no_stock} of {len(line)} products hold no inventory. A price " + f"change does nothing for a SKU with nothing to sell, so they are " + f"hidden by default — switch this off to include them.") + + pool = [p for p in line + if (not only_stock or p["inventory"] > 0) + and (not pick_sizes or p["size"] in pick_sizes)] + key, reverse = LINE_SORTS[order] + pool = sorted(pool, key=lambda p: _line_sort_key(p, key), reverse=reverse) + + if not pool: + st.warning("No products match those filters — loosen them to continue.") + st.stop() + + # Say why the pool is smaller than the line. Without this the headline reads + # "99 products" while the slider stops at 68, and nothing on screen explains it. + hidden = len(line) - len(pool) + if hidden: + why = [] + if only_stock and n_no_stock: + why.append(f"{n_no_stock} have no stock") + if pick_sizes: + why.append(f"size filter: {', '.join(pick_sizes)}") + st.info(esc( + f"Showing **{len(pool)} of {len(line)}** — {hidden} hidden " + f"({'; '.join(why)}). Clear the filters above to reach all {len(line)}.")) + + if len(pool) == 1: # a slider needs a range to be a slider + how_many = 1 + st.caption("1 product matches — it will be analyzed on its own.") + else: + how_many = st.slider( + f"How many to analyze — up to {len(pool)}", 1, len(pool), + min(10, len(pool)), + help="Each product takes roughly 30–60 seconds against COSMOS, so start " + "small and widen once you trust the results.") + chosen = pool[:how_many] + mins = max(1, round(how_many * 45 / 60)) + est = (f"about **{mins} minute{'s' if mins != 1 else ''}**" if mins < 60 else + f"about **{mins / 60:.1f} hours** — consider a smaller batch") + st.caption(f"Analyzing the top **{how_many}** of {len(pool)} by *{order.lower()}* " + f"· {est}") + + st.dataframe( + pd.DataFrame([{ + "": "▶" if i < how_many else "", + "SKU": p["sku"], "Size": p["size"], "Inventory": p["inventory"], + "Units/day (30d)": p["avg_30d"], "Cover (days)": p["cover_days"], + "Min PO": p["min_po_quantity"], + } for i, p in enumerate(pool)]), + hide_index=True, use_container_width=True, height=320, + column_config={ + "": st.column_config.TextColumn(width="small", help="Included in this run"), + "Inventory": st.column_config.NumberColumn(format="%d"), + "Units/day (30d)": st.column_config.NumberColumn(format="%d"), + "Min PO": st.column_config.NumberColumn(format="%d"), + }) + + b1, b2 = st.columns([1, 3]) + with b1: + if st.button(f"▶ Analyze {how_many}", type="primary", use_container_width=True): + _clear_live_cache() + st.session_state.live_skus = tuple(p["sku"] for p in chosen) + st.session_state.live_comp = st.session_state.get("live_comp", False) + st.rerun() + with b2: + if st.button("↩ Clear this line", use_container_width=True): + st.session_state.line = [] + st.rerun() + st.stop() + +# ---------------------------------------------------------------- load data +if not st.session_state.live_skus: + st.markdown("## Utopia Pricing Agent") + st.info("Open the sidebar and choose what to analyze:\n\n" + "- **Single product** — enter one SKU for a full price analysis.\n" + "- **Product line** — enter a SKU prefix and press **Find products**. " + "You'll see the whole line with stock and sales, then choose how many " + "to analyze and in what order.") + st.stop() +n = len(st.session_state.live_skus) +_loader = st.empty() + + +def _on_progress(frac: float, message: str): + frac = min(max(frac, 0.0), 1.0) + pct = int(round(frac * 100)) + # Segmented bar (filled vs empty) for a crisp, on-brand look. + fill = int(round(frac * 34)) + bar = ("" + "█" * fill + "" + + "" + "█" * (34 - fill) + "") + try: + _loader.markdown( + f"""
+
⚙️ Analyzing against COSMOS
+
{n} product{'s' if n > 1 else ''} · live fees, 6-month + history, elasticity & bulk economics
+
{bar}
+
{pct}%
+
{esc(message)}
+
""", + unsafe_allow_html=True, + ) + except Exception: + pass + + +_on_progress(0.0, "Connecting to COSMOS…") +data = _load_live(st.session_state.live_skus, + st.session_state.get("live_comp", False), progress=_on_progress) +_loader.empty() +if data["errors"]: + st.warning("Some SKUs could not be analyzed: " + esc( + " · ".join(f"**{s}** ({m})" for s, m in data["errors"].items()))) + +summary, details = data["summary"], data["details"] + +for sku in details: # keep statuses across reruns; init the new ones + st.session_state.status.setdefault(sku, "Pending") + +if summary.empty: + st.info("No SKUs produced an analysis. Check the warnings above (typical causes: " + "wrong SKU code, or the SKU has no sales/price data in COSMOS).") + st.stop() + +# ---------------------------------------------------------------- sidebar filters (cont.) +with st.sidebar: + f_mkt = st.selectbox("Marketplace", ["All"] + sorted(summary["marketplace"].unique()), + format_func=lambda m: "🌐 All" if m == "All" else mkt_label(m)) + f_conf = st.selectbox( + "Model trust", ["All", "High", "Medium", "Low", "None"], + help="How far this SKU's model may be trusted: backtest accuracy, breadth " + "of price evidence, elasticity usability and data completeness. Only " + "High may be bulk-approved.") + f_stat = st.selectbox("Status", ["All", "Pending", "Approved", "Rejected"]) + st.divider() + st.caption("CONTROLS") + kill = st.toggle("🛑 Global kill switch", + help="Emergency stop: pauses all approvals.") + st.button("↺ Reset workspace", on_click=reset_workspace, use_container_width=True) + st.divider() + st.caption("DATA STATUS") + st.caption(f"Prices as of {TODAY:%b %d, %Y}\n\n" + f"{len(details)} SKUs · 6-month COSMOS history\n\n" + + ("Competitor prices: included\n\n" if st.session_state.get("live_comp") + else "Competitor prices: not included\n\n") + + "Engine v1 · live COSMOS data") + +# ---------------------------------------------------------------- header +hour = datetime.now().hour +greet = "Good morning" if hour < 12 else ("Good afternoon" if hour < 18 else "Good evening") +pending = [s for s, v in st.session_state.status.items() + if v == "Pending" and s in details] +# Bulk approval requires a model that has been SCORED on that SKU's own history, +# not merely a confident-looking label. `trust.auto_approve` is High only when the +# backtest, the evidence base and the data completeness all pass. +bulk_eligible = [s for s in pending + if (details[s].get("trust") or {}).get("auto_approve") + and details[s]["action"] in ("Increase", "Decrease") + and abs(details[s]["delta_pct"]) <= 5] + +h1, h2 = st.columns([3, 1.3]) +with h1: + # The headline slot goes to the thing the reviewer is here to do, not to a + # greeting. The greeting keeps the warmth, at caption size where it belongs. + n_pend = len(pending) + if len(details) == 1: + # Lead with the product and the verdict. "1 recommendation to review" is a queue + # heading, and on a single product there is no queue — the reader already knows what + # they opened and wants to know what to do about it. + _hs = next(iter(details)) + _hd = details[_hs] + _title = (_hd.get("cfg") or {}).get("title") or _hs + _act = _hd["action"] + _verb = {"Increase": "Raise", "Decrease": "Lower", + "Maintain": "Hold", "Investigate": "Investigate"}.get(_act, _act) + if _act in ("Increase", "Decrease"): + _hl = (f"{_verb} to ${_hd['rec_price']:,.2f} " + f"({_hd['delta_pct']:+.1f}% from ${_hd['current_price']:,.2f})") + elif _act == "Maintain": + _hl = f"Hold at ${_hd['current_price']:,.2f}" + else: + _hl = f"Investigate — hold at ${_hd['current_price']:,.2f}" + st.markdown(f"## {esc(_hl)}") + _why = ", ".join(REASON_LABELS.get(c, c) for c in _hd["reasons"]) or "no signals" + st.caption(esc(f"{_title} · {_hs} · {_why}")) + else: + st.markdown(f"## {n_pend} recommendation{'s' if n_pend != 1 else ''} to review") + st.caption(f"{greet}, Utopia 👋 · sorted by expected profit impact") +with h2: + st.markdown("") + # Explain a control that cannot fire, rather than leaving a dead button. + if not bulk_eligible and not kill: + low_trust = [s for s in pending + if not (details[s].get("trust") or {}).get("auto_approve")] + why_blocked = (f"{len(low_trust)} of {n_pend} below High trust" + if low_trust else "nothing eligible") + else: + why_blocked = "" + st.button(f"✅ Bulk approve · {len(bulk_eligible)}", + disabled=kill or not bulk_eligible, use_container_width=True, + on_click=bulk_approve, args=(bulk_eligible,), + help=("Bulk approval needs a model scored on that SKU's own history. " + + (f"Blocked: {why_blocked}." if why_blocked else + "Approves pending High-trust recommendations within the 5% step cap."))) + if why_blocked: + st.caption(esc(f"Needs High trust · {why_blocked}")) + +if kill: + st.error("🛑 **Kill switch active** — approvals and price pushes are paused across all " + "marketplaces. Toggle it off in the sidebar to resume.") + +# ---------------------------------------------------------------- stat tiles +needing_action = [s for s in pending if details[s]["action"] != "Maintain"] +opportunity = sum(max(details[s]["impact_30d"], 0) for s in pending) +# Same thresholds the decision logic uses, imported rather than restated so the +# tiles and the recommendations can never drift apart. +from dashboard.live_data import HIGH_COVER_DAYS, LOW_COVER_DAYS + +stockout_risk = sum(1 for s in details if 0 < details[s]["cover_days"] <= LOW_COVER_DAYS) +overstock_risk = sum(1 for s in details if details[s]["cover_days"] >= HIGH_COVER_DAYS) + +# The opportunity figure inherits the model's measured error, so it is shown as a +# band. A precise-looking $32,974 from a model backtested at ±58% reads as a promise. +_errs = [e for s in pending if (e := model_error_pct(details[s])) is not None] +_err = max(_errs) if _errs else None + +# ONE SKU is a different question from a portfolio, so it gets different tiles. +# +# The counts above answer "where in my catalogue should I look?" — they are triage. On a +# single product that question is already answered, and the same tiles degenerate into +# tautologies: "1 SKU needing action" out of one, "0 stockout-risk SKUs", "1 pending +# approval". Nothing there tells a seller whether to take the price. +# +# So on a single product the header answers the seller's questions instead: what does this +# move earn, what margin does it leave, how much room is there before it loses money, will +# stock last, is it selling, and who else is on the listing. +if len(details) == 1: + _sku = next(iter(details)) + _d = details[_sku] + _e1 = model_error_pct(_d) + _econ = {x["key"]: x for x in (_d.get("scen_econ") or [])} + _cur_e = _econ.get("current") or {} + # The row at the price we are RECOMMENDING TODAY, not the rung the cascade named. When a + # move is step-capped those differ: `rec_key` here is the $20.00 destination while the + # headline recommends $17.94, and quoting the destination's margin beside a $17.94 headline + # would promise 13.4% for a move that delivers 6.6%. + _rec_e = next((x for x in (_d.get("scen_econ") or []) + if abs(x["price"] - _d["rec_price"]) < 0.011), + _econ.get(_d.get("rec_key")) or {}) + _fi = _d.get("floor_info") or {} + _floor = _fi.get("floor") + _cover = _d.get("cover_days") + _out = _d.get("outlook") or {} + _cm = _d.get("comp_meta") or {} + _ppc = _d.get("ppc") or {} + + # 1. What the move is worth. Held to the same error band as the portfolio figure. + _impact = _d.get("impact_30d") or 0.0 + _t_impact = ("💰", money_range(_impact, _e1) if _impact else "—", + "Impact · 30d if approved", + (f"net of ads + storage · ±{_e1 * 100:.0f}% model error" if _e1 + else "net of advertising and storage")) + + # 2. Margin now vs after. The number a seller judges a price by — so both halves have to + # be the same model at two prices. The current row's headline margin is computed from + # BOOKED profit at the average price customers paid; comparing that to a projection makes + # the move look better or worse than it is. On this SKU booked reads 6.7% while the model + # at today's price reads 3.3%, so "6.7% → 6.6%" said the move achieved nothing when it + # actually roughly doubles margin. + _m_rec = modelled(_rec_e).get("net_margin_pct") + _m_now = modelled(_cur_e).get("net_margin_pct") + _t_margin = ("📊", + f"{_m_rec:.1f}%" if _m_rec is not None else "—", + "Net margin after the move", + (f"now {_m_now:.1f}% · same model, both prices" if _m_now is not None + else "after ads and storage")) + + # 3. Headroom to the floor — how much room before the price stops paying. + if _floor and _d.get("current_price"): + _head = (_d["current_price"] - _floor) / _d["current_price"] * 100 + _t_floor = ("🛟", f"{_head:+.1f}%", "Headroom above the floor", + f"floor ${_floor:,.2f} ({_fi.get('binding', 'break-even')}) · " + f"today ${_d['current_price']:,.2f}") + else: + _t_floor = ("🛟", "—", "Headroom above the floor", "no floor — cost data missing") + + # 4. Will the stock last? A date beats a threshold count on a single SKU. + # + # Both readings are shown, because COSMOS's Inventory Planning grid prints the LONGER + # one and a single unexplained number here reads as a mismatch with their tool. They are + # different questions: ours is what is on the shelf now, COSMOS's counts stock still in + # transit. Neither is wrong; the pricing rules use the shorter one deliberately, because + # a SKU cannot sell a unit that has not landed. + _inv_cls = ((_d.get("cfg") or {}).get("inv_class") or "alpha").lower() + _onhand = _d.get("cover_days_onhand") + _band_hex, _band_word = theme.cover_band(_cover, _inv_cls) + _so, _arr = _out.get("stockout_date"), _out.get("next_arrival_date") + if _so and _arr: + _cover_note = (f"{_band_word} · out ~{_so:%d %b} · restock {_arr:%d %b}" + + (" — gap" if _so < _arr else " — covered")) + elif _so: + _cover_note = f"{_band_word} · out ~{_so:%d %b} · no restock scheduled" + else: + _cover_note = f"{_band_word} · {_inv_cls.capitalize()}-class band" + # Matches the COSMOS Inventory Planning grid. On-hand is shown beside it because the two + # differ by stock still in transit, and a stockout judged on undelivered units is worth + # being able to see. + if _onhand is not None and _cover is not None and _onhand != _cover: + _cover_note += f" · {_onhand:,} d on hand, rest inbound" + # A COSMOS band and a pricing trigger are different things, and the colour is the loudest + # thing on the tile — so where they disagree, say so. Without this a pink 82-day SKU reads + # as "the engine is about to discount this", when the cut rule does not fire until 90. + if _cover is not None and _band_word in ("high", "low") and not ( + 0 < _cover <= LOW_COVER_DAYS or _cover >= HIGH_COVER_DAYS): + _edge = HIGH_COVER_DAYS if _band_word == "high" else LOW_COVER_DAYS + _cover_note += (f" · COSMOS band only — no price move until {_edge} d") + _t_cover = ("📦", f"{_cover:,} d" if _cover is not None else "—", + "Inventory cover", _cover_note, _band_hex) + + # 5. Is it actually selling? + _u = _d.get("units_day") + _t_vel = ("⚡", f"{_u:,.0f}/day" if _u else "—", "Sales velocity", + (f"TACoS {_ppc['tacos']:.1f}%" if _ppc.get("tacos") is not None + else "30-day average")) + + # 6. Who else is on this listing — the one tile that can invalidate all the others. + if _cm.get("state_usable"): + _bb = str(_cm.get("state") or "—").replace("_", " ").title() + _riv = _cm.get("rivals") or 0 + _t_comp = ("🏆", _bb, "Buy Box", + (f"{_riv} rival(s) · median ${_cm['median']:,.2f}" + if _cm.get("median") else f"{_riv} rival(s)")) + else: + _t_comp = ("🏆", "N/A", "Buy Box", + "not covered by the competitor sheet" + if not _cm.get("sheet_covers_sku") else "no usable competitor data") + + tiles = [_t_impact, _t_margin, _t_floor, _t_cover, _t_vel, _t_comp] +else: + tiles = [ + ("⚡", str(len(needing_action)), "SKUs needing action", "queue below, ranked by impact"), + ("💰", money_range(opportunity, _err), "Profit opportunity · 30d", + (f"open positive impacts · ±{_err * 100:.0f}% model error" if _err + else "sum of open positive impacts")), + ("⏳", str(len(pending)), "Pending approvals", f"{len(bulk_eligible)} bulk-eligible"), + ("📉", str(stockout_risk), "Stockout-risk SKUs", f"cover ≤ {LOW_COVER_DAYS} days"), + ("📦", str(overstock_risk), "Overstock-risk SKUs", f"cover ≥ {HIGH_COVER_DAYS} days"), + ] +st.markdown('
' + # Tiles are (icon, value, label, note) with an OPTIONAL 5th accent colour, so a + # banded reading can tint its icon without every other tile growing a field. + + "".join(theme.tile(*t) for t in tiles) + + '
', unsafe_allow_html=True) + +st.markdown("") + +# ---------------------------------------------------------------- action pills +counts = summary["action"].value_counts().to_dict() +pills = [("All", "All", len(summary)), + ("Increase", "↑ Raise", counts.get("Increase", 0)), + ("Decrease", "↓ Lower", counts.get("Decrease", 0)), + ("Maintain", "→ Hold", counts.get("Maintain", 0)), + ("Investigate", "🔍 Check", counts.get("Investigate", 0))] +with st.container(key="pillrow"): # keyed so the CSS can keep these pills tight + pcols = st.columns([0.8, 1, 1, 1, 1, 2]) + for col, (a, lbl, n) in zip(pcols, pills): + col.button(f"{lbl} · {n}", key=f"pill_{a}", + type="primary" if st.session_state.action_sel == a else "secondary", + use_container_width=True, on_click=set_action, args=(a,), + help=f"Show {a.lower()} recommendations" if a != "All" else "Show all") + +# ---------------------------------------------------------------- queue +rows = summary.copy() +if f_mkt != "All": + rows = rows[rows["marketplace"] == f_mkt] +if f_conf != "All": + rows = rows[rows["trust_tier"] == f_conf] +if f_stat != "All": + rows = rows[[st.session_state.status[s] == f_stat for s in rows["sku"]]] +if st.session_state.action_sel != "All": + rows = rows[rows["action"] == st.session_state.action_sel] +if q: + mask = (rows["sku"].str.contains(q, case=False, regex=False) + | rows["title"].str.contains(q, case=False, regex=False)) + rows = rows[mask] + +if rows.empty: + st.info("No recommendations match the current filters.") + +for _, r in rows.iterrows(): + sku = r["sku"] + d = details[sku] + status = st.session_state.status[sku] + modified_price = st.session_state.modified.get(sku) + tier, score = d["confidence"] + stat_icon = theme.REC_STATUS[status][1] + glyph = ACTION_GLYPH[d["action"]] + + # Header: SKU · action+price · reason. ASIN, marketplace and inventory band moved + # into the card — six competing facts in one line had no hierarchy and wrapped. + asin = d.get("asin") or d["competitors"].iloc[0]["asin"] + inv_cls = d["cfg"]["inv_class"].lower() + trust = d.get("trust") or {} + target = modified_price if modified_price is not None else d["rec_price"] + mod_tag = " (modified)" if modified_price is not None else "" + if d["action"] == "Increase": + act = (f":green[**↑ RAISE ${d['current_price']:.2f} → ${target:.2f} " + f"(+{abs(d['delta_pct']):.1f}%){mod_tag}**]") + elif d["action"] == "Decrease": + act = (f":red[**↓ LOWER ${d['current_price']:.2f} → ${target:.2f} " + f"(−{abs(d['delta_pct']):.1f}%){mod_tag}**]") + elif d["action"] == "Investigate": + act = f":orange[**🔍 INVESTIGATE · hold ${d['current_price']:.2f}**]" + else: + act = f":gray[**→ HOLD ${d['current_price']:.2f}**]" + + # Trust rides in the row header, so it is visible before the row is opened. + trust_mark = {"High": "", "Medium": " · ⚠️ medium trust", + "Low": " · ⚠️ low trust", "None": " · ⛔ data incomplete"}.get( + trust.get("tier", ""), "") + label = esc(f"{stat_icon} **{sku}** · {act} · {r['primary_reason']}{trust_mark}") + + with st.expander(label): + # ---------- decision card ---------- + # Keyed container so the responsive CSS can stack these three columns on a + # narrow window instead of squeezing the metrics. + with st.container(key=f"deccard_{sku}"): + c1, c2, c3 = st.columns([2.4, 2.2, 1.4]) + with c1: + asin_url = _amazon_url(asin, r["marketplace"]) + asin_md = f"[{asin}]({asin_url})" if asin_url else esc(str(asin)) + st.caption(f"{r['title']} · ASIN {asin_md} · {mkt_label(r['marketplace'])}" + f" · {INV_CLASS_LABELS.get(inv_cls, inv_cls)}") + st.markdown(esc(f"#### {glyph} {d['action']}" + ( + f" to **${d['rec_price']:.2f}**" if d["action"] in ("Increase", "Decrease") else ""))) + st.markdown(esc(f"**Current price ${d['current_price']:.2f}**")) + chips = "".join(theme.chip(REASON_LABELS.get(c, c), theme.BRAND) + for c in d["reasons"]) + err = model_error_pct(d) + trust_chip = "" + if trust: + tcol = {"High": theme.STATUS["good"], "Medium": theme.STATUS["warning"], + "Low": theme.STATUS["serious"], + "None": theme.STATUS["critical"]}[trust["tier"]] + tlabel = f"🎯 {trust['tier']} trust" + if err: + tlabel += f" · ±{err * 100:.0f}% backtested" + trust_chip = theme.chip(tlabel, tcol) + # Only ONE reliability badge. The old confidence tier was derived + # from days-with-sales and happily read "Medium (62)" for a SKU + # whose elasticity could not be told apart from zero — shown next + # to "Low trust" it just contradicted it. The tier is still used + # internally to width the forecast band. + st.markdown(trust_chip + theme.status_badge(status) + chips, + unsafe_allow_html=True) + if trust.get("tier") in ("Low", "None"): + st.caption(esc(f"→ {trust['permits']} · see **🎯 Track record**")) + if modified_price is not None: + st.caption(esc(f"✏️ Approved at modified price ${modified_price:.2f} " + f"(engine recommended ${d['rec_price']:.2f})")) + for c in (d.get("clamps") or []): + st.caption(esc(f"🎯 {c}")) + # Guardrail detail is reference, not a decision input — one line of + # it belongs on the card, the rest behind a disclosure. + fl = d.get("floor_info") or {} + with st.popover("🛡️ Guardrails & break-even", use_container_width=True): + st.markdown(esc( + f"**Allowed range ${d['min_price']:.2f}–${d['max_price']:.2f}** · " + f"objective: {OBJECTIVE_LABELS.get(r['objective'], r['objective'])}")) + be_rows = [("Fees + COGS only", d["break_even"], "ignores advertising")] + if d.get("break_even_with_ads"): + be_rows.append(("Including advertising", d["break_even_with_ads"], + "at current ad cost per unit")) + if d.get("break_even_empirical"): + be_rows.append(("From actual booked profit", d["break_even_empirical"], + "carries every cost COSMOS books")) + st.dataframe( + pd.DataFrame(be_rows, columns=["Break-even", "Price", "Basis"]), + hide_index=True, use_container_width=True, + column_config={"Price": st.column_config.NumberColumn(format="$%.2f")}) + if fl.get("binding") and fl["binding"] != "accounting": + st.caption(esc( + f"Floor is set by the **{fl['binding']}** figure " + f"(${fl['floors'][fl['binding']]:.2f}). The fees-only number " + f"(${fl['floors']['accounting']:.2f}) ignores advertising, so " + f"pricing to it loses money on every unit.")) + with c2: + # Calibrated net profit for the headline metric (matches the tables). + _econ = {x["key"]: x for x in (d.get("scen_econ") or [])} + _rec_e = _econ.get(d["rec_key"]) + _cur_e = _econ.get("current") + rec_units = _rec_e["units_day"] if _rec_e else round(d["rec_units_day"]) + cur_units = _cur_e["units_day"] if _cur_e else round(d["units_day"]) + rec_net = _rec_e["net_30d"] if _rec_e else d["rec_profit_30d"] + cur_net = _cur_e["net_30d"] if _cur_e else d["profit_30d"] + m1, m2, m3, m4 = st.columns(4) + m1.metric("Units/day", f"{rec_units:,}", + f"{rec_units - cur_units:+,} vs now", delta_color="off") + # Point estimate stays the headline so it can be scanned; the range + # sits underneath. A range in the value slot has no break + # opportunity and shatters character-by-character in a narrow column. + # Point estimate as the headline, uncertainty as a short suffix. The + # delta cell is ~110px wide, so the full range belongs on the + # Scenarios tab, not here. + m2.metric("Net profit/30d", compact_money(rec_net), + (f"±{err * 100:.0f}% model error" if err else + f"{'+' if rec_net >= cur_net else '−'}" + f"{compact_money(abs(rec_net - cur_net))} vs now"), + delta_color="off") + # Cover in days, with the DATE it runs out — "26 days" is abstract, + # "out on 24 Aug" is a diary entry someone can act on. + ol = d.get("outlook") or {} + so = ol.get("stockout_date") + m3.metric("Cover", f"{d['rec_cover_days']} d", + (f"out ~{so:%d %b}" if so else + f"{d['rec_cover_days'] - d['cover_days']:+d} d"), + delta_color="off") + m4.metric("PPC/day", f"${d['ppc']['spend_day']:,.0f}", + f"TACoS {d['ppc']['tacos']:.1f}%", delta_color="off") + with c3: + # The primary action states what the evidence actually supports. At + # Low trust a bold "Approve" invites a commitment the model cannot + # back — the same click, honestly labelled, is a test. + tier = trust.get("tier", "Medium") + if tier == "None": + st.button("⛔ Blocked — fix data", key=f"ap_{sku}", disabled=True, + use_container_width=True, + help="; ".join(trust.get("reasons", [])) or "data incomplete") + elif tier == "Low": + st.button(f"🧪 Start test at ${d['rec_price']:.2f}", key=f"ap_{sku}", + type="primary", disabled=kill or status == "Approved", + on_click=set_status, args=(sku, "Approved"), + use_container_width=True, + help="Backtest error is high on this SKU, so treat the move " + "as an experiment: run it, then re-read the response.") + else: + st.button("✅ Approve", key=f"ap_{sku}", type="primary", + disabled=kill or status == "Approved", + on_click=set_status, args=(sku, "Approved"), + use_container_width=True) + with st.popover("✏️ Modify", use_container_width=True, disabled=kill): + st.number_input("New price ($)", key=f"mod_{sku}", + min_value=float(d["min_price"]), max_value=float(d["max_price"]), + value=float(min(max(d["rec_price"], d["min_price"]), + d["max_price"])), + step=0.05, format="%.2f") + st.caption(esc(f"Allowed ${d['min_price']:.2f}–${d['max_price']:.2f} · " + ">5% from current needs elevated approval")) + st.button("Apply & approve", key=f"modb_{sku}", + on_click=apply_modify, args=(sku, details), use_container_width=True) + st.button("✖️ Reject", key=f"rj_{sku}", disabled=kill or status == "Rejected", + on_click=set_status, args=(sku, "Rejected"), use_container_width=True) + if status != "Pending": + st.button("↩️ Undo", key=f"un_{sku}", + on_click=set_status, args=(sku, "Pending"), use_container_width=True) + + # ---------- strategy options ---------- + scen = d["scenarios"].set_index("scenario") + if d["action"] == "Investigate": + st.info("No price options proposed — investigate first. A change without a " + "confirmed cause is blocked by the root-cause gate.") + else: + # Calibrated economics keyed by scenario, so this table matches the + # Scenarios tab (net profit anchored to actual booked profit). + econ_by_key = {x["key"]: x for x in (d.get("scen_econ") or [])} + cover_by_key = {k: scen.loc[k]["cover_days"] for k in scen.index} + + def opt_row(label, key): + e = econ_by_key.get(key) + if not e: + return None + return { + "Option": label, "Price": e["price"], "Units/day": e["units_day"], + "Profit/30d": e["net_30d"], "Margin %": e["net_margin_pct"], + "Cover (days)": int(cover_by_key.get(key, 0)), + } + + # One row per distinct price. Rungs can legitimately share a price (e.g. + # nothing in the grid is bolder than the recommendation), so merge their + # labels instead of rendering the same row twice. + # + # Two things this table used to say that were not true: + # + # 1. It called the first row "Current" at a price that is NOT the current price. + # That row is real booked history, so its price is the AVERAGE SOLD price over + # the window (revenue / units) — deliberately, so revenue reconciles. But the + # rungs below it are built from today's price, so on one SKU the table read + # Current $18.18 / Conservative $17.43 and the "conservative" option looked like + # a cut on an Increase recommendation. Same column, two different bases, one + # label. Naming the basis is what makes the column readable. + # 2. It starred "Recommended" against the TARGET price while the header recommended + # the step-capped price for today. A row labelled Recommended has to be the + # thing being recommended. + win = d.get("scen_window") or 90 + rec_now, tgt = float(d["rec_price"]), float(d["target_price"]) + merged: dict[str, list[str]] = {} + for name, key in [(f"Current · avg sold ({win}d)", "current")] \ + + list(d["strategy_keys"].items()): + merged.setdefault(key, []).append(name) + + # Which grid key are we actually moving to today? + rec_key_now = next( + (k for k, e in econ_by_key.items() if abs(e["price"] - rec_now) < 0.011), None) + + opt_rows = [] + for key, names in merged.items(): + e = econ_by_key.get(key) + label = " · ".join(names) + # This rung is the destination, not this week's move — said once, over the + # whole merged label, not once per name it merged. + if d.get("stepped") and e is not None and abs(e["price"] - tgt) < 0.011: + label = f"Target (~14d) · {label}" + star = (rec_key_now is None and "Recommended" in names) or key == rec_key_now + opt_rows.append(opt_row(("★ " if star else "") + label, key)) + + # The step-capped price may not be one of the named rungs. If it is a grid row that + # nothing else labelled, surface it — otherwise the price in the header appears + # nowhere in the table underneath it. + if rec_key_now is not None and rec_key_now not in merged: + row = opt_row("★ Recommended now (5% step cap)", rec_key_now) + if row: + opt_rows.append(row) + opt_rows = [x for x in opt_rows if x] + opt_rows.sort(key=lambda x: x["Price"]) + + with st.container(key=f"optrow_{sku}"): + oc1, oc2 = st.columns([4, 1]) + with oc1: + st.dataframe( + pd.DataFrame(opt_rows), hide_index=True, use_container_width=True, + column_config={ + "Price": st.column_config.NumberColumn(format="$%.2f"), + "Units/day": st.column_config.NumberColumn(format="%d"), + "Profit/30d": st.column_config.NumberColumn(format="$%d"), + "Margin %": st.column_config.NumberColumn(format="%.1f%%"), + }, + ) + cur_econ = econ_by_key.get("current") or {} + cur_sold = cur_econ.get("price") + cap = ("Profit/30d is net of advertising and storage — same " + "basis as the Scenarios tab.") + # Reconcile the two prices ON SCREEN. They are both correct and they are + # different things; without this the reader is left to conclude one is a bug. + if cur_sold and abs(cur_sold - d["current_price"]) >= 0.01: + cap += (f" The Current row is **${cur_sold:,.2f}** — the average price " + f"actually *sold* over the last {win} days (revenue ÷ units), " + f"which is what its profit is computed from. Today's price is " + f"**${d['current_price']:,.2f}**, and every option below is " + f"calculated from that, so an option can sit below the Current " + f"row without being a price cut.") + st.caption(cap) + with oc2: + st.button("📊 View more", key=f"vm_{sku}", use_container_width=True, + on_click=goto_view, args=(sku, VIEW_SCENARIOS), + help="Jump to the full Scenarios breakdown") + + # ---------- section switcher (jumpable, unlike native tabs) ---------- + view_key = f"view_{sku}" + if view_key not in st.session_state: + st.session_state[view_key] = VIEW_KEYS[0] + sel = st.segmented_control("Section", VIEW_KEYS, key=view_key, + format_func=lambda k: VIEW_LABELS[k], + label_visibility="collapsed") + if sel is None: # ignore an accidental deselect + sel = st.session_state[view_key] or VIEW_KEYS[0] + + if sel == "price": + st.plotly_chart(price_demand_chart(d), use_container_width=True, + config={"displayModeBar": False}, key=f"pd_{sku}") + with st.popover("View as table"): + st.dataframe(d["hist"].tail(30), hide_index=True, use_container_width=True) + + elif sel == "inventory": + # Dated outlook up top: when it empties, when relief lands, and how + # many days of the last month were spent effectively out of stock. + ol = d.get("outlook") or {} + oos, known = d.get("stockout_days_30d") or (0, 0) + i1, i2, i3 = st.columns(3) + so, arr = ol.get("stockout_date"), ol.get("next_arrival_date") + if so: + src = ("COSMOS projection" if ol.get("stockout_source") == "projection" + else "extrapolated from velocity") + i1.metric("Runs out", f"{so:%d %b}", + f"{ol.get('days_to_stockout')} days · {src}", delta_color="off") + else: + i1.metric("Runs out", "not in horizon", + f"within {ol.get('horizon_days') or 0} days", delta_color="off") + if arr: + gap = (arr - so).days if so else None + i2.metric("Next arrival", f"{arr:%d %b}", + (f"{gap:+d} days vs stockout" if gap is not None + else f"{ol.get('next_arrival_units', 0):,.0f} units"), + delta_color="off") + else: + i2.metric("Next arrival", "none scheduled", + f"within {ol.get('horizon_days') or 0} days", delta_color="off") + i3.metric("Days out of stock", f"{oos}", + f"of {known} days with a reading" if known + else "no inventory readings", delta_color="off") + if so and arr and (arr - so).days > 0: + st.warning(esc( + f"⚠️ Stock runs out around **{so:%d %b}** but the next arrival is " + f"**{arr:%d %b}** — **{(arr - so).days} days uncovered.** Raising " + f"price slows the burn; it does not create stock.")) + st.markdown(inventory_table_html(d), unsafe_allow_html=True) + st.caption( + "Cell: big = projected units · purple = inventory value · blue = days of " + "cover · right = incoming units. Shading per this SKU's " + f"{d['cfg']['inv_class'].capitalize()}-class cover bands." + ) + mp = d.get("min_po") or {} + if mp.get("qty"): + po_note = (f"COSMOS suggests a min PO of **{mp['qty']:,.0f} units**" + + (f" within **{mp['days']:,.0f} days**" if mp.get("days") else "") + + " to avoid stockout.") + else: + po_note = "No minimum-PO recommendation from COSMOS." + st.caption(f"**Real COSMOS INVP projection** (invp-insight dateMap — projected " + f"units, value, cover days & warehouse arrivals). {po_note}") + st.plotly_chart(inventory_chart(d), use_container_width=True, + config={"displayModeBar": False}, key=f"inv_{sku}") + + elif sel == "repricing": + # What the recommended price does to the SHELF. The Inventory tab above is + # COSMOS's projection at TODAY's price, so a raise or a cut is otherwise shown + # with no stock consequence at all. + econ = {x["key"]: x for x in (d.get("scen_econ") or [])} + cur_e = econ.get("current") or {} + # The row at the price we actually recommend today, not the rung the cascade + # named — those differ whenever the move is step-capped. + rec_e = next((x for x in (d.get("scen_econ") or []) + if abs(x["price"] - d["rec_price"]) < 0.011), None) + # Demand response from the PRICE RATIO, not from the scenario table's unit + # counts. Those are rounded to whole units, and at low volume the rounding eats + # the entire signal: one real SKU sells 10 a month, and a 5% cut should lift that + # to 10.7 — both round to 10, so the re-projection reported a price cut with no + # effect on stock whatsoever. + # + # (p_new / p_now) ** elasticity is exact, and it is the same relationship the + # Scenarios tab applies: their common anchor cancels when you take the ratio of + # two rows, so this cannot drift from that table. + _ed = d.get("elasticity_detail") or {} + el = _ed.get("elasticity") or FALLBACK_ELASTICITY + u_now = float(d.get("units_day") or 0.0) + ratio = ((d["rec_price"] / d["current_price"]) ** el + if d.get("current_price") else 1.0) + u_new = u_now * ratio + rows = reproject_inventory(d.get("inv_projection") or [], u_now or 0, u_new or 0) + + if d["action"] == "Maintain" or abs(d["rec_price"] - d["current_price"]) < 0.005: + st.info("**No price change is recommended**, so the stock outlook is the " + "one on the Inventory tab. Nothing to re-project.") + elif not rows: + st.warning(esc( + "Cannot re-project the shelf for this SKU — that needs COSMOS's weekly " + "projection and a demand response at both prices, and one of them is " + "missing. The Inventory tab still shows the outlook at today's price.")) + else: + pct = (u_new / u_now - 1) * 100 if u_now else 0.0 + verb = "slows" if u_new < u_now else "speeds up" + r1, r2, r3 = st.columns(3) + r1.metric("Price", f"${d['rec_price']:,.2f}", + f"{d['delta_pct']:+.1f}% vs ${d['current_price']:,.2f}", + delta_color="off") + r2.metric("Units/day", f"{u_new:,.1f}", f"{pct:+.1f}% vs {u_now:,.1f} today", + delta_color="off") + last = rows[-1] + r3.metric("Stock at horizon", f"{last['units_new']:,}", + f"{last['delta_units']:+,} vs today's price", delta_color="off") + + # COSMOS sometimes projects a completely flat shelf — same units every week, + # no draw at all. That happens on very low-velocity SKUs (one real example + # holds 167 units and COSMOS shows 167 for all eleven weeks). There is then no + # depletion to re-scale, so the units column cannot move however the price + # changes, and only cover responds. Say so: a table of "+0" with no + # explanation reads as a broken feature rather than an upstream gap. + if all(r["delta_units"] == 0 for r in rows): + st.info(esc( + "COSMOS projects **no depletion** for this SKU — the same " + f"{rows[0]['units_now']:,} units in every one of its " + f"{len(rows)} weekly snapshots. There is no draw to re-scale, so the " + "unit columns cannot respond to a price change; only cover days move, " + "because those are computed from our own velocity.")) + + first_out = next((r for r in rows if r["stockout"]), None) + if first_out: + st.error(esc( + f"At ${d['rec_price']:,.2f} the shelf empties by " + f"**{pd.Timestamp(first_out['date']):%d %b}** — earlier than COSMOS " + f"projects at today's price. A cut sells the remaining stock faster; " + f"it does not create any.")) + else: + st.success(esc( + f"Demand {verb} by {abs(pct):.1f}%, so the shelf lasts " + f"{'longer' if u_new < u_now else 'less time'}. No stockout inside " + f"COSMOS's {len(rows)}-week horizon at this price.")) + + st.dataframe( + pd.DataFrame([{ + "Week": pd.Timestamp(r["date"]).strftime("%m/%d/%Y"), + "Units (today's price)": r["units_now"], + "Units (new price)": r["units_new"], + "Change": r["delta_units"], + "Cover now": r["cover_now"], + "Cover new": r["cover_new"], + "Arriving": int(r["arrival"]), + } for r in rows]), + hide_index=True, use_container_width=True, + column_config={ + "Units (today's price)": st.column_config.NumberColumn(format="%d"), + "Units (new price)": st.column_config.NumberColumn(format="%d"), + "Change": st.column_config.NumberColumn(format="%+d"), + "Cover now": st.column_config.NumberColumn(format="%d d"), + "Cover new": st.column_config.NumberColumn(format="%d d"), + "Arriving": st.column_config.NumberColumn(format="%d"), + }, + ) + st.caption( + "**This table is OUR projection, not COSMOS's.** The Inventory tab is " + "COSMOS's own forecast at today's price; this re-runs it at the " + "recommended price by scaling each week's SALES with the same elasticity " + "the Scenarios tab uses. Arrivals are unchanged — a PO already placed " + "does not move because we re-priced — and unit value stays on COSMOS's " + "cost basis. Stock is floored at zero; a negative shelf is not a forecast. " + "**Both cover columns divide by OUR velocity** so the difference between " + "them is the price effect alone — which is why they can sit a day or two " + "off the Inventory tab, where COSMOS's own figure is shown verbatim." + ) + + elif sel == "scenarios": + render_scenarios(d, key_prefix=f"sc_{sku}") + + elif sel == "competitors": + comp = d["competitors"] + meta = d.get("comp_meta") or {} + rivals = comp[~comp["is_us"]] # third-party sellers only + + if len(rivals) == 0: + # Nothing to compare against — show a message, NOT your own listing. + if meta.get("sheet_covers_sku"): + # The workbook covers this SKU but produced no usable rival: every match + # was fuzzy, or the only rivals had their own Buy Box suppressed. Say which, + # because "no data" and "data we refused to price against" are different. + st.warning( + "📋 **The comparison sheet covers this SKU but has no priceable " + "rival.** Every matched rival was either a NEAR colour match " + "(unverified) or had its own Buy Box suppressed, so none may move a " + "price. Open the workbook to see the raw pairings.") + elif not meta.get("scraped"): + st.info("🔍 **Competitor prices not scraped.** Tick **Include " + "competitor prices** in the sidebar and reload to pull Buy " + "Box + rival offers for this ASIN." + + (f" This SKU is also outside the comparison sheet, which " + f"covers {meta['sheet_covered_skus']} SKU(s) of " + f"{meta['sheet_line']}." + if meta.get("sheet_line") else "")) + else: + bb = meta.get("buy_box_status") + bb_price = meta.get("buy_box_price") + own = meta.get("own_offers", 0) + detail = "" + if bb == "WON" and bb_price: + detail = (f" You hold the **Buy Box at ${bb_price:.2f}**" + + (f" across {own} of your own offers" if own > 1 else "") + + ".") + st.success("✅ **No competitors on this ASIN.** Utopia Brands is the " + f"only seller — no third-party offers were found.{detail} " + "Pricing is driven by your own economics, not rivals.") + else: + # Real competitors exist → show ONLY the rival sellers. + # + # The two sources describe different things and must not share a sentence: an + # Apify row is another seller on OUR listing, a sheet row is a rival brand's own + # listing. Calling the latter "an offer on this ASIN" would be simply untrue. + if meta.get("source") == "comparison-sheet": + as_of = meta.get("sheet_as_of") + st.caption( + f"**{len(rivals)} like-for-like rival(s)** from the comparison sheet — " + f"each is a *different brand's own listing* matched on size + colour, " + f"not an offer on your ASIN" + + (f" · {meta['exact_rivals']} exact match(es) priceable" + if meta.get("exact_rivals") is not None else "") + + (f" · median ${meta['median']:,.2f}" if meta.get("median") else "") + + f" · your price ${d['current_price']:.2f}" + + (f" · sheet dated {as_of:%d %b %Y}" if as_of else "") + ".") + else: + st.caption(f"**{len(rivals)} competitor offer(s)** on this ASIN" + + (f" · competitor median ${meta['median']:,.2f}" + if meta.get("median") else "") + + f" · your price ${d['current_price']:.2f}.") + disp = pd.DataFrame({ + "Seller": rivals["seller"], + "ASIN": rivals["asin"].map(lambda a: _amazon_url(a, r["marketplace"])), + "ASIN code": rivals["asin"], + "Effective price": rivals["effective_price"].map(lambda v: f"${v:,.2f}"), + "Coupon": rivals["coupon"].map(lambda v: f"${v:,.2f}" if v else "—"), + "Badge": rivals["badge"], + "In stock": rivals["in_stock"].map({True: "Yes", False: "No"}), + "Scraped": rivals["freshness"], + }) + st.dataframe( + disp, hide_index=True, use_container_width=True, + column_config={ + "ASIN": st.column_config.LinkColumn( + "ASIN", display_text=r"/dp/([A-Z0-9]+)", help="Open on Amazon"), + "ASIN code": None, + }, + ) + st.caption("Third-party sellers on the same ASIN. Your own offers are " + "excluded. Prices are the effective (after-coupon) offer price.") + + elif sel == "ppc": + p = d["ppc"] + st.markdown("**Whole business — from sales-insight (last 30 days)**") + b1, b2, b3, b4 = st.columns(4) + b1.metric("Total revenue/30d", dash(p.get("revenue_30d"), "${:,.0f}"), + f"{p.get('units_30d', 0):,} units") + b2.metric("Ad budget spent/30d", dash(p["spend_30d"], "${:,.0f}"), + "marketing + promotion") + b3.metric("Ad spend/day", dash(p["spend_day"], "${:,.0f}")) + b4.metric("TACoS", dash(p["tacos"], "{:.1f}%"), "ad spend ÷ TOTAL revenue") + + if p.get("acos") is not None: + st.markdown("**Advertising only — from the Amazon Ads data COSMOS " + "proxies (`marketing/dashboard`)**") + a1, a2, a3, a4 = st.columns(4) + a1.metric("ACoS", dash(p["acos"], "{:.1f}%"), + "ad spend ÷ ad-attributed sales") + a2.metric("Ad-attributed sales", dash(p["ad_sales_30d"], "${:,.0f}"), + f"{p['ad_units_30d']:,} units" if p.get("ad_units_30d") + else "") + a3.metric("ROAS", dash(p["roas"], "{:.2f}×"), "sales per $1 of ad spend") + a4.metric("Assigned budget", dash(p["daily_budget"], "${:,.0f}"), + dash(p["budget_utilization"], "{:.0f}% used")) + e1, e2, e3, e4 = st.columns(4) + e1.metric("CPC", dash(p["cpc"], "${:.2f}")) + e2.metric("Clicks", dash(p["clicks"], "{:,}")) + e3.metric("Impressions", dash(p["impressions"], "{:,}")) + e4.metric("Conversion", dash(p["conversion"], "{:.2f}%"), + "orders ÷ clicks") + if p.get("ad_share_pct") is not None: + st.info(esc( + f"📣 Ads can claim **{p['ad_share_pct']:.0f}%** of units " + f"({p['ad_units_30d']:,} of {p['units_30d']:,}); the rest is " + f"organic. ACoS **{p['acos']:.1f}%** is the cost of the " + f"attributed portion — TACoS **{p['tacos']:.1f}%** spreads the " + f"same spend over all revenue. Judge campaigns on ACoS; judge " + f"the SKU's price on TACoS.")) + else: + st.info("No advertising campaigns found for this SKU in the last 30 " + "days, so ACoS / ad-attributed sales are not applicable.") + + st.plotly_chart(ppc_chart(d), use_container_width=True, + config={"displayModeBar": False}, key=f"ppc_{sku}") + st.caption( + "Two different denominators, both real: **TACoS** = total ad spend ÷ " + "**total** revenue (from `sales-insight`, includes promotions); " + "**ACoS** = ad spend ÷ **ad-attributed** sales (from the Ads data). " + "The pricing model uses total ad cost per unit, because a price change " + "moves all units — not only the ad-attributed ones.") + + elif sel == "costs": + cur_p, rec_p = d["current_price"], d["rec_price"] + rp = d.get("referral_pct", REFERRAL_PCT) + ret = d.get("returns_pct", 0.0) + var = d.get("variable", VARIABLE) + cost_rows = [ + ("Selling price", cur_p, rec_p), + (f"Referral fee ({rp * 100:.1f}%)", -cur_p * rp, -rec_p * rp), + ("FBA fee", -d["cfg"]["fba"], -d["cfg"]["fba"]), + ("Landed cost", -d["cfg"]["cost"], -d["cfg"]["cost"]), + ] + if ret: + cost_rows.append((f"Returns reserve ({ret * 100:.1f}%)", + -cur_p * ret, -rec_p * ret)) + cost_rows += [ + ("Other fees / variable", -var, -var), + ("Take-home / unit", unit_take_home(cur_p, d), unit_take_home(rec_p, d)), + ] + cdf = pd.DataFrame(cost_rows, columns=["Line", "At current price", "At recommended"]) + st.dataframe(cdf, hide_index=True, use_container_width=True, + column_config={ + "At current price": st.column_config.NumberColumn(format="$%.2f"), + "At recommended": st.column_config.NumberColumn(format="$%.2f"), + }) + st.caption(esc(f"Break-even ${d['break_even']:.2f} · guardrail floor " + f"${d['min_price']:.2f} · ceiling ${d['max_price']:.2f}")) + if d["cfg"]["cost"] <= 0 and d["cfg"]["fba"] <= 0: + st.warning("⚠️ COSMOS returned **no cost data** for this SKU — every line " + "above that depends on COGS/FBA is wrong until the product's " + "costs are filled in COSMOS/Inventory Planner.") + + elif sel == "track": + # How wrong has this model been on THIS SKU? Previously buried at the + # bottom of the last tab, under the narration it is meant to qualify. + tr, bt = d.get("trust") or {}, d.get("backtest") + if tr: + icon = {"High": "🟢", "Medium": "🟡", "Low": "🟠", "None": "⛔"}[tr["tier"]] + st.markdown(f"### {icon} {tr['tier']} trust") + st.markdown(esc(f"**What this permits:** {tr['permits']}")) + if tr["reasons"]: + st.markdown("**Why it isn't higher**") + for why in tr["reasons"]: + st.markdown(esc(f"- {why}")) + st.divider() + if bt: + sc = bt["scores"] + st.markdown(esc( + f"**Backtest** — fitted on older history, then asked to predict the " + f"profit actually booked over {bt['n_folds']} held-out " + f"{bt['holdout_days']}-day window(s), at the price really charged:")) + names = {"anchored": "Anchored (no calibration)", + "anchored_gap": "Anchored + per-unit gap", + "persistence": "Assume last month repeats (baseline)", + "legacy": "Previous engine (TACoS + factor)"} + st.dataframe( + pd.DataFrame([ + {"Method": ("→ " if m == bt["selected"] else "") + names[m], + "Avg error $/30d": sc[m]["mae"], + "Error % of actual": sc[m]["mape"], + "Bias $/30d": sc[m]["bias"]} + for m in ("anchored", "anchored_gap", "persistence", "legacy")]), + hide_index=True, use_container_width=True, + column_config={ + "Avg error $/30d": st.column_config.NumberColumn(format="$%d"), + "Error % of actual": st.column_config.NumberColumn(format="%.1f%%"), + "Bias $/30d": st.column_config.NumberColumn(format="$%d"), + }) + gp = d.get("gap_per_unit") or 0.0 + st.caption(esc( + f"→ marks the method actually used for this SKU's projections, " + f"chosen by this table" + + (f" (applying a **${gp:.2f}/unit** unmodeled cost)" if gp else "") + + ". Lower is better. **Bias** shows the direction of the miss: " + "positive means the model flatters this SKU. Beating *'assume last " + "month repeats'* is the minimum bar — a model that can't has added " + "nothing, and drops this SKU to Low trust.")) + with st.popover("Per-fold detail"): + st.dataframe(pd.DataFrame([ + {"Train days": f["train_days"], "Held-out days": f["test_days"], + "Price charged": f["test_price"], + "Actual $/30d": f["actual_net_30d"], + "Selected": f["pred"][bt["selected"]], + "Persistence": f["pred"]["persistence"], + "Previous": f["pred"]["legacy"]} for f in bt["folds"]]), + hide_index=True, use_container_width=True) + else: + st.info("Not enough history to hold any out — this SKU's model is " + "unscored, so recommendations stay human-approved.") + + elif sel == "why": + st.markdown(esc(d["explanation"])) + ev = d.get("evidence") or {} + if any(v is not None for v in ev.values()): + st.markdown("**Evidence from COSMOS actuals (6 months)**") + lines = [] + if ev.get("actual_profit_per_unit") is not None: + lines.append(f"- Actual profit/unit (ads incl.): " + f"**${ev['actual_profit_per_unit']:.2f}**") + if ev.get("unprofitable_months"): + lines.append(f"- Months that lost money: **{ev['unprofitable_months']}**") + if ev.get("best_observed_price") is not None: + lines.append(f"- Best observed price: **${ev['best_observed_price']:.2f}** " + f"(${ev.get('best_observed_profit_day') or 0:,.0f}/day actual)") + if ev.get("unmodeled_cost_gap") is not None: + lines.append(f"- Model vs actual gap: **${ev['unmodeled_cost_gap']:.2f}/unit**") + if ev.get("suggested_price") is not None: + lines.append(f"- Margin-floor suggested price: " + f"**${ev['suggested_price']:.2f}**") + if ev.get("trend"): + lines.append(f"- Sales trend: **{ev['trend']}**") + st.markdown(esc("\n".join(lines))) + st.markdown("**Root-cause checks**") + for check, result in d["root_cause"]: + ok = result.startswith(("confirmed", "positive", "detected")) + icon = "✅" if ok else ("⚠️" if ("missing" in result or "not feasible" in result) + else "▫️") + st.markdown(esc(f"{icon} {check} — *{result}*")) + st.markdown("**Risks**") + for risk, mit in d["risks"]: + st.markdown(f"- {risk} — *{mit}*") + st.caption("Every number here comes from the deterministic engine over " + "COSMOS data — see **🎯 Track record** for how accurate it has " + "been on this SKU.") + +st.divider() +st.caption("Utopia Pricing Agent · live COSMOS data · read-only (no prices are " + "written back) · legacy analyst UI: `streamlit run legacy_app.py`") diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..8a3d527 --- /dev/null +++ b/claude.md @@ -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=` 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). \ No newline at end of file diff --git a/competetor/Competitor_Price_Comparison_UBMICROFIBERDUVET_2026-07-29_1923.xlsx b/competetor/Competitor_Price_Comparison_UBMICROFIBERDUVET_2026-07-29_1923.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..351c08157f49583bf47fd71fc67ecd3412c57669 GIT binary patch literal 97599 zcmZ^JLzrl>x@Fn6ZQHiZQ?_l}wr$%wW!tV(w#}}4-=O;qdXVhB^JS2%WUsaUKT(hd z20;M;0Du6v$o$e486@;b-sP4M?hjs8TO(poqek6^#lrg|KeD46Dp^HqvIWMYD&EWz(5uJd;d9= zWhk1&r9={$zeF@5WL!1vkrd;j%(_*e_Bt?*gf zsS%}?YN9UcEH7Q}sq8jTdr+mB{F}{ILKSwW2Z;Ze1y;ZiaMzhV^Rc&tS(5_5#D_NW7V^?-4_b2?H%D~WHOEZ2otI_!d3ID)xpJdQZ$>*X)R0t+mZmt}S8CUP;p{u;; zi4A8K8?3#9J9m!O$%QC;9FRT5uH1o~N9(>~LCEGJi#g-9=Z)Hg(#jZR`NbEX-HR;q zY^WG5`9&+Y?Zw!Lk(xPo*xoz&u)*4hk*Yc83kx#xuUf0(GcsRiIvUCMWtWUO`Eu5Cf>Ta9bZ2*mG^VUQJz1fQO z$i0EY1?~8iS;`g#EQrjpfG6=l!r`sDucx%G@nUAr93QS3t7JcmD!{UGa>KU=jVhHQl{CPH>e9a9y5Hk)OwxS*~8pu zN8+ySXAPfcd2w6f)r!AOM61bSB%*m^w4H~D94(TtOvwBYIicy63OGQ;kc_DXp|8y& z)rfn@K5e}aW4kfoFI9G#yy^uSk{iLV2x3_K*-pn!^VT2WC1Uf>s@+>fX&R`2X&AdK zm8>+wEAAPtgae>URo5To%ExK;;>PTWUYX%Uhh4>6H*{~d2 z&;nW=DuVU@AdSqs%Plk`eri^;Ch_TyT}~&s%&a89Atvgw|Ms1Uibw3j3+0f?yef3i z0Qt(*9hB@p4V*OnYWEFYX11n@eGy+mmpX(eE8{<7VgWD8)P(E_R~S@4gAaLN9QwJa?|n1Td>f^PV8gWzZj*pBW9~L z^ij*WvC2Y6VWjRjJvORRmqfY7nfLed%bYLw?&^J=B)Be9la?4QUOJ#jkfi-HYT}k^ z2=dfeixhHFWzmE^5-C>*+xATPNh)RasIG&eok#2NtggVBk7o9bNAIM08k$+S%~ATi zHMzHij6>?NVai?(ZxV*vj3*Jd)h!>F_2XH`^^mxPK{-vh9ZRSe*rqdQi;E*SJ)r|? zQ7PVT@a=5a9c!O+9i+o2UEStnZGQgaq+hYV=!Pd1cQpLIknp-7G<|kZILyOKFWoF< zM1cjx#=4q>MyyT0ef5q8WzSfjHXl5wP7&x{NmZXwYXq1&zR+7HTJ+TZBu!iIU_S9+ zv(%);wvN&b(}qZibHE}U*{o_}$E2ZO6?axu>%P9%lD8REZuVQVW-Jc|Q4S3nNx~=@ zy;Q330aLm6gj#Y)m7`CTxfLl5qx;}^)Imp%*7te);N3QfTS6w=V;B+8Srmvk z1qTcbo#{~FIx!v$8E`szT;vug(6|)GKm8rL=N_hm5_P7AkJC?-CMZ-+cYzcMC9)m{ zln~wV;N~XD(k*)RohHC0ScS=W8GS-HqMw)9QFu*-^r3cx3_4_UUuC)rtCvh)deGB0 zhnDqzwE9P51@Db%m(zZeYX?~O7@0=6*tg#&GSVNh z%CNVyPX3KFo4eWTE`T8Gb7iymd3z7Wd#UPAi7_=Izcjcvej=DrwALewUL1;HO=&~T z$Oa>fhrh-HOo1uKZkT8$7V~mcHD?Tzo#3*-5&z~Atobu63r+&4E@MV3ThXB$6wlW8 z4Q&0uQ0%{}s6N2|Eg(vOBOq>ej#f_QCMM2K^#6VQS4CR1teiH+ zkiMvucP?F&E+8a}%s$J3q8S^4eEMTwcI<#g@+0FlcrV@kzMfA5f>~%KQrK1D;48d3 zy=JnnFpSi{8*d#Pqnn+%z5E;2j33IqX!?j38TY&jw&OhptveB)P`n!+(y2B47CqUw zKA>4o9*)cUK;Uy}{eyO$`IdnFN@6^FBCc~ZsV;jAbcVaD^NgjsgM2+f1L1oMUo`xd zFhy&Kmo=�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 literal 0 HcmV?d00001 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/pricing_rules.yaml b/config/pricing_rules.yaml new file mode 100644 index 0000000..fddddb1 --- /dev/null +++ b/config/pricing_rules.yaml @@ -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 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..4e5b6c8 --- /dev/null +++ b/config/settings.py @@ -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() diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000..3a90fd8 --- /dev/null +++ b/dashboard/__init__.py @@ -0,0 +1 @@ +"""Dashboard package: theme, demo data, and the live COSMOS data adapter.""" diff --git a/dashboard/line.py b/dashboard/line.py new file mode 100644 index 0000000..4904413 --- /dev/null +++ b/dashboard/line.py @@ -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) diff --git a/dashboard/live_data.py b/dashboard/live_data.py new file mode 100644 index 0000000..beff0f0 --- /dev/null +++ b/dashboard/live_data.py @@ -0,0 +1,1917 @@ +"""Live COSMOS adapter — builds the exact data structure the dashboard renders. + +Produces a ``{"summary": DataFrame, "details": {sku: {...}}}`` structure where +every number comes from the real pipeline: + + * fees / costs / break-even . takehome-calculator (via analyze_price) + * sales trend + inventory .... invp-insight + * 6-month daily history ...... sales-insight (price, units, ad spend, profit) + * elasticity + optimal price . pricing_agent.elasticity (log-log OLS fit) + * competitors (optional) ..... Apify Buy Box scrape when APIFY_TOKEN is set + +Fields the real APIs cannot provide are explicitly None (the UI shows "—"): +ad-attributed sales / ACoS (no ads API), purchase orders, competitor history. +""" +from __future__ import annotations + +import logging +import math +from datetime import date, datetime, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd + +logger = logging.getLogger("pricing_agent.dashboard") + +HISTORY_DAYS = 180 +FORECAST_DAYS = 30 +DOW = np.array([1.00, 0.94, 0.90, 0.93, 1.02, 1.22, 1.18]) # Mon..Sun +FALLBACK_ELASTICITY = -1.3 +REFERRAL_PCT = 0.15 # fallback referral fraction when a detail lacks fee params +VARIABLE = 0.55 # fallback per-unit variable expense + +# --- safety policy ----------------------------------------------------------- +# One price move may not exceed this. The UI has always claimed a 5% cap (bulk +# approve, the Modify warning); now the engine enforces it, so a "destination" +# price is reached in steps that can each be read before the next one. +MAX_STEP_PCT = 0.05 +# A recommendation may not exceed the highest price with a real sample behind it +# by more than this. Beyond it there is no evidence, only extrapolation. +OBSERVED_HEADROOM = 1.05 +# Margin held above the true (ad-inclusive) break-even by the guardrail floor. +FLOOR_SAFETY = 1.02 +# Default averaging window. 30 days is one month of noise; 90 spans enough price +# variation for the current-price baseline to be representative. +DEFAULT_WINDOW_DAYS = 90 +# Inventory thresholds, shared with the dashboard tiles so the two can never +# disagree. Both are inclusive: a SKU sitting exactly on the line counts as at +# risk, because a strict `<` once filed a SKU with 26 days of on-hand cover as +# "no stockout risk" on the strength of a projection that read 35. +# +# Read from pricing_rules.yaml because they are policy, not code — and because they have to be +# reconcilable with the COSMOS Inventory Planning bands that colour the same numbers on screen. +# The two are deliberately different: COSMOS's pink "high" band at 70 days is a REPLENISHMENT +# warning, while crossing `HIGH_COVER_DAYS` here spends real margin on a 5% discount. See the +# measured impact table in pricing_rules.yaml. +def _cover_rules() -> tuple[int, int]: + try: + from config.settings import get_rules + r = get_rules() + return int(r.low_cover_days), int(r.high_cover_days) + except Exception: # noqa: BLE001 — config must never stop the module importing + return 35, 90 + + +LOW_COVER_DAYS, HIGH_COVER_DAYS = _cover_rules() + +REASON_LABELS = { + "EXCESS_STOCK": "Excess inventory", + "INBOUND_PO": "Incoming stock", + "LOW_STOCK": "Low stock", + "STOCKOUT_BEFORE_ARRIVAL": "Stockout before PO arrival", + "COMPETITOR_PROMO": "Competitor promotion", + "VELOCITY_DROP": "Weak sales velocity", + "PREMIUM_HEADROOM": "Premium headroom", + "BELOW_BREAK_EVEN": "Selling below break-even", + "SEASONAL_LOW": "Off-season demand", + "NEW_LAUNCH": "New launch ramp", + "COMPETITOR_UNDERCUT": "Competitor undercut", + "NO_SIGNALS": "Stable — no action needed", + "UNEXPLAINED_DROP": "Unexplained sales drop", + "NO_COST_DATA": "Missing cost data (COSMOS)", + "PROFIT_OPTIMAL": "Profit-optimal price differs", + "LOSING_MONEY": "Losing money after ads", + "BEST_OBSERVED": "Better price already proven", + "AD_SPIRAL": "Ad cost scales with price — price is not the lever", + "BUYBOX_SUPPRESSED": "Buy Box suppressed — sells nothing at any price", + "COMPETITOR_PREMIUM": "Priced above the rival field", +} + + +def _sold_badge(units_month: float) -> str: + for floor, label in [(2000, "2K+"), (1000, "1K+"), (500, "500+"), (300, "300+"), + (100, "100+"), (50, "50+")]: + if units_month >= floor: + return f"{label} sold in last month" + return "—" + +CONF_BAND = {"High": 0.18, "Medium": 0.28, "Low": 0.40, "Insufficient": 0.50} + +RISKS = { + "Increase": [("Volume may drop more than the elasticity model expects", + "Small step; re-evaluate after 14 days"), + ("Raising price can slow organic ranking", + "Monitor sessions/share after the change")], + "Decrease": [("Demand may not respond at the modeled elasticity", + "Small step; re-evaluate after 14 days"), + ("Margin given up is certain, extra volume is not", + "Floor at min-safe; weekly checkpoint")], + "Maintain": [("Situation can drift while holding", + "Re-evaluated automatically on next refresh")], + "Investigate": [("No confirmed cause — a price cut could be the wrong lever", + "Human review; check listing health & traffic manually")], +} + + +# ---------------------------------------------------------------- fee model +def _fee_params(fees: dict, price: float) -> dict: + """Per-unit fee model parameters from the raw takehome-calculator response.""" + if not fees or price <= 0: + return {"referral_pct": 0.15, "returns_pct": 0.0, "cost": 0.0, "fba": 0.0, + "variable": 0.0} + other = sum(float(fees.get(k) or 0.0) for k in ( + "lowInventoryFee", "inboundPlacementFee", "eprFee", "vat", + "orderHandling", "weightHandling", "warehouseExpense")) + cost = float(fees.get("costPerUnit") or 0.0) + if cost <= 0 and isinstance(fees.get("costBreakdown"), dict): + cb = fees["costBreakdown"] + cost = sum(float(cb.get(k) or 0.0) for k in ("purchasePrice", "freight", "duty")) + return { + "referral_pct": float(fees.get("referralFee") or 0.0) / price, + "returns_pct": float(fees.get("returnFee") or 0.0) / price, + "cost": cost, + "fba": float(fees.get("fbaFee") or 0.0), + "variable": other, + } + + +def _take_home(price: float, fp: dict) -> float: + return price * (1 - fp["referral_pct"] - fp["returns_pct"]) - fp["cost"] - fp["fba"] - fp["variable"] + + +# ---------------------------------------------------------------- history +def _hist_frame(history, current_price: float, today: date) -> pd.DataFrame: + """Daily SalesDay points → the chart frame (full 180-day daily index).""" + idx = pd.date_range(pd.Timestamp(today) - pd.Timedelta(days=HISTORY_DAYS - 1), + periods=HISTORY_DAYS) + rows = {} + for p in history or []: + try: + dt = pd.Timestamp(f"{p.date[6:]}-{p.date[:2]}-{p.date[3:5]}") + except Exception: + continue + rows[dt] = {"price": p.sale_price, "units": p.units or 0.0, + "ad_spend": (p.marketing_cost or 0.0) + (p.promotion_spend or 0.0), + "revenue": p.revenue or 0.0, "profit": p.profit or 0.0, + # Daily inventory — the ONLY historical stock series we get, and + # what lets a stockout be told apart from a collapse in demand. + "inventory": p.inventory} + df = pd.DataFrame.from_dict(rows, orient="index").reindex(idx) + if df.empty or "price" not in df: + df = pd.DataFrame(index=idx, columns=["price", "units", "ad_spend", + "revenue", "profit", "inventory"], + dtype=float) + df["units"] = df["units"].fillna(0.0) + df["ad_spend"] = df["ad_spend"].fillna(0.0) + df["revenue"] = df["revenue"].fillna(0.0) + df["profit"] = df["profit"].fillna(0.0) + df["price"] = df["price"].ffill().bfill().fillna(current_price) + # inventory is deliberately NOT filled: a missing reading is "unknown", not + # "empty shelf". Filling it with 0 would manufacture phantom stockouts. + df["comp_median"] = np.nan # no historical competitor series in COSMOS + out = df.reset_index().rename(columns={"index": "date"}) + return out[["date", "price", "comp_median", "units", "ad_spend", "revenue", + "profit", "inventory"]] + + +def _change_dates(hist: pd.DataFrame) -> list: + """Dates where our price moved ≥1% vs the previous day (max 8 markers).""" + p = hist["price"].to_numpy(dtype=float) + out = [] + for i in range(1, len(p)): + if p[i - 1] > 0 and abs(p[i] - p[i - 1]) / p[i - 1] >= 0.01: + out.append(hist["date"].iloc[i]) + return out[-8:] + + +def _forecast(hist: pd.DataFrame, tier: str, today: date) -> pd.DataFrame: + recent = float(hist["units"].tail(28).mean()) if len(hist) else 0.0 + dates = pd.date_range(pd.Timestamp(today) + pd.Timedelta(days=1), periods=FORECAST_DAYS) + p50 = recent * DOW[[d.weekday() for d in dates]] + band = CONF_BAND.get(tier, 0.4) + return pd.DataFrame({"date": dates, "p50": np.round(p50, 1), + "p10": np.round(p50 * (1 - band), 1), + "p90": np.round(p50 * (1 + band), 1)}) + + +# ---------------------------------------------------------------- inventory outlook +# An arrival smaller than this many days of sales is noise, not relief. +MATERIAL_ARRIVAL_DAYS = 3 + + +def _proj_date(value) -> date | None: + """Parse a projection date — COSMOS mixes ISO `dataDate` with MM/DD/YYYY keys.""" + s = str(value or "").strip() + for fmt in ("%Y-%m-%d", "%m/%d/%Y"): + try: + return datetime.strptime(s[:10], fmt).date() + except ValueError: + continue + return None + + +def _inventory_outlook(projections: list, units_day: float, + today: date | None = None) -> dict: + """When does this SKU run out, and when does the next stock actually land? + + COSMOS already projects inventory forward in weekly steps and those snapshots + net off incoming arrivals, so the projection is the better answer whenever the + curve reaches zero inside the horizon. When it never does, we fall back to + on-hand ÷ velocity and SAY SO via `source` — a projected date and a + straight-line extrapolation are not the same claim and must not look alike. + + `horizon_end` exists so "no stockout" can be reported as "not within N weeks" + rather than implying "never". + """ + today = today or date.today() + out = {"stockout_date": None, "stockout_source": None, "days_to_stockout": None, + "next_arrival_date": None, "next_arrival_units": 0.0, + "horizon_end": None, "horizon_days": None} + rows = [(d, p) for p in (projections or []) + if (d := _proj_date(p.get("date"))) is not None] + rows.sort(key=lambda dp: dp[0]) + + if rows: + out["horizon_end"] = rows[-1][0] + out["horizon_days"] = (rows[-1][0] - today).days + for d, p in rows: + if float(p.get("inventory") or 0) <= 0: + out["stockout_date"] = d + out["stockout_source"] = "projection" + break + # First arrival big enough to matter — a handful of units is not relief. + floor = max(units_day * MATERIAL_ARRIVAL_DAYS, 1.0) + for d, p in rows: + if float(p.get("warehouse_arrival") or 0) >= floor: + out["next_arrival_date"] = d + out["next_arrival_units"] = float(p["warehouse_arrival"]) + break + + # Projection never empties. Only extrapolate when there is NOTHING inbound — + # a straight line from on-hand ÷ velocity cannot see arrivals, so using it on + # a SKU with stock landing would invent a stockout the projection disproves. + # (Observed: a pillow whose projected inventory RISES 52k→63k on a 10,830-unit + # arrival was being reported as "out on 29 Aug".) With stock inbound, the + # honest answer is "not within the horizon". + if (out["stockout_date"] is None and units_day > 0 and rows + and not any(float(p.get("warehouse_arrival") or 0) > 0 for _, p in rows)): + on_hand = float(rows[0][1].get("inventory") or 0) + if on_hand > 0: + out["stockout_date"] = today + timedelta(days=int(on_hand / units_day)) + out["stockout_source"] = "velocity" + + if out["stockout_date"]: + out["days_to_stockout"] = (out["stockout_date"] - today).days + return out + + +# ---------------------------------------------------------------- scenarios +def _scenarios(cur_price: float, units_day: float, inventory: float, fp: dict, + elasticity: float, comp_median: float | None, + extra_keys: dict | None = None) -> pd.DataFrame: + grid = { + "current": cur_price, + "up_2": round(cur_price * 1.02, 2), "up_5": round(cur_price * 1.05, 2), + "down_2": round(cur_price * 0.98, 2), "down_5": round(cur_price * 0.95, 2), + } + if comp_median and comp_median > 0: + grid["match_median"] = round(comp_median, 2) + for k, v in (extra_keys or {}).items(): + if v and v > 0: + grid[k] = round(v, 2) + + rows = [] + for key, p in grid.items(): + if p <= 0: + continue + units = units_day * (p / cur_price) ** elasticity if cur_price > 0 else units_day + th = _take_home(p, fp) + rows.append({ + "scenario": key, "price": p, "units_day": round(units, 1), + "revenue_30d": round(units * 30 * p), "profit_30d": round(units * 30 * th), + "margin_pct": round(th / p * 100, 1) if p else 0.0, + "cover_days": round(inventory / max(units, 0.1)), + }) + return pd.DataFrame(rows) + + +# ---------------------------------------------------------------- decision +def _order_ladder(action: str, rec_key: str, cons_key: str, aggr_key: str, + scen: pd.DataFrame, cur_price: float) -> tuple[str, str]: + """Force Conservative ≤ Recommended ≤ Aggressive (mirrored for a decrease). + + Each branch of `_decide` picks the three rungs independently, so a + recommendation landing on `max_profit` / `min_safe` could sit BEYOND the rung + labelled "aggressive" — the ladder then read Conservative $26.59 · + Recommended $31.45 · Aggressive $27.37, i.e. the aggressive option was the + *least* aggressive of the three. Rebuild the two flanking rungs from the + candidate prices so the ladder always reads in order. + + When no candidate is bolder than the recommendation, the aggressive rung + collapses onto it; the UI merges rungs that share a price into one row. + """ + if action not in ("Increase", "Decrease") or not len(scen): + return cons_key, aggr_key + prices = dict(zip(scen["scenario"], scen["price"])) + rec_p = prices.get(rec_key) + if rec_p is None: + return cons_key, aggr_key + sign = 1 if action == "Increase" else -1 + # candidates that move in the action's direction, ordered mildest → boldest + side = sorted(((k, p) for k, p in prices.items() if sign * (p - cur_price) > 0), + key=lambda kp: sign * kp[1]) + if not side: + return rec_key, rec_key + milder = [k for k, p in side if sign * (p - rec_p) <= 0] + bolder = [k for k, p in side if sign * (p - rec_p) >= 0] + return (milder[0] if milder else rec_key), (bolder[-1] if bolder else rec_key) + + +def price_floor(r, cur_price: float) -> dict: + """The lowest price this SKU may be set to, and why. + + The fee-stack break-even carries no advertising, so it sits well below the + price at which a unit actually stops losing money. Four floors are computed + and the highest wins: + + accounting fees + COGS only (what the API returns) + with_ads + advertising held flat (fixed + ad) / margin_rate + ad_curve + advertising rising with price (fixed + a) / (margin − slope) + empirical fitted to ACTUAL booked profit (everything COSMOS books) + """ + floors = {"accounting": r.break_even or 0.0, + "with_ads": r.break_even_with_ads or 0.0, + "empirical": r.break_even_empirical or 0.0} + # The ad-curve break-even only acts as a floor when it is actually reachable. + # When it isn't, it is a diagnosis (price can't fix this SKU), and using it + # would put a price 4x anything ever charged on screen as a "safe floor". + if r.break_even_ad_curve and not r.ad_curve_unrecoverable: + floors["ad_curve"] = r.break_even_ad_curve + binding = max(floors, key=lambda k: floors[k]) + value = floors[binding] + return {"floors": floors, "binding": binding, + "floor": round(value * FLOOR_SAFETY, 2) if value else 0.0, + "unrecoverable": bool(r.ad_curve_unrecoverable), + "contribution_per_dollar": r.contribution_per_dollar} + + +MATERIAL_GAIN = 1.10 # observed profit must beat today's by this much to act + +# A day counts as "effectively out of stock" below this many days of cover. +# NOT zero: Amazon's feed never reports a clean 0 — the audited SKU had 90/90 days +# of inventory readings and not one at zero — so an `inventory <= 0` test would +# never fire. One day of cover is deliberately conservative; no SKU examined so far +# contains an in-sample stockout to calibrate against, so this is a judgement call +# and should be revisited once one does. +STOCKOUT_COVER_DAYS = 1.0 +# Share of the drop window that must be stock-constrained before a velocity drop +# is explained by supply rather than demand. +STOCKOUT_SHARE = 0.20 + + +def _stockout_days(hist: pd.DataFrame, window: int = 30) -> tuple[int, int]: + """(days effectively out of stock, days with a reading) over the last `window`. + + Returns the denominator too, because "0 of 0" and "0 of 30" are very different + claims and the caller must not treat a data gap as proof of healthy stock. + """ + if "inventory" not in hist or not len(hist): + return 0, 0 + tail = hist.tail(window) + inv, units = tail["inventory"], tail["units"] + known = inv.notna() + if not known.any(): + return 0, 0 + # Threshold per row against that row's own demand, so a slow SKU is not judged + # by a fast SKU's yardstick. + floor = (units * STOCKOUT_COVER_DAYS).clip(lower=1.0) + return int((known & (inv <= floor)).sum()), int(known.sum()) + + +def _evidence_target(r, cur_price: float, keys: set) -> tuple | None: + """The price this SKU has ACTUALLY run that booked the most profit per day. + + Returns (key, why, gain_per_day) or None. ``best_observed_price`` has always + been computed and displayed; it was never allowed to drive a recommendation, + which is how an untested extrapolation could outrank a price with 54 days of + booked profit behind it. + + Acting requires the observed winner to beat today's own observed performance + by a clear margin — otherwise the difference is noise between price bands. + """ + from pricing_agent.performance import observed_profit_at + + bop, bday = r.best_observed_price, r.best_observed_profit_day + if not bop or bday is None or not cur_price or "best_observed" not in keys: + return None + if abs(bop - cur_price) / cur_price < 0.02: + return None # already there + cur_band = observed_profit_at(cur_price, r.price_bands or []) + cur_day = cur_band["actual_profit_per_day"] if cur_band else None + if cur_day is not None and bday <= max(cur_day * MATERIAL_GAIN, cur_day + 1): + return None # not materially better than today + if bday <= 0: + return None # every observed price lost money + days = next((b["days"] for b in (r.price_bands or []) + if abs(b["avg_price"] - bop) < 0.01), 0) + gain = bday - cur_day if cur_day is not None else bday + why = (f"${bop:.2f} booked ${bday:,.0f}/day of actual profit over {days} days" + + (f", against ${cur_day:,.0f}/day at today's price" if cur_day is not None + else "") + + " — the best of any price this product has run") + return (bop, why, gain) + + +def _decide(r, cur_price: float, hist: pd.DataFrame, fp: dict, scen: pd.DataFrame, + outlook: dict | None = None, comp=None, cover_days: int | None = None): + """Deterministic action from real signals, with an ordered strategy ladder. + + Two gates sit on top of the branch logic: + + 1. The model may only set price when its elasticity is statistically usable + AND its optimum is a genuine interior maximum. + 2. When the model is gated out — or when nothing else fires — observed + evidence gets its turn, because a price with weeks of booked profit behind + it outranks any projection. + + `comp` is an optional `CompetitiveState`. It may only ever ADD a verdict: when it + is absent, stale or unusable every branch below behaves exactly as it did before + competitor data existed. Nothing waits on it and nothing is blocked by it. + + Returns (action, rec_key, cons_key, aggr_key, reasons, root_cause, objective). + """ + action, rec, cons, aggr, reasons, root, objective = _decide_raw( + r, cur_price, hist, fp, scen, outlook, comp, cover_days) + keys = set(scen["scenario"]) if len(scen) else {"current"} + + # Price is not always the lever. When ad cost per unit climbs almost as fast + # as price, each extra $1 of price buys only cents of contribution, so no + # price reaches break-even — recommending a raise would cost volume and fix + # nothing. The lever is ad efficiency or COGS. + # Both this and BUYBOX_SUPPRESSED return Investigate-and-hold, so neither moves a price + # either way — but if the Buy Box is suppressed that is the thing to go and fix, and it + # must not be relabelled as an advertising problem on the way out. + if r.ad_curve_unrecoverable and "BUYBOX_SUPPRESSED" not in reasons: + cpd = r.contribution_per_dollar + detail = (f"each extra $1 of price yields only ${cpd:.2f} of contribution" + if cpd is not None else "ad cost rises faster than price") + if r.break_even_ad_curve and r.observed_price_max: + detail += (f"; break-even against that curve is ${r.break_even_ad_curve:.2f}, " + f"{r.break_even_ad_curve / r.observed_price_max:.1f}x the highest " + f"price ever charged (${r.observed_price_max:.2f})") + return ("Investigate", "current", "current", "current", ["AD_SPIRAL"], + root + [("Ad cost vs price", + detail + " — no reachable price breaks even. Fix ad " + "efficiency or COGS before touching price.")], + "fix_ad_efficiency") + + model_ok = bool(r.elasticity_actionable and r.profit_optimal_price + and not r.profit_optimal_is_corner) + if rec == "max_profit" and not model_ok: + rec, action, reasons = "current", "Maintain", ["NO_SIGNALS"] + root = root + [("Model gate", "elasticity not usable for pricing — " + + ((r.elasticity or {}).get("why") or "corner solution"))] + + # Evidence branch: only where the cascade found nothing to act on. Inventory + # and break-even signals keep priority — they are about risk, not optimisation. + if rec == "current" and action in ("Maintain",): + ev = _evidence_target(r, cur_price, keys) + if ev: + bop, why, gain = ev + rec = "best_observed" + action = "Increase" if bop > cur_price else "Decrease" + reasons = ["BEST_OBSERVED"] + objective = "observed_best" + root = root + [("Observed evidence", why)] + + cons, aggr = _order_ladder(action, rec, cons, aggr, scen, cur_price) + return action, rec, cons, aggr, reasons, root, objective + + +def _decide_raw(r, cur_price: float, hist: pd.DataFrame, fp: dict, scen: pd.DataFrame, + outlook: dict | None = None, comp=None, cover_days: int | None = None): + """Branch logic: which action and which scenario key each rung starts from.""" + keys = set(scen["scenario"]) if len(scen) else {"current"} + be = r.break_even or 0.0 + # The RECONCILED cover the dashboard tile shows, not COSMOS's raw field. + # + # COSMOS returns `cover_days: 0` on some low-velocity SKUs that are in fact sitting on + # months of stock, and 0 satisfies NEITHER inventory rule: `0 < cover <= 35` is false and + # `cover >= 90` is false, so both go silent and the SKU falls through to whatever rung is + # next. Measured on UBMICROFIBERBS4PCFULLGREY — 167 units on hand, 15 sales in six months, + # 334 days of real cover — COSMOS reported 0 and the SKU was filed COMPETITOR_UNDERCUT + # instead of EXCESS_STOCK, quietly bypassing "inventory outranks competitor position". + # + # `build_live_sku` already reconciles this for the tile (inventory / velocity when COSMOS's + # figure is missing). It just did it AFTER the decision. Passing the same number in is what + # makes the cascade and the tile agree by construction rather than by coincidence; the + # thresholds themselves are untouched. + cover = cover_days if cover_days is not None else r.cover_days + th_now = _take_home(cur_price, fp) + outlook = outlook or {} + + # real root-cause checks + recent_prices = hist["price"].tail(30) + own_change = bool(len(recent_prices) > 1 and recent_prices.iloc[0] > 0 and + abs(recent_prices.iloc[-1] - recent_prices.iloc[0]) / recent_prices.iloc[0] > 0.01) + prior_ad = float(hist["ad_spend"].iloc[-44:-14].mean()) if len(hist) >= 44 else 0.0 + last_ad = float(hist["ad_spend"].tail(14).mean()) + ad_collapse = bool(prior_ad > 1 and last_ad < prior_ad * 0.5) + drop = bool(r.avg_30d and r.avg_6m and r.avg_30d < 0.70 * r.avg_6m) + + # A velocity drop caused by empty shelves is a SUPPLY problem, and cutting + # price to fix it pays margin for nothing. Until now the two were + # indistinguishable and every such SKU was filed as UNEXPLAINED_DROP. + oos_days, known_days = _stockout_days(hist) + stock_explained = bool(known_days and oos_days >= known_days * STOCKOUT_SHARE) + + # Inventory outlook — dated urgency rather than a bare threshold crossing. + stockout_on = outlook.get("stockout_date") + arrival_on = outlook.get("next_arrival_date") + # True when we empty the shelf before relief lands, or no relief is scheduled. + stockout_first = bool(stockout_on and (arrival_on is None or stockout_on < arrival_on)) + inbound = bool(arrival_on) + + no_cost = fp["cost"] <= 0 and fp["fba"] <= 0 + root = [] + if no_cost: + root.append(("Cost data in COSMOS", "missing — costPerUnit/fbaFee are 0; " + "fill COGS in COSMOS to price this SKU")) + else: + root.append(("Take-home at current price", + f"{'confirmed negative' if th_now < 0 else 'positive'} — " + f"${th_now:.2f}/unit before ads")) + if r.ad_cost_per_unit is not None: + net = th_now - r.ad_cost_per_unit + root.append(("Net after ad cost", + f"${net:.2f}/unit (ads ${r.ad_cost_per_unit:.2f}/unit)")) + root.append(("Own price change (30d)", "detected" if own_change else "not found")) + root.append(("Ad-spend collapse", "detected — spend halved" if ad_collapse else "not found")) + if cover is not None: + root.append(("Inventory cover", f"{cover} days on hand")) + if known_days: + root.append(("Days effectively out of stock (30d)", + f"{oos_days} of {known_days} days with a reading" + + (" — enough to explain the velocity drop" + if stock_explained else ""))) + if stockout_on: + src = ("COSMOS projection" if outlook.get("stockout_source") == "projection" + else "extrapolated from on-hand ÷ velocity") + line = f"stock out around {stockout_on:%d %b} ({src})" + if arrival_on: + gap = (arrival_on - stockout_on).days + line += (f"; next arrival {arrival_on:%d %b}" + + (f" — {gap} days uncovered" if gap > 0 else " — arrives in time")) + else: + line += f"; no arrival scheduled within {outlook.get('horizon_days') or 0} days" + root.append(("Stockout outlook", line)) + if r.elasticity and r.elasticity.get("elasticity"): + root.append(("Elasticity fit", + f"e={r.elasticity['elasticity']} (r²={r.elasticity.get('r2')}, " + f"{r.elasticity.get('confidence')} confidence)")) + else: + root.append(("Elasticity fit", "not measurable — price barely moved in 6 months")) + + prices = dict(zip(scen["scenario"], scen["price"])) if len(scen) else {} + + def pick(key, fallback="current"): + return key if key in keys else fallback + + def raise_target(): + """Highest of min_safe / up_5 — a raise must never land below current.""" + cands = [k for k in ("min_safe", "up_5") if k in keys + and prices.get(k, 0) > cur_price] + return max(cands, key=lambda k: prices[k]) if cands else pick("up_5") + + # ── competitor signals ─────────────────────────────────────────────────── + # Read ONCE, here, so every branch below sees the same facts. `comp_usable` is the only + # gate: absent, stale, failed and "ownership unknown" all collapse to False and the + # cascade then behaves exactly as it did before competitor data existed. A competitor + # rule can only ever ADD a verdict, never withhold one. + from config.settings import get_rules + rules = get_rules() + # Scoped kill switch. Rather than branching around the two competitor rules — which would + # be a SECOND "pretend competitor data isn't there" path to keep in step with the first — + # the flag routes through the EXISTING fail-safe: the state is replaced with an explicit + # unavailable one, so `comp_usable` goes False and every branch below behaves exactly as it + # does for missing or stale data. One path, already tested. + if comp is not None and not rules.competitor_rules_enabled: + from pricing_agent.competitive_state import unavailable + comp = unavailable( + "competitor rules are switched off in pricing_rules.yaml " + "(competitor_rules_enabled: false) — this verdict is competitor-blind") + comp_usable = bool(comp is not None and getattr(comp, "usable", False)) + comp_suppressed = comp_undercut = False + comp_price = comp_gap = None + if comp_usable: + from pricing_agent.competitive_state import BASIS_SAME_ASIN, BASIS_SHEET + from pricing_agent.schemas import BuyBoxStatus + comp_suppressed = comp.status is BuyBoxStatus.SUPPRESSED + comp_gap = comp.undercut_pct + comp_price = comp.competitor_min + # What counts as "a cheaper competitor" depends on WHAT the cheaper price is a price + # of, and the two cases are not interchangeable: + # + # * SAME ASIN (Apify): only LOST_PRICE qualifies. A rival holding the Buy Box at or + # ABOVE our price is LOST_ELIGIBILITY — cutting fixes nothing and donates margin. + # * LIKE-FOR-LIKE SHEET: a rival BRAND's equivalent product is cheaper. This is a + # competitive-position signal and holds regardless of who owns our Buy Box — we can + # hold our own perfectly well while a different product undercuts us. It is exactly + # what the comparison workbook exists to report, and the sibling tool's own action + # classifier already treats it this way. + basis_qualifies = ( + (comp.basis == BASIS_SAME_ASIN and comp.status is BuyBoxStatus.LOST_PRICE) + or comp.basis == BASIS_SHEET + ) + comp_undercut = bool( + basis_qualifies + and comp_gap is not None + and comp_gap >= rules.competitor_undercut_material_pct + and comp_price + ) + root.append(("Competitor position", + f"{comp.status.value} ({comp.source}, basis={comp.basis}" + + (f", {comp.rivals} rival(s)" if comp.rivals else "") + + ")" + + (f" — cheapest comparable rival ${comp_price:.2f}, " + f"{comp_gap:.1%} below us" if comp_gap is not None and comp_price + else ""))) + # We HOLD the Buy Box and sit well above the rival field. Deliberately a NOTE and + # nothing else: holding the Buy Box at a premium is usually a position worth keeping, + # and the elasticity fit is the thing with actual evidence behind it. If the model + # already wants a raise it says so on its own rung; if it does not, a premium is not + # grounds to overrule it. So this can only ever SUPPORT a raise a human is already + # considering — it never sets a price and never changes an action. + if (comp.status is BuyBoxStatus.WON and comp_price and cur_price + and cur_price > comp_price * (1 + rules.competitor_premium_material_pct)): + over = (cur_price - comp_price) / comp_price + model_wants_raise = bool( + r.profit_optimal_price and r.profit_optimal_price > cur_price * 1.02) + root.append(( + "Competitor premium", + f"we hold the Buy Box at ${cur_price:.2f}, {over:.0%} above the cheapest " + f"rival (${comp_price:.2f})" + + (" — and the elasticity fit independently points higher, so the raise " + "already has evidence behind it" + if model_wants_raise else + " — the elasticity fit does NOT point higher, so this is context for a " + "human, not a reason to raise: no price change is recommended from it"))) + for note in getattr(comp, "notes", []) or []: + root.append(("Competitor data note", note)) + elif comp is not None: + # Say WHY it was ignored. "No competitor data" and "competitor data 9h old" look + # identical in a blank cell, and only one of them is worth re-running a scrape for. + root.append(("Competitor Buy Box", + f"not used — {getattr(comp, 'unusable_because', 'unavailable')}")) + + if no_cost: + return ("Investigate", "current", "current", "current", + ["NO_COST_DATA"], root, "fix_data_first") + + # A suppressed Buy Box sells NOTHING at any price, so every downstream number on this + # SKU — margin, elasticity, profit-optimal price — describes a listing nobody can buy + # from. It therefore sits directly under NO_COST_DATA: those are the only two states + # where the answer is "go and fix something", not "set a price". + # + # This does NOT reorder inventory risk against profit optimisation; those two keep their + # existing order below. It also matches the sibling competitor tool, whose action + # classifier already ranks a suppressed Buy Box above every pricing question. + if comp_suppressed: + why = comp.reason or "Amazon is not showing our offer" + return ("Investigate", "current", "current", "current", + ["BUYBOX_SUPPRESSED"], + root + [("Buy Box", f"SUPPRESSED — {why}. No price change can help until " + f"the listing is reinstated.")], + "fix_buybox_first") + + if be and cur_price < be: + rec = raise_target() + return ("Increase", rec, pick("up_2"), rec, + ["BELOW_BREAK_EVEN"], root, "margin_protection") + + if r.is_losing_money: + rec = raise_target() + return ("Increase", rec, pick("up_2"), rec, + ["LOSING_MONEY"] + (["VELOCITY_DROP"] if drop else []), + root, "margin_protection") + + if cover is not None and 0 < cover <= LOW_COVER_DAYS: + # STOCKOUT_BEFORE_ARRIVAL / INBOUND_PO are ADDITIVE — they explain, they do + # not escalate. This branch already recommends +5%, which IS the step cap + # (MAX_STEP_PCT), so there is nothing stronger to escalate to. Do not read + # their absence from the action as an oversight. + return ("Increase", pick("up_5"), pick("up_2"), pick("up_5"), + ["LOW_STOCK"] + + (["STOCKOUT_BEFORE_ARRIVAL"] if stockout_first else []) + + (["INBOUND_PO"] if inbound else []), + root, "low_stock_protection") + + if drop and not own_change and not ad_collapse: + # An empty shelf is not a demand mystery. Naming the cause keeps this off + # the Investigate pile, where it would have sat waiting for a human to + # rediscover that the SKU had simply run out. + if stock_explained: + return ("Maintain", "current", "current", pick("up_2"), + ["STOCKOUT_BEFORE_ARRIVAL", "VELOCITY_DROP"], root, + "low_stock_protection") + # A material competitor undercut is a THIRD known cause, alongside our own price + # change and an ad collapse. Once we hold that explanation, filing the drop as + # UNEXPLAINED is simply inaccurate — it sends someone off to rediscover a rival price + # already sitting in the root cause above. Same precedent as `stock_explained` + # directly above: a named cause converts an Investigate into an actionable verdict. + # + # So fall THROUGH rather than returning, and let the competitor branch below decide. + # It is the branch that knows what to do about a rival price, and its guardrails still + # apply. Observed on real SKUs: UBMICROFIBERDUVETKINGPURPLE (rival 15.6% below) and + # UBMICROFIBERBS4PCFULLGREY (35.2% below) were both filed UNEXPLAINED_DROP while the + # sheet held the explanation. + if not comp_undercut: + return ("Investigate", "current", "current", "current", + ["UNEXPLAINED_DROP", "VELOCITY_DROP"], root, "max_profit") + + if cover is not None and cover >= HIGH_COVER_DAYS: + # Same rule: additive. −5% is already the cap in the other direction. + return ("Decrease", pick("down_5"), pick("down_2"), pick("down_5"), + ["EXCESS_STOCK"] + (["VELOCITY_DROP"] if drop else []) + + (["INBOUND_PO"] if inbound else []), + root, "overstock_reduction") + + # A rival holds the Buy Box and is materially cheaper. Sits BELOW every inventory rule + # (a stockout costs more than a lost Buy Box — chasing a rival down while the shelf is + # emptying pays margin to sell out faster) and ABOVE profit-optimal, because a price the + # market is actively beating us on is a stronger signal than a modelled optimum. + # + # The recommendation is only ever a TARGET. It is floored at break-even x1.02, capped at + # +-5% per step and clamped to observed headroom by the guardrails downstream, exactly + # like every other branch — so "match the rival" can never be executed below break-even + # no matter what the rival charges. + if comp_undercut and "comp_match" in keys: + return ("Decrease", pick("comp_match", pick("down_2")), pick("down_2"), + pick("down_5"), + ["COMPETITOR_UNDERCUT"] + (["VELOCITY_DROP"] if drop else []), + root + [("Undercut basis", + f"{comp.basis} — " + + ("a rival brand's like-for-like variant is cheaper; we may still " + "hold our own Buy Box" + if comp.basis == BASIS_SHEET else + "we lost the Buy Box on our own listing to a cheaper offer"))], + "competitive_position") + + if r.profit_optimal_price and cur_price > 0: + gap = (r.profit_optimal_price - cur_price) / cur_price + if gap >= 0.02: + return ("Increase", pick("max_profit", pick("up_2")), pick("up_2"), + pick("up_5"), ["PROFIT_OPTIMAL", "PREMIUM_HEADROOM"], root, "max_profit") + if gap <= -0.02: + return ("Decrease", pick("max_profit", pick("down_2")), pick("down_2"), + pick("down_5"), ["PROFIT_OPTIMAL"], root, "max_profit") + + return ("Maintain", "current", "current", pick("up_2"), ["NO_SIGNALS"], root, "max_profit") + + +def _confidence(action: str, hist: pd.DataFrame, r) -> tuple[str, int]: + days_with_sales = int((hist["units"] > 0).sum()) if len(hist) else 0 + el = (r.elasticity or {}).get("confidence") + if days_with_sales >= 120 and el in ("high", "medium"): + tier, score = "High", 82 + elif days_with_sales >= 90: + tier, score = "Medium", 62 + elif days_with_sales >= 30: + tier, score = "Low", 45 + else: + tier, score = "Insufficient", 25 + if action == "Investigate" and tier in ("High", "Medium"): + tier, score = "Low", 40 + return tier, score + + +_SHEET_CACHE: dict = {} + + +def _load_competitor_sheet(force: bool = False): + """The competitor comparison workbook, loaded ONCE per process. + + Cached because a product line is 60+ SKUs and re-reading a 100 KB workbook per SKU would + turn one file read into sixty. Keyed on the resolved path and its mtime, so replacing the + sheet on disk is picked up on the next run without a restart. + + Returns None whenever there is no usable sheet — not configured, not found, unreadable, + wrong shape. That is a normal state, and every caller renders it as N/A. + """ + from config.settings import get_settings + from pricing_agent.competitor_sheet import CompetitorSheet + + s = get_settings() + try: + if s.competitor_sheet_path: + path = Path(s.competitor_sheet_path) + if not path.is_file(): + logger.warning("COMPETITOR_SHEET_PATH is set to %s but there is no file " + "there — competitor data will read N/A", path) + return None + key = (str(path), path.stat().st_mtime) + if force or _SHEET_CACHE.get("key") != key: + _SHEET_CACHE.clear() + _SHEET_CACHE.update(key=key, sheet=CompetitorSheet.load(path)) + return _SHEET_CACHE.get("sheet") + + dirs = [d for d in (s.competitor_sheet_dirs or "").split(";") if d.strip()] + key = ("discover", tuple(dirs)) + if force or _SHEET_CACHE.get("key") != key: + _SHEET_CACHE.clear() + _SHEET_CACHE.update(key=key, sheet=CompetitorSheet.discover(*dirs)) + return _SHEET_CACHE.get("sheet") + except Exception: # noqa: BLE001 — a bad sheet must never break a pricing run + logger.warning("competitor sheet could not be loaded; competitor data reads N/A", + exc_info=True) + return None + + +def _competitors(sku: str, asin: str | None, cur_price: float, units_month: float, + snap, sheet_row=None, sheet=None) -> tuple[pd.DataFrame, float | None, dict]: + """Our row + competitor rivals when available. + + Returns (frame, competitor_median, meta). `meta` carries the Buy Box status and + a `rivals` count so the UI can tell apart "not scraped", "scraped — no third-party + sellers", and "scraped — rivals found". + + TWO KINDS OF RIVAL, and the tab must not blur them: + + * `snap` (Apify) holds other sellers' offers on **our own ASIN** — same product, same + page, and a cheaper one means we lost the Buy Box. + * `sheet_row` holds a rival BRAND's equivalent variant, matched like-for-like on size and + colour. Different ASIN, different listing, different product. + + The sheet rows were previously read for the DECISION but never for this tab, so a SKU the + workbook covered — with two exactly-matched rivals priced — still rendered "Competitor + prices not scraped". That is the workbook and the recommendation disagreeing in front of a + stakeholder, which is the one thing the competitor wiring exists to prevent. + """ + rows = [{ + "seller": f"★ {sku} (us)", "asin": asin or "—", "is_us": True, + "effective_price": round(cur_price, 2), "coupon": 0.0, "badge": "—", + "sold_badge": _sold_badge(units_month), "rating": None, "reviews": None, + "bsr": None, "delivery_days": None, "in_stock": True, + "freshness": "live (COSMOS)", + }] + comp_median, rivals = None, 0 + meta = {"scraped": snap is not None, "buy_box_status": None, + "buy_box_price": None, "buy_box_seller": None, "own_offers": 0, + # Which of the two sources the rival rows below actually came from, so the UI can + # word "offers on this ASIN" vs "a rival brand's own listing" correctly instead of + # calling both the same thing. + "source": None, "exact_rivals": 0, "sheet_as_of": None} + if snap is not None: + comp_median = snap.competitive_median + meta["buy_box_status"] = getattr(snap.buy_box_status, "value", None) + meta["buy_box_price"] = snap.buy_box_price + meta["buy_box_seller"] = snap.buy_box_seller_name + meta["own_offers"] = sum(1 for o in snap.offers if o.is_ours) + for o in snap.offers: + if o.is_ours or o.price is None: + continue + rivals += 1 + rows.append({ + "seller": o.seller_name or "Unknown seller", "asin": snap.asin or asin or "—", + "is_us": False, "effective_price": round(o.price, 2), "coupon": 0.0, + "badge": "Featured Offer" if o.is_buy_box else "—", "sold_badge": "—", + "rating": None, "reviews": None, "bsr": None, "delivery_days": None, + "in_stock": True, "freshness": "competitor scrape", + }) + # Like-for-like rivals from the comparison workbook. Added when the per-ASIN scrape found + # no third-party offers — which is the normal case here, because we are the brand owner and + # sell our own ASINs alone. Our real competition is another brand's listing, and that is + # exactly what the sheet holds. + if sheet_row is not None and rivals == 0: + meta["source"] = "comparison-sheet" + meta["sheet_as_of"] = getattr(sheet, "as_of", None) + prices = [] + for brand, price in sorted(sheet_row.rivals.items()): + fuzzy = brand in sheet_row.fuzzy_rivals + supp = brand in sheet_row.suppressed_rivals + # Both exclusions are shown rather than hidden: a reader deciding whether to act + # needs to see the number AND why it did not move the price. + if fuzzy: + badge = "NEAR colour match — verify, does not price" + elif supp: + badge = "Buy Box suppressed — not buyable" + else: + badge = "exact match" + prices.append(price) + rows.append({ + "seller": brand, "asin": sheet_row.rival_asins.get(brand) or "—", + "is_us": False, "effective_price": round(price, 2), "coupon": 0.0, + "badge": badge, "sold_badge": "—", "rating": None, "reviews": None, + "bsr": None, "delivery_days": None, "in_stock": not supp, + "freshness": ("comparison sheet" + + (f" · {sheet.as_of:%d %b}" if getattr(sheet, "as_of", None) + else "")), + }) + rivals += 1 + if prices: + # A real median, not the upper of a pair: with two rivals at $19.99 and $24.99, + # `prices[len//2]` reports $24.99, which is simply the wrong number to print next + # to the word "median". + import statistics + comp_median = round(float(statistics.median(prices)), 2) + meta["exact_rivals"] = len(prices) + elif rivals: + meta["source"] = "apify" + + meta["rivals"] = rivals + return pd.DataFrame(rows), comp_median, meta + + +def _explain(sku, action, cur_price, rec_row, cur_row, reasons, tier, r) -> str: + labels = ", ".join(REASON_LABELS.get(c, c).lower() for c in reasons) + if "NO_COST_DATA" in reasons: + return (f"COSMOS has **no cost data** for {sku} (costPerUnit and fbaFee came back 0), " + f"so break-even and margins cannot be computed. Fix the product's COGS in " + f"COSMOS/Inventory Planner first — any price recommendation would be a guess.") + if "AD_SPIRAL" in reasons: + cpd = getattr(r, "contribution_per_dollar", None) + be_c = getattr(r, "break_even_ad_curve", None) + return ( + f"**Price is not the lever on this SKU.** Advertising cost per unit rises " + f"with price almost as fast as price itself" + + (f", so each extra $1 of price leaves only **${cpd:.2f}** of contribution" + if cpd is not None else "") + + (f". Break-even against that curve is **${be_c:.2f}** — far beyond any price " + f"this product has ever run" if be_c else ".") + + f" Raising from ${cur_price:.2f} would shed volume without reaching profit. " + f"The fix is ad efficiency (bid/placement/targeting) or landed cost; " + f"re-run pricing once ad cost per unit is under control." + ) + if action == "Investigate": + return (f"Sales are running well below the 6-month baseline but **no checked cause " + f"explains it** — no price change on our side and no ad-spend collapse. " + f"Cutting price without a confirmed cause risks paying margin for a problem " + f"price didn't create. Recommend holding ${cur_price:.2f} and reviewing " + f"listing health and traffic manually.") + if action == "Maintain": + return (f"Signals: {labels}. Best response is to hold at **${cur_price:.2f}** — " + f"expected ≈ ${cur_row['profit_30d']:,.0f}/30d at " + f"{cur_row['units_day']:.0f} units/day. Re-evaluated on every refresh.") + delta = rec_row["price"] - cur_price + extra = "" + if r.actual_profit_per_unit is not None: + extra = (f" COSMOS actuals show {'a loss' if r.actual_profit_per_unit < 0 else 'profit'} of " + f"${abs(r.actual_profit_per_unit):.2f}/unit over the last 3 months (ads included).") + return (f"Driven by {labels}: move from ${cur_price:.2f} to **${rec_row['price']:.2f}** " + f"({'+' if delta >= 0 else '−'}${abs(delta):.2f}). Expected: " + f"{rec_row['units_day']:.0f} units/day, profit ${rec_row['profit_30d']:,.0f}/30d " + f"(from ${cur_row['profit_30d']:,.0f}), margin {rec_row['margin_pct']:.1f}%, " + f"cover {rec_row['cover_days']:.0f} days. Confidence is {tier.lower()}; " + f"guardrails enforced on every option.{extra}") + + +# ---------------------------------------------------------------- bulk economics +SCEN_LABELS = { + "current": "Current", "up_2": "+2%", "up_5": "+5%", "down_2": "−2%", + "down_5": "−5%", "match_median": "Match competitor", "min_safe": "Safe floor", + "max_profit": "Profit-optimal", "best_observed": "Best observed", +} + + +def _storage_30d(svc, sku: str, cur_price: float, units_30d: float, + inventory: float, days: int = 30) -> float: + """One bulk-calculator call → the storage charge over `days` (fixed across prices). + + **The API returns a PER-DAY charge.** This is not documented and is easy to + reintroduce, so: `storageCharges` scales linearly with `inventory` and is + completely independent of `saleUnits` — identical at 100, 1,000, 10,000 and + 43,007 units. 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. Consuming it as a monthly + figure understated storage 30x — $971 vs $29,127 per 30 days on one SKU, + about $0.55/unit against a $1.36/unit unexplained profit gap. + + Bulk take-home per unit is linear in price and equals our fee model, so a + single call at the current price gives the rate; every scenario's economics + then reconcile to what the bulk calculator would return. + """ + try: + b = svc.bulk_quote(sku, cur_price, sale_units=max(units_30d, 1), + inventory=max(inventory, 1), marketing=0.0) + return float(b.storage_charges or 0.0) * days + except Exception: + logger.warning("bulk storage lookup failed for %s", sku) + return 0.0 + + +def _scenario_economics(cur_price: float, units_day: float, fp: dict, + elasticity: float, tacos_frac: float, storage_30d: float, + grid: dict, actual_30d: float | None = None, + actual_rev: float | None = None, + actual_ad: float | None = None, + actual_units: float | None = None, + realized_price: float | None = None, + ad_model: dict | None = None, + bands: list | None = None, + gap_per_unit: float = 0.0, + ) -> tuple[list[dict], dict]: + """Economics per candidate price, on ONE consistent price basis. + + Two rules make this table honest, and both were once broken: + + 1. **Anchor demand at the price customers actually paid**, not the list price. + Units come from a window in which the SKU sold at ``realized_price`` + (revenue ÷ units), which sits below list whenever there are promos or + coupons. Anchoring the elasticity ratio at list while taking units from + that window inflates every projection and makes modelled revenue + non-monotonic in price — impossible under ``p^(1+e)``. + + 2. **Ad spend per unit rises with price**, because a higher price converts + worse. Holding TACoS constant implies ad spend *falls* when you raise + price, which is backwards. ``ad_model`` (fitted on observed price bands) + supplies the real relationship; without it we hold ad $/unit flat. + + The current row carries both readings: ``*_actual`` are COSMOS's booked facts, + while the modelled fields put it on the same basis as every other row so the + two are comparable. There is deliberately no calibration factor — a + multiplicative correction fitted at one price also scales the slope, which + flattens the very price/profit relationship this table exists to measure. + """ + from pricing_agent.performance import ad_per_unit_at + + anchor = realized_price if (realized_price or 0) > 0 else cur_price + ad_flat = (actual_ad / actual_units) if (actual_ad and actual_units) else ( + tacos_frac * anchor) + + rows = [] + for key, p in grid.items(): + if p <= 0: + continue + ratio = (p / anchor) ** elasticity if anchor > 0 else 1.0 + u_day = max(units_day * ratio, 0.0) + u_30d = u_day * 30 + revenue = u_30d * p + th_pu = _take_home(p, fp) # == bulk take-home/unit + gross = th_pu * u_30d - storage_30d # bulk total take-home + ad_pu = ad_per_unit_at(p, ad_model, ad_flat) # $/unit, price-dependent + ad = ad_pu * u_30d + # Costs COSMOS books that the fee model cannot see, as a constant $/unit. + # Constant, not proportional: it corrects the level without bending the + # slope between prices, and the backtest decides whether to apply it. + gross -= gap_per_unit * u_30d + obs = _observed_band(p, bands) + rows.append({ + "key": key, "label": SCEN_LABELS.get(key, key), "price": round(p, 2), + "delta_pct": (p - cur_price) / cur_price * 100 if cur_price else 0.0, + "units_day": int(round(u_day)), "units_30d": int(round(u_30d)), + "revenue_30d": round(revenue), "ad_30d": round(ad), + "ad_per_unit": round(ad_pu, 2), + "gross_30d": round(gross), "net_30d": round(gross - ad), + "th_pu": round(th_pu, 2), + # provenance: has this price ever actually been run? + "observed_days": obs["days"] if obs else 0, + "observed_net_30d": (round(obs["actual_profit_per_day"] * 30) + if obs else None), + "is_actual": False, + }) + cur = next((x for x in rows if x["key"] == "current"), rows[0] if rows else None) + + # The current row keeps COSMOS's booked facts ALONGSIDE its modelled twin, so + # the reader can see the model's own baseline instead of a silent basis change. + if cur: + cur["is_actual"] = True + # Snapshot the model's own reading BEFORE the booked figures overwrite it. Every + # economic field gets a twin, not just some of them: a caller that finds + # `modelled_net_30d` but no `modelled_price` is the one that ends up comparing a + # modelled profit against a booked price. Read them through `modelled()`. + cur["modelled_price"] = cur["price"] + cur["modelled_revenue_30d"] = cur["revenue_30d"] + cur["modelled_net_30d"] = cur["net_30d"] + cur["modelled_units_30d"] = cur["units_30d"] + cur["modelled_units_day"] = cur["units_day"] + cur["modelled_ad_30d"] = cur["ad_30d"] + if actual_units: + cur["units_30d"] = int(round(actual_units)) + cur["units_day"] = int(round(actual_units / 30)) + if actual_rev: + cur["revenue_30d"] = round(actual_rev) + if actual_ad is not None: + cur["ad_30d"] = round(actual_ad) + if actual_30d is not None: + cur["net_30d"] = round(actual_30d) + cur["price"] = round(anchor, 2) # what buyers actually paid + cur["list_price"] = round(cur_price, 2) + + if cur: + # The modelled margin needs the modelled numerator AND denominator. Computing it here, + # once, is what stops a caller pairing a booked margin with a projected one. + cur["modelled_net_margin_pct"] = ( + round(cur["modelled_net_30d"] / cur["modelled_revenue_30d"] * 100, 1) + if cur.get("modelled_revenue_30d") else 0.0) + + for x in rows: + x["net_margin_pct"] = round(x["net_30d"] / x["revenue_30d"] * 100, 1) \ + if x["revenue_30d"] else 0.0 + x["net_vs_current"] = round(x["net_30d"] - (cur["net_30d"] if cur else 0)) + x["ad_vs_current"] = round(x["ad_30d"] - (cur["ad_30d"] if cur else 0)) + x["advice"] = _scenario_advice(x, cur) + best = max(rows, key=lambda z: z["net_30d"]) if rows else None + summary = _scenario_summary(cur, best, tacos_frac, False) + return rows, {"best_key": best["key"] if best else None, "summary": summary, + "calibrated": False, "factor": 1.0, "anchor_price": round(anchor, 2)} + + +# The economic fields the `current` row carries twice — once booked, once modelled. +_TWINNED = ("price", "units_30d", "units_day", "revenue_30d", "ad_30d", "net_30d", + "net_margin_pct") + + +def modelled(row: dict | None) -> dict: + """A scenario row as THE MODEL sees it — safe to compare against any other row. + + The `current` row deliberately carries two readings of the same product: + + * **booked** — COSMOS's actual units, revenue, ad spend and profit over the window, + priced at the average customers really paid (revenue / units); + * **modelled** — the same model every other row uses, evaluated at TODAY's price. + + Both are correct and they answer different questions. Booked says what this SKU earned; + modelled is the only one that can be differenced against another price, because a delta + has to subtract like from like. + + Getting that wrong put three wrong numbers on screen before this function existed — + an impact of -$78 on a +5% raise (so the headline tile read "Profit opportunity $0"), + a strategy table whose "Conservative" option looked like a price cut, and a margin tile + claiming 6.7% -> 6.6% for a move that actually doubles margin. Each was a separate + hand-written ``row.get("modelled_x", row["x"])`` that covered some fields and not others. + + Every other row is already modelled, so this returns it unchanged. Use it for ANY + cross-row arithmetic; reach for the raw row only to report what was actually booked. + """ + if not row: + return {} + out = dict(row) + for k in _TWINNED: + twin = f"modelled_{k}" + if twin in row: + out[k] = row[twin] + return out + + +def booked(row: dict | None) -> dict | None: + """A row as COSMOS BOOKED it, or None when the row is a projection rather than a fact. + + The counterpart to `modelled()`, so "what did this actually earn?" has an answer that is + equally hard to get wrong. Only the `current` row is ever a fact. + """ + if not row or not row.get("is_actual"): + return None + return dict(row) + + +def modelled_net_30d(row: dict | None, fallback: float = 0.0) -> float: + """The model's 30-day net for a row. Thin wrapper over `modelled()`.""" + if not row: + return fallback + return float(modelled(row).get("net_30d", fallback)) + + +def reproject_inventory(projections: list, units_day_now: float, units_day_new: float, + ) -> list[dict]: + """COSMOS's weekly stock projection, re-run at a different rate of sale. + + Answers the one question the Inventory tab cannot: COSMOS projects the shelf at TODAY's + price, so a recommendation to raise or cut is shown with no consequence for stock. Raising + slows the burn and stretches cover; cutting empties the shelf sooner. On a SKU already + close to a stockout that is the difference between a sound price move and an expensive one. + + This is OUR model, not COSMOS's, and it is labelled that way wherever it is shown. Only + two things are re-derived, and only from what the scenario table already computed: + + * **units sold per week** scale by `units_day_new / units_day_now` — the same elasticity + response the Scenarios tab uses, so the two can never disagree; + * **cover days** are recomputed against the new rate. + + Everything else is carried through UNCHANGED and deliberately: + + * **arrivals** — a purchase order already placed does not move because we re-priced; + * **unit value** — taken from COSMOS's own `inventoryValue / inventory` for that week, + so the value column stays on COSMOS's cost basis rather than being re-derived from + our COGS and quietly disagreeing with the tab above. + + Stock is floored at zero and the week is flagged, because a negative shelf is not a + forecast. Returns [] when the inputs cannot support the arithmetic, so the caller shows + nothing rather than a fabricated table. + """ + if not projections or units_day_now <= 0 or units_day_new <= 0: + return [] + + ratio = units_day_new / units_day_now + rows: list[dict] = [] + # Walk the weeks forward carrying our own running stock level: each week's DRAW is what + # scales with price, not the stock level itself. Rescaling the level directly would also + # rescale the arrivals baked into it and quietly inflate the shelf. + prev_units: float | None = None + running: float | None = None + for i, p in enumerate(projections): + units = float(p.get("inventory") or 0.0) + arrival = float(p.get("warehouse_arrival") or 0.0) + value = float(p.get("inventory_value") or 0.0) + per_unit = (value / units) if units else 0.0 + + if running is None: # week 0 is today's shelf — unchanged by price + running = units + else: + # COSMOS's own week-on-week movement, split into the draw and the arrival. + drawn = max((prev_units or 0.0) + arrival - units, 0.0) + running = running + arrival - drawn * ratio + + stockout = running <= 0 + running = max(running, 0.0) + # BOTH covers are computed here, on OUR velocities — deliberately not COSMOS's + # `coverDays` for the "now" column. + # + # COSMOS divides by its own 7-day average (64/day on the audited SKU) while the new + # column divides by the modelled rate at the new price (66/day). Putting those side by + # side made a 5% RAISE look like it SHRANK cover, 78 -> 76, which is backwards: the + # whole difference was the divisor, not the price. One basis, so the delta between the + # two columns is the price effect and nothing else. + # + # The consequence is that this column can differ by a day or two from the Inventory + # tab, which shows COSMOS's figure verbatim. That is stated in the caption. + cover_now = (units / units_day_now) if units_day_now > 0 else None + cover_new = (running / units_day_new) if units_day_new > 0 else None + rows.append({ + "date": p.get("date"), + "units_now": int(round(units)), + "units_new": int(round(running)), + "delta_units": int(round(running - units)), + "value_new": round(running * per_unit), + "cover_now": int(round(cover_now)) if cover_now is not None else None, + "cover_new": int(round(cover_new)) if cover_new is not None else None, + # COSMOS's own figure, carried alongside rather than mixed into the comparison. + "cover_cosmos": (int(p["cover_days"]) if p.get("cover_days") is not None else None), + "arrival": arrival, + "stockout": stockout, + "week": i, + }) + prev_units = units + return rows + + +def _observed_band(price: float, bands: list | None, tol: float = 0.02): + """The observed price band matching `price`, when this price has really run.""" + near = [b for b in (bands or []) + if b.get("enough_data") and abs(b["avg_price"] - price) <= tol * price] + return max(near, key=lambda b: b["days"]) if near else None + + +def _scenario_advice(x: dict, cur: dict | None) -> str: + """One-line marketing/pricing advice for a single price point. + + Compares MODELLED against MODELLED. The current row also carries COSMOS's + booked facts, and comparing a projection against them mixes two price bases — + that is how this column once managed to say ad spend was both higher at +2% + and lower at +5%. + """ + if not cur or x["key"] == "current": + return "Baseline — today's actual price, volume and ad spend." + if x.get("observed_days"): + obs = x.get("observed_net_30d") + return (f"OBSERVED: this price ran {x['observed_days']} days and booked " + f"${obs:,.0f}/30d." if obs is not None else + f"OBSERVED: this price ran {x['observed_days']} days.") + _base = modelled(cur) + base_net, base_ad = _base["net_30d"], _base["ad_30d"] + up = x["price"] > cur.get("list_price", cur["price"]) + better = x["net_30d"] > base_net + ad_delta = x["ad_30d"] - base_ad + ad_note = (f"ad spend ~${abs(ad_delta):,.0f} {'higher' if ad_delta > 0 else 'lower'}/30d" + if abs(ad_delta) >= 50 else "ad spend ~flat") + if up and better: + return f"Raise: fatter margin per unit and {ad_note} — net profit improves." + if up and not better: + return f"Raise: better unit margin but volume falls; {ad_note} — net dips." + if not up and better: + return f"Cut: volume + revenue rise ({ad_note}) yet net still improves — worth testing." + return f"Cut: cheaper than optimal, {ad_note} — erodes net profit." + + +def _scenario_summary(cur: dict | None, best: dict | None, tacos_frac: float, + calibrated: bool = False) -> str: + if not cur or not best: + return "" + tacos = tacos_frac * 100 + grounded = " (calibrated to your actual booked profit)" if calibrated else "" + base = (f"At **${cur['price']:.2f}** you net **${cur['net_30d']:,.0f}/30d**{grounded} on " + f"{cur['units_30d']:,} units after **${cur['ad_30d']:,.0f}** ad spend " + f"(TACoS {tacos:.1f}%).") + if best["key"] == "current": + return base + " Current price is already the profit-optimal point in this range." + direction = "raising" if best["price"] > cur["price"] else "lowering" + gain = best["net_30d"] - cur["net_30d"] + ad_move = best["ad_30d"] - cur["ad_30d"] + ad_phrase = (f"ad spend would rise ~${ad_move:,.0f}" if ad_move > 0 + else f"ad spend would ease ~${abs(ad_move):,.0f}") + return (base + f" Net profit peaks at **${best['net_30d']:,.0f}/30d** by {direction} " + f"to **${best['price']:.2f}** (**+${gain:,.0f}/30d**); {ad_phrase}/30d as volume " + f"shifts.") + + +# ---------------------------------------------------------------- build one SKU +def build_live_sku(svc, sku: str, with_competitive: bool = False, + today: date | None = None, on_stage=None) -> dict: + from pricing_agent.analyze import analyze_price + + def stage(frac: float, label: str): + if on_stage: + on_stage(frac, label) + + today = today or date.today() + stage(0.03, "reading current price") + cur_price = svc.get_current_price(sku) + if not cur_price or cur_price <= 0: + # "No selling price" is a SYMPTOM, and on the SKUs that matter most it has a known + # cause: a suppressed Buy Box sells nothing, so COSMOS records no sales, so there is + # no realized price to read. Reporting only the symptom buries the single most + # actionable finding in the competitor data behind a shrug — and these are precisely + # the rows a human needs to see. So if the sheet knows why, say why. + # + # No verdict is fabricated: without a price there is genuinely nothing to price, and + # this SKU still returns an error row rather than a recommendation. It just returns + # one that names the cause. + _sheet = _load_competitor_sheet() + if _sheet is not None and _sheet.covers(sku): + _row = _sheet.rows[sku.strip().upper()] + if _row.is_suppressed: + raise ValueError( + f"{sku}: Buy Box SUPPRESSED ({_row.our_buybox}) — Amazon is not showing " + f"our offer, which is WHY COSMOS has no selling price for it. It sells " + f"nothing at any price; reinstate the listing before pricing it.") + raise ValueError(f"{sku}: no current selling price found in COSMOS") + + stage(0.10, "fetching 6-month sales history") + history = svc.get_sales_history(sku, days=HISTORY_DAYS) + stage(0.45, "fees, margin, trend & elasticity") + # Analysis NEVER depends on competitors — the recommendation, scenarios and verdict + # are driven purely by our own economics. Competitor prices are fetched separately + # below, for the Competitors tab only. + r = analyze_price(sku, cur_price, svc=svc, with_elasticity=True, history=history, + with_competitive=False, current_price=cur_price) + stage(0.82, "bulk economics & scenarios") + + # COSMOS's own forward inventory projection (real weekly snapshots: units, value, + # cover days, incoming arrivals) — the authoritative INVP outlook. + inv_projection, min_po, invp_title = [], None, None + # COSMOS's OWN headline figures, carried through verbatim. The Inventory Planning grid + # prints these next to the same weekly projection, so recomputing our own from + # sales-insight history put two different daily-sale numbers on two screens for one SKU + # (ours 56 / 62 / 65 against COSMOS's 64 / 130 / 67). The weekly cells always agreed + # because they come straight from the dateMap; only the header was re-derived. + invp_stats: dict = {} + try: + invp = svc.get_invp(sku) + if invp: + inv_projection = invp.projections + min_po = {"qty": invp.min_po_quantity, "days": invp.min_po_days} + invp_title = invp.parent_asin_title + invp_stats = { + # `averageSale` is the figure COSMOS shows large; the 7-day average tracks it. + "avg_sale": invp.avg_sale, + "avg_7d": invp.avg_7d, + "avg_14d": invp.avg_14d, + "avg_30d": invp.avg_30d, + "avg_6m": invp.avg_6m, + "potential_daily_sale": invp.potential_daily_sale, + # On-hand as COSMOS counts it, kept apart from the first projected week (which + # already includes that week's arrival). + "inventory": invp.inventory, + "limbo_inventory": invp.limbo_inventory, + } + except Exception: + logger.warning("invp projection lookup failed for %s", sku) + + hist = _hist_frame(history, cur_price, today) + fp = _fee_params(r.fees_detail or {}, cur_price) + + # Daily actuals over the last 90 data-days (units, revenue, ad, profit). ALL current + # facts derive from ONE window on this series so units/day and revenue reconcile: + # revenue/day ÷ units/day == the actual average selling price. + daily = [{ + "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) + (p.promotion_spend or 0)), + "profit": float(p.profit or 0), + } for p in (history or [])][-180:] # keep the full 6 months for the price suggestion + + def window_agg(days: int) -> dict: + w = daily[-days:] if daily else [] + n = len(w) or 1 + u = sum(x["units"] for x in w) + rev = sum(x["revenue"] for x in w) + ad = sum(x["ad"] for x in w) + prof = sum(x["profit"] for x in w) + return {"days": n, "units_day": u / n, "revenue_day": rev / n, + "ad_day": ad / n, "profit_day": prof / n, + "units": u, "revenue": rev, "ad": ad, "profit": prof} + + w30 = window_agg(30) + # Canonical velocity = actual last-30-day average (reconciles with revenue), with a + # COSMOS-average fallback only when there is no recent sales history at all. + units_day = w30["units_day"] or float(r.avg_30d or r.avg_6m or 0.0) + units_day = max(units_day, 0.0) + inventory = float(r.inventory or 0.0) + units_month = w30["units"] + + # Competitor prices. Fetched in their own try/except because a scrape is best-effort by + # nature: blocked, timed out or never run are all normal, and none of them may stop a + # verdict being produced. As of the competitor wiring these DO reach the decision — but + # only ever to ADD a verdict (a suppressed Buy Box, a material undercut), never to + # withhold one. With no token set, a failed scrape, or data past its age limit, the + # verdict is byte-identical to the competitor-blind one. + comp_snap = None + if with_competitive: + stage(0.70, "competitor Buy Box + rival prices") + try: + from config.settings import get_settings + from pricing_agent.providers import get_provider + from pricing_agent.schemas import SkuInput + prov = get_provider(get_settings()) + comp_snap = prov.competitive(SkuInput( + sku=sku, asin=r.asin, landed_cost=fp["cost"], target_price=cur_price)) + except Exception: + logger.warning("competitor scrape failed for %s", sku) + # Loaded BEFORE the tab is built so the workbook can populate it. It was previously read + # only further down, for the decision, which is how a SKU the sheet covered still rendered + # "Competitor prices not scraped". + _sheet = _load_competitor_sheet() + _sheet_row = _sheet.rows.get(sku.strip().upper()) if _sheet is not None else None + comp_df, comp_median, comp_meta = _competitors( + sku, r.asin, cur_price, units_month, comp_snap, + sheet_row=_sheet_row, sheet=_sheet) + comp_meta["median"] = comp_median + + # The single competitive fact the cascade is allowed to read. Built through + # competitive_state so that the Apify path and the Playwright workbook path cannot + # silently disagree — see that module's docstring for which is authoritative and why. + from config.settings import get_rules, get_settings + from pricing_agent.competitive_state import ( + from_apify, from_playwright_cache, reconcile, unavailable, + ) + _rules, _st = get_rules(), get_settings() + max_age = _rules.competitor_state_max_age_hours + + # ── the comparison sheet comes first, and it GATES ──────────────────────── + # The workbook currently covers one product line. A SKU inside it has real like-for-like + # rival prices; a SKU outside it has nothing, and those two must not read the same. So a + # covered SKU is priced from the sheet, and an uncovered one gets an explicit N/A naming + # what the sheet does cover — rather than falling through to a per-ASIN scrape that would + # quietly price a SKU the sheet was supposed to govern (`competitor_sheet_only`). + # + # Same object the Competitors tab was built from a few lines above: the tab and the verdict + # must never be able to read different rows. + sheet = _sheet + comp_state = None + if sheet is not None: + if sheet.covers(sku): + comp_state = sheet.state_for( + sku, our_price=cur_price, + max_age_hours=_rules.competitor_sheet_max_age_hours) + elif _st.competitor_sheet_only: + comp_state = unavailable( + f"N/A — {sku} is not covered by the competitor sheet; " + f"{sheet.coverage_note()}") + + if comp_state is None: + if comp_snap is not None: + comp_state = from_apify(comp_snap, our_price=cur_price, max_age_hours=max_age) + else: + comp_state = unavailable( + "competitor scrape not run for this SKU" + if not with_competitive else "competitor scrape failed or returned nothing") + # Cross-check against the sibling workbook tool's own scrape of the same ASIN. Apify + # stays authoritative (it is the only source carrying a seller id, so the only one that + # can tell WON from LOST_PRICE) — this exists so that when the two disagree, the + # disagreement is logged and carried on the state instead of surfacing later as a + # workbook and a recommendation that contradict each other. + # Skipped when the state already came from the sheet: the sheet IS the Playwright run's + # output, so cross-checking it against that same scraper's cache compares a source to + # itself and can only ever agree. + from pricing_agent.competitive_state import SOURCE_SHEET + if comp_state.source != SOURCE_SHEET: + try: + other = from_playwright_cache( + r.asin, our_price=cur_price, zip_code=_st.apify_zip_code, + max_age_hours=max_age) + if other is not None: + comp_state = reconcile(comp_state, other, sku=sku, + material_pct=_rules.competitor_undercut_material_pct) + except Exception: # a cross-check must never be able to fail a verdict + logger.debug("competitor cross-check skipped for %s", sku, exc_info=True) + comp_meta["state"] = comp_state.status.value + comp_meta["state_usable"] = comp_state.usable + comp_meta["state_source"] = comp_state.source + comp_meta["state_basis"] = comp_state.basis + comp_meta["state_why_unused"] = comp_state.unusable_because + # So the UI can say "N/A — not in the covered line" rather than a bare dash. + comp_meta["sheet_covers_sku"] = bool(sheet is not None and sheet.covers(sku)) + comp_meta["sheet_line"] = sheet.line if sheet is not None else None + comp_meta["sheet_covered_skus"] = len(sheet.rows) if sheet is not None else 0 + + el = (r.elasticity or {}).get("elasticity") or FALLBACK_ELASTICITY + be = r.break_even or 0.0 + # The floor that actually matters: the highest of accounting / ad-inclusive / + # empirical break-even. Everything below it loses money on every unit. + floor_info = price_floor(r, cur_price) + safe_floor = floor_info["floor"] or (be * 1.12 if be > 0 else 0.0) + extra = {} + if safe_floor > 0: + extra["min_safe"] = safe_floor + if r.profit_optimal_price: + extra["max_profit"] = r.profit_optimal_price + if r.best_observed_price: + extra["best_observed"] = r.best_observed_price + # A price that matches the cheapest RIVAL, offered as a candidate only when competitor + # state is usable. It is a candidate, not a decision: the guardrails downstream floor it + # at break-even x1.02 and cap the move at ±5%, so this key can never execute a price + # below break-even however cheap the rival is. + if comp_state.usable and comp_state.competitor_min: + extra["comp_match"] = comp_state.competitor_min + # comp_median deliberately NOT passed — the scenario ECONOMICS (units, revenue, profit) + # are still computed from our own fee stack and elasticity alone. A competitor price can + # add a candidate price to evaluate; it never alters the maths applied to one. + scen = _scenarios(cur_price, max(units_day, 0.1), inventory, fp, el, None, extra) + + # Dated inventory urgency — computed before the decision so the branches can + # say "out on the 12th, restock on the 26th" instead of just "cover is low". + outlook = _inventory_outlook(inv_projection, units_day, today) + + # Cover days, reconciled ONCE, here — before the decision that depends on it. + # + # COSMOS's own `coverDays` IS the figure, by decision: the Inventory Planning grid is the + # number the business reads and plans against, so this dashboard must not quote a different + # one for the same SKU. It counts inbound stock against a 7-day velocity, which is why it + # runs longer than what is physically on the shelf — for UBMICROFIBERDUVETTWINWHITE, 78 + # days on (3,999 on hand + 1,030 inbound) / 64 a day, against 63 on-hand-only. + # + # The on-hand figure is still computed, still shown beside it, and is still the FALLBACK, + # because COSMOS returns `coverDays: 0` on some very low-velocity SKUs that are in fact + # sitting on months of stock (UBMICROFIBERBS4PCFULLGREY: 167 units, 334 real days, COSMOS + # said 0). A zero with stock on hand is a missing answer, not a full shelf, and taking it + # literally silences both inventory rules. + # + # Trade-off, stated: matching COSMOS means a stockout risk is judged on stock that has not + # landed yet. A shipment that slips leaves this reading optimistic. + cosmos_cover = r.cover_days + onhand_cover = int(round(inventory / units_day)) if units_day > 0 else 0 + projected_cover = int(cosmos_cover) if cosmos_cover else onhand_cover + cur_cover = projected_cover + if cosmos_cover in (0, None) and onhand_cover: + logger.info("%s: COSMOS reported cover_days=%r with %.0f units on hand — falling back " + "to the on-hand figure of %dd", sku, cosmos_cover, inventory, cur_cover) + + action, rec_key, cons_key, aggr_key, reasons, root, objective = _decide( + r, cur_price, hist, fp, scen, outlook, comp_state, cur_cover) + # Every SKU the competitor rules touched, named, with the rule that fired. This is the + # audit trail for the change: without it, "did the competitor wiring move this price?" is + # only answerable by re-running with the feature off. + comp_reasons = [c for c in reasons + if c in ("BUYBOX_SUPPRESSED", "COMPETITOR_UNDERCUT")] + if comp_reasons: + logger.info("competitor rule fired for %s: %s (state=%s source=%s, action=%s)", + sku, ",".join(comp_reasons), comp_state.status.value, + comp_state.source, action) + + # Score the profit model against this SKU's own held-out history. Cheap — it + # reuses the 180 days already fetched — and it is the only thing that turns + # "the engine says X" into "the engine has been within Y% on this SKU". + from pricing_agent.backtest import trust_score, walk_forward + storage_30d = _storage_30d(svc, sku, cur_price, units_day * 30, inventory) + try: + bt = walk_forward(history, fp, storage_30d=storage_30d, list_price=cur_price) + except Exception: + logger.warning("backtest failed for %s", sku) + bt = None + trust = trust_score(bt, r.elasticity, r.price_bands, + has_costs=not (fp["cost"] <= 0 and fp["fba"] <= 0)) + tier, score = _confidence(action, hist, r) + + s = scen.set_index("scenario") + cur_row, rec_row = s.loc["current"], s.loc[rec_key] + + # ── SAFETY: clamp the target, then step toward it ──────────────────────── + # `target_price` is where the evidence says to end up; `rec_price` is the most + # this move may travel today. Splitting them keeps every change small enough + # to read a real demand response before the next one — which is also the only + # way to earn the price variation a trustworthy elasticity needs. + obs_max = r.observed_price_max + clamps = [] + stepped = False + + if action == "Investigate": + # The root-cause gate said price is not the lever. Lifting the price to a + # floor here would contradict that verdict, so hold and report the breach. + target_price = rec_price_final = cur_price + if safe_floor and cur_price < safe_floor: + clamps.append(f"selling ${safe_floor - cur_price:.2f} BELOW the " + f"${safe_floor:.2f} floor ({floor_info['binding']} " + f"break-even) — but see the verdict: price is not the " + f"lever here") + else: + target_price = float(rec_row["price"]) + if obs_max and target_price > obs_max * OBSERVED_HEADROOM: + target_price = round(obs_max * OBSERVED_HEADROOM, 2) + clamps.append(f"capped at ${target_price:.2f} — {OBSERVED_HEADROOM:.0%} of " + f"the highest price with a real sample behind it " + f"(${obs_max:.2f})") + if safe_floor and target_price < safe_floor: + target_price = round(safe_floor, 2) + clamps.append(f"lifted to the ${safe_floor:.2f} floor " + f"({floor_info['binding']} break-even + safety)") + step_cap = cur_price * MAX_STEP_PCT + rec_price_final = target_price + if abs(target_price - cur_price) > step_cap + 0.005: + direction = math.copysign(1.0, target_price - cur_price) + rec_price_final = round(cur_price + direction * step_cap, 2) + # Rounding to cents must never push the move back over the cap. + if abs(rec_price_final - cur_price) > step_cap: + rec_price_final = round(rec_price_final - direction * 0.01, 2) + stepped = abs(rec_price_final - target_price) > 0.005 + if stepped: + clamps.append(f"moving ${rec_price_final:.2f} now ({MAX_STEP_PCT:.0%} step " + f"cap); re-evaluate in 14 days on the way to " + f"${target_price:.2f}") + if abs(rec_price_final - cur_price) / cur_price < 0.005: + action, rec_price_final = "Maintain", cur_price + + delta = float(rec_price_final - cur_price) + delta_pct = delta / cur_price * 100 if cur_price else 0.0 + + # 30-day actuals, all from the SAME window aggregate so they reconcile. + spend_30d = w30["ad"] + revenue_30d = w30["revenue"] + actual_profit_30d = w30["profit"] + actual_units_30d = w30["units"] + tacos_frac = spend_30d / revenue_30d if revenue_30d else 0.0 + # Amazon Advertising metrics, proxied by COSMOS. `sales-insight` knows only + # total marketing spend (hence TACoS); this endpoint carries the attributed + # side — ad sales, ACoS, ROAS, CPC and the campaign budget. + stage(0.90, "advertising metrics") + try: + mk = svc.get_marketing(sku, days=30) + except Exception: + logger.warning("marketing metrics failed for %s", sku) + mk = None + ppc = dict( + spend_30d=round(spend_30d), spend_day=round(w30["ad_day"], 2), + revenue_30d=round(revenue_30d), units_30d=int(round(actual_units_30d)), + tacos=round(tacos_frac * 100, 1), + # attributed side — None only when the SKU has no campaigns + ad_spend_30d=round(mk.ad_spend) if mk else None, + ad_sales_30d=round(mk.ppc_sales) if mk else None, + ad_units_30d=int(round(mk.units)) if mk else None, + acos=round(mk.acos * 100, 1) if mk else None, + roas=round(mk.roas, 2) if mk else None, + cpc=round(mk.cpc, 2) if mk else None, + clicks=int(round(mk.clicks)) if mk else None, + impressions=int(round(mk.impressions)) if mk else None, + conversion=round(mk.conversion * 100, 2) if mk else None, + daily_budget=round(mk.daily_budget) if mk else None, + budget_utilization=round(mk.budget_utilization * 100, 1) if mk else None, + # share of units the ads can actually claim — the rest is organic + ad_share_pct=(round(mk.units / actual_units_30d * 100, 1) + if mk and actual_units_30d else None), + ) + + # Scenario economics, anchored to the price customers actually paid. + # The baseline uses a 90-day window, not 30: a single month is mostly noise + # (this SKU's months ranged $-0.54 to $+2.75 profit/unit), and the baseline + # is what every projection is compared against. + wb = window_agg(DEFAULT_WINDOW_DAYS) + scale = 30.0 / (wb["days"] or 30) + grid = {row["scenario"]: row["price"] for _, row in scen.iterrows()} + # Only add the recommendation as its own row when no candidate already sits at + # that price — otherwise the table shows the same price twice. + if not any(abs(p - rec_price_final) < 0.005 for p in grid.values()): + grid["recommended"] = rec_price_final + rec_econ_key = "recommended" + else: + rec_econ_key = next(k for k, p in grid.items() + if abs(p - rec_price_final) < 0.005) + realized = (wb["revenue"] / wb["units"]) if wb["units"] else cur_price + # The backtest chose the calibration; apply exactly that one here, so the + # table on screen is the model that was actually scored. + gap_pu = 0.0 + if bt and bt["selected"] == "anchored_gap" and wb["units"]: + from pricing_agent.backtest import unmodeled_gap_per_unit + gap_pu = unmodeled_gap_per_unit( + {"units": wb["units"], "price": realized, "ad": wb["ad"], + "profit": wb["profit"], "days": wb["days"], + "units_day": wb["units_day"]}, + fp, storage_30d * wb["days"] / 30.0, r.ad_cost_model) + scen_econ, scen_meta = _scenario_economics( + cur_price, max(wb["units_day"] or units_day, 0.1), fp, el, + (wb["ad"] / wb["revenue"] if wb["revenue"] else 0.0), storage_30d, grid, + actual_30d=wb["profit"] * scale, actual_rev=wb["revenue"] * scale, + actual_ad=wb["ad"] * scale, actual_units=wb["units"] * scale, + realized_price=realized, ad_model=r.ad_cost_model, bands=r.price_bands, + gap_per_unit=gap_pu) + + # Headline profit/impact comes from the same ad-inclusive net as the tables, so + # the tiles, queue sort and decision card all agree with the Scenarios tab. + # + # BOTH SIDES OF THIS SUBTRACTION MUST BE THE SAME MODEL AT TWO PRICES. + # + # The current row deliberately carries two readings: COSMOS's BOOKED profit, priced at the + # average customers actually paid over the window, and its MODELLED twin at today's price. + # Subtracting a projection from the booked figure mixes those bases and measures the wrong + # move. Observed on UBMICROFIBERDUVETTWINWHITE: today $17.09, recommendation $17.94, but + # the booked row sits at $18.18 — so `rec - booked` priced a CUT from $18.18 and returned + # -$78 on a +5% raise. The tile then showed "Profit opportunity $0" next to a live RAISE + # recommendation, and the queue sorted on that number. + # + # Against the modelled baseline the same move is +$1,207, which is the actual effect of + # going from $17.09 to $17.94. The booked figure keeps its own job — anchoring the + # Scenarios tab to reality — it is just not a baseline for a price DELTA. + _econ = {x["key"]: x for x in scen_econ} + _cur_econ = _econ.get("current", {}) + cur_net_30d = modelled_net_30d(_cur_econ, float(cur_row["profit_30d"])) + # Applied to the recommendation too, so a "Maintain" that lands on the current row + # subtracts a value from itself and correctly reports zero rather than the booked/modelled + # difference. + rec_net_30d = modelled_net_30d(_econ.get(rec_econ_key, {}), cur_net_30d) + impact_30d = rec_net_30d - cur_net_30d + # The booked figure is still what the card reports as "profit today". + cur_booked_30d = float(_cur_econ.get("net_30d", cur_row["profit_30d"])) + + stage(0.94, "finalizing") + # Prefer the real Amazon listing title (from INVP); fall back to brand, then SKU. + # Never append the SKU — it's already shown next to the title in the UI. + marketplace, brand = "AMAZON_USA", None + try: + product = svc.get_product(sku) + if product: + marketplace = product.marketplace or marketplace + brand = product.brand or product.manufacturer + except Exception: + pass + title = (invp_title or "").strip() or brand or sku + + cfg = dict( + sku=sku, title=title, marketplace=marketplace, objective=objective, + base=cur_price, cost=fp["cost"], fba=fp["fba"], + velocity=units_day, elasticity=el, inventory=inventory, comp_offset=1.0, + rating=None, reviews=None, inv_class="alpha", bsr=None, + ) + + explanation = _explain(sku, action, cur_price, rec_row, cur_row, reasons, tier, r) + + # `cosmos_cover` / `onhand_cover` / `projected_cover` / `cur_cover` were reconciled above, + # before `_decide`, so the tile below and the rule that fired read the same number. Do not + # recompute them here — that is precisely how the two came to disagree. + rec_units_day = float(_econ.get(rec_econ_key, {}).get("units_day") or units_day) + if rec_units_day > 0 and units_day > 0: + rec_cover = int(round(cur_cover * units_day / rec_units_day)) + else: + rec_cover = cur_cover + + return dict( + cfg=cfg, hist=hist, change_dates=_change_dates(hist), scenarios=scen, + forecast=_forecast(hist, tier, today), competitors=comp_df, + action=action, rec_key=rec_key, + strategy_keys={"Conservative": cons_key, "Recommended": rec_key, + "Aggressive": aggr_key}, + reasons=reasons, root_cause=root, risks=RISKS[action], + confidence=(tier, score), + current_price=float(cur_price), rec_price=float(rec_price_final), + # where the evidence says to end up, vs how far this single move goes + target_price=float(target_price), stepped=stepped, clamps=clamps, + delta=delta, delta_pct=float(delta_pct), impact_30d=impact_30d, + cover_days=cur_cover, rec_cover_days=rec_cover, + cover_days_onhand=onhand_cover, cover_days_projected=projected_cover, + outlook=outlook, stockout_days_30d=_stockout_days(hist), + break_even=round(be, 2), + # Floors: the guardrail may never sit below the price at which a unit + # actually stops losing money once advertising is counted. + break_even_with_ads=r.break_even_with_ads, + break_even_empirical=r.break_even_empirical, + break_even_ad_curve=r.break_even_ad_curve, + ad_curve_unrecoverable=r.ad_curve_unrecoverable, + contribution_per_dollar=r.contribution_per_dollar, + floor_info=floor_info, + min_price=round(max(safe_floor, be * 1.05, 0.01), 2), + max_price=round(max(cur_price, safe_floor, be * 1.05) * 1.25, 2), + observed_price_min=r.observed_price_min, observed_price_max=r.observed_price_max, + elasticity_actionable=r.elasticity_actionable, + elasticity_detail=r.elasticity, + profit_optimal_blocked_reason=r.profit_optimal_blocked_reason, + profit_optimal_unconstrained=r.profit_optimal_unconstrained, + ad_cost_model=r.ad_cost_model, price_bands=r.price_bands, + # how wrong the model has been on THIS SKU, and what that permits + backtest=bt, trust=trust, gap_per_unit=round(gap_pu, 3), + units_day=float(units_day), + rec_units_day=rec_units_day, + profit_30d=cur_net_30d, rec_profit_30d=rec_net_30d, + po=None, explanation=explanation, ppc=ppc, + asin=r.asin, comp_median_now=None, comp_meta=comp_meta, # chart stays competitor-free + referral_pct=fp["referral_pct"], returns_pct=fp["returns_pct"], + variable=fp["variable"], + # bulk-reconciled, ad-inclusive scenario economics + scen_econ=scen_econ, scen_best_key=scen_meta["best_key"], + scen_summary=scen_meta["summary"], storage_30d=round(storage_30d), + actual_profit_30d=round(actual_profit_30d), + scen_calibrated=scen_meta["calibrated"], scen_factor=scen_meta["factor"], + # daily actuals + params so the UI window filter (7/14/30/90d) can recompute + # everything from one consistent averaging window + daily=daily, scen_window=DEFAULT_WINDOW_DAYS, + recompute=dict(cur_price=float(cur_price), fp=fp, el=el, + storage_30d=float(storage_30d), grid=grid, + ad_model=r.ad_cost_model, bands=r.price_bands, + gap_per_unit=gap_pu), + # real COSMOS INVP forward projection (weekly units/value/cover/arrivals) + inv_projection=inv_projection, min_po=min_po, invp_stats=invp_stats, + # extra live evidence for the AI-reasoning tab + evidence=dict( + actual_profit_per_unit=r.actual_profit_per_unit, + unprofitable_months=r.unprofitable_months, + best_observed_price=r.best_observed_price, + best_observed_profit_day=r.best_observed_profit_day, + unmodeled_cost_gap=r.unmodeled_cost_gap, + suggested_price=r.suggested_price, + trend=r.trend, + ), + ) + + +def scenarios_for_window(d: dict, days: int) -> tuple[list[dict], dict, dict]: + """Recompute scenario economics using the last `days` of daily actuals as the + averaging window. Everything (velocity, revenue, ad, profit) derives from this one + window, so units/day and revenue always reconcile. Returns (econ, meta, facts).""" + daily = d.get("daily") or [] + rp = d.get("recompute") or {} + w = daily[-days:] if daily else [] + n = len(w) or 1 + u = sum(x["units"] for x in w) + rev = sum(x["revenue"] for x in w) + ad = sum(x["ad"] for x in w) + prof = sum(x["profit"] for x in w) + facts = {"days": n, "units_day": round(u / n), "revenue_day": round(rev / n), + "ad_day": round(ad / n), "profit_day": round(prof / n), + "avg_price": round(rev / u, 2) if u else 0.0} + # Normalise the window rate to a 30-day basis so the /30d columns stay comparable. + scale = 30.0 / n + tacos_frac = ad / rev if rev else 0.0 + econ, meta = _scenario_economics( + rp.get("cur_price", d["current_price"]), max(u / n, 0.1), rp["fp"], rp["el"], + tacos_frac, rp.get("storage_30d", 0.0), rp["grid"], + actual_30d=prof * scale, actual_rev=rev * scale, actual_ad=ad * scale, + actual_units=u * scale, + realized_price=(rev / u) if u else None, + ad_model=rp.get("ad_model"), bands=rp.get("bands"), + gap_per_unit=rp.get("gap_per_unit", 0.0)) + return econ, meta, facts + + +# ---------------------------------------------------------------- entry point +def get_live_data(skus: tuple[str, ...], with_competitive: bool = False, + progress_cb=None) -> dict: + """Analyze `skus` against live COSMOS. Failed SKUs land in ``errors``.""" + from config.settings import get_settings + from pricing_agent.analyze import _build_service + + svc = _build_service(get_settings()) + details: dict[str, dict] = {} + errors: dict[str, str] = {} + total = len(skus) + for i, sku in enumerate(skus): + def on_stage(frac: float, label: str, i=i, sku=sku): + # Map per-SKU stage fraction onto the overall bar. + if progress_cb: + overall = (i + frac) / total if total else 1.0 + prefix = f"{sku}" + (f" ({i + 1}/{total})" if total > 1 else "") + progress_cb(overall, f"{prefix} — {label}") + try: + details[sku] = build_live_sku(svc, sku, with_competitive=with_competitive, + on_stage=on_stage) + except Exception as e: # one bad SKU must not sink the load + logger.exception("live build failed for %s", sku) + errors[sku] = str(e) + if progress_cb: + progress_cb(1.0, f"Done — {total} product{'s' if total != 1 else ''} analyzed") + + rows = [] + for sku, d in details.items(): + rows.append({ + "sku": sku, "title": d["cfg"]["title"], "marketplace": d["cfg"]["marketplace"], + "objective": d["cfg"]["objective"], "action": d["action"], + "current_price": d["current_price"], "rec_price": d["rec_price"], + "delta": d["delta"], "delta_pct": d["delta_pct"], "impact_30d": d["impact_30d"], + "cover_days": d["cover_days"], "confidence_tier": d["confidence"][0], + "confidence_score": d["confidence"][1], + "trust_tier": (d.get("trust") or {}).get("tier", "None"), + "primary_reason": REASON_LABELS.get(d["reasons"][0], d["reasons"][0]), + }) + summary = (pd.DataFrame(rows).sort_values("impact_30d", ascending=False, + key=lambda sr: sr.abs()) + if rows else pd.DataFrame(columns=[ + "sku", "title", "marketplace", "objective", "action", "current_price", + "rec_price", "delta", "delta_pct", "impact_30d", "cover_days", + "confidence_tier", "confidence_score", "trust_tier", + "primary_reason"])) + return {"summary": summary, "details": details, "errors": errors} diff --git a/dashboard/theme.py b/dashboard/theme.py new file mode 100644 index 0000000..76d23d1 --- /dev/null +++ b/dashboard/theme.py @@ -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'{text}' + ) + + +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'

{note}
' if note else "" + ic_style = f' style="background:{accent}"' if accent else "" + return ( + f'
{icon}
' + f'
{value}
{label}
{note_html}
' + ) + + +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" diff --git a/data/apify_cache.json b/data/apify_cache.json new file mode 100644 index 0000000..f7f1c87 --- /dev/null +++ b/data/apify_cache.json @@ -0,0 +1 @@ +{"B07SXGNLM7": {"ts": 1784881028.0169413, "item": {"title": "Utopia Bedding Cal King Fitted Sheet - Bottom Sheet - Deep Pocket - Soft Microfiber -Shrinkage and Fade Resistant-Easy Care -1 Fitted Sheet Only (White)", "url": "https://www.amazon.com/dp/B07SXGNLM7?language=en", "asin": "B07SXGNLM7", "originalAsin": "B07SXGNLM7", "price": {"value": 18.99, "currency": "$"}, "inStock": true, "inStockText": "In Stock", "listPrice": null, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.6, "starsBreakdown": {"5star": 0.8, "4star": 0.12, "3star": 0.05, "2star": 0.01, "1star": 0.02}, "reviewsCount": 141449, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Sheets & Pillowcases > Fitted Sheets", "videosCount": 6, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B07SXGNLM7&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "https://m.media-amazon.com/images/I/41EwlzZbf6L._AC_US.jpg", "https://m.media-amazon.com/images/I/41bT2A1j-lL._AC_US.jpg", "https://m.media-amazon.com/images/I/41wJdCT7F5L._AC_US.jpg", "https://m.media-amazon.com/images/I/41-PJgyR41L._AC_US.jpg", "https://m.media-amazon.com/images/I/41vKMe7k1ML._AC_US.jpg", "https://m.media-amazon.com/images/I/A1+hg29vCUL.SS125_PKplay-button-mb-image-grid-small_.png"], "highResolutionImages": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71kbiKyPPCL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81y9TnId4zL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81Z2vCaTbFL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/819R5vOFdHL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81JWG8TER5L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81xLpZiMt0L._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": [{"title": "Safer chemicals", "description": "Made with chemicals safer for human health and the environment.", "certifiedBy": [{"name": "OEKO-TEX STANDARD 100", "image": "https://m.media-amazon.com/images/I/51YvKwF01yL._SS144_.jpg", "description": " OEKO-TEX\u00ae STANDARD 100 certified products require every component of a textiles production including all thread, buttons, and trims to be tested against a list of more than 1,000 regulated and unregulated chemicals which may be harmful to human health. The assessment process is globally standardised, independently conducted, and updated at least once per year based on new scientific information or regulatory requirements. Learn more about this certification", "url": "https://www.oeko-tex.com/en/our-standards/standard-100-by-oeko-tex", "data": [{"key": "Certification Number", "value": "2023OK1318"}]}]}], "description": null, "features": ["Brushed Microfiber", "Fitted Sheet - California King fitted sheet by Utopia Bedding measuring 72 by 84 inches with a 15 inches deep pocket that fits over-sized mattresses up to 15 inches deep and designing that complements your bed set quite well", "All - Around Elastic - All - Around Elastic to pull in the borders to make it easily stretch and fit the base of the mattress", "Brushed Microfiber Polyester - Breathable Brushed Microfiber Polyester fabric brings a soft and cozy feel to your bed that tempts you to stay in bed for long", "Shrink And Fade Resistant - The microfiber material is processed to make it resistant to shrinkage and fading which adds to the longevity of the sheet by keeping it in great condition", "Care Instructions - Machine wash cold; do not bleach; tumble dry or iron on normal temperature"], "attributes": [{"key": "Material Type", "value": "Polyester"}, {"key": "Fabric Type", "value": "Brushed Microfiber"}, {"key": "Thread Count", "value": "300"}, {"key": "Fabric Weight", "value": "130 grams_per_square_meter"}, {"key": "Product Care Instructions", "value": "Hand Wash Only, Machine Wash"}, {"key": "Size", "value": "California King( Pack of 1)"}, {"key": "Item Dimensions L x W", "value": "84\"L x 72\"W"}, {"key": "Fitted Sheet Pocket Depth", "value": "15 inches"}, {"key": "Color", "value": "White"}, {"key": "Pattern", "value": "Solid"}, {"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Number of Pieces", "value": "1"}, {"key": "Unit Count", "value": "1 Count"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "UPC", "value": "810013785210"}, {"key": "Model Number", "value": "UB1264"}, {"key": "Manufacturer Part Number", "value": "UB1264"}, {"key": "Best Sellers Rank", "value": "#60 in Home & Kitchen (See Top 100 in Home & Kitchen)#1 in Fitted Bed Sheets"}, {"key": "ASIN", "value": "B07SXGNLM7"}, {"key": "Customer Reviews", "value": "4.6 4.6 out of 5 stars (141,449) 4.6 out of 5 stars"}], "productOverview": [], "variantAsins": ["B0BVZ8RYWJ", "B07T48C5GL", "B0C8JJJ5YX", "B00XK9CJHU", "B00XKBMV2G", "B084MFMCNY", "B07VWWN63G", "B0CRHQRCYG", "B00XK9CYVG", "B00XK9CCBI", "B0BVZ6M8KN", "B07VVQTXQR", "B0GHGQRB9S", "B07PKVGBM4", "B07W22V8GX", "B0C8JGW1W5", "B0C4MLW7D9", "B07JJHRVG9", "B00XK9C2K4", "B084MFV7D2", "B09KCF6S9G", "B0DTYBGGNV", "B00XK9CFQU", "B00XK9D26M", "B00XK9CHUO", "B08H8QCGV2", "B09S3RRTKB", "B084KY7JW1", "B0BVZCSF8N", "B0CT8VY3TS", "B0BVZB3VFW", "B084MFGBYB", "B0C8JJHSCZ", "B09KCF9VJ4", "B08BK3Q6JF", "B00XK9CS80", "B00XK9C0KG", "B0BVZ7F66X", "B07ZNVLN4R", "B09996WGQN", "B08H8PWS3S", "B00XK9BZ8E", "B0C8JH33WR", "B0B5KXF3NJ", "B00XK9BVI8", "B0C8JJ32RJ", "B07SXGNLM7", "B0C8JJ32RP", "B07ZNX4RKT", "B0BVZ9VGX2", "B00XK9CO16", "B084MFTS2M", "B09S3QTT3J", "B08H2LM9PG", "B0C8JJTSQQ", "B08H8PZXLL", "B01IE7P2VW", "B084KXMX5J", "B09S3QLTSY", "B00XK9C6H8", "B0CT9245KT", "B0D96971ZR", "B084KQHLS8", "B0C8JG5MN3", "B00XK9CQK0", "B09KCHPK9Z", "B0C8JGK66X", "B07PFQDVXC", "B07ZNVPLG4", "B0GHGQLQVB", "B0C8JHQTDK", "B0C8JFNRYB", "B0B5KY9R2K", "B0C8JHLX2R", "B08H2K4NVV", "B09S3RGCX1", "B09KCDMS2D", "B00XK9BW1O", "B0CVGZ8XPD", "B0D96BN6QC", "B00XK9C4RK", "B08H8PRPZW", "B09S3R1J62", "B09S3RCXKS", "B084KPRMKL", "B07PGVXDWR", "B08H8GC45P", "B084MG3KMW", "B084MG22P1", "B00XK9C8WQ", "B08H8QD58W", "B07ZNXF64C", "B0CRHT3RF2", "B08H8R5C56", "B07PDSKCZL", "B07SYHFGX5", "B08H8P6M54", "B07VVQQC43", "B08H8P6YW4", "B08H2L6C32", "B09S3QS6GH", "B0D968FKMZ", "B0C8JMJ8QB", "B07T35TVKW", "B0CT9288J6", "B0C8JF9VVK"], "variantDetails": [{"name": "Lavender Twin", "thumbnail": "https://m.media-amazon.com/images/I/41SqGGA2guL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yGVkaKVxL._AC_SL1500_.jpg"], "asin": "B0BVZ8RYWJ", "price": null}, {"name": "Grey California King( Pack of 1)", "thumbnail": "https://m.media-amazon.com/images/I/41YFT70iSZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/715ODRGbEdL._AC_SL1500_.jpg"], "asin": "B07T48C5GL", "price": null}, {"name": "White Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41shlghmxZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71P8PMYp1kL._AC_SL1500_.jpg"], "asin": "B0C8JJJ5YX", "price": null}, {"name": "Black Queen", "thumbnail": "https://m.media-amazon.com/images/I/41hIOYyrFxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/717lqT88NhL._AC_SL1500_.jpg"], "asin": "B00XK9CJHU", "price": null}, {"name": "Black Full", "thumbnail": "https://m.media-amazon.com/images/I/41hIOYyrFxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/717lqT88NhL._AC_SL1500_.jpg"], "asin": "B00XKBMV2G", "price": null}, {"name": "Grey Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ABfN6n2gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815ZkQedkFL._AC_SL1500_.jpg"], "asin": "B084MFMCNY", "price": null}, {"name": "Spa Blue Full", "thumbnail": "https://m.media-amazon.com/images/I/41uekRKhkeL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71-7IsLX+LL._AC_SL1500_.jpg"], "asin": "B07VWWN63G", "price": null}, {"name": "Emerald Twin", "thumbnail": "https://m.media-amazon.com/images/I/41IGOruA+rL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/710T293S+zL._AC_SL1500_.jpg"], "asin": "B0CRHQRCYG", "price": null}, {"name": "Grey Twin", "thumbnail": "https://m.media-amazon.com/images/I/41YFT70iSZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/715ODRGbEdL._AC_SL1500_.jpg"], "asin": "B00XK9CYVG", "price": null}, {"name": "Grey King", "thumbnail": "https://m.media-amazon.com/images/I/41e034k4DmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71m2IQICgzL._AC_SL1500_.jpg"], "asin": "B00XK9CCBI", "price": null}, {"name": "Sage Full", "thumbnail": "https://m.media-amazon.com/images/I/41kdf3Q4OHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71vOAo9LKjL._AC_SL1500_.jpg"], "asin": "B0BVZ6M8KN", "price": null}, {"name": "Spa Blue Twin", "thumbnail": "https://m.media-amazon.com/images/I/41uekRKhkeL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71-7IsLX+LL._AC_SL1500_.jpg"], "asin": "B07VVQTXQR", "price": null}, {"name": "Grey Twin (Pack of 4)", "thumbnail": "https://m.media-amazon.com/images/I/31MzVQLrPML._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71OggI6gVGL._AC_SL1500_.jpg"], "asin": "B0GHGQRB9S", "price": null}, {"name": "Grey King (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41X5xs78iCL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81b8khwPWwL._AC_SL1500_.jpg"], "asin": "B07PKVGBM4", "price": null}, {"name": "Spa Blue King", "thumbnail": "https://m.media-amazon.com/images/I/41uekRKhkeL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71-7IsLX+LL._AC_SL1500_.jpg"], "asin": "B07W22V8GX", "price": null}, {"name": "Navy Full (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41TXs1uG+eL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YCyqN7ygL._AC_SL1500_.jpg"], "asin": "B0C8JGW1W5", "price": null}, {"name": "White Queen (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41yyOMRHEdL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yai1-D-aL._AC_SL1500_.jpg"], "asin": "B0C4MLW7D9", "price": null}, {"name": "White European Double (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/31jNM+TltTL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/51wqAEk6pBL._AC_SL1500_.jpg"], "asin": "B07JJHRVG9", "price": null}, {"name": "White Full", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B00XK9C2K4", "price": null}, {"name": "Grey King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ABfN6n2gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815ZkQedkFL._AC_SL1500_.jpg"], "asin": "B084MFV7D2", "price": null}, {"name": "Coral Queen", "thumbnail": "https://m.media-amazon.com/images/I/41CHc1PECSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71NwqqwnNXL._AC_SL1500_.jpg"], "asin": "B09KCF6S9G", "price": null}, {"name": "White Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41gJJnKvzhL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71V15Ws86LL._AC_SL1500_.jpg"], "asin": "B0DTYBGGNV", "price": null}, {"name": "White King", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B00XK9CFQU", "price": null}, {"name": "White Twin", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B00XK9D26M", "price": null}, {"name": "Beige Queen", "thumbnail": "https://m.media-amazon.com/images/I/41Wn7EQX3nL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71lEtfHfXJL._AC_SL1500_.jpg"], "asin": "B00XK9CHUO", "price": null}, {"name": "Burgundy King", "thumbnail": "https://m.media-amazon.com/images/I/41n+eka4VuL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Ao3ck5mGL._AC_SL1500_.jpg"], "asin": "B08H8QCGV2", "price": null}, {"name": "Sage King", "thumbnail": "https://m.media-amazon.com/images/I/41kdf3Q4OHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71vOAo9LKjL._AC_SL1500_.jpg"], "asin": "B09S3RRTKB", "price": null}, {"name": "Navy King", "thumbnail": "https://m.media-amazon.com/images/I/41kD6A9iabL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/7178wU6bdUL._AC_SL1500_.jpg"], "asin": "B084KY7JW1", "price": null}, {"name": "Orange King", "thumbnail": "https://m.media-amazon.com/images/I/41wqLOSayTL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UrggYi7EL._AC_SL1500_.jpg"], "asin": "B0BVZCSF8N", "price": null}, {"name": "Burgundy Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41qbPTv279L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81m61xb3+GL._AC_SL1500_.jpg"], "asin": "B0CT8VY3TS", "price": null}, {"name": "Emerald Queen", "thumbnail": "https://m.media-amazon.com/images/I/41IGOruA+rL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/710T293S+zL._AC_SL1500_.jpg"], "asin": "B0BVZB3VFW", "price": null}, {"name": "Navy Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41TXs1uG+eL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YCyqN7ygL._AC_SL1500_.jpg"], "asin": "B084MFGBYB", "price": null}, {"name": "Black King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/415eVkKO6qL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81fV6LTj+8L._AC_SL1500_.jpg"], "asin": "B0C8JJHSCZ", "price": null}, {"name": "Coral King", "thumbnail": "https://m.media-amazon.com/images/I/41CHc1PECSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71NwqqwnNXL._AC_SL1500_.jpg"], "asin": "B09KCF9VJ4", "price": null}, {"name": "Black Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/415eVkKO6qL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81fV6LTj+8L._AC_SL1500_.jpg"], "asin": "B08BK3Q6JF", "price": null}, {"name": "Black Twin", "thumbnail": "https://m.media-amazon.com/images/I/41hIOYyrFxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/717lqT88NhL._AC_SL1500_.jpg"], "asin": "B00XK9CS80", "price": null}, {"name": "Grey Full", "thumbnail": "https://m.media-amazon.com/images/I/41YFT70iSZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/715ODRGbEdL._AC_SL1500_.jpg"], "asin": "B00XK9C0KG", "price": null}, {"name": "Emerald King", "thumbnail": "https://m.media-amazon.com/images/I/41IGOruA+rL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/710T293S+zL._AC_SL1500_.jpg"], "asin": "B0BVZ7F66X", "price": null}, {"name": "White Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41shlghmxZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71P8PMYp1kL._AC_SL1500_.jpg"], "asin": "B07ZNVLN4R", "price": null}, {"name": "Beige Twin (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/411fujvTWxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yw4sF6PXL._AC_SL1500_.jpg"], "asin": "B09996WGQN", "price": null}, {"name": "Black Twin (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41QERqPVP5L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71pQykvQTaL._AC_SL1500_.jpg"], "asin": "B08H8PWS3S", "price": null}, {"name": "White King (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41yyOMRHEdL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yai1-D-aL._AC_SL1500_.jpg"], "asin": "B00XK9BZ8E", "price": null}, {"name": "Beige King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41WTrUD4LrL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811a8Ohr2UL._AC_SL1500_.jpg"], "asin": "B0C8JH33WR", "price": null}, {"name": "White Twin XL (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41yyOMRHEdL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yai1-D-aL._AC_SL1500_.jpg"], "asin": "B0B5KXF3NJ", "price": null}, {"name": "White Full (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41fm-KUk0zL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61bS-yq1njL._AC_SL1500_.jpg"], "asin": "B00XK9BVI8", "price": null}, {"name": "Black Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/415eVkKO6qL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81fV6LTj+8L._AC_SL1500_.jpg"], "asin": "B0C8JJ32RJ", "price": null}, {"name": "White California King( Pack of 1)", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B07SXGNLM7", "price": null}, {"name": "Beige Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41WTrUD4LrL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811a8Ohr2UL._AC_SL1500_.jpg"], "asin": "B0C8JJ32RP", "price": null}, {"name": "White Full (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41shlghmxZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71P8PMYp1kL._AC_SL1500_.jpg"], "asin": "B07ZNX4RKT", "price": null}, {"name": "Purple Twin", "thumbnail": "https://m.media-amazon.com/images/I/41HD1+nzjQL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716Bo1r5YwL._AC_SL1500_.jpg"], "asin": "B0BVZ9VGX2", "price": null}, {"name": "White Queen", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B00XK9CO16", "price": null}, {"name": "Navy Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/317sCqLciBL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Td6Sxlv+L._AC_SL1500_.jpg"], "asin": "B084MFTS2M", "price": null}, {"name": "Teal Queen", "thumbnail": "https://m.media-amazon.com/images/I/41mLzj1qRhL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Vrg5-5ePL._AC_SL1500_.jpg"], "asin": "B09S3QTT3J", "price": null}, {"name": "Brown Queen", "thumbnail": "https://m.media-amazon.com/images/I/41+JaM+hczL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716B6aFzMGL._AC_SL1500_.jpg"], "asin": "B08H2LM9PG", "price": null}, {"name": "Beige Full (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41WTrUD4LrL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811a8Ohr2UL._AC_SL1500_.jpg"], "asin": "B0C8JJTSQQ", "price": null}, {"name": "Black Queen (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41ATSbVayqL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71l8KzDgmLL._AC_SL1500_.jpg"], "asin": "B08H8PZXLL", "price": null}, {"name": "Grey Queen", "thumbnail": "https://m.media-amazon.com/images/I/41fSzrS9NZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/718jTjIZI3L._AC_SL1500_.jpg"], "asin": "B01IE7P2VW", "price": null}, {"name": "Navy Queen", "thumbnail": "https://m.media-amazon.com/images/I/41kD6A9iabL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/7178wU6bdUL._AC_SL1500_.jpg"], "asin": "B084KXMX5J", "price": null}, {"name": "Teal King", "thumbnail": "https://m.media-amazon.com/images/I/41mLzj1qRhL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Vrg5-5ePL._AC_SL1500_.jpg"], "asin": "B09S3QLTSY", "price": null}, {"name": "Beige King", "thumbnail": "https://m.media-amazon.com/images/I/41Wn7EQX3nL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71lEtfHfXJL._AC_SL1500_.jpg"], "asin": "B00XK9C6H8", "price": null}, {"name": "Purple Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ZQxXZRm+L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81Y+9eyARML._AC_SL1500_.jpg"], "asin": "B0CT9245KT", "price": null}, {"name": "Ice Blue Queen", "thumbnail": "https://m.media-amazon.com/images/I/41LPanz5L-L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/718u760RIEL._AC_SL1500_.jpg"], "asin": "B0D96971ZR", "price": null}, {"name": "Navy Full", "thumbnail": "https://m.media-amazon.com/images/I/41kD6A9iabL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/7178wU6bdUL._AC_SL1500_.jpg"], "asin": "B084KQHLS8", "price": null}, {"name": "Black Full (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/3197kHzflGL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71heEJgxiAL._AC_SL1500_.jpg"], "asin": "B0C8JG5MN3", "price": null}, {"name": "Beige Twin", "thumbnail": "https://m.media-amazon.com/images/I/41Wn7EQX3nL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71lEtfHfXJL._AC_SL1500_.jpg"], "asin": "B00XK9CQK0", "price": null}, {"name": "Lavender Queen", "thumbnail": "https://m.media-amazon.com/images/I/41SqGGA2guL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yGVkaKVxL._AC_SL1500_.jpg"], "asin": "B09KCHPK9Z", "price": null}, {"name": "Beige Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41WTrUD4LrL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811a8Ohr2UL._AC_SL1500_.jpg"], "asin": "B0C8JGK66X", "price": null}, {"name": "Grey Full (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/416KMPV39EL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71EOkE0nXCL._AC_SL1500_.jpg"], "asin": "B07PFQDVXC", "price": null}, {"name": "White King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41shlghmxZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71P8PMYp1kL._AC_SL1500_.jpg"], "asin": "B07ZNVPLG4", "price": null}, {"name": "Grey Twin XL (Pack of 4)", "thumbnail": "https://m.media-amazon.com/images/I/31BqiODBLmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71OxLUcQSKL._AC_SL1500_.jpg"], "asin": "B0GHGQLQVB", "price": null}, {"name": "Black Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/415eVkKO6qL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81fV6LTj+8L._AC_SL1500_.jpg"], "asin": "B0C8JHQTDK", "price": null}, {"name": "Grey Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ABfN6n2gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815ZkQedkFL._AC_SL1500_.jpg"], "asin": "B0C8JFNRYB", "price": null}, {"name": "Grey Twin XL (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41X5xs78iCL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81b8khwPWwL._AC_SL1500_.jpg"], "asin": "B0B5KY9R2K", "price": null}, {"name": "Navy Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41TXs1uG+eL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YCyqN7ygL._AC_SL1500_.jpg"], "asin": "B0C8JHLX2R", "price": null}, {"name": "Burgundy Queen", "thumbnail": "https://m.media-amazon.com/images/I/41n+eka4VuL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Ao3ck5mGL._AC_SL1500_.jpg"], "asin": "B08H2K4NVV", "price": null}, {"name": "Denim Blue Queen", "thumbnail": "https://m.media-amazon.com/images/I/41lpd3oHNkL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71HkOFzlcVL._AC_SL1500_.jpg"], "asin": "B09S3RGCX1", "price": null}, {"name": "Lavender King", "thumbnail": "https://m.media-amazon.com/images/I/41SqGGA2guL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yGVkaKVxL._AC_SL1500_.jpg"], "asin": "B09KCDMS2D", "price": null}, {"name": "Beige Full", "thumbnail": "https://m.media-amazon.com/images/I/41Wn7EQX3nL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71lEtfHfXJL._AC_SL1500_.jpg"], "asin": "B00XK9BW1O", "price": null}, {"name": "Coral Full", "thumbnail": "https://m.media-amazon.com/images/I/41CHc1PECSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71NwqqwnNXL._AC_SL1500_.jpg"], "asin": "B0CVGZ8XPD", "price": null}, {"name": "Ivory Queen", "thumbnail": "https://m.media-amazon.com/images/I/41+UUv5Ek8L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71LTYhkJDVL._AC_SL1500_.jpg"], "asin": "B0D96BN6QC", "price": null}, {"name": "White Twin (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41yyOMRHEdL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71yai1-D-aL._AC_SL1500_.jpg"], "asin": "B00XK9C4RK", "price": null}, {"name": "Navy Twin (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41eKQKL7k1L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71GV7+ZTyyL._AC_SL1500_.jpg"], "asin": "B08H8PRPZW", "price": null}, {"name": "Pink Queen", "thumbnail": "https://m.media-amazon.com/images/I/41D2Fp5oUKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71S18GFY4ML._AC_SL1500_.jpg"], "asin": "B09S3R1J62", "price": null}, {"name": "Yellow Queen", "thumbnail": "https://m.media-amazon.com/images/I/316sWIZCcTL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61Nms7-1XPL._AC_SL1500_.jpg"], "asin": "B09S3RCXKS", "price": null}, {"name": "Navy Twin", "thumbnail": "https://m.media-amazon.com/images/I/41kD6A9iabL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/7178wU6bdUL._AC_SL1500_.jpg"], "asin": "B084KPRMKL", "price": null}, {"name": "Grey Twin (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41X5xs78iCL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81b8khwPWwL._AC_SL1500_.jpg"], "asin": "B07PGVXDWR", "price": null}, {"name": "Brown King", "thumbnail": "https://m.media-amazon.com/images/I/41+JaM+hczL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716B6aFzMGL._AC_SL1500_.jpg"], "asin": "B08H8GC45P", "price": null}, {"name": "Grey Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ABfN6n2gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815ZkQedkFL._AC_SL1500_.jpg"], "asin": "B084MG3KMW", "price": null}, {"name": "Grey Full (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41ABfN6n2gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815ZkQedkFL._AC_SL1500_.jpg"], "asin": "B084MG22P1", "price": null}, {"name": "Black King", "thumbnail": "https://m.media-amazon.com/images/I/41hIOYyrFxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/717lqT88NhL._AC_SL1500_.jpg"], "asin": "B00XK9C8WQ", "price": null}, {"name": "Navy Queen (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41eKQKL7k1L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71GV7+ZTyyL._AC_SL1500_.jpg"], "asin": "B08H8QD58W", "price": null}, {"name": "Bright White Twin (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41shlghmxZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71P8PMYp1kL._AC_SL1500_.jpg"], "asin": "B07ZNXF64C", "price": null}, {"name": "Olive Queen", "thumbnail": "https://m.media-amazon.com/images/I/41TwTaiM0fL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71oqnD2vhxL._AC_SL1500_.jpg"], "asin": "B0CRHT3RF2", "price": null}, {"name": "Burgundy Full", "thumbnail": "https://m.media-amazon.com/images/I/41n+eka4VuL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Ao3ck5mGL._AC_SL1500_.jpg"], "asin": "B08H8R5C56", "price": null}, {"name": "Grey Queen (Pack of 6)", "thumbnail": "https://m.media-amazon.com/images/I/41X5xs78iCL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81b8khwPWwL._AC_SL1500_.jpg"], "asin": "B07PDSKCZL", "price": null}, {"name": "White Twin XL", "thumbnail": "https://m.media-amazon.com/images/I/41qiLZAJrjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71UMsb6UYKL._AC_SL1500_.jpg"], "asin": "B07SYHFGX5", "price": null}, {"name": "Purple King", "thumbnail": "https://m.media-amazon.com/images/I/41HD1+nzjQL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716Bo1r5YwL._AC_SL1500_.jpg"], "asin": "B08H8P6M54", "price": null}, {"name": "Spa Blue Queen", "thumbnail": "https://m.media-amazon.com/images/I/41uekRKhkeL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71-7IsLX+LL._AC_SL1500_.jpg"], "asin": "B07VVQQC43", "price": null}, {"name": "Purple Full", "thumbnail": "https://m.media-amazon.com/images/I/41HD1+nzjQL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716Bo1r5YwL._AC_SL1500_.jpg"], "asin": "B08H8P6YW4", "price": null}, {"name": "Purple Queen", "thumbnail": "https://m.media-amazon.com/images/I/41HD1+nzjQL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/716Bo1r5YwL._AC_SL1500_.jpg"], "asin": "B08H2L6C32", "price": null}, {"name": "Sage Queen", "thumbnail": "https://m.media-amazon.com/images/I/41kdf3Q4OHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71vOAo9LKjL._AC_SL1500_.jpg"], "asin": "B09S3QS6GH", "price": null}, {"name": "Dusk Blue Queen", "thumbnail": "https://m.media-amazon.com/images/I/411wnrvVShL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71bUTefILRL._AC_SL1500_.jpg"], "asin": "B0D968FKMZ", "price": null}, {"name": "Navy King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41TXs1uG+eL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YCyqN7ygL._AC_SL1500_.jpg"], "asin": "B0C8JMJ8QB", "price": null}, {"name": "Grey Twin XL", "thumbnail": "https://m.media-amazon.com/images/I/41YFT70iSZL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/715ODRGbEdL._AC_SL1500_.jpg"], "asin": "B07T35TVKW", "price": null}, {"name": "Spa Blue Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41KQcrFJj7L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81B3gvtvMcL._AC_SL1500_.jpg"], "asin": "B0CT9288J6", "price": null}, {"name": "Beige Twin XL (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/41WTrUD4LrL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811a8Ohr2UL._AC_SL1500_.jpg"], "asin": "B0C8JF9VVK", "price": null}], "reviewsLink": "https://www.amazon.com/product-reviews/B07SXGNLM7?language=en", "hasReviews": true, "delivery": "Wednesday, July 29", "fastestDelivery": "Today 6 PM - 11 PM", "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "California King( Pack of 1)"}], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2218, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 17, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 189, "absolute5Star": 2004}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5378, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 16, "absolute3Star": 4, "absolute4Star": 442, "absolute5Star": 4869}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25489, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1969, "absolute5Star": 23285}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595481, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2870, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54774, "absolute5Star": 533036}}, "bestsellerRanks": [{"rank": 60, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 1, "category": "Fitted Bed Sheets", "url": "https://www.amazon.com/gp/bestsellers/home-garden/10749171/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description", "rawImages": [{"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e289422b-8a3c-42be-91b6-a7cc85a934df.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "king bed fitted sheet, california king fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/026d1d74-92e6-4f47-807a-9a7fc11cfa90.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "fitted king white, white king fitted sheet, fitted sheet queen white, white fitted sheet,", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c84f475e-b0bd-4ff4-8567-9508686f20c4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/37fef54b-fa0e-4e1b-b6db-d8bdf62c0aaa.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Spa Blue fitted sheet queen, fitted sheet queen, twin fitted sheets 6 pack, bottom sheets twin", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f6e37363-8d87-42ca-878e-b81efabd6766.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "fitted sheets twin, twin fitted sheet 6 pack, fitted twin sheet, fitted sheet queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/bf3778d2-bf1c-4239-b258-9cc20c3506a5.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ce4370b7-e0a4-4bbe-bfbb-831fd8748851.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}], "rawVideos": [], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e289422b-8a3c-42be-91b6-a7cc85a934df.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "king bed fitted sheet, california king fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/026d1d74-92e6-4f47-807a-9a7fc11cfa90.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "fitted king white, white king fitted sheet, fitted sheet queen white, white fitted sheet,", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c84f475e-b0bd-4ff4-8567-9508686f20c4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/37fef54b-fa0e-4e1b-b6db-d8bdf62c0aaa.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "Spa Blue fitted sheet queen, fitted sheet queen, twin fitted sheets 6 pack, bottom sheets twin", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f6e37363-8d87-42ca-878e-b81efabd6766.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "fitted sheets twin, twin fitted sheet 6 pack, fitted twin sheet, fitted sheet queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/bf3778d2-bf1c-4239-b258-9cc20c3506a5.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "king bed fitted sheet, queen fitted sheet, twin fitted sheet, twin xl fitted sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ce4370b7-e0a4-4bbe-bfbb-831fd8748851.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find the fitted sheet comfortable and easy to sleep on, with a nice bright white color and smooth texture that stays in place. They appreciate its deep pockets that keep it secure and consider it good value for money. The durability receives mixed feedback - while some say it holds up well in the wash, others report it rips after first use.", "keywords": [{"name": "Fit", "sentiment": "positive", "text": "Customers find that the fitted sheet fits well and stays in place.", "customersMentionedCount": {"total": 5114, "positive": 4350, "negative": 764}, "partialReviews": [{"text": "I have ordered these sheets in the past, and they were a perfect fit - but this two pack for our full size mattress contained the same size sheets...", "highlightedPart": "perfect fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1YRNZV736OJZ0"}, {"text": "On time delivery and exactly as expected. Nice fabric and great fit. I will buy similar product again when I need a color change.", "highlightedPart": "great fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RSHW7A8T9LATQ"}, {"text": "It fits perfectly and is nice and soft. It didn't shrink when I washed it. It was delivered much faster than I thought, also! I would buy it again.", "highlightedPart": "fits perfectly", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R391FA7RQ0K80T"}, {"text": "This works perfectly, fits great, and was much less expensive than buying from my local stores. It's not a super soft sheet but it's also not rough....", "highlightedPart": "fits great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2MORXNARN0MHE"}]}, {"name": "Quality", "sentiment": "positive", "text": "Customers find the fitted sheet to be of good quality.", "customersMentionedCount": {"total": 4286, "positive": 3887, "negative": 399}, "partialReviews": [{"text": "Finally! A bottom sheet that stay put. Good quality and like I just said... deep enough pockets to stay in place even with a 2 inch matress topper....", "highlightedPart": "Good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2FYEF5B0YJGBH"}, {"text": "Super soft, great quality, perfect fitted sheet! This sheet was a home run! Fit perfectly on my 10 inch memory foam mattress with extra wiggle room!...", "highlightedPart": "great quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R36OK9VO05QLM1"}, {"text": "Great product. We have a 4 inch memory foam on top of our regular mattress and this sheet has been holding up so well....", "highlightedPart": "Great product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RM03WJV2Z2JFR"}, {"text": "Great sheets! Great price! Would like it better if I could have bought just four instead of six...but, very nice product for the money.", "highlightedPart": "Great sheets", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1PWFNIW3U1UAW"}]}, {"name": "Softness", "sentiment": "positive", "text": "Customers find the fitted sheet soft and smooth against the skin.", "customersMentionedCount": {"total": 4145, "positive": 3879, "negative": 266}, "partialReviews": [{"text": "Ordered a king size and it\u2019s very, doesn\u2019t have a tight fit. Soft but also uncomfortable due to how much movement the sheet gives when on my mattress.", "highlightedPart": "Soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2L7SA1UOQO7FE"}, {"text": "This sheet is very soft, but after 1 wash the elastic stretched out and I am unable to fit the sheet to my bed. I may have received a defective item.", "highlightedPart": "very soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RFSOBARFAW8R8"}, {"text": "These sheets are super soft. They are a little light and thin, so if you are looking for heavy, thick sheets don't by them. They are very comfortable.", "highlightedPart": "super soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3ED03NUT80WU8"}, {"text": "These are so soft, it's a shame to put a top sheet on. The pockets are very deep. I have an 11\" memory foam bed, and the corners are staying put....", "highlightedPart": "so soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2PK00V2J2Q8QW"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find the fitted sheet offers good value for money.", "customersMentionedCount": {"total": 2775, "positive": 2650, "negative": 125}, "partialReviews": [{"text": "Great quality at a great price! I have had no issues with these sheets. They stay on mattress well and they clean up well. I recommend these sheets", "highlightedPart": "great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RSHV4QSDDHGKZ"}, {"text": "Works for my bed, plenty big pocket for my sleep number mattress. Great value for someone like me who is hard on sheets and wants to swap them out...", "highlightedPart": "Great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R81Q7Z4BBAIBA"}, {"text": "Considering the price of bedsheets nowadays, this was a really good price for me. Wonderful sheets if I needed any more, I would get the same ones.", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1UQZQZZ6PJH60"}, {"text": "So far so good...good value, very comfortable bottom sheet, fits my Tempur-Pedic mattress very well...the test will be to see if the fabric...", "highlightedPart": "good value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R26GK9LO35PWY3"}]}, {"name": "Comfort", "sentiment": "positive", "text": "Customers find the fitted sheet comfortable and easy to sleep on, describing it as cozy. One customer mentions it helps with allergies, while another notes there are no wrinkles.", "customersMentionedCount": {"total": 1722, "positive": 1616, "negative": 106}, "partialReviews": [{"text": "...them on pockets are xtra deep materials, pretty thick, soft and comfortable. Excellent price. There is nothing I dislike of them absolutely wonderful", "highlightedPart": "comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2N664C05PZAM5"}, {"text": "...I was weary that the material might be warm, but it\u2019s very comfortable and very breathable. I would highly recommend these you won\u2019t be disappointed!", "highlightedPart": "very comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2UGKF7G4O2LHR"}, {"text": "...Our twin bed has a plush top and super thick and comfy. I was worried about getting one to fit and this fitted sheet was PERFECT!! thank you so much!", "highlightedPart": "comfy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RVRAGXN9Q7LFW"}, {"text": "These sheets are very soft and cozy! We bought for our kids beds in the camper and they fit the bunks perfectly! Plus have extra incase of accidents.", "highlightedPart": "cozy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2ENOZUY2U3RD7"}]}, {"name": "Color", "sentiment": "positive", "text": "Customers like the color of the fitted sheet, describing it as a nice bright white that resembles silk, with one customer noting that the color has not faded.", "customersMentionedCount": {"total": 1126, "positive": 989, "negative": 137}, "partialReviews": [{"text": "...WE HAVE A NEW MATTRESS AND ITS SO WIDE ETC. LOVE THE COLOR. MATCHES THE BEAUTIFUL MINT GREEN MATTRESS PAD WE GOT FROM AMAZON TOO. THANKS SO MUCH", "highlightedPart": "LOVE THE COLOR", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R38GSBUYX6G8P0"}, {"text": "I love this fitted sheet. It is soft, the perfect size, and a nice color (I got white). I will definitely purchase in more colors.", "highlightedPart": "nice color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2Y27F6GHSEDF0"}, {"text": "Comfortable, great color, feels great! If you have some old sheets, replace them ASAP! It will change the way you sleep, I guarantee it.", "highlightedPart": "great color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RHR7WR2MLRAQJ"}, {"text": "This fitted sheet is a beautiful color and feels amazing\u2014especially since my husband and I tend to get hot at night....", "highlightedPart": "beautiful color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1R9F1B3EPB1F1"}]}, {"name": "Deep pockets", "sentiment": "positive", "text": "Customers appreciate the deep pockets of the fitted sheet, which keep it securely in place.", "customersMentionedCount": {"total": 961, "positive": 827, "negative": 134}, "partialReviews": [{"text": "Deep pockets!! Finally found a sheet that fits my temperpedic and don't have to constantly put the sheets back on every morning!! Definately recommend", "highlightedPart": "Deep pockets", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R24BDS39MK7V81"}, {"text": "...These are deep pocket which is great and needed for today's mattresses, however if you wash these sheets with anything else you will find everything...", "highlightedPart": "deep pocket", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R271N1V5OA1ZT3"}, {"text": "For $16.00 these sheets are a terrific bargain. The have really nice deep pockets and are nice and soft. I would buy them again in a nanosecond!", "highlightedPart": "nice deep pockets", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RI3LGX123GHQK"}, {"text": "Nice softness i love the deep pockets,but i prefer cotton sheets if you have rough feet the sheets will not last long. But great price", "highlightedPart": "love the deep pockets", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2FVEHBGZOMJCI"}]}, {"name": "Durability", "sentiment": "mixed", "text": "Customers have mixed experiences with the fitted sheet's durability, with some reporting it holds up well in the wash and lasts a long time, while others mention it rips after first use and tears easily.", "customersMentionedCount": {"total": 1086, "positive": 644, "negative": 442}, "partialReviews": [{"text": "Deep pocket sheets that don't slide off. They are soft and durable. The color is as pictured and even with 4 dogs, the hair and wrinkles are minimal.", "highlightedPart": "durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R11W22FCRP9VRE"}, {"text": "Arrived wrinkled, washed and used. Threads coming out. Packaging was intact which means a used item was packed and sold as new.", "highlightedPart": "used", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RR0NKPNP666VF"}, {"text": "...love most about this bed sheet is that is very stain resistant and very durable. But the top quality about this bed sheet is that it is wrinkle free....", "highlightedPart": "very durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2DM7ET6TWTEOM"}, {"text": "The fabric is soft and lightweight just not durable. It was comfortable so I was saddened that It ripped just after a couple washes.", "highlightedPart": "not durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RFUW8MY6NEZH4"}]}]}, "monthlyPurchaseVolume": "200+ bought in past month", "productPageReviews": [{"username": "Dottie", "userId": "amzn1.account.AEZVD7HGBNRHUK3VH35TG4NWNNPQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEZVD7HGBNRHUK3VH35TG4NWNNPQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Comfortable Fitted Bed Sheet", "reviewDescription": "This is a really nice fitted sheet. Very soft and it did fit my queen mattress; after laundering . Great value.", "date": "2026-07-20", "position": 1, "reviewedIn": "Reviewed in the United States on July 20, 2026", "reviewId": "R2ZPZSFM2ZYR35", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2ZPZSFM2ZYR35/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Grey, Size: Queen", "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "Queen"}]}, {"username": "Kinnidy Settles", "userId": "amzn1.account.AGJLHZKOALRBOPLLSEV3GKCALBKQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGJLHZKOALRBOPLLSEV3GKCALBKQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Good quality", "reviewDescription": "This black twin sheet set is soft and comfortable and fits the bed nicely. The material feels smooth and works well for everyday use.The fitted sheet stays in place pretty well and doesn\u2019t slip off easily, which is always a plus.The color is a nice deep black and looks clean and simple on the bed. It also seems to wash well without fading so far.Overall, it\u2019s a solid, comfortable sheet set that does the job and feels good to sleep on.", "date": "2026-06-17", "position": 2, "reviewedIn": "Reviewed in the United States on June 17, 2026", "reviewId": "RQMVYEN2T3T79", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RQMVYEN2T3T79/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "5 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/f6e492fc-586a-40dc-9660-d843e2a8bfdd._CR0%2C26%2C281%2C281_UX460_SX48_.jpg", "variant": "Color: Black, Size: Twin (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "Black"}, {"key": "Size", "value": "Twin (Pack of 2)"}]}, {"username": "Carina cast", "userId": "amzn1.account.AH6NCPCQYQMQD72XHUTBDPDP3USA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH6NCPCQYQMQD72XHUTBDPDP3USA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great cover", "reviewDescription": "I really like this fitted sheet. It fits my mattress perfectly and stays in place without slipping off during the night. The fabric feels soft and comfortable against the skin, making it nice to sleep on. It washes well and hasn\u2019t faded or shrunk after multiple uses. Overall, a simple but high-quality sheet that does exactly what it should.", "date": "2026-06-26", "position": 3, "reviewedIn": "Reviewed in the United States on June 26, 2026", "reviewId": "R3CX9QV534AJOI", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3CX9QV534AJOI/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "9 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/c9f8832d-2dd1-4ab3-9479-6542ec4fe584._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "Color: White, Size: Queen", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen"}]}, {"username": "Mamamiya", "userId": "amzn1.account.AGO4ETSJHITXF6VWYE7S6FMH5H3A", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGO4ETSJHITXF6VWYE7S6FMH5H3A?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "The material feels soft and comfortable.", "reviewDescription": "I got this fitted sheet for my son\u2019s bed because he moves around a lot in his sleep and his old sheet would come off the corners almost every morning. After switching to this one, it stays in place much better and saves me from fixing the bed every day.The material feels soft and comfortable, and my son said the bed feels \u201ccozier\u201d now. It\u2019s also lightweight and breathable enough that he doesn\u2019t get too hot at night. I\u2019ve washed it a few times already and it still looks clean with no noticeable shrinking or fading.I\u2019m giving it 4 stars because the fabric is a little thinner than I expected. It\u2019s not bad at all for the price, but I think a slightly thicker material would make it feel even better and more durable long term.For a simple everyday fitted sheet though, it does the job well and makes bedtime a little easier in our house.", "date": "2026-04-28", "position": 4, "reviewedIn": "Reviewed in the United States on April 28, 2026", "reviewId": "R2G6MH350TTT3G", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2G6MH350TTT3G/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "8 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Beige, Size: Twin", "variantAttributes": [{"key": "Color", "value": "Beige"}, {"key": "Size", "value": "Twin"}]}, {"username": "JMargo", "userId": "amzn1.account.AE5SYJWR3K4GQP7XEDRCUZ53DHEA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE5SYJWR3K4GQP7XEDRCUZ53DHEA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Quality sheet!", "reviewDescription": "Good quality, washes well, fit my queen bed perfectly and its soft and cool. This fitted sheet looks and feels great! For the price, I was worried it would be that weird, thin, synthetic microfiber. I'm glad I took a chance because it exceeded my expectations. Will be purchasing more in different colors!", "date": "2026-06-22", "position": 5, "reviewedIn": "Reviewed in the United States on June 22, 2026", "reviewId": "R2T82B0Y4BZGL", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2T82B0Y4BZGL/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "7 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Beige, Size: Queen", "variantAttributes": [{"key": "Color", "value": "Beige"}, {"key": "Size", "value": "Queen"}]}, {"username": "Steven", "userId": "amzn1.account.AE2KB7BCKXGLHEK4XRQDHCZM3SAQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE2KB7BCKXGLHEK4XRQDHCZM3SAQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Stocked up for toddler bed chaos with zero regrets", "reviewDescription": "When you're transitioning a toddler to a twin bed, you need sheets in bulk. Accidents happen, nap schedules are unpredictable, and laundry doesn't always get done on time. This 6-pack solved that problem completely.The quality genuinely surprised me at this price point. They're soft right out of the bag, and after multiple washes there's been no shrinking, pilling, or fading. They fit our 8\" mattress well, but there is a slight looseness to them, so if you're running a thicker mattress you'll get a snugger fit, but for a standard toddler twin setup they work great.The small detail that stood out: they actually include top/bottom orientation tags. It sounds minor, but when you're half-asleep making a bed at 6am, not having to guess which end is which is quietly appreciated. Not something you'd expect from a budget bulk pack.For the price per sheet, these are exceptional value. Soft, durable, and thoughtfully made. I'll be reordering when these eventually wear out.Bottom line: Best bang-for-buck sheets I've found for a toddler bed. The 6-pack quantity is perfect for staying ahead of laundry.", "date": "2026-06-10", "position": 6, "reviewedIn": "Reviewed in the United States on June 10, 2026", "reviewId": "R147VZEKXS8JNK", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R147VZEKXS8JNK/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "4 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Navy, Size: Twin (Pack of 6)", "variantAttributes": [{"key": "Color", "value": "Navy"}, {"key": "Size", "value": "Twin (Pack of 6)"}]}, {"username": "Kaitlyn nielson", "userId": "amzn1.account.AGXUW4QQT77RHE6D5USWN3AHRREA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGXUW4QQT77RHE6D5USWN3AHRREA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Deep pockets", "reviewDescription": "Very soft and good quality. The corner pockets are deep enough that it doesn't slip up on the corners and it is fitted very well.", "date": "2026-07-15", "position": 7, "reviewedIn": "Reviewed in the United States on July 15, 2026", "reviewId": "R32A7XD4KJV9QC", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R32A7XD4KJV9QC/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Sage, Size: King", "variantAttributes": [{"key": "Color", "value": "Sage"}, {"key": "Size", "value": "King"}]}, {"username": "Houston Ruthie", "userId": "amzn1.account.AHF4G2OM2N5UFRA4RDW5D7ZGTVKQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHF4G2OM2N5UFRA4RDW5D7ZGTVKQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Our Fave Sheet!", "reviewDescription": "This is a wonderful sheet that is warm and snuggly. It breathes and is soft and keeps its beauty after many washes. We\u2019ve had this one sheet for almost a year and we keep washing it and putting back on our bed and it\u2019s fabulous! I wash it in cool water most times and on delicate and dry it a little bit and it still looks beautiful and feels fabulous! Recommend !!!", "date": "2026-06-06", "position": 8, "reviewedIn": "Reviewed in the United States on June 6, 2026", "reviewId": "R3SOI9OCJJOI0H", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3SOI9OCJJOI0H/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "5 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Grey, Size: King", "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "King"}]}], "productPageReviewsFromOtherCountries": [{"username": "Marielle Lefebvre", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Parfait", "reviewDescription": "Pas besoin de repasser et tr\u00e8s agr\u00e9able au contact de la peau", "date": "2021-06-03", "position": 1, "reviewedIn": "Reviewed in France on June 3, 2021", "reviewId": "R14WK689PI2PH9", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: European Double (Pack of 6)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "European Double (Pack of 6)"}]}, {"username": "Pascale", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Very soft & good quality", "reviewDescription": "I wanted to give 5 stars, but for some reason it network accepts only 4.It fits perfectly. And it's very soft. Good qualityDefinitely buying more.", "date": "2025-02-02", "position": 2, "reviewedIn": "Reviewed in the Netherlands on February 2, 2025", "reviewId": "R16P0LQC4N0Z1", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/f8f1707c-35fa-4cc5-be83-af95731d78ee._CR0%2C0%2C768%2C768_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Belinda", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Buena calidad a buen precio. Tacto muy suave.", "reviewDescription": "Muy buenas!", "date": "2019-10-02", "position": 3, "reviewedIn": "Reviewed in Spain on October 2, 2019", "reviewId": "R1A9I85KNGVJZ1", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: European Double (Pack of 6)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "European Double (Pack of 6)"}]}, {"username": "M Ma", "userId": null, "userProfileLink": null, "ratingScore": 3, "reviewTitle": "Updated: I can\u2019t complain for its price", "reviewDescription": "I had just received the fitted sheets today so it may not be the best review. I threw it in the washer and it came out pretty decent. The material is soft and it feels nice against the skin. However, I do foresee that the synthetic material may be stuffy for hot sleepers. I will update the review after some use.Update: I bumped it down from a 4 star to a 3 star because of the fact that the material retains heat and it gets stuffy for hot sleepers. However, I cannot complain much because of its price.", "date": "2020-02-10", "position": 4, "reviewedIn": "Reviewed in Singapore on February 10, 2020", "reviewId": "R1CN5N3M4XYRD", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Isabelle Ren\u00e9", "userId": null, "userProfileLink": null, "ratingScore": 4, "reviewTitle": "-", "reviewDescription": "good quality fabric", "date": "2023-01-13", "position": 5, "reviewedIn": "Reviewed in Canada on January 13, 2023", "reviewId": "R1E9WZB5X2GER1", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Burgundy, Size: Full", "variantAttributes": [{"key": "Color", "value": "Burgundy"}, {"key": "Size", "value": "Full"}]}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B07SXGNLM7?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 18.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2218, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 17, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 189, "absolute5Star": 2004}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5378, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 16, "absolute3Star": 4, "absolute4Star": 442, "absolute5Star": 4869}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25489, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1969, "absolute5Star": 23285}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595481, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2870, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54774, "absolute5Star": 533036}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": "Wednesday, July 29", "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B07SXGNLM7?language=en", "input": "https://www.amazon.com/dp/B07SXGNLM7"}}, "B08DTH86Q2": {"ts": 1785168647.1616864, "item": {"title": "Utopia Bedding Gusset Bed Pillow, Hotel Pillows Queen Size Set of 2, White | Cotton Blended Down alternative pillows for sleeping, soft fluffy cooling comfort for Back, side & stomach sleepers, 2 pack", "url": "https://www.amazon.com/dp/B08DTH86Q2?language=en", "asin": "B08DTH86Q2", "originalAsin": "B08DTH86Q2", "price": {"value": 26.07, "currency": "$"}, "inStock": true, "inStockText": "In Stock", "listPrice": {"value": 28.89, "currency": "$"}, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.5, "starsBreakdown": {"5star": 0.73, "4star": 0.14, "3star": 0.07, "2star": 0.02, "1star": 0.04}, "reviewsCount": 78530, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Bed Pillows & Positioners > Bed Pillows", "videosCount": 18, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B08DTH86Q2&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/61olyTdK-ZL._AC_SX300_SY300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/31-AOgF6YbL._AC_US.jpg", "https://m.media-amazon.com/images/I/41Bzl-HlI9L._AC_US.jpg", "https://m.media-amazon.com/images/I/415ZsrJB59L._AC_US.jpg", "https://m.media-amazon.com/images/I/51Zibt71IOL._AC_US.jpg", "https://m.media-amazon.com/images/I/415tB5xGStL._AC_US.jpg", "https://m.media-amazon.com/images/I/41WhKWSaD3L._AC_US.jpg", "https://m.media-amazon.com/images/I/912ONiuzsEL.SS125_PKplay-button-mb-image-grid-small_.png"], "highResolutionImages": ["https://m.media-amazon.com/images/I/61olyTdK-ZL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81rSOkr4KxL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81y504SJ1dL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81aTOGFUgUL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/815O00vtP8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71JV+yDkqJL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71SiEV-EXwL._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": [{"title": "Safer chemicals", "description": "Made with chemicals safer for human health and the environment.", "certifiedBy": [{"name": "OEKO-TEX STANDARD 100", "image": "https://m.media-amazon.com/images/I/51YvKwF01yL._SS144_.jpg", "description": " OEKO-TEX\u00ae STANDARD 100 certified products require every component of a textiles production including all thread, buttons, and trims to be tested against a list of more than 1,000 regulated and unregulated chemicals which may be harmful to human health. The assessment process is globally standardised, independently conducted, and updated at least once per year based on new scientific information or regulatory requirements. Learn more about this certification", "url": "https://www.oeko-tex.com/en/our-standards/standard-100-by-oeko-tex", "data": [{"key": "Certification Number", "value": "2023OK1318"}]}]}], "description": null, "features": ["Hotel-like Comfort \u2013 Bring the luxurious, soft, and plush hotel sleep experience into your everyday life. These queen pillows set of 2 instantly upgrade your bedroom, guest room, or dorm into a cozy retreat that feels like a 5-star stay.", "Perfect for Every Lifestyle & Space \u2013 Whether you're setting up your first apartment, refreshing your home, or looking for thoughtful bedroom essentials, these bed pillows are a perfect fit. Ideal for housewarming gifts, dorm rooms, guest bedrooms, or everyday home comfort.", "Gusseted Design for Lasting Loft- Featuring gusseted edges with stylish piping, these pillows maintain their loft and shape over time. The structured design enhances durability while adding a refined, hotel-inspired look to your bedding.", "Cool & Breathable -Enjoy a refreshing night\u2019s sleep with a soft cotton blend cover that promotes airflow and helps regulate temperature. Ideal for hot sleepers or anyone who prefers a cool, dry, and breathable sleep environment.", "Adaptive Comfort & Easy Care - Designed to suit all sleeping positions - whether you sleep on your back, side, or stomach - these pillows provide personalized softness and support for a restful night\u2019s sleep. They are easy to maintain with simple spot cleaning, keeping them fresh, clean, and consistently comfortable."], "attributes": [{"key": "Pillow Type", "value": "Bed Pillow"}, {"key": "Other Special Features of the Product", "value": "Corded"}, {"key": "Indoor Outdoor Usage", "value": "Indoor"}, {"key": "Recommended Uses For Product", "value": "Multi Position Sleeper"}, {"key": "Item Firmness Description", "value": "Plush"}, {"key": "Color", "value": "White"}, {"key": "Shape", "value": "Rectangular"}, {"key": "Pattern", "value": "Gusseted"}, {"key": "Style Name", "value": "Gusseted"}, {"key": "Theme", "value": "Cool"}, {"key": "Fill Material", "value": "Polyester"}, {"key": "Cover Material", "value": "Polycotton"}, {"key": "Product Care Instructions", "value": "Spot Clean"}, {"key": "Fabric Type", "value": "Polycotton"}, {"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Number of Items", "value": "2"}, {"key": "Model Name", "value": "UB1553"}, {"key": "UPC", "value": "840143510055"}, {"key": "Set Name", "value": "Queen Size Bed Pillow Set of 2"}, {"key": "Age Range Description", "value": "Adult"}, {"key": "Item Type Name", "value": "Bed Pillows"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "Model Number", "value": "UB1553"}, {"key": "Manufacturer Part Number", "value": "UB1553"}, {"key": "Best Sellers Rank", "value": "#35 in Home & Kitchen (See Top 100 in Home & Kitchen)#2 in Bed Pillows"}, {"key": "ASIN", "value": "B08DTH86Q2"}, {"key": "Customer Reviews", "value": "4.5 4.5 out of 5 stars (78,530) 4.5 out of 5 stars"}, {"key": "Size", "value": "Queen (Pack of 2)"}, {"key": "Item Dimensions L x W", "value": "26\"L x 18\"W"}, {"key": "Item Weight", "value": "4 pounds"}, {"key": "Unit Count", "value": "2 Count"}, {"key": "Item Thickness", "value": "5 inches"}, {"key": "Pillowcase Length", "value": "26 inches"}], "productOverview": [], "variantAsins": ["B0DVT2JTRN", "B0F5QBWTL1", "B08DTH86Q2", "B07N7HP146", "B0B8D6LQSP", "B0B8CZ98Q6", "B09DSRLTQH", "B08DS9ZNYW"], "variantDetails": [{"name": "White Queen (Pack of 4)", "thumbnail": "https://m.media-amazon.com/images/I/41kZkw8fNXL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71A55qUnqmL._AC_SL1500_.jpg"], "asin": "B0DVT2JTRN", "price": null}, {"name": "White King (Pack of 4)", "thumbnail": "https://m.media-amazon.com/images/I/41ODEpm7yXL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71JD+vHtl4L._AC_SL1500_.jpg"], "asin": "B0F5QBWTL1", "price": null}, {"name": "White Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/31-AOgF6YbL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61olyTdK-ZL._AC_SL1500_.jpg"], "asin": "B08DTH86Q2", "price": null}, {"name": "Grey King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/31Rjo8FOWuL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/616CB1F+w3L._AC_SL1500_.jpg"], "asin": "B07N7HP146", "price": null}, {"name": "White European (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/31td0eK9XhL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61tqa1wh1uL._AC_SL1500_.jpg"], "asin": "B0B8D6LQSP", "price": null}, {"name": "White Standard (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/31K7lLVKEAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61OectbA-pL._AC_SL1500_.jpg"], "asin": "B0B8CZ98Q6", "price": null}, {"name": "Grey Queen (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/31rMXEX9+WL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/618NU1WogKL._AC_SL1500_.jpg"], "asin": "B09DSRLTQH", "price": null}, {"name": "White King (Pack of 2)", "thumbnail": "https://m.media-amazon.com/images/I/313rADyzRLL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/61Yqp9b3CPL._AC_SL1500_.jpg"], "asin": "B08DS9ZNYW", "price": null}], "reviewsLink": "https://www.amazon.com/product-reviews/B08DTH86Q2?language=en", "hasReviews": true, "delivery": "Saturday, August 1", "fastestDelivery": "Saturday, August 1", "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen (Pack of 2)"}], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2364, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 16, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 199, "absolute5Star": 2141}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5372, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 15, "absolute3Star": 4, "absolute4Star": 444, "absolute5Star": 4862}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25478, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1965, "absolute5Star": 23278}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595648, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2871, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54784, "absolute5Star": 533192}}, "bestsellerRanks": [{"rank": 35, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 2, "category": "Bed Pillows", "url": "https://www.amazon.com/gp/bestsellers/home-garden/10671043011/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nThe video showcases the product in use.The video guides you through product setup.The video compares multiple products.The video shows the product being unpacked.\nGusset Bed Pillow Video\nDoes this pillow stay fluffy, or does it go flat over time? \nThis pillow is designed to maintain its shape and loft. The gusseted structure helps keep it fluffy and resistant to flattening with regular use \nIs this pillow suitable for side sleepers? \nYes, it offers a balance of softness and support, helping keep the head and neck aligned for side sleepers \nIs this a good cooling pillow for hot sleepers? \nYes, the breathable cotton-blend fabric promotes airflow, helping you stay cool and comfortable \nIs this best pillow for people who prefer soft pillows? \nYes, this pillow is ideal for those who prefer a soft and gentle feel, making it comfortable for everyday sleeping \nIs this pillow easy to clean? \nIt is recommended to spot clean this pillow to maintain its shape and softness over time", "rawImages": [{"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8fef2d13-3706-4057-965d-865bc7f955c5.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "gusset queen pillows, utopia bedding gusseted pillow queen, gusset utopia pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fbe6926e-c876-4b84-98a9-644b10045327.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "utopia bedding gusset pillow, gusset queen pillows, utopia bedding gusseted pillow queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3e0cff01-d8b6-4cc1-acd0-7e7caa3e09b8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e1082c40-9a17-40cd-b307-a81ede1b5357.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b889d41b-109a-4901-b879-84c9db4e6790.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}], "rawVideos": [{"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/848fac8f-ff76-404b-bd79-a2994580066f/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/71WY+25xcpL.jpg"}], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8fef2d13-3706-4057-965d-865bc7f955c5.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "gusset queen pillows, utopia bedding gusseted pillow queen, gusset utopia pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fbe6926e-c876-4b84-98a9-644b10045327.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "utopia bedding gusset pillow, gusset queen pillows, utopia bedding gusseted pillow queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3e0cff01-d8b6-4cc1-acd0-7e7caa3e09b8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e1082c40-9a17-40cd-b307-a81ede1b5357.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-8-hero-video", "title": "Gusset Bed Pillow Video", "text": null, "video": {"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/848fac8f-ff76-404b-bd79-a2994580066f/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/71WY+25xcpL.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "utopia pillows, utopia cooling hotel pillows, queen pillows set of 2, bed pillows, pillows for bed", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b889d41b-109a-4901-b879-84c9db4e6790.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-11-faq", "items": [{"name": "Does this pillow stay fluffy, or does it go flat over time?", "text": "This pillow is designed to maintain its shape and loft. The gusseted structure helps keep it fluffy and resistant to flattening with regular use"}, {"name": "Is this pillow suitable for side sleepers?", "text": "Yes, it offers a balance of softness and support, helping keep the head and neck aligned for side sleepers"}, {"name": "Is this a good cooling pillow for hot sleepers?", "text": "Yes, the breathable cotton-blend fabric promotes airflow, helping you stay cool and comfortable"}, {"name": "Is this best pillow for people who prefer soft pillows?", "text": "Yes, this pillow is ideal for those who prefer a soft and gentle feel, making it comfortable for everyday sleeping"}, {"name": "Is this pillow easy to clean?", "text": "It is recommended to spot clean this pillow to maintain its shape and softness over time"}]}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find these pillows soft but firm, comfortable, and good value for money. They receive positive feedback for sleep quality, with one customer noting they wake up feeling refreshed. The support and durability aspects receive mixed reviews - while some say they provide good neck and head support and hold up well, others report they offer little support and start tearing at the seams. Additionally, several customers mention the pillows become extremely flat over time.", "keywords": [{"name": "Softness", "sentiment": "positive", "text": "Customers appreciate the pillows' softness, describing them as soft but firm, very fluffy, and not too stiff.", "customersMentionedCount": {"total": 3804, "positive": 2707, "negative": 1097}, "partialReviews": [{"text": "...like firm pillows buts I do not these kept my neck up high they are soft but they didn\u2019t sink to form my head it\u2019s like they are balloons not pillows.", "highlightedPart": "soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3922P5JJGYDA1"}, {"text": "...It's still fluffy yet firm enough to support the space between your legs when laying on your side. From there you can barely hear the material inside.", "highlightedPart": "fluffy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2NHKKX1Z2TD5J"}, {"text": "Just so mediocre product. They're too soft & empty not plum & round like in the photos here. A bit disappointed with the overall feel of the pillows.", "highlightedPart": "too soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RUQCO5TUFEDJN"}, {"text": "...They are not down but feel like it. They can be firm while allowing your head to sink in and that is just what I was looking for. Price is great, too.", "highlightedPart": "firm", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RDLF35UYPCFQ2"}]}, {"name": "Quality", "sentiment": "positive", "text": "Customers find these pillows to be of good quality.", "customersMentionedCount": {"total": 3691, "positive": 3190, "negative": 501}, "partialReviews": [{"text": "Great pillows. The filling does not come out of the pillowcase. Very light, like a cloud. But at the same time dense. Very comfortable to sleep....", "highlightedPart": "Great pillows", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RKBF6CR4V6VOO"}, {"text": "Better than expected, firm enough and of good quality. Recently my Amazon purchases have been awful, super low quality items, but these were great!!...", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2951BLG1OMF3O"}, {"text": "I bought from the pillow guy several years ago. Great pillow, but these weren't quite as fluffy and made to accomadate side sleepers of wheich I am....", "highlightedPart": "Great pillow", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3PTPABXBPATOU"}, {"text": "Its a cotton candy pillow. They get nice and fluffy after a quick tumble in the dryer, but the second you lay your head on it the pillow disappears....", "highlightedPart": "nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3U3M7PHV3UUTG"}]}, {"name": "Comfort", "sentiment": "positive", "text": "Customers find these pillows wonderfully comfortable and easy to sleep on.", "customersMentionedCount": {"total": 3023, "positive": 2637, "negative": 386}, "partialReviews": [{"text": "I love this pillows. It is very fluffy and comfortable. I did not expect it to be this good. I bought another set for my kids because it was so good.", "highlightedPart": "comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1DS8B7SFN5G9K"}, {"text": "...The first one had us hooked. They are very comfortable, heavy made, they don't slide around on the bed. Absolutely, much better than a mattress alone.", "highlightedPart": "very comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2ME37VN0HIMW2"}, {"text": "they\u2019re comfy, and huge! not good for belly sleepers, but overall good. low stars because the plastic on the pillows stuck to them and won\u2019t come off.", "highlightedPart": "comfy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1PW5PHEJ8KZ3G"}, {"text": "So comfortable and holds up well. I\u2019ve spent way more on \u201c luxury\u201d pillows that go hard and flat in a couple of months I wish I found this one sooner", "highlightedPart": "So comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2TWZTAQDHE0FC"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find these pillows to be good value for money, offering great quality for their price point.", "customersMentionedCount": {"total": 1524, "positive": 1335, "negative": 189}, "partialReviews": [{"text": "...alternative to damn pillows, this is a great choice at a great price , you are supposed to change your pillows, I found out, every year which a few...", "highlightedPart": "great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RIJ8PEAHO4HRB"}, {"text": "Great pillow and great value! Soft but not too soft, supportive, and stayed comfortable throughout the night. Slept great with this pillow!", "highlightedPart": "great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3MDY18IQANLX7"}, {"text": "I guess you get what you pay for. These pillows are MEH...good price but if you are looking for a pillow that you just sink into when you fall into...", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1S19LXQ2YUC6T"}, {"text": "...on the bed in my decorative shams and for the price I find them good value (again my husband would disagree bc he hates them) I\u2019m hoping they last...", "highlightedPart": "good value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3PES6JIXXK989"}]}, {"name": "Sleep quality", "sentiment": "positive", "text": "Customers report wonderful sleep with these pillows, and one mentions waking up feeling refreshed.", "customersMentionedCount": {"total": 734, "positive": 646, "negative": 88}, "partialReviews": [{"text": "...so good ... we are loving these pillows ... we have been getting the best sleep we have had in a while ... my wife no longer wakes up with headaches...", "highlightedPart": "best sleep", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RH9GS20AQEXY6"}, {"text": "This is a great pillow for the great sleep,I love it so much it\u2019s make me sleep comfortable I would recommend it for anyone who wants to sleep...", "highlightedPart": "great sleep", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3TGUX4XCJSLB"}, {"text": "...He said they were very comfortable and he slept like a baby. I brought him a set for Christmas.", "highlightedPart": "slept like a baby", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RQA1QK07ZRY2Y"}, {"text": "...I\u2019ve been sleeping better since I started using them, and they feel plush without being too soft or too firm....", "highlightedPart": "sleeping better", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R8AYESK6510CC"}]}, {"name": "Support", "sentiment": "mixed", "text": "Customers have mixed opinions about the pillow's support, with some finding it provides good neck and head support while others report very little or no support at all.", "customersMentionedCount": {"total": 1251, "positive": 692, "negative": 559}, "partialReviews": [{"text": "They are full, supportive and soft. They haven\u2019t started to break down or flatten out like other pillows that I\u2019ve tried even after multiple washings", "highlightedPart": "supportive", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RZ012W0UVF2R7"}, {"text": "Terribly flat pillows. No support. It claims for all sleepers. I\u2019m a side and back sleeper and it felt like I didn\u2019t even have a pillow under my head.", "highlightedPart": "No support", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1TA3BB4XW8E7P"}, {"text": "I am a side sleeper. Initially, I loved the comfort, support, and sleep quality, but then I began waking up with shoulder, neck, and back discomfort....", "highlightedPart": "support", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R21N0V1MSULJY5"}, {"text": "Very disappointed in these pillows. No support at all, I kept thinking they would fluff up but they have not, my head sinks through it to the bed....", "highlightedPart": "No support at all", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1KDHNFCYN4Y4Y"}]}, {"name": "Durability", "sentiment": "mixed", "text": "Customers have mixed experiences with the durability of the pillow, with some reporting it holds up well and lasts longer, while others mention issues with seams tearing at the seams and popping open.", "customersMentionedCount": {"total": 660, "positive": 329, "negative": 331}, "partialReviews": [{"text": "...I wish I had gone for something super cheap instead, they are really flimsy and overpriced like wow... I buy a lot of hypoallergenic type pillows...", "highlightedPart": "flimsy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2NUD72X0Z7EXX"}, {"text": "...They easily fluff back up and hold their shape. You can\u2019t go wrong with the price. They are made with quality materials and will last a long time.", "highlightedPart": "hold their shape", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RBBDI79OAZM7R"}, {"text": "...I washed two on delicate and they both ripped at the seams and are completely destroyed. I would not purchase again because of this.", "highlightedPart": "ripped at the seams", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RZJM5TR058ALY"}, {"text": "...They are great. Very comfortable. They keep their shape, they are sturdy but not too hard, and usually I have to break my pillows in a little, but...", "highlightedPart": "sturdy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RJRV34CTSAM6U"}]}, {"name": "Flatness", "sentiment": "negative", "text": "Customers report that the pillows become extremely flat after use.", "customersMentionedCount": {"total": 891, "positive": 264, "negative": 627}, "partialReviews": [{"text": "These pillows are flat and not comfortable at all. I woke up with my neck hurting so much \ud83e\udd26\ud83c\udffd\u200d\u2640\ufe0f smh. A waste of money on pillows I won\u2019t be using \ud83d\ude2d", "highlightedPart": "flat", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1SRPJXS7GGPT"}, {"text": "...This pillow is soft but very flat. Needless to say, it wasn't a good night's sleep. When I slept on my side there was next to no support for my neck....", "highlightedPart": "very flat", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1Y3OCZVNIJ1MI"}, {"text": "...Wonderful sleep, I'm a side sleeper and I like my pillow not too flat, but curves into my neck for support but also soft enough to now be over my...", "highlightedPart": "not too flat", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1DXPZQQXQIUSK"}, {"text": "Pillows are super flat! Even after letting them fluff up still are super flat. It seems everyone is receiving different pillows based on the reviews....", "highlightedPart": "super flat", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1R2K4A8K0WRMV"}]}]}, "monthlyPurchaseVolume": "40K+ bought in past month", "productPageReviews": [{"username": "bridget", "userId": "amzn1.account.AFSTLN34TUY2KNSUJ6XQ22KLVSBA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFSTLN34TUY2KNSUJ6XQ22KLVSBA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Really nice pillows.", "reviewDescription": "Really nice and soft but with support. I also like the squared off sides seems to make the pillows have a better shape.Good product for the price. I would consider these pillows medium firm.", "date": "2026-07-18", "position": 1, "reviewedIn": "Reviewed in the United States on July 18, 2026", "reviewId": "R29A5ZRMRJSAVM", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R29A5ZRMRJSAVM/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "4 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Jena", "userId": "amzn1.account.AHU4G2QLIHTB5PBJZS5FMV4KRIVA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHU4G2QLIHTB5PBJZS5FMV4KRIVA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great value hotel-style pillows that stay fluffy and supportive", "reviewDescription": "The down alternative filling feels plush and fluffy, but still gives enough support for my neck and shoulders.Stays fluffy after use \u2013 Unlike some cheaper pillows that go flat quickly, these bounce back well after I fluff them in the morning.Cooling and breathable \u2013 I tend to sleep warm, and these don\u2019t trap heat the way my old pillows did. They feel cool and comfortable through the night.How they work for different sleepers:I mainly sleep on my side/stomach, and the loft feels just right for me. The gusseted design helps the pillow keep its shape and provides consistent support across the surface, which is great if you move around a lot.", "date": "2026-07-17", "position": 2, "reviewedIn": "Reviewed in the United States on July 17, 2026", "reviewId": "R6A9EQGWQSFG7", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R6A9EQGWQSFG7/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "4 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}, {"username": "Robin Hurych", "userId": "amzn1.account.AH6SFYL2HTNKTAXK76TIU5YZ4VSA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH6SFYL2HTNKTAXK76TIU5YZ4VSA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Very surprised!", "reviewDescription": "Very surprised! For the price, I thought I'd use them for my shams. However, I'm very surprised at the quality, fullness, comfort and size. I just ordered 6 more as my son asked for them for his room too. And now I'll be using them for use and my shams!!", "date": "2026-07-15", "position": 3, "reviewedIn": "Reviewed in the United States on July 15, 2026", "reviewId": "RO9Q8D8B5XT8S", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RO9Q8D8B5XT8S/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "8 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}, {"username": "Amazon Customer", "userId": "amzn1.account.AHKZX6QJAVGCTGUUZD7WQQNT6E6Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHKZX6QJAVGCTGUUZD7WQQNT6E6Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Very firm", "reviewDescription": "Nice pillows but not what I was looking for. They\u2019re very firm and have remained firm for the past few months. You\u2019re unable to adjust the firmness so I would need to buy more pillows.No odor, bright white and they\u2019re definitely king size pillows.", "date": "2026-07-04", "position": 4, "reviewedIn": "Reviewed in the United States on July 4, 2026", "reviewId": "R2X0MG4YMFK9FA", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2X0MG4YMFK9FA/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "13 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}, {"username": "Chris P", "userId": "amzn1.account.AETKRBZPPXSRKGVAGNSNXOIDG6HQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AETKRBZPPXSRKGVAGNSNXOIDG6HQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Takes the weight off your shoulders", "reviewDescription": "I purchased these king-size pillows to replace some older ones that had gone flat, and they've been a great upgrade. Right out of the package, they fluffed up nicely and looked full and supportive.I've been sleeping on them for several weeks now, and they've held their shape much better than I expected. They're soft and comfortable but still provide enough support for my head and neck. I tend to switch between sleeping on my side and back, and these have worked well for both positions.The cover feels smooth and well-made, and the pillows have a nice hotel-quality feel without the high price tag. They aren't overly firm or too soft, which can be hard to find.For the money, this set offers excellent value. If you're looking for comfortable, fluffy pillows that don't break the bank, I'd definitely recommend giving these a try.", "date": "2026-06-10", "position": 5, "reviewedIn": "Reviewed in the United States on June 10, 2026", "reviewId": "R3320BE1DGXQDK", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3320BE1DGXQDK/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "53 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}, {"username": "Amazon Customer", "userId": "amzn1.account.AGSYXFPMIKL3M7FLGQARFH2B3ZRA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGSYXFPMIKL3M7FLGQARFH2B3ZRA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Amazing pillows", "reviewDescription": "Love, love, love these pillows!I have been looking for pillows for a long time, that are not full and \u201cfluffy\u201d to replace a pillows I\u2019ve had, longer than I like to admit. These are the fourth king sized pillows I have ordered and they are wonderful! I am a side/stomach sleeper and do not like full, overly stuffed, full pillows. I use the extra pillow between my knees to replace a body pillow that I did nothing but struggle with all night. These are not thin, but not overly full and they conform to your body. I have had them a month and still love them. If you like a softer pillow that adapts to your sleeping position, this is it. I even feel like I\u2019m sleeping better.", "date": "2026-06-29", "position": 6, "reviewedIn": "Reviewed in the United States on June 29, 2026", "reviewId": "R3CLVJWGHWNEIG", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3CLVJWGHWNEIG/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "9 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}, {"username": "Chettster", "userId": "amzn1.account.AFYCAZMHOKNQTQWL5HNME7CKVMJQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFYCAZMHOKNQTQWL5HNME7CKVMJQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "A really nice set of pillows", "reviewDescription": "These pillows are great. Very comfortable and one night of sleep was all I needed to break these pillows in. When I saw the box, I was a bit shocked. But after I took them out of the packaging they responded well to reshaping. Super happy with this purchase.", "date": "2026-06-30", "position": 7, "reviewedIn": "Reviewed in the United States on June 30, 2026", "reviewId": "R2D7QFK4XWMZDG", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2D7QFK4XWMZDG/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "26 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/61ae80ef-36be-4fad-b32a-d0a84ce6187e._CR0%2C0%2C476%2C476_SX460_SX48_.jpg", "variant": "Color: Grey, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Erin", "userId": "amzn1.account.AE7ISZAQFAKJ6OYOH4H47WEIKUIQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE7ISZAQFAKJ6OYOH4H47WEIKUIQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Soft, no firmness", "reviewDescription": "Comfy but not firm or supportive as other have said . When I lay back my head sinks down into the pillow. I was hoping for more support but they are very plush and comfy", "date": "2026-07-22", "position": 8, "reviewedIn": "Reviewed in the United States on July 22, 2026", "reviewId": "R3GBIZDKRCM7YP", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3GBIZDKRCM7YP/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "3 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: King (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "King (Pack of 2)"}]}], "productPageReviewsFromOtherCountries": [{"username": "Incre\u00edble producto!!! Precio calidad inmejorable!!", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Almohadas", "reviewDescription": "Las mejores almohadas que he podido usar!!!!", "date": "2025-10-15", "position": 1, "reviewedIn": "Reviewed in Spain on October 15, 2025", "reviewId": "R11KOES20G1SCD", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Grey, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Robert Demir", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "worth the prize", "reviewDescription": "stable and good", "date": "2025-11-15", "position": 2, "reviewedIn": "Reviewed in Sweden on November 15, 2025", "reviewId": "R11VV8RZZYERC0", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: Grey, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Angelah", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Great Pillows!", "reviewDescription": "These Euro pillows are awesome!Very comfy!Happy with purchase!", "date": "2025-10-29", "position": 3, "reviewedIn": "Reviewed in Australia on October 29, 2025", "reviewId": "R1GYDW9L7W01FA", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: European (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "European (Pack of 2)"}]}, {"username": "Gracie86", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Pillows", "reviewDescription": "This pack of two pillows shake out easily and are very soft and comfortable there is definitely no odorous smell to them, they fit into my Queen sized pillow cases very well. I definitely would recommend them to anyone looking for new pillows", "date": "2022-02-26", "position": 4, "reviewedIn": "Reviewed in Canada on February 26, 2022", "reviewId": "R1R0H4VPOSGBFT", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}, {"username": "Rashed", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "\u062c\u064a\u062f \u062c\u062f\u0627", "reviewDescription": "\u0645\u0631\u064a\u062d \u0648\u0644\u0627\u0643\u0646 \u0644\u064a\u0633 \u0637\u0628\u064a \u0628\u0630\u0627\u0643 \u0627\u0644\u062d\u062f \u0646\u0635 \u0637\u0628\u064a \u062a\u0642\u0631\u064a\u0628\u0627", "date": "2026-03-15", "position": 5, "reviewedIn": "Reviewed in the United Arab Emirates on March 15, 2026", "reviewId": "R1T5Y9HF9K18QQ", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "Color: White, Size: Queen (Pack of 2)", "variantAttributes": [{"key": "Color", "value": "White"}, {"key": "Size", "value": "Queen (Pack of 2)"}]}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B08DTH86Q2?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 26.07, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2364, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 16, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 199, "absolute5Star": 2141}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5372, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 15, "absolute3Star": 4, "absolute4Star": 444, "absolute5Star": 4862}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25478, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1965, "absolute5Star": 23278}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595648, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2871, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54784, "absolute5Star": 533192}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": "Saturday, August 1", "fastestDelivery": "Thursday, July 30"}, {"url": "https://www.amazon.com/gp/product/B08DTH86Q2?smid=A3AQP8TDYVYCGL", "condition": "Used - Like New", "price": {"value": 22.03, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2364, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 16, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 199, "absolute5Star": 2141}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5372, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 15, "absolute3Star": 4, "absolute4Star": 444, "absolute5Star": 4862}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25478, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1965, "absolute5Star": 23278}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595648, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2871, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54784, "absolute5Star": 533192}}, "position": 2, "isPinnedOffer": false, "shippingPrice": null, "delivery": "August 10 - 16", "fastestDelivery": "August 10 - 14"}, {"url": "https://www.amazon.com/gp/product/B08DTH86Q2?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 35.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2364, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 16, "absolute2Star": 6, "absolute3Star": 2, "absolute4Star": 199, "absolute5Star": 2141}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5372, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 47, "absolute2Star": 15, "absolute3Star": 4, "absolute4Star": 444, "absolute5Star": 4862}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25478, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 160, "absolute2Star": 48, "absolute3Star": 27, "absolute4Star": 1965, "absolute5Star": 23278}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595648, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2871, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54784, "absolute5Star": 533192}}, "position": 3, "isPinnedOffer": false, "shippingPrice": null, "delivery": "Saturday, August 1", "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B08DTH86Q2?language=en", "input": "https://www.amazon.com/dp/B08DTH86Q2"}}, "B00S1TC442": {"ts": 1785485051.1877604, "item": {"title": "Utopia Bedding Queen Comforter, Duvet Insert, White, 88 x 88 Inches | Soft and Fluffy Down Alternative Bed Comforter, All Season, Breathable, Corner Tabs, Box Stitched, Durable, Machine Washable", "url": "https://www.amazon.com/dp/B00S1TC442?language=en", "asin": "B00S1TC442", "originalAsin": "B00S1TC442", "price": null, "inStock": false, "inStockText": "", "listPrice": null, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.6, "starsBreakdown": {"5star": 0.78, "4star": 0.12, "3star": 0.05, "2star": 0.02, "1star": 0.03}, "reviewsCount": 155941, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Duvets & Sets > Duvets & Down Comforters", "videosCount": 17, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B00S1TC442&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/813dSy5zzWL._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/41VXWSOIIJL._AC_US.jpg", "https://m.media-amazon.com/images/I/41dli5fMpQL._AC_US.jpg", "https://m.media-amazon.com/images/I/51bFaD4DUkL._AC_US.jpg", "https://m.media-amazon.com/images/I/41aWmZbRxyL._AC_US.jpg", "https://m.media-amazon.com/images/I/41QSBq-3lPL._AC_US.jpg", "https://m.media-amazon.com/images/I/41yyKf7obIL._AC_US.jpg", "https://m.media-amazon.com/images/I/41sx0pupl7L.SS125_PKplay-button-mb-image-grid-small_.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/813dSy5zzWL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/61AuQ8fhUhL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81DODgCsasL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71kaX2SoqBL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71mOtM9hT3L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71Gb2PVY3QL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71jVymvWU8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/8109OqXnMEL._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": null, "description": null, "features": ["Fabric - Microfiber (100% Polyester) Filling- 100% Polyester", "Imported", "Queen Size Duvet Insert: Our 88\" x 88\" fluffy bed quilt fits standard Queen beds and duvet covers, offering full coverage and balanced, all-season warmth.", "Durable Box-Stitch Construction: Elegant piped edges and classic box quilting keeps the fill evenly distributed to prevent clumping and maintains the quilted blanket\u2019s loft wash after wash.", "Soft Down-Alternative Fill: Premium siliconized fiberfill creates a lightweight, fluffy feel, making this comforter suitable for year-round use.", "Secure Corner Tabs: The built-in corner tabs make it easy to attach a duvet cover while helping to prevent the comforter from shifting or bunching during sleep.", "Easy Care: Designed for durability, machine wash the duvet insert in cold water on a gentle cycle and line dry or tumble dry no heat."], "attributes": [{"key": "Color", "value": "White"}, {"key": "Style Name", "value": "Modern"}, {"key": "Blanket Form", "value": "Comforter"}, {"key": "Theme", "value": "Plain"}, {"key": "Pattern", "value": "Solid"}, {"key": "Sport Type", "value": "Camping"}, {"key": "Material Type", "value": "Polyester"}, {"key": "Fill Material", "value": "down-alternative"}, {"key": "Weave Type", "value": "Microfiber weave"}, {"key": "Product Care Instructions", "value": "Machine Wash, Do Not Bleach, Tumble Dry No Heat, Do Not Iron, Do Not Dry Clean"}, {"key": "Fabric Type", "value": "Fabric - Microfiber (100% Polyester) Filling- 100% Polyester"}, {"key": "Additional Features", "value": "Machine Wash"}, {"key": "Recommended Uses For Product", "value": "Bedding"}, {"key": "Seasons", "value": "All, Fall, Spring, Winter"}, {"key": "Embellishment Feature", "value": "Piping"}, {"key": "Fabric Warmth Description", "value": "Lightweight"}, {"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Age Range Description", "value": "All Ages"}, {"key": "Number of Items", "value": "1"}, {"key": "Included Components", "value": "1 x Queen Comforter"}, {"key": "Model Name", "value": "UB0063"}, {"key": "UPC", "value": "840143565659"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "Item Type Name", "value": "duvets"}, {"key": "Model Number", "value": "FBA_UB0063"}, {"key": "Manufacturer Part Number", "value": "FBA_UB0063"}, {"key": "Best Sellers Rank", "value": "#181 in Home & Kitchen (See Top 100 in Home & Kitchen)#2 in Bedding Duvets & Down Comforters"}, {"key": "ASIN", "value": "B00S1TC442"}, {"key": "Customer Reviews", "value": "4.6 4.6 out of 5 stars (155,941) 4.6 out of 5 stars"}, {"key": "Item Dimensions L x W", "value": "88\"L x 88\"W"}, {"key": "Size", "value": "Queen"}, {"key": "Item Weight", "value": "2.15 kg"}, {"key": "Item Thickness", "value": "3 inches"}], "productOverview": [], "variantAsins": [], "variantDetails": [], "reviewsLink": "https://www.amazon.com/product-reviews/B00S1TC442?language=en", "hasReviews": true, "delivery": null, "fastestDelivery": null, "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 181, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 2, "category": "Bedding Duvets & Down Comforters", "url": "https://www.amazon.com/gp/bestsellers/home-garden/10671048011/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nPrevious page\nNext page\nPrevious page\nNext page\nPrevious page\nNext page\nHow heavy is the comforter, and will it feel too heavy or too warm? \nThe comforter is designed to provide cozy warmth without extra weight. It maintains a balanced temperature and doesn\u2019t trap excess heat, offering a light, but cozy feel that stays comfortable without causing overheating. Ideal for year-round comfort. \nCan I use this comforter inside a duvet cover? \nYes. The inclusion of corner tabs/loops makes it particularly suitable for use as a duvet insert as well. It\u2019s versatile to be used alone or inside a cover. \nWill the comforter bunch up after washing? \nThe box-stitch design helps keep the filling evenly distributed and reduces bunching. Proper washing and drying will also help maintain its loft. \nIs this comforter pet-friendly? \nYes. The durable microfiber fabric tends to resist snagging and is easy to clean, making it suitable for homes with pets. \nIs this comforter noisy or crinkly? \nNo. The microfiber fabric is soft, smooth, and quiet, so it doesn\u2019t make noise when you move while sleeping. \nExplore more products` choices \n\t\nPremium Comforte \nAdd to Cart \nLightweight Comforter \nAdd to Cart \nPin Tuck Comforter Set \nAdd to Cart \nSolid Comforter Set \nAdd to Cart \nPrinted Comforter Set \nAdd to Cart \nAll Season Comforter \nAdd to Cart \nKids Comforter Set \nAdd to Cart \nCustomer Reviews \n\t\n4.6 out of 5 stars 155,941 \n\t\n4.6 out of 5 stars 126,864 \n\t\n4.1 out of 5 stars 34 \n\t\n4.6 out of 5 stars 67,131 \n\t\n4.6 out of 5 stars 67,131 \n\t\n4.6 out of 5 stars 18,017 \n\t\n4.6 out of 5 stars 5,947 \n\t\nPrice \n\t$29.98$29.98 \t$22.40$22.40 \t$32.49$32.49 \t$23.50$23.50 \t$22.49$22.49 \t$25.64$25.64 \t$22.48$22.48 \t\nFill Material \n\t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t\nFabric Type \n\t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t\nDesign \n\tBox Style \tBox Style \tPintuck \tJump and Tack \tJump and Tack \tJump and Tack \tSnake Pattern \t\nAvailable Sizes \n\tTwin/TX/Full/Queen/King/CKing \tTwin/TX/Full/Queen/King/CKing \tQueen/King \tTwin/Queen/King \tTwin/Queen/King \tTwin/Full/Queen/King \tTwin \t\nAvailable Color \n\t11 Colors Available \t9 Colors Available \tGrey \tWhite/Grey/Sage Green \t6 Colors Available \tWhite/Grey \t15 Colors Available \t\nMachine Washable \n\t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714", "rawImages": [{"name": "Woman on bed wrapped in Utopia Bedding down-alternative comforter, ultra soft box stitched, washable", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/57fd1705-f21b-4c33-940d-7a142816bf93.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Comparison of Utopia Bedding comforter with box-stitching vs other comforter with clumping fill.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/78053e24-bc4f-40d3-b494-c8a9d0653d56.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Close up comparison of Utopia Bedding comforter\u2019s premium fill vs other comforter\u2019s rough lumpy fill", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ab8abdbb-c75f-4bba-8685-e51f84ad8236.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Cozy white duvet shown with a dog, child, and family, highlighting comfort, durability, and warmth.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f88327db-df59-4ac9-b4c4-b246c96a20d4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Happy family cuddling in bed under a white comforter, enjoying a cozy moment while reading together", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/063dbbea-c66e-4555-ac59-d557d8e179ee.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Industrial sewing machine stitching white bedding for enhanced durability and a smooth, clean finish", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a68e69ed-f820-4e71-b5f1-2917ba4db4f7.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Young woman neatly folding a comforter on a stylish, well-made bed in a bright and cozy bedroom", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/743157d4-c9a8-4b64-be4a-c38c53eb2f76.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Guide to duvet sizes for different bed types showing best fit and better drape option with dimension", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b80478fd-2c7c-44e0-a888-04ffcf7657fe.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Instructions for unpacking vacuum-sealed comforter cut carefully, unroll and shake, wait 24\u201348 hours", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/59df9d18-8ae9-4454-93e7-b12759468b7a.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Care instructions: gentle wash, no heat dry, no iron, no bleach, no dry clean.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/56da61b4-2f8f-4497-9268-280b587d7839.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Available colors", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/63a65631-73c5-45d1-a541-dd9e118762d4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/41VXWSOIIJL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/14eec84b-5d91-4d8a-84c6-60eb9cac4963.__CR139,0,2222,2500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/51LLkn3aCXL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/97817e88-f405-4235-9ef7-44902103a064.__CR83,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/901609a8-1b6f-4367-9d43-1a8b5f1fe3cb.__CR83,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c2746ee9-4bbd-4416-b038-15577b14f33a.__CR83,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/51wIqvBqIqL.__AC_SR200,225___.jpg"}], "rawVideos": [], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "Woman on bed wrapped in Utopia Bedding down-alternative comforter, ultra soft box stitched, washable", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/57fd1705-f21b-4c33-940d-7a142816bf93.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Comparison of Utopia Bedding comforter with box-stitching vs other comforter with clumping fill.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/78053e24-bc4f-40d3-b494-c8a9d0653d56.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Close up comparison of Utopia Bedding comforter\u2019s premium fill vs other comforter\u2019s rough lumpy fill", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ab8abdbb-c75f-4bba-8685-e51f84ad8236.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "Cozy white duvet shown with a dog, child, and family, highlighting comfort, durability, and warmth.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f88327db-df59-4ac9-b4c4-b246c96a20d4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Happy family cuddling in bed under a white comforter, enjoying a cozy moment while reading together", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/063dbbea-c66e-4555-ac59-d557d8e179ee.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Industrial sewing machine stitching white bedding for enhanced durability and a smooth, clean finish", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a68e69ed-f820-4e71-b5f1-2917ba4db4f7.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Young woman neatly folding a comforter on a stylish, well-made bed in a bright and cozy bedroom", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/743157d4-c9a8-4b64-be4a-c38c53eb2f76.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Guide to duvet sizes for different bed types showing best fit and better drape option with dimension", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b80478fd-2c7c-44e0-a888-04ffcf7657fe.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Instructions for unpacking vacuum-sealed comforter cut carefully, unroll and shake, wait 24\u201348 hours", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/59df9d18-8ae9-4454-93e7-b12759468b7a.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Care instructions: gentle wash, no heat dry, no iron, no bleach, no dry clean.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/56da61b4-2f8f-4497-9268-280b587d7839.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Available colors", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/63a65631-73c5-45d1-a541-dd9e118762d4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-11-faq", "items": [{"name": "How heavy is the comforter, and will it feel too heavy or too warm?", "text": "The comforter is designed to provide cozy warmth without extra weight. It maintains a balanced temperature and doesn\u2019t trap excess heat, offering a light, but cozy feel that stays comfortable without causing overheating. Ideal for year-round comfort."}, {"name": "Can I use this comforter inside a duvet cover?", "text": "Yes. The inclusion of corner tabs/loops makes it particularly suitable for use as a duvet insert as well. It\u2019s versatile to be used alone or inside a cover."}, {"name": "Will the comforter bunch up after washing?", "text": "The box-stitch design helps keep the filling evenly distributed and reduces bunching. Proper washing and drying will also help maintain its loft."}, {"name": "Is this comforter pet-friendly?", "text": "Yes. The durable microfiber fabric tends to resist snagging and is easy to clean, making it suitable for homes with pets."}, {"name": "Is this comforter noisy or crinkly?", "text": "No. The microfiber fabric is soft, smooth, and quiet, so it doesn\u2019t make noise when you move while sleeping."}]}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find the comforter soft, warm, and comfortable, with good quality and value for money. They like its appearance, noting it looks just like the picture, and appreciate its weight, being neither too heavy nor light. The thickness receives mixed feedback, with some finding it thin but warm while others say it's not thick and fluffy enough.", "keywords": [{"name": "Softness", "sentiment": "positive", "text": "Customers find the comforter soft and luxurious, describing it as fluffy and warm.", "customersMentionedCount": {"total": 6870, "positive": 6369, "negative": 501}, "partialReviews": [{"text": "This is so warm and soft, that I ordered another one. This is worth every penny. It is light weight, strong navy blue color and fit the bed perfectly.", "highlightedPart": "soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R9BNX00CQI30W"}, {"text": "...I put it in the dryer, no heat, for 30 minutes, fluffy and perfect. It is warm so, if you run hot, I wouldn\u2019t recommend. I would definitely buy again.", "highlightedPart": "fluffy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RRAX9BIYVT22G"}, {"text": "I love the thickness of this comforter it is very soft and very comfortable on my bed. I adore this comforter inside my duvet set and love it to death", "highlightedPart": "very soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2BZRN28C87YHE"}, {"text": "I'm very happy with this purchase. Great quality, super soft. Not overly thick, but keeps me cuddly all night. Dog gives it an A+ for comfort as well!", "highlightedPart": "super soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2A9ASDF67Q0BG"}]}, {"name": "Warmth", "sentiment": "positive", "text": "Customers find the comforter warm, with one mentioning it keeps them nice and toasty.", "customersMentionedCount": {"total": 6002, "positive": 5066, "negative": 936}, "partialReviews": [{"text": "This is so warm and soft, that I ordered another one. This is worth every penny. It is light weight, strong navy blue color and fit the bed perfectly.", "highlightedPart": "warm", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R9BNX00CQI30W"}, {"text": "So big, so fluffy and so warm HOWEVER - if you sweat at night, be prepared to sweat some more... this comfortable might be a little toooooo warm.", "highlightedPart": "so warm", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RAKB0WGZL7XE7"}, {"text": "...I was surprised at how lightweight the comforter was. It keeps you warm but not too warm since her mattress is a memory foam. She has no complaints!", "highlightedPart": "keeps you warm", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RL7O3H331OX2D"}, {"text": "...Let me say, this product is the best! It keeps me warm and at the same time it doesnt get hot itself. Its also soft, light and pleasing to the eye....", "highlightedPart": "keeps me warm", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/ROI86V27K3KLO"}]}, {"name": "Quality", "sentiment": "positive", "text": "Customers find the comforter to be of good quality, describing it as the best duvet insert.", "customersMentionedCount": {"total": 5115, "positive": 4398, "negative": 717}, "partialReviews": [{"text": "I'm very happy with this purchase. Great quality, super soft. Not overly thick, but keeps me cuddly all night. Dog gives it an A+ for comfort as well!", "highlightedPart": "Great quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2A9ASDF67Q0BG"}, {"text": "...this comforter with little expectation but I must say the item is good quality. Very comfy and I believe it\u2019s good for use through multiple seasons.", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R19KYN2VB7RHBA"}, {"text": "Great product, super light and airy, but still warm. No weird smell. My husband was really worried about the word Alternative, but he likes it too.", "highlightedPart": "Great product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R31IE1ZSGMDUQY"}, {"text": "This by far exceeded my expectations! Great comforter. Very fluffy and soft. Fits my oversized queen mattress perfectly! Would recommend to anyone.", "highlightedPart": "Great comforter", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RZM0NS3N57099"}]}, {"name": "Comfort", "sentiment": "positive", "text": "Customers find the comforter very cozy and snuggly, with one customer noting it's not suffocating.", "customersMentionedCount": {"total": 5034, "positive": 4866, "negative": 168}, "partialReviews": [{"text": "...It was very chilly the first night we used it and it was so cozy and comfortable, I felt as I were wrapped in a cloud. It doesn't add a lot of weight.", "highlightedPart": "comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1ENY0ZHZ22SIF"}, {"text": "It\u2019s comfy but makes me a little warm in the summer months. Doesn\u2019t have the down feel as much as I would like but for the cost it\u2019s a nice comforter.", "highlightedPart": "comfy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RFR56EVP8HL8N"}, {"text": "These comforters are very comfortable and full. They feel like clouds and puffy pillows, I\u2019ve ordered a couple now and they are perfect for my family!", "highlightedPart": "very comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2OILBX5IS3QLI"}, {"text": "You need it. Not heavy but so warm and cozy. Hands down best comforter I\u2019ve ever owned. I go to sleep feeling like I\u2019m in a luxe hotel. BUY. IT. NOW.", "highlightedPart": "cozy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2CK9YJVP6VDC1"}]}, {"name": "Weight", "sentiment": "positive", "text": "Customers appreciate the comforter's weight, describing it as not too heavy or light, with one customer noting it feels like a weighted blanket.", "customersMentionedCount": {"total": 4135, "positive": 3751, "negative": 384}, "partialReviews": [{"text": "...My guests love how warm and lightweight it is. I always size up on duvets and comforters. Ordered a king size for a queen bed and the fit is perfect.", "highlightedPart": "lightweight", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1U4VXXS1LR30G"}, {"text": "I love this comforter. It is ultra soft, light yet warm. I am getting ready to order another one just to have when this wears out; I love it this much", "highlightedPart": "light", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2PVKL21AXP70L"}, {"text": "I love my new duvet! It\u2019s super soft and light weight, so I don\u2019t get too hot in the middle of the night.. I couldn\u2019t be happier with my purchase :-)", "highlightedPart": "light weight", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3JXFIKOLFEWGN"}, {"text": "Not too light, not too heavy! Very soft, perfect temp and a good snuggly comforter ! Perfect! And bonus it has the loops for duvet cover attachment :)", "highlightedPart": "not too heavy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3161CCE19FRST"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find the comforter offers good value for money.", "customersMentionedCount": {"total": 3954, "positive": 3696, "negative": 258}, "partialReviews": [{"text": "Bought this after seeing one similar by an expensive brand. Great price and beautiful duvet cover. I like the ties inside and the zipper to close it.", "highlightedPart": "Great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3HRJFG9PDZZVL"}, {"text": "...so fluffy after a few years of having it and many washes! Such a great value! Being a Minnesota gal I think everyone here needs one of these babies!", "highlightedPart": "great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3PFR7GD2JW4XZ"}, {"text": "I was definitely a little nervous considering it was such a good price and I know these can be expensive, but it definitely exceeded my expectations!...", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RS77GXKOH0U42"}, {"text": "Great for the price! Once it sits for 24 hours it fluffed up great! Very comfortable, I haven\u2019t washed it yet, but it was definitely worth the price", "highlightedPart": "Great for the price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RSH89B63M5OMP"}]}, {"name": "Appearance", "sentiment": "positive", "text": "Customers love the appearance of the comforter, noting that it looks just like the picture and has a fluffy look.", "customersMentionedCount": {"total": 2623, "positive": 2443, "negative": 180}, "partialReviews": [{"text": "I love this duvey. Its nice and fluffy and just warm enough. However, I think the instructions say it is washable....I don't think I would wash it....", "highlightedPart": "nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RCVUFEF15DCXG"}, {"text": "It's very nice and warm. We fought over who could lay on it first, the dogs won. Its light weight but also very warm. Just what we were looking for.", "highlightedPart": "very nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2OIQGHYX08NYK"}, {"text": "This bed cover is absolutely beautiful! I bought the color sage and it matched wonderfully! The fabric is nice and soft! So far we have had no issues!", "highlightedPart": "beautiful", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2QD7GLE8RBWHR"}, {"text": "It is a nice comforter but it gets hot. I was expecting it to breathe more. I put a thin, lightweight duvet over it to help. Still a good purchase.", "highlightedPart": "nice comforter", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R14PLFMS461FJX"}]}, {"name": "Thickness", "sentiment": "mixed", "text": "Customers have mixed opinions about the comforter's thickness, with some finding it thin but warm, while others note it is not extremely thick and fluffy.", "customersMentionedCount": {"total": 1524, "positive": 688, "negative": 836}, "partialReviews": [{"text": "...the filling in the corners does not stay in place; and while it is thin & lightweight, it is also super warm (I think the fabric is not breathable).", "highlightedPart": "thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3J38WH0ZGVGBI"}, {"text": "...The color is nice, the material is nice and thick, it fits my bed perfectly and it's easy to use. These are all the reasons why I love this comforter", "highlightedPart": "thick", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RZLE2I0E99Z9I"}, {"text": "...It's 100% polyester and does not feel like down at all. It's very thin and a little uncomfortable. Will be looking for an actual down alternative.", "highlightedPart": "very thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1BEACR9855GV1"}, {"text": "...I like that this one is light weight and not too thick, but also really warm. I just moved up north and have high hopes for when it gets really cold!", "highlightedPart": "not too thick", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3DOYMJ5ER47JZ"}]}]}, "monthlyPurchaseVolume": "10K+ bought in past month", "productPageReviews": [{"username": "Korgan", "userId": "amzn1.account.AHER4Y3FIWJEGB4HW7DVRGCDDUKQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHER4Y3FIWJEGB4HW7DVRGCDDUKQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Pleasant surprise, warm and fluffyPleasant surprise, warm and fluffy", "reviewDescription": "For the past couple years my girlfriend and I have gone through a few cheap comforters. We never really gave much thought to them and subsequently they never impressed us. I grew up using a down comforter and have very fond memories of that blanket. The comfort and warmth was something that I hadn't experienced since I had moved out and gotten my own comforter. So this has been in the back of my mind ever since but I wasn't sure if I wanted to dish out that kind of money for a blanket and also deal with the hassle of dry cleaning it. Also by purchasing a down blanket we'd be supporting an industry in which the down feathers from geese are taken from them and that's not something we personally wanted to support. So for all those reason we started looking into down alternative. The first thing that made me question these products was the price, they seem TOO cheap. I was raised with the mantra, \"you get what you pay for\" and with that in mind I was quite weary when looking at these products. I always make sure to compare many different products and read through the reviews, both good and bad before I buy something. I decided to take a chance on this particular one as it seemed to be the highest rated by customers and I liked what I saw in the images provided by said customer. If anything, the price was low enough that I wasn't too worried if I didn't end up liking it.Now I wasn't actually home when it arrived but my girlfriend confirmed that it was very flat right out of the package, as was expected. By the time I got home 2-3 hours later it appeared to be fully fluffed. The material it is made of is very soft and comfortable on your skin and I was pleasantly surprised with the thickness/fluffyness. It is a fairly light blanket, similar to a down blanket I suppose in that regard. So far I have only slept with it one night but it did a great job of keeping me warm. I live in Wisconsin and the temperatures this week have been ranging from -10 over night to single digits during the day. I had been using 2 blankets prior to getting this one and sometimes I would still wake up in the night feeling cold. But so far with this blanket alone I have stayed warm which again was a pleasant surprise.I do have to say that this doesn't remind me a whole lot of my down blanket and I think that may have to do with the material used in the shell. And you're not going to be 100% able to recreate a down blanket not using down. So if you are looking for something that feels exactly like down and won't be satisfied otherwise, this might not be for you. However, if you are looking for a good quality, warm, fluffy and affordable priced blanket that will give you that \"cloud\" like feeling, I think you would be really happy with this blanket.Again this is my opinion from using it for one night, I can not comment on the durability or longevity of this blanket, but I am hoping that it holds up over time.**also our cats immediately fell in love with it, it is their new favorite spot to nap.", "date": "2017-12-29", "position": 1, "reviewedIn": "Reviewed in the United States on December 29, 2017Reviewed in the United States on December 29, 2017", "reviewId": "R3B71HSCSTZG6S", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3B71HSCSTZG6S/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": ["https://m.media-amazon.com/images/I/71+h6o0ePPL._SY500_.jpg", "https://m.media-amazon.com/images/I/61kXc--FEFL._SY500_.jpg", "https://m.media-amazon.com/images/I/712zB2EZReL._SY500_.jpg", "https://m.media-amazon.com/images/I/61p6n0S6GbL._SY500_.jpg", "https://m.media-amazon.com/images/I/71802ZNLKAL._SY500_.jpg"], "reviewReaction": "4,828 people found this helpful", "isVerified": false, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "SBright", "userId": "amzn1.account.AFNH7F52MM2VLPCUSSAYGW5YYPUQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFNH7F52MM2VLPCUSSAYGW5YYPUQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Bought a duvet insert. Received a cuddle!", "reviewDescription": "I'm pleasantly surprised by this insert! It's incredibly fluffy with a good, but not heavy weight to it. It's not too hot, which is great for these summer months. My husband and I both slept really well under it for the first time last night - high sleep quality thus far! It has plenty of tie holds, making it easy to use. When I climbed under, it curled perfectly around me and felt like a cuddly teddy bear hug! So far, I am in love with this insert!", "date": "2026-07-26", "position": 2, "reviewedIn": "Reviewed in the United States on July 26, 2026", "reviewId": "R11SXFPE18J1RP", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R11SXFPE18J1RP/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "2 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Outdoorsy Boy's Mom", "userId": "amzn1.account.AGZPR22QJ4S7JPSDEKFEX6UXPQ7Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGZPR22QJ4S7JPSDEKFEX6UXPQ7Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Soft and very good comforter for duvet covers!", "reviewDescription": "Incredibly soft and comfortably warm comforter! It fit perfectly in a twin duvet cover and is very lofty and ultra soft with full baffles that are comfortable to lie under. Easy to use and is a pure bright white. Surprising quality for the price!", "date": "2026-07-20", "position": 3, "reviewedIn": "Reviewed in the United States on July 20, 2026", "reviewId": "R2EHBXEJ36WVP4", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2EHBXEJ36WVP4/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "2 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/5f7fd544-82d4-4020-af0f-d0a8d0d8ea0e._CR49%2C0%2C351%2C351_UX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Marie Phillips", "userId": "amzn1.account.AH4NKL234CKM4GQF6K5QKDWGCM3A", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH4NKL234CKM4GQF6K5QKDWGCM3A?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Incredible Comfortable", "reviewDescription": "These Utopia queen sheets are extremely comfortable and have a soft, smooth feel that makes sleeping on them really enjoyable. The fabric feels lightweight but still cozy, and they fit the mattress well without slipping off or bunching up. They also wash easily and come out feeling fresh and soft, which makes them great for regular everyday use.Overall, they\u2019re a very comfortable, reliable sheet set that makes the bed feel clean, soft, and inviting every night while still being easy to care for.", "date": "2026-05-08", "position": 4, "reviewedIn": "Reviewed in the United States on May 8, 2026", "reviewId": "R1JKNEMHAFDJ5Q", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R1JKNEMHAFDJ5Q/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "14 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Brad", "userId": "amzn1.account.AHDTF22EMTXHMGO7D4DZ6CURHXKA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHDTF22EMTXHMGO7D4DZ6CURHXKA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "So far so good", "reviewDescription": "Great first impression. Nice and fluffy right out of the packaging, didn\u2019t need much shaking out to loosen up. Feels warm without being heavy, and just really comfortable to sink into \u2014 soft cover fabric, no scratchiness. Good loft for the price. Corner ties held it in place inside my duvet cover, no shifting so far. Only had it a short time, but so far it\u2019s a solid value pick. Will update if that changes after a few washes.", "date": "2026-07-16", "position": 5, "reviewedIn": "Reviewed in the United States on July 16, 2026", "reviewId": "R2EGMAG0C540KG", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2EGMAG0C540KG/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "4 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "RosAnne", "userId": "amzn1.account.AEEDEM6FZEPJ4PEUIGBR6S2H76EQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEEDEM6FZEPJ4PEUIGBR6S2H76EQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Soft, Luxurious, and Perfect for a Cozy Night's Sleep!Soft, Luxurious, and Perfect for a Cozy Night's Sleep!", "reviewDescription": "I couldn't be happier with this comforter! From the moment I put it on my bed, my room looked more inviting and elegant. The comforter is incredibly soft, fluffy, and lightweight while still providing the perfect amount of warmth for a comfortable night's sleep.The quality is outstanding. The box stitching keeps the filling evenly distributed, so it stays plush without any clumping. I also love the corner tabs, which make it easy to secure inside a duvet cover if needed.My guest room has received so many compliments since I added this comforter. Everyone who has stayed over has mentioned how comfortable, cozy, and luxurious the bed feels. Several guests even asked where I purchased it because they wanted one for their own home!It washes well, maintains its shape beautifully, and looks just as good after use as it did when I first opened it. This comforter has exceeded my expectations in every way. If you're looking for a soft, high-quality comforter that makes both your bedroom and guest room feel like a luxury hotel, I highly recommend this one!", "date": "2026-07-07", "position": 6, "reviewedIn": "Reviewed in the United States on July 7, 2026Reviewed in the United States on July 7, 2026", "reviewId": "RQ5VRP3CI9Y6M", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RQ5VRP3CI9Y6M/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": ["https://m.media-amazon.com/images/I/61CHgxgR8iL._SY500_.jpg", "https://m.media-amazon.com/images/I/61zrF-XFp7L._SY500_.jpg"], "reviewReaction": "12 people found this helpful", "isVerified": false, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/6ae2f9b8-798d-45ef-b70d-b059b6e08661._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "gina schlagel", "userId": "amzn1.account.AE3ALSAYMIMOYGUPUKSGA2NQXHVQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE3ALSAYMIMOYGUPUKSGA2NQXHVQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great for price", "reviewDescription": "Thick but not too warm. Good quality. Soft. Easy to use. Sizing was correct. No lint or tearing. Color was as expected.", "date": "2026-06-29", "position": 7, "reviewedIn": "Reviewed in the United States on June 29, 2026", "reviewId": "RSURUXTC9E5WI", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RSURUXTC9E5WI/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "10 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Grecia Tenorio", "userId": "amzn1.account.AFQCVBO4YVWVSH7MA7C4NCPYXZBQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFQCVBO4YVWVSH7MA7C4NCPYXZBQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "It\u2019s cozy and fluffy without it being heavy or too warm.", "reviewDescription": "I ordered this in the color white in the size queen and I have to say I do like it. My only worry was if it was going to be too warm and it is surprisingly very light and airy, but it still gives you that feeling of comfy, which is what I mainly wanted. Right now it is a little warmer throughout the night but every now and then where I live I tend to have to put another blanket on top of me when the weather drops at night, which is annoying however, with this, it provides enough warmth without it being so heavy on you.I have to say for the price I can\u2019t complain.", "date": "2026-06-01", "position": 8, "reviewedIn": "Reviewed in the United States on June 1, 2026", "reviewId": "R1S9VPENOA0MTY", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R1S9VPENOA0MTY/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "24 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "productPageReviewsFromOtherCountries": [{"username": "Jason c", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Luxuriously fluffy, lightweight warmth, and no clumping\u2014perfect all-season duvet!Luxuriously fluffy, lightweight warmth, and no clumping\u2014perfect all-season duvet!", "reviewDescription": "I was looking for a budget-friendly, high-quality queen comforter to use inside my favorite duvet cover without dealing with feathers poking through. I chose this white down-alternative insert from Utopia Bedding and have been sleeping under it for over a week.Pros:\u2022Perfect Fit & Loft: The 88 x 88-inch dimensions fit standard queen beds beautifully and offer great, fluffy coverage.\u2022Smart Box-Stitching: The classic box quilted pattern effectively anchors the fill, preventing it from clumping or shifting to one side after laundering.\u2022Handy Corner Tabs: The built-in anchor loops make it incredibly easy to tie down a duvet cover so the blanket never bunches up at night.\u2022All-Season Comfort: The siliconized fiberfill feels exceptionally lightweight yet provides a perfectly balanced warmth for year-round use.Cons:\u2022Expansion Wait Time: Because the comforter arrives vacuum-packed, you have to wait 24 to 48 hours for it to fully fluff up and lose its packing wrinkles.\u2022Line Dry Preference: For the absolute best care to preserve the loft, the manufacturer suggests line drying or using a zero-heat tumble cycle, which takes longer.Verdict:If you want hotel-grade cloud comfort without paying hundreds of dollars for real down, this alternative insert is an absolute steal. It is breathable, easy to clean, holds its shape, and feels like a true premium upgrade. An undeniable 5-star bedroom addition!", "date": "2026-06-28", "position": 1, "reviewedIn": "Reviewed in Canada on June 28, 2026Reviewed in Canada on June 28, 2026", "reviewId": "R17GIG4HVIO3HZ", "reviewUrl": null, "reviewImages": ["https://m.media-amazon.com/images/I/51pGItaCkOL._SY500_.jpg"], "reviewReaction": null, "isVerified": false, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/252e4c67-2e73-49b0-b59f-e476d6714ede._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "Guilherme Pacheco", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Vale muito a pena!", "reviewDescription": "Edredom de qualidade boa, daqueles um pouco pesadinhos.", "date": "2025-08-14", "position": 2, "reviewedIn": "Reviewed in Brazil on August 14, 2025", "reviewId": "R1KKAO8XUOVFYM", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/208df572-f3ab-4ffd-b490-4e06a5cb988d._CR0%2C0%2C333%2C333_UX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Yen Tan", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Very comfortable", "reviewDescription": "Fluffy, keeps you warm and comfortable. Quite heavy that it feels like a weighted blanket.Acts as a mattress topper too if you nap on top of the comforter. Good buy.", "date": "2020-08-14", "position": 3, "reviewedIn": "Reviewed in Singapore on August 14, 2020", "reviewId": "R1L2WFZJC4D756", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Amazon Kunde", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Gute Qualit\u00e4t", "reviewDescription": "Gute Qualit\u00e4t", "date": "2026-05-07", "position": 4, "reviewedIn": "Reviewed in Germany on May 7, 2026", "reviewId": "R1O2QM9IVLP5C8", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Lari", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Perfekte Ganzjahresdecke \u2013 Kuschelig und Pflegeleicht!", "reviewDescription": "Ich bin absolut begeistert von dieser Bettdecke! Mit den Ma\u00dfen 220 x 240 cm hat sie die perfekte Komfortgr\u00f6\u00dfe und deckt das Bett wunderbar ab.\u200bVorteile auf einen Blick:\u200bIdeales Schlafklima: Die 370 GSM F\u00fcllung h\u00e4lt genau, was sie verspricht. Sie ist warm genug f\u00fcr k\u00fchlere N\u00e4chte, aber gleichzeitig so atmungsaktiv, dass man im Sommer nicht direkt ins Schwitzen ger\u00e4t. Eine echte Ganzjahresdecke!\u200bTraumhaft weich: Das Material f\u00fchlt sich super kuschelig und angenehm auf der Haut an. Man f\u00fchlt sich wie in einem luxuri\u00f6sen Hotelbett.\u200bGute Verarbeitung: Durch die Steppung verrutscht die F\u00fcllung auch nach dem Waschen nicht. Alles bleibt sch\u00f6n gleichm\u00e4\u00dfig verteilt.\u200bFazit:Hervorragendes Preis-Leistungs-Verh\u00e4ltnis. Wer eine pflegeleichte, super weiche und vielseitige Decke sucht, kann hier bedenkenlos zugreifen. Absolute Kaufempfehlung!", "date": "2026-07-01", "position": 5, "reviewedIn": "Reviewed in Germany on July 1, 2026", "reviewId": "R1OII2GO62PZ3X", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B00S1TC442?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 29.98, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B00S1TC442?smid=A3AQP8TDYVYCGL", "condition": "Used - Like New", "price": {"value": 19.49, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 2, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B00S1TC442?smid=AW37KTGKBENGJ", "condition": "New", "price": {"value": 30.88, "currency": "$"}, "shipsFrom": "TJXD", "seller": {"id": "AW37KTGKBENGJ", "url": "https://www.amazon.com/sp?seller=AW37KTGKBENGJ&language=en", "name": "TJXD", "businessName": "shenzhenshiqingrongshangmaoyouxiangongsi", "VAT": null, "address": ["\u5ba3\u5dde\u533a", "\u5ba3\u57ce\u5efa\u6750\u88c5\u9970\u5927\u5e02\u573a13\u5e6213\u5ba4", "\u5ba3\u57ce\u5e02", "\u5b89\u5fbd", "242000", "CN"], "phone": null, "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=AW37KTGKBENGJ", "rating30Days": {"starsOutOf5": 5, "ratingCount": 1, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 0, "percentage5Star": 100, "absolute1Star": 0, "absolute2Star": 0, "absolute3Star": 0, "absolute4Star": 0, "absolute5Star": 1}, "rating90Days": {"starsOutOf5": 5, "ratingCount": 1, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 0, "percentage5Star": 100, "absolute1Star": 0, "absolute2Star": 0, "absolute3Star": 0, "absolute4Star": 0, "absolute5Star": 1}, "rating12Months": {"starsOutOf5": 5, "ratingCount": 1, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 0, "percentage5Star": 100, "absolute1Star": 0, "absolute2Star": 0, "absolute3Star": 0, "absolute4Star": 0, "absolute5Star": 1}, "ratingLifetime": {"starsOutOf5": 5, "ratingCount": 1, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 0, "percentage5Star": 100, "absolute1Star": 0, "absolute2Star": 0, "absolute3Star": 0, "absolute4Star": 0, "absolute5Star": 1}}, "position": 3, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B00S1TC442?language=en", "input": "https://www.amazon.com/dp/B00S1TC442"}}, "B0714K41PB": {"ts": 1785485051.1877604, "item": {"title": "Utopia Bedding Throw Pillows, Decorative 18x18 Pillow Inserts, Set of 4 | Down alternative pillow insert with plush fluffy fill for beds sofas couches and chairs. Suitable for indoor outdoor use.", "url": "https://www.amazon.com/dp/B0714K41PB?language=en", "asin": "B0714K41PB", "originalAsin": "B0714K41PB", "price": {"value": 23.89, "currency": "$"}, "inStock": true, "inStockText": "", "listPrice": {"value": 26.49, "currency": "$"}, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.7, "starsBreakdown": {"5star": 0.82, "4star": 0.11, "3star": 0.04, "2star": 0.01, "1star": 0.02}, "reviewsCount": 126537, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Decorative Pillows, Inserts & Covers > Throw Pillows", "videosCount": 17, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B0714K41PB&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/715BTRR6epL._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/41zV0bmW-4L._AC_US.jpg", "https://m.media-amazon.com/images/I/418gtS8yhgL._AC_US.jpg", "https://m.media-amazon.com/images/I/41FePvwYZdL._AC_US.jpg", "https://m.media-amazon.com/images/I/41NKvDGGnCL._AC_US.jpg", "https://m.media-amazon.com/images/I/51T4BbiGMDL._AC_US.jpg", "https://m.media-amazon.com/images/I/41OekJka5AL._AC_US.jpg", "https://m.media-amazon.com/images/I/51efFHinVML.SS125_PKplay-button-mb-image-grid-small_.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/715BTRR6epL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/91S4-3jgr8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71O+SzHUTXL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71LwE0-t4fL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/91jUW5Vkc8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/813fAOa1f2L._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": null, "description": null, "features": ["18 x 18 Pillow Inserts (Set of 4) \u2013 Offers a standard fit for 18x18 pillow covers and protectors. For a fuller, fluffed-up look, use them with covers one size smaller. Soft, fluffy couch pillows for living room, bedroom, or patio d\u00e9cor", "High-Rebound Filling \u2013 These pillows bounce back into shape every time after use. They are filled with 100% virgin fiber. It makes them lightweight, comfortable, and supportive. Solving the long-standing problem of the pillows going flat or saggy", "2-in-1 Utility \u2013 As much as they are decorative throw pillows, these inserts can prove to be quite relieving when placed behind the back while you are working at your desk, or relaxing on your couch, bed, or sofa. All while acing your room d\u00e9cor", "Breathable Cover \u2013 Made with 115 GSM brushed fabric, the outer cover of these throw pillow inserts has a soft texture and is soothing to the touch. While it is light, it is also durable, ensuring you get full value out of its everyday usage", "Packaging & Care \u2013 Upon receiving the compressed package, unpack the pillow inserts, shake & pat them, and press them from all sides to fluff them up. We recommend spot cleaning these throw pillow inserts for a better life and performance"], "attributes": [{"key": "Pillow Type", "value": "Throw Pillow"}, {"key": "Other Special Features of the Product", "value": "Throw Pillows"}, {"key": "Indoor Outdoor Usage", "value": "Indoor"}, {"key": "Recommended Uses For Product", "value": "Sofa"}, {"key": "Item Firmness Description", "value": "Plush"}, {"key": "Closure Type", "value": "Sewn Seam"}, {"key": "Fill Material", "value": "Polyester"}, {"key": "Product Care Instructions", "value": "Spot Clean"}, {"key": "Thread Count", "value": "170"}, {"key": "Material Features", "value": "Soft, Durable, Breathable, Comfortable"}, {"key": "Fabric Type", "value": "100 Gsm Plain Weave Fabric"}, {"key": "Size", "value": "18x18 Inch"}, {"key": "Item Dimensions L x W", "value": "18\"L x 18\"W"}, {"key": "Item Weight", "value": "2.6 pounds"}, {"key": "Unit Count", "value": "4 Count"}, {"key": "Item Thickness", "value": "18 inches"}, {"key": "Pillowcase Width", "value": "18 inches"}, {"key": "Pillowcase Length", "value": "18 inches"}, {"key": "Color", "value": "White"}, {"key": "Shape", "value": "Square"}, {"key": "Pattern", "value": "Plain"}, {"key": "Style Name", "value": "Plain"}, {"key": "Theme", "value": "Space"}, {"key": "Occasion", "value": "decoration"}, {"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Number of Items", "value": "4"}, {"key": "Model Name", "value": "UB860"}, {"key": "UPC", "value": "817706027077 810013785623"}, {"key": "Set Name", "value": "Utopia Bedding Throw Pillow Set (4 Piece)"}, {"key": "Age Range Description", "value": "Adult"}, {"key": "Item Type Name", "value": "Throw Pillow"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "Model Number", "value": "UB860"}, {"key": "Manufacturer Part Number", "value": "UB 860"}, {"key": "Best Sellers Rank", "value": "#126 in Home & Kitchen (See Top 100 in Home & Kitchen)#1 in Throw Pillows"}, {"key": "ASIN", "value": "B0714K41PB"}, {"key": "Customer Reviews", "value": "4.7 4.7 out of 5 stars (126,537) 4.7 out of 5 stars"}], "productOverview": [], "variantAsins": [], "variantDetails": [], "reviewsLink": "https://www.amazon.com/product-reviews/B0714K41PB?language=en", "hasReviews": true, "delivery": null, "fastestDelivery": null, "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 126, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 1, "category": "Throw Pillows", "url": "https://www.amazon.com/gp/bestsellers/home-garden/3732321/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nThe video showcases the product in use.The video guides you through product setup.The video compares multiple products.The video shows the product being unpacked.\nThrow pillow set\nMerchant Video\nPrevious page\nNext page\nIs this pillow insert good for my couch? \nYes! This throw pillow insert offers the perfect balance of softness and support, keeping your couch comfortable and stylish \nDo these pillow inserts maintain their shape over time? \nYes! Made with high-quality 7D virgin fiber, these inserts resist flattening, even with frequent use, keeping pillows looking and feeling like new \nAre these pillow inserts top rated for decorative pillows? \nAbsolutely! These inserts hold their shape beautifully, making decorative pillows look full and plush. Our 4.6/5 rating from over 100,000 reviews shows how much customers love them \nDo these pillow inserts feel soft yet supportive? \nAbsolutely. They are designed to feel soft and cozy while still providing enough support to keep their shape \nIs this pillow easy to clean? \nIt is recommended to spot clean this pillow to maintain its shape and softness over time \nExplore more Product's Choices \n\t\nThrow Pillow (Set of 4) \nAdd to Cart \nThrow Pillow (Set of 2) \nAdd to Cart \nPiping Throw Pillow \nAdd to Cart \nWater Resistant Pillow \nAdd to Cart \nGusseted Bed Pillow \nAdd to Cart \nPremium Bed Pillow \nAdd to Cart \nBody Pillow \nAdd to Cart \nCustomer Reviews \n\t\n4.7 out of 5 stars 126,537 \n\t\n4.6 out of 5 stars 218,036 \n\t\n4.6 out of 5 stars 12,612 \n\t\n4.8 out of 5 stars 1,336 \n\t\n4.5 out of 5 stars 78,594 \n\t\n4.5 out of 5 stars 43,596 \n\t\n4.6 out of 5 stars 25,143 \n\t\nPrice \n\t$23.89$23.89 \t$16.05$16.05 \t$12.89$12.89 \t$16.49$16.49 \t$26.07$26.07 \t$23.91$23.91 \t$26.49$26.49 \t\nFabric \n\t100 GSM Plain Weave Fabric \t115 GSM Brushed Fabric \t100% Microfiber \tWater Resistant Microfiber \tT200 Polycotton \tT250 Satin Stripped Polycotton \t100% Microfiber \t\nAvailable Sizes \n\t13 Sizes available \t15 Sizes available \t10 Sizes available \t4 Sizes available \tQueen/King/Standard/European \tQueen/King/Standard/European \t20 x 54 Inches \t\nAvailable Colors \n\tWhite \t7 Colors available \tWhite with Navy Piping \tWhite \t6 Colors available \tWhite /Light Grey / Navy \t7 Colors available \t\nFill Material \n\t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t\u2014 \t100% Polyester \t\nCare Instructions \n\tSpot Clean \tSpot Clean \tSpot Clean \tSpot Clean \tSpot Clean \tSpot Clean \tSpot Clean", "rawImages": [{"name": "throw 12x20 pillow insert pack of 1, utopia bedding throw pillows set of 1, bed pillows 1 pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a388f031-498c-4304-8c3a-7b8c46265bcd.__CR1,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "bed pillows for sleeping, long lumbar pillow 14x30 utopia, 17x17 pillow insert 1", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1638fb3-6e0f-48b0-a4c7-7e4e8492c323.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "single 18 x 18 pillow insert, 20x20 pillow single, utopia bedding throw pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/0d4f6a26-53f7-409d-85ff-551137cb141e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Throw pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/db55a2b4-8ac5-486a-a773-6f42f5c93f43.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Pllow inserts", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/406f9d33-f8e2-4a59-b42c-e500d2c7596f.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "2", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a27544f9-c144-468c-8fe6-104262fba676.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "1", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ba66b5ce-5314-4482-a72b-ef8ca50435f2.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Throw pillow inserts couch pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c8e5c795-e461-4c4f-bc30-9def1f5242ae.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/41zV0bmW-4L.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/41kQnEMh1cL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/31jxUedTFyL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/41m%2Bdd1dnSL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/31egl1QvKAL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/41EYw6hDYQL.__AC_SR200,225___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/I/410hzcQojeL.__AC_SR200,225___.jpg"}], "rawVideos": [{"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/52b6af72-4820-45fc-b739-834b8c8d3548/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/710Bb+js3LL.jpg"}], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "throw 12x20 pillow insert pack of 1, utopia bedding throw pillows set of 1, bed pillows 1 pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a388f031-498c-4304-8c3a-7b8c46265bcd.__CR1,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "bed pillows for sleeping, long lumbar pillow 14x30 utopia, 17x17 pillow insert 1", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1638fb3-6e0f-48b0-a4c7-7e4e8492c323.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "single 18 x 18 pillow insert, 20x20 pillow single, utopia bedding throw pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/0d4f6a26-53f7-409d-85ff-551137cb141e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-8-hero-video", "title": "Throw pillow set", "text": null, "video": {"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/52b6af72-4820-45fc-b739-834b8c8d3548/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/710Bb+js3LL.jpg"}}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Throw pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/db55a2b4-8ac5-486a-a773-6f42f5c93f43.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Pllow inserts", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/406f9d33-f8e2-4a59-b42c-e500d2c7596f.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "2", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a27544f9-c144-468c-8fe6-104262fba676.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "1", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ba66b5ce-5314-4482-a72b-ef8ca50435f2.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Throw pillow inserts couch pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c8e5c795-e461-4c4f-bc30-9def1f5242ae.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-11-faq", "items": [{"name": "Is this pillow insert good for my couch?", "text": "Yes! This throw pillow insert offers the perfect balance of softness and support, keeping your couch comfortable and stylish"}, {"name": "Do these pillow inserts maintain their shape over time?", "text": "Yes! Made with high-quality 7D virgin fiber, these inserts resist flattening, even with frequent use, keeping pillows looking and feeling like new"}, {"name": "Are these pillow inserts top rated for decorative pillows?", "text": "Absolutely! These inserts hold their shape beautifully, making decorative pillows look full and plush. Our 4.6/5 rating from over 100,000 reviews shows how much customers love them"}, {"name": "Do these pillow inserts feel soft yet supportive?", "text": "Absolutely. They are designed to feel soft and cozy while still providing enough support to keep their shape"}, {"name": "Is this pillow easy to clean?", "text": "It is recommended to spot clean this pillow to maintain its shape and softness over time"}]}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find these throw pillows to be of good quality, soft and fluffy, with perfect size for 18-inch covers. They appreciate their appearance, value for money, and how well they fluff up, with one customer noting they're easy to fluff in the dryer. The fullness receives mixed feedback, with some saying they're nicely filled while others find them not very filled.", "keywords": [{"name": "Quality", "sentiment": "positive", "text": "Customers are satisfied with the quality of these throw pillows, describing them as nice inserts that turned out great.", "customersMentionedCount": {"total": 3010, "positive": 2688, "negative": 322}, "partialReviews": [{"text": "The pillows were purchased to decorate our couch. They seem to be good quality and the shape retention is good even with being tightly wrapped...", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1ZQXMKW0ZAMYH"}, {"text": "Great pillows! They are soft. Perfect for pillow covers that I got. Great for when you buy holiday pillow covers. I loved that it came in a 4 pack....", "highlightedPart": "Great pillows", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R37VPCA83IKSKH"}, {"text": "These pillow inserts are definitely great quality. They were packaged nicely and they are very nice to the touch. Soft and provides great support....", "highlightedPart": "great quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3SAZXSZQSDL6I"}, {"text": "...Other than my incorrect assumption, nice pillows perfect for filling that uncomfortable valley between the seat and the armrest of the sofa....", "highlightedPart": "nice pillows", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RV96L1DNMTXWO"}]}, {"name": "Softness", "sentiment": "positive", "text": "Customers find these throw pillows soft and fluffy, describing them as plump and firm.", "customersMentionedCount": {"total": 2828, "positive": 2150, "negative": 678}, "partialReviews": [{"text": "...So far they have kept their shape and firmness. They are soft but not uncomfortably hard. I have only had them for about a week, but like them so far.", "highlightedPart": "soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3B53CXL5GRK69"}, {"text": "Perfect fit for my couch pillow cases! Super soft and fluffy, very comfortable to lay/sit on too! Bright white but definitely recommend a pillow case!", "highlightedPart": "fluffy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RM554Y1MRSEOX"}, {"text": "These are very soft but I only gave four stars because they are a little stiff. You are unable to give them a dip in the top if that\u2019s what you like....", "highlightedPart": "very soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R23IZM1L23Y348"}, {"text": "...I would not recommend. These pillows are not fluffy and all honesty the feel extremely cheap. I will be returning them and will purchase better ones.", "highlightedPart": "not fluffy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RJS87H6TD004Z"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find these throw pillows offer good value for money.", "customersMentionedCount": {"total": 1675, "positive": 1525, "negative": 150}, "partialReviews": [{"text": "...Great inserts! Quantity is 4, fits nicely in cases and a great value! They come very well packaged in vacuum packed plastic rolls....", "highlightedPart": "great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RANVZ8D2WAFSH"}, {"text": "amazing workmanship and quality at a great price and compared to similar products on the market this item exceeds and stands out all on its own ....", "highlightedPart": "great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R30AW7YMHB06IL"}, {"text": "...definitely purchase again I was looking for some pillow inserts at a good price but good quality and this product gave me that and much more I...", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1ZHUJJIWL1R9W"}, {"text": "...At four for $19.99, they're a good value -- and if you can persuade those you share a home with to let them be decorative rather than use them for...", "highlightedPart": "good value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2EMMY039D2FG"}]}, {"name": "Size", "sentiment": "positive", "text": "Customers like the size of these throw pillows, particularly noting that they are perfect for 18-inch covers, with one customer mentioning they fluff up to full size.", "customersMentionedCount": {"total": 1047, "positive": 735, "negative": 312}, "partialReviews": [{"text": "High quality, perfect size. Just how we used to have in Europe. Great to sleep on. They are soft and not flat at all. Really fluffy and comfortable.", "highlightedPart": "perfect size", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3BD3IW9XXRCCS"}, {"text": "They are true to size I ordered 20 x 20 the packaging though is a little rough cause I had to take a night to cut it out of the package and I almost...", "highlightedPart": "true to size", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1K5NU7EAKCR3K"}, {"text": "...to fit 18 inch pillow covers, but the pillows are 16 inch and so too small. I'll be giving them away and going to Walmart to get replacements....", "highlightedPart": "too small", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RM07BMQMVE1G8"}, {"text": "Very nice. Yes they are the right size but they are not as plush as i would want in putting them in a cover. SIZE UP. But over there are just decor....", "highlightedPart": "right size", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R14EQOT2YG4P6N"}]}, {"name": "Appearance", "sentiment": "positive", "text": "Customers love the appearance of these throw pillows, describing them as great-looking and extremely nice.", "customersMentionedCount": {"total": 958, "positive": 865, "negative": 93}, "partialReviews": [{"text": "...like this pillows, I put them in the dryer to puff them up and they are nice and flufie! Over all,I am happy with thus purchase. I will recommended.", "highlightedPart": "nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2E2MALVRLX61Q"}, {"text": "...They did after just a little fluffing. They look great and I'm excited to switch out the covers for different holidays instead of storing a bunch...", "highlightedPart": "look great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R29VMZ3W3U2FHA"}, {"text": "These pillows are so fluffy, soft, and beautiful! Truly, I was surprised with how fluffy it came out. Yet, I really like my pillows extra full....", "highlightedPart": "beautiful", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RBCY0GFZBN3YZ"}, {"text": "The pillows are nice but a little to soft to make sturdy livingroom sofa peoples They would work great in a den or bedroom", "highlightedPart": "pillows are nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1WY7WELW4JEJF"}]}, {"name": "Fluffiness", "sentiment": "positive", "text": "Customers find these throw pillows fluffy and easy to fluff, with one customer noting they maintain their shape well in the dryer.", "customersMentionedCount": {"total": 891, "positive": 650, "negative": 241}, "partialReviews": [{"text": "...Inserted while still a bit compressed. They fluffed up nicely with just a little shaking and patting to give full, fluffy, supportive pillows.", "highlightedPart": "fluffed up nicely", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1SAWRH2DKVGEL"}, {"text": "...Pillows are vacuum packed for delivery but fluff up nicely. Outer cover is good quality. I expect these pillows will last for quite a while....", "highlightedPart": "fluff up nicely", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R12GJYV03EHTML"}, {"text": "I put the pillows in the dryer, they fluffed right up. Had a problem, contacted the company. They were quick to resolve the issue. Would recommend.", "highlightedPart": "fluffed right up", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R36T0KLB4HOR4K"}, {"text": "Perfect I threw them in the dryer for 5 mins they fluffed up I love Utopia pillows I have them in my bed as well and they fit my covers perfect on...", "highlightedPart": "fluffed up", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R12HPK6MHJZMS4"}]}, {"name": "Fit", "sentiment": "positive", "text": "Customers find that the throw pillows fit their covers nicely.", "customersMentionedCount": {"total": 874, "positive": 832, "negative": 42}, "partialReviews": [{"text": "...came packed flat as a pancake but puffed up nicely in the dryer... perfect fit for the variety of decorative/seasonal pillow covers found on Amazon.", "highlightedPart": "perfect fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3W2BPES5URO0O"}, {"text": "...They fit perfectly and did the job. They came in a really small package so I was skeptical, but they worked well. Would recommend", "highlightedPart": "fit perfectly", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R355ISKZCUJUM3"}, {"text": "Great pillows. Great fit. Cost less than most store. Would definitely buy again!", "highlightedPart": "Great fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3C2H77LSL6S66"}, {"text": "...I got the 16x16 Christmas cases I bought and it was such a good fit! I am making these gifts for my aides and neighbors. It's perfect!", "highlightedPart": "good fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1EAUPHT41CX95"}]}, {"name": "Fullness", "sentiment": "mixed", "text": "Customers have mixed opinions about the pillows' fullness, with some finding them nicely filled while others report they are not very filled.", "customersMentionedCount": {"total": 988, "positive": 555, "negative": 433}, "partialReviews": [{"text": "...few wrinkles the covers had were gone, and the pillows were fluffy and full! I put 18\" square covers on them, which they filled perfectly....", "highlightedPart": "full", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R17JFGX5B7OAE2"}, {"text": "I put them in pillow forms that I had. They were not very full and thus my pillows are more soft than good for backs and sturdy.", "highlightedPart": "not very full", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2W8EDQNYING9I"}, {"text": "...The quality seems to be very good. In addition, they are very full and not flat. The are very comfortable and support your back and neck well....", "highlightedPart": "very full", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RVF8CWN5A875Q"}, {"text": "I'm surprised these have such high reviews. They're really thin/not full. I wouldn't recommend them if you want a pillow that's nice and fluffy.", "highlightedPart": "not full", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2C6E0TV6X1LP9"}]}]}, "monthlyPurchaseVolume": "20K+ bought in past month", "productPageReviews": [{"username": "Nany B.", "userId": "amzn1.account.AHOX26OL4QL5YE5JITHKGLQEFMOQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHOX26OL4QL5YE5JITHKGLQEFMOQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great inserts", "reviewDescription": "I love this inserts. It came vacuum sealed, takes a second to recover shape. They are comfortable, right size, not expensive, good quality.", "date": "2026-07-18", "position": 1, "reviewedIn": "Reviewed in the United States on July 18, 2026", "reviewId": "R39PYB9YEZ8LYU", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R39PYB9YEZ8LYU/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "2 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Elizabeth R", "userId": "amzn1.account.AEDAANIPK4VR2TUFIAG2ZOCFVA5Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEDAANIPK4VR2TUFIAG2ZOCFVA5Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Soft, Fluffy Throw Pillows That Instantly Freshen Up Any Space", "reviewDescription": "This set of 4 Utopia Bedding throw pillows is a great value for the price. They arrive well-compressed but fluff up nicely after a little time, giving sofas, beds, and chairs a clean, full look. The 18x18 size is versatile and works well with most pillow covers. They\u2019re soft yet supportive enough for light use, making them perfect for both decor and casual lounging. The crisp white color gives any room a bright, refreshed feel. Overall, a budget-friendly way to quickly upgrade home decor.", "date": "2026-06-23", "position": 2, "reviewedIn": "Reviewed in the United States on June 23, 2026", "reviewId": "R3N2S0F8F8VFRS", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3N2S0F8F8VFRS/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "8 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Leonardo Guillen", "userId": "amzn1.account.AHQQQ7URA3GV3I7XAAKQ2KGDCMMA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHQQQ7URA3GV3I7XAAKQ2KGDCMMA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Good pillows", "reviewDescription": "Very soft, very comfortable", "date": "2026-07-18", "position": 3, "reviewedIn": "Reviewed in the United States on July 18, 2026", "reviewId": "R2GMXVGA65IHJF", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2GMXVGA65IHJF/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/a62a303e-9bbf-470e-b468-9b26a191c0b5._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "L.J.", "userId": "amzn1.account.AHCHTFVRMEZQLTIYUKOUSM5H5ZRA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHCHTFVRMEZQLTIYUKOUSM5H5ZRA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Excellent inserts for the price.", "reviewDescription": "Fluffed up fairly well by manipulating them a little and then into the dryer with wool dryer balls. Medium heat for about 20 minutes. They come vacuum packed and are a pain to get out of the packaging. You must be careful with this part and have a little patience or you may wind up with ruined inserts. They fit well in my pillow covers and look very nicely shaped. I don't \"chop\" my pillows so I can't speak to this. They didn't have any funky odor either which is huge to me. For the price, these are excellent inserts. So much so, that I also purchased the 12x20 inserts and they are just as nice. Would recommend.", "date": "2026-05-06", "position": 4, "reviewedIn": "Reviewed in the United States on May 6, 2026", "reviewId": "R3PDAM8VICGMAH", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3PDAM8VICGMAH/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "26 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Tammy Jo", "userId": "amzn1.account.AGUX6XPT7KGMUUERMBSLUUVZMQBA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGUX6XPT7KGMUUERMBSLUUVZMQBA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Not as full as expected", "reviewDescription": "They come vacuum packed so there is a LOT of fluffing that needs to be done. Not as full as I had expected but all in all a good pillow.", "date": "2026-07-25", "position": 5, "reviewedIn": "Reviewed in the United States on July 25, 2026", "reviewId": "R1IXHMYP79NDAL", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R1IXHMYP79NDAL/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/4d6248ff-43ec-458c-a9b1-9c520a99ca7c._CR0%2C0%2C1079%2C1079_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Ashley", "userId": "amzn1.account.AEQ3ZYHFZICODGNQH3QPZYSUS5ZQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEQ3ZYHFZICODGNQH3QPZYSUS5ZQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Super full!", "reviewDescription": "Pillows were fluffy, clean, and perfect size just as other reviews have said! We used the 22x22inch pillows for our 20x20 covers on our couch and they look amazing!", "date": "2026-07-07", "position": 6, "reviewedIn": "Reviewed in the United States on July 7, 2026", "reviewId": "RVXD52ZJBCAT2", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RVXD52ZJBCAT2/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "3 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "JBird", "userId": "amzn1.account.AEC7APR23WDVNCRL2MB5ILKWA6XQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEC7APR23WDVNCRL2MB5ILKWA6XQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Perfect Support Upgrade for a Dog Car SeatPerfect Support Upgrade for a Dog Car Seat", "reviewDescription": "Summary:These 14 x 22 inch pillow inserts gave my 6 lb dog Bailey the extra support her gray car seat needed. Using all four pillows added cushioning and structure so she stays comfortably propped up during car rides. They have also held up well supporting a 20 lb small dog that later joined our household.Full Review:I originally purchased this set of four 14 x 22 inch pillow inserts in September 2025 to improve the support inside Bailey\u2019s gray car seat. The inserts that came with the seat were too thin, so I upgraded to these to match the comfort of her pink seat that she has loved for more than a year.The down alternative fill is supportive but still soft where her head rests. Using all four pillows in place of the originals made the seat feel much more secure. Bailey weighs about 6 lbs, so the added structure keeps her comfortably propped up so she can look out the window instead of sinking down into the seat.For anyone wondering about the setup, this is the gray car seat I used them inside:https://a.co/d/epDz6P7\u2705 Pros:\u2022 Good structure without feeling stiff\u2022 Lightweight but supportive filling\u2022 14 x 22 size works well for seat support or decorative pillows\u2022 Using all four pillows allowed me to customize the support inside the seat\u26d4 Cons:\u2022 No cons based on my usage.\ud83d\udd04 Update \u2013 March 2026:After several months of use, these inserts have held their shape well. I actually purchased another set after a second dog joined our household who weighs about 20 lbs. The pillows still provided the structure needed to support the car seat without flattening, which confirmed they hold up well with regular use.\u2b50 Final Word:These inserts were an easy way to add structure and comfort to a pet seat. If you need extra support inside a pet bed, seat, or decorative pillow cover, this set of four works very well. I would purchase them again for similar setups.", "date": "2026-03-12", "position": 7, "reviewedIn": "Reviewed in the United States on March 12, 2026Reviewed in the United States on March 12, 2026", "reviewId": "RHMPC43VR3MBP", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RHMPC43VR3MBP/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": ["https://m.media-amazon.com/images/I/71AGphgidkL._SY500_.jpg"], "reviewReaction": "21 people found this helpful", "isVerified": false, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/7b24c6a3-64a1-4eb5-930e-d8be8899d82b._CR0%2C0%2C361%2C361_UX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Eric C.", "userId": "amzn1.account.AGWOAZHHVG3BF4CZ67IAN47TQD5Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGWOAZHHVG3BF4CZ67IAN47TQD5Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "I have soft, comfortable pillows, now!!!!", "reviewDescription": "These pillow inserts are absolutely AMAZING. I have soft pillows now. I had to replace my feathered pillow inserts that came with my sectional because the feathers kept coming through the pillows and sticking everyone. They were also hard and uncomfortable. I'll definitely buy this brand of pillow inserts again.", "date": "2026-07-07", "position": 8, "reviewedIn": "Reviewed in the United States on July 7, 2026", "reviewId": "R2YAL3Y1YDTE73", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2YAL3Y1YDTE73/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "productPageReviewsFromOtherCountries": [{"username": "Inger Larsson", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Kuddar", "reviewDescription": "Fluffiga och fina kuddar", "date": "2026-07-16", "position": 1, "reviewedIn": "Reviewed in Sweden on July 16, 2026", "reviewId": "R1YCC7TRNTPL1E", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Cliente de Amazon", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Cojines", "reviewDescription": "Muy buenos los rellenos de cojines y de muy buen orecio", "date": "2025-05-09", "position": 2, "reviewedIn": "Reviewed in Mexico on May 9, 2025", "reviewId": "R1ZV8QDAOYSNS7", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Jose", "userId": null, "userProfileLink": null, "ratingScore": 3, "reviewTitle": "Basic throw pillows", "reviewDescription": "The pillows are quite basic, and in my opinion a bit overpriced for what they offer. They are soft and flexible, but don\u2019t feel particularly special.", "date": "2026-02-27", "position": 3, "reviewedIn": "Reviewed in the United Kingdom on February 27, 2026", "reviewId": "R28LIJXRWOKTFW", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Kristopher", "userId": null, "userProfileLink": null, "ratingScore": 3, "reviewTitle": "Too small", "reviewDescription": "Smaller than I thought, returning for refund", "date": "2025-12-04", "position": 4, "reviewedIn": "Reviewed in Australia on December 4, 2025", "reviewId": "R2FN8GQ1FKMXC7", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Sandy", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Great quality", "reviewDescription": "Great inserts that don\u2019t stay flat after the first use", "date": "2024-06-02", "position": 5, "reviewedIn": "Reviewed in Australia on June 2, 2024", "reviewId": "R2WOF6YSN8MDYN", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/5d34b9e6-474a-47e5-87a9-217161c3a13b._CR0%2C0%2C1150%2C1150_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B0714K41PB?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 23.89, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B0714K41PB?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 28.89, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 2, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B0714K41PB?language=en", "input": "https://www.amazon.com/dp/B0714K41PB"}}, "B00U6HREPQ": {"ts": 1785485051.1877604, "item": {"title": "Utopia Bedding Queen Size Mattress Protector Zippered, 100% Waterproof | Bed Bug and Dust Mite Proof Mattress Encasement, Absorbent 6 Sided Mattress Cover fits upto 15\" deep, Machine Washable", "url": "https://www.amazon.com/dp/B00U6HREPQ?language=en", "asin": "B00U6HREPQ", "originalAsin": "B00U6HREPQ", "price": {"value": 22.49, "currency": "$"}, "inStock": true, "inStockText": "", "listPrice": {"value": 24.99, "currency": "$"}, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.6, "starsBreakdown": {"5star": 0.79, "4star": 0.12, "3star": 0.05, "2star": 0.01, "1star": 0.03}, "reviewsCount": 132727, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Mattress Protectors & Encasements > Mattress Encasements", "videosCount": 15, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B00U6HREPQ&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/71iPH-uMadL._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/51k9iNv09UL._AC_US.jpg", "https://m.media-amazon.com/images/I/41ZnjKdSCXL._AC_US.jpg", "https://m.media-amazon.com/images/I/41LR3E4tjBL._AC_US.jpg", "https://m.media-amazon.com/images/I/41qo4-g+DiL._AC_US.jpg", "https://m.media-amazon.com/images/I/41V-g4w1nYL._AC_US.jpg", "https://m.media-amazon.com/images/I/41Uqi0HT6bL._AC_US.jpg", "https://m.media-amazon.com/images/I/51oxnUAIbkL.SS125_PKplay-button-mb-image-grid-small_.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/71iPH-uMadL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/614yGyGBoPL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/610s1a9uEML._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/611rIdYEwHL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/618kgOZiJdL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/61+LSYD4oiL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/61lfVXTMLBL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/61X1YZV+trL._AC_SL1200_.jpg", "https://m.media-amazon.com/images/I/614oNK7e8QL._AC_SL1200_.jpg"], "importantInformation": null, "sustainabilityFeatures": [{"title": "Safer chemicals", "description": "Made with chemicals safer for human health and the environment.", "certifiedBy": [{"name": "OEKO-TEX STANDARD 100", "image": "https://m.media-amazon.com/images/I/51YvKwF01yL._SS144_.jpg", "description": " OEKO-TEX\u00ae STANDARD 100 certified products require every component of a textiles production including all thread, buttons, and trims to be tested against a list of more than 1,000 regulated and unregulated chemicals which may be harmful to human health. The assessment process is globally standardised, independently conducted, and updated at least once per year based on new scientific information or regulatory requirements. Learn more about this certification", "url": "https://www.oeko-tex.com/en/our-standards/standard-100-by-oeko-tex", "data": [{"key": "Certification Number", "value": "2023OK1318"}]}]}], "description": null, "features": ["360\u00b0 Coverage Protection - Fits 60 inches by 80 inches queen size mattress up to 15 inches deep, fully encasing all sides. The stretchy fabric slips on easily and stays securely in place without shifting. Refer to the installation video below for step-by-step guidance.", "Soft and Durable Fabric - The non-crinkling design keeps the queen size mattress cover quiet and comfortable without feeling stiff. Made from smooth, knitted polyester, that lays flat across the mattress.", "Secure Zippered Closure - The wrap-around zipper encloses the mattress for a tight, secure fit. The mattress protector is designed to provide all-around coverage without shifting.", "Easy Machine Care \u2013 The queen size mattress encasement is simple to maintain. Machine wash and tumble dry on low heat with your regular laundry to keep it clean. Note: Packaging may vary slightly."], "attributes": [{"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Age Range Description", "value": "All-Ages"}, {"key": "Model Name", "value": "UB1205"}, {"key": "Number of Items", "value": "1"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "UPC", "value": "840143562986"}, {"key": "Unit Count", "value": "1 Count"}, {"key": "Item Weight", "value": "1188 g"}, {"key": "Item Type Name", "value": "mattress-encasement"}, {"key": "Model Number", "value": "UB1205"}, {"key": "Manufacturer Part Number", "value": "UB0037"}, {"key": "Best Sellers Rank", "value": "#17 in Home & Kitchen (See Top 100 in Home & Kitchen)#1 in Mattress Encasements"}, {"key": "ASIN", "value": "B00U6HREPQ"}, {"key": "Customer Reviews", "value": "4.6 4.6 out of 5 stars (132,727) 4.6 out of 5 stars"}, {"key": "Other Special Features of the Product", "value": "Bug Control, Dust Mite Control, Washable, Water Proof, Zippered"}, {"key": "Closure Type", "value": "Zipper"}, {"key": "Water Resistance Level", "value": "Waterproof"}, {"key": "Fabric Type", "value": "100% Polyester"}, {"key": "Product Care Instructions", "value": "Machine Wash"}, {"key": "Material Type", "value": "Polyester"}, {"key": "Size", "value": "Queen"}, {"key": "Item Display Dimensions", "value": "80 x 60 x 15 inches"}, {"key": "Color", "value": "White"}], "productOverview": [], "variantAsins": [], "variantDetails": [], "reviewsLink": "https://www.amazon.com/product-reviews/B00U6HREPQ?language=en", "hasReviews": true, "delivery": null, "fastestDelivery": null, "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 17, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 1, "category": "Mattress Encasements", "url": "https://www.amazon.com/gp/bestsellers/home-garden/10671066011/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nPremium Waterproof Mattress Protection \nThe video showcases the product in use.The video guides you through product setup.The video compares multiple products.The video shows the product being unpacked.\naplus content video\nDoes this mattress encasement protect against bed bugs? \nThe fully zippered, six-sided encasement creates a secure barrier that helps prevent bed bugs from entering or exiting. It is designed to assist in bed bug protection as part of an overall treatment and prevention plan. \nIs the encasement really 100% waterproof on all sides? \nYes, the TPU backing provides waterproof protection across the entire encasement, including the top, bottom, and all four sides. \nWill the fabric make noise or feel uncomfortable under the sheets? \nThe knitted polyester fabric is designed to stay soft and quiet, so it won\u2019t create a plastic-like noise or disturb your sleep. \nIs the zipper strong enough for daily use? \nThe zipper is built for full closure around the mattress and helps maintain a secure seal without snagging or breaking easily. \nHow should I wash the mattress encasement? \nIt is machine washable and can be tumble-dried on low heat, making it easy to keep clean without special care.", "rawImages": [{"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/453cbaf3-786d-4b2e-a28d-21ee2010ff19.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/49251450-4b02-4f3c-aa7a-e00f27091ad8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/52ba53fe-a2fc-4c96-b751-601c8b0da059.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d4338871-eccd-44d0-9f6d-86a1da874a3e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/da417f71-09bb-4b40-b7c3-7b4e5021c6bc.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}], "rawVideos": [{"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/afd47c25-63e1-4a74-874b-b50e2b45c551/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/71lfVkavSvL.jpg"}], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/453cbaf3-786d-4b2e-a28d-21ee2010ff19.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/49251450-4b02-4f3c-aa7a-e00f27091ad8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/52ba53fe-a2fc-4c96-b751-601c8b0da059.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-8-hero-video", "title": "aplus content video", "text": null, "video": {"url": "https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/afd47c25-63e1-4a74-874b-b50e2b45c551/default.jobtemplate.hls.m3u8", "previewImageUrl": "https://m.media-amazon.com/images/I/71lfVkavSvL.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d4338871-eccd-44d0-9f6d-86a1da874a3e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin xl encasement utopia mattress encasement mattress encasement king queen mattress protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/da417f71-09bb-4b40-b7c3-7b4e5021c6bc.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-11-faq", "items": [{"name": "Does this mattress encasement protect against bed bugs?", "text": "The fully zippered, six-sided encasement creates a secure barrier that helps prevent bed bugs from entering or exiting. It is designed to assist in bed bug protection as part of an overall treatment and prevention plan."}, {"name": "Is the encasement really 100% waterproof on all sides?", "text": "Yes, the TPU backing provides waterproof protection across the entire encasement, including the top, bottom, and all four sides."}, {"name": "Will the fabric make noise or feel uncomfortable under the sheets?", "text": "The knitted polyester fabric is designed to stay soft and quiet, so it won\u2019t create a plastic-like noise or disturb your sleep."}, {"name": "Is the zipper strong enough for daily use?", "text": "The zipper is built for full closure around the mattress and helps maintain a secure seal without snagging or breaking easily."}, {"name": "How should I wash the mattress encasement?", "text": "It is machine washable and can be tumble-dried on low heat, making it easy to keep clean without special care."}]}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find the mattress protector to be of good quality, easy to install with two people, and appreciate its fit that fully encases the mattress. The product offers good value for money and effectively protects against spills, with one customer noting it works well at keeping the mattress clean. While some customers report it holds up well after washing, others mention issues with durability. The water resistance aspect receives mixed feedback, with some customers confirming its waterproof properties while others disagree.", "keywords": [{"name": "Quality", "sentiment": "positive", "text": "Customers find the mattress cover to be of good quality.", "customersMentionedCount": {"total": 3160, "positive": 2882, "negative": 278}, "partialReviews": [{"text": "...Great product, nice fabric, protective, seems like it will be durable and comfortable. Will definitely purchase for mattress protection in the future.", "highlightedPart": "Great product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RM0EG6V151H4B"}, {"text": "On time delivery..good quality.waterproof..easy to put on matress..washable..i have covered all my mattresses..it completely covers entire matress..", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RYFHGJS8S86KM"}, {"text": "This mattress encasement is of great quality. It was easy to install. Our queen mattress is very thick and it fit quite nicely. Highly recommend.", "highlightedPart": "great quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R12XLKYES1YBPW"}, {"text": "...So far no issues. I don't expect any. It's a good product and the sales literature suggests that they will do what it takes to make the buyer happy....", "highlightedPart": "good product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1ILHTIY3MTVMU"}]}, {"name": "Fit", "sentiment": "positive", "text": "Customers find that the mattress protector fits well and stays securely in place, fully encasing the mattress.", "customersMentionedCount": {"total": 3085, "positive": 2329, "negative": 756}, "partialReviews": [{"text": "Perfect fit and keeps our mattress protected from everyday life. Soft to lay on however a little tough to put on however it's worth it to put it on!", "highlightedPart": "Perfect fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2FPL51YWR6QA"}, {"text": "Stitching and zipper are done well. No complaints at all. Fits well, but little room so it\u2019s not tight to put on. Bought 3 and all worked perfectly.", "highlightedPart": "Fits well", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2GTL1GROKVQ8B"}, {"text": "It is as advertised. It fits perfectly and I love that it zips up. Since we just purchased this we can't vouch for durability or water resistance....", "highlightedPart": "fits perfectly", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1SN12N44DB39A"}, {"text": "...We have a king size mattress so I ordered the King size one. It was loose and a little bit of extra room. I thought it should be a little more snug....", "highlightedPart": "loose", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/REPOKYN8IGCGW"}]}, {"name": "Easy to install", "sentiment": "positive", "text": "Customers find the mattress protector easy to install, particularly noting that it can be put on with two people and is simple to slip onto the mattress.", "customersMentionedCount": {"total": 2308, "positive": 1934, "negative": 374}, "partialReviews": [{"text": "Easy to put on, good price, very comfortable. Would recommend if you have too many options and if your brain is fried from overanalysing each of them.", "highlightedPart": "Easy to put on", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1SOU8DNXHSTTS"}, {"text": "...We haven't made it there yet, but it is in the near future. Easy to install, also possible fir one person to do it with a little more time and effort...", "highlightedPart": "Easy to install", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R27SNG94DP5HL3"}, {"text": "I love it \u2764\ufe0f I\u2019ve had it for about two weeks now it\u2019s easy to put on covers my entire bed and I have a Pillowtop so I was surprised but very happy...", "highlightedPart": "easy to put", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RRXMOY1DY8FO2"}, {"text": "Easy to put on the mattress but they are not snug enough which causes them to slip and slide on the bed creating tears and broken zippers after long...", "highlightedPart": "Easy to put on the mattress", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2UH6S9VOKUAVD"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find the mattress protector to be good value for money.", "customersMentionedCount": {"total": 1249, "positive": 1084, "negative": 165}, "partialReviews": [{"text": "...It doesn't sound like your laying on plastic. It is a great price, keeps everything off your mattress including liquid. If I need to I will buy again.", "highlightedPart": "great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2CFL9XYORVFRL"}, {"text": "great value and great protection. they fit perfectly around both of our queen sized mattresses and provide great protection from spills/accidents.", "highlightedPart": "great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2VWH40UQD9J9E"}, {"text": "Easy to put on, good price, very comfortable. Would recommend if you have too many options and if your brain is fried from overanalysing each of them.", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1SOU8DNXHSTTS"}, {"text": "...These are great quality. Good value. Would definitely purchase from this seller again! They are very durable and well-made.", "highlightedPart": "Good value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3T3V51B9K0C9G"}]}, {"name": "Functionality", "sentiment": "positive", "text": "Customers find that the mattress protector works well and serves its purpose, with one customer specifically noting its effectiveness at keeping the mattress clean.", "customersMentionedCount": {"total": 1210, "positive": 1067, "negative": 143}, "partialReviews": [{"text": "...Also the fit isnt super tight, it does have loose areas but it still works great and once the sheets are on, you cant tell where the loose parts are....", "highlightedPart": "works great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3S8YHE4E3LRXL"}, {"text": "Works well. Keep mattress dry. Holds up nicely after being washed and dried multiple times. However it is a pain to take off and put back on....", "highlightedPart": "Works well", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3DXRFDVA57HMA"}, {"text": "...It is! The price is great for what it is. It\u2019s not fancy but does the job and it doesn\u2019t make me sweaty (I have Egyptian cotton sheets though)", "highlightedPart": "does the job", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2DQ8HCGD28JZZ"}, {"text": "Bought this to seal up an older mattress in our camper. Worked great. Got rid of the old, stale odor and eliminated allergies.", "highlightedPart": "Worked great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3VWAECJ9THR6W"}]}, {"name": "Mattress protection", "sentiment": "positive", "text": "Customers praise the mattress protector's effectiveness, noting it has saved their mattresses and provides full coverage.", "customersMentionedCount": {"total": 1046, "positive": 910, "negative": 136}, "partialReviews": [{"text": "great value and great protection. they fit perfectly around both of our queen sized mattresses and provide great protection from spills/accidents.", "highlightedPart": "great protection", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2VWH40UQD9J9E"}, {"text": "Nice quality and good protection....goes on mattress quickly. Not hot to sleep on. We love these covers. Best ones around", "highlightedPart": "good protection", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2YIM6PH9NC2U2"}, {"text": "...This mattress cover breathes and protects the mattress so you can sleep comfortably and get that added security you want for your mattress.", "highlightedPart": "protects the mattress", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R12M1I5ORY1OYI"}, {"text": "...This one requires two people. Sturdier as it covers and protects mattress. Material is thicker.", "highlightedPart": "protects mattress", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R388UT79Q80ZDQ"}]}, {"name": "Water resistance", "sentiment": "mixed", "text": "Customers have mixed experiences with the mattress protector's water resistance, with some finding it truly waterproof while others report it is not actually waterproof.", "customersMentionedCount": {"total": 1709, "positive": 751, "negative": 958}, "partialReviews": [{"text": "...it said that it was waterproof however unfortunately for me, it is not waterproof. It\u2019s a good protector, but I want it to be waterproof. Said it was.", "highlightedPart": "not waterproof", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RLJLIL9OZIMLC"}, {"text": "Definitely protective and waterproof. Definitely noisy, crinkly, and not breathable. I find myself sweating where I lay with this mattress protector....", "highlightedPart": "waterproof", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3FRNIN0R4D1VB"}, {"text": "Just as described. Remember people, it is water resistant, not water proof. I have a king size bed. Way too heavy to be sliding this on by myself....", "highlightedPart": "not water proof", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R4JWHT886M4X"}, {"text": "This was the perfect size for our bed. Clean, and water proof. I will be buying more. I recommend this for all beds to keep them clean and sanitized.", "highlightedPart": "water", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1138EKANU6QL7"}]}, {"name": "Durability", "sentiment": "mixed", "text": "Customers have mixed experiences with the mattress protector's durability, with some reporting that it holds up well after washing and has no tearing issues, while others mention that it tears and falls apart.", "customersMentionedCount": {"total": 1169, "positive": 727, "negative": 442}, "partialReviews": [{"text": "...Great product, nice fabric, protective, seems like it will be durable and comfortable. Will definitely purchase for mattress protection in the future.", "highlightedPart": "durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RM0EG6V151H4B"}, {"text": "I got this and was so disappointed it was ripped and smelled like cigarettes don\u2019t know if that is a smoke burn but it was ripped and the quality...", "highlightedPart": "ripped", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R13RIJQB6PGAJD"}, {"text": "...Over all I feel like I went with a good brand. I like it. It\u2019s nice and sturdy also my king size bed as you can see in the picture is large and I...", "highlightedPart": "sturdy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RZKYNMOCB9G22"}, {"text": "I love these mattress covers. Perfect. Very durable, roomy, keeps out the dirt and bugs and I am storing this out in the shop, not just putting it...", "highlightedPart": "Very durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/REHLBVILI9GTB"}]}]}, "monthlyPurchaseVolume": "10K+ bought in past month", "productPageReviews": [{"username": "Yiming zhang", "userId": "amzn1.account.AEGGESQB4CP6LONEWTOMHM266UQA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AEGGESQB4CP6LONEWTOMHM266UQA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great Quality Mattress Protector", "reviewDescription": "This mattress protector fits my queen mattress perfectly and was very easy to put on. The fabric is soft, quiet, and doesn\u2019t make any plastic-like noise when sleeping. It provides great protection while still feeling comfortable and breathable. After washing, it kept its shape well and still fits nicely. Overall, it\u2019s a great value for the price, and I would definitely recommend it to anyone looking to protect their mattress.", "date": "2026-07-13", "position": 1, "reviewedIn": "Reviewed in the United States on July 13, 2026", "reviewId": "RIEIM9XSV3HIY", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RIEIM9XSV3HIY/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "16 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "M. Sunova", "userId": "amzn1.account.AGA3SYXPOPFGKFAKXAVI2RIOE4SQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGA3SYXPOPFGKFAKXAVI2RIOE4SQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Quiet, secure fit and great protection for a guest bed", "reviewDescription": "I purchased this mattress protector for my guest room. After many years working in the hotel industry, one thing I learned is that every mattress should have a quality protector, whether you think you need one or not.This one was easy to put on, and since it has a zippered design, it fit securely without sliding around. I also appreciated that it didn't have the noisy, plastic-like crinkling sound that some waterproof protectors have.It's been a great addition to my guest bed, and I'm very satisfied with the quality. If I needed another mattress protector, I would definitely purchase this one again. I would happily recommend it.", "date": "2026-06-26", "position": 2, "reviewedIn": "Reviewed in the United States on June 26, 2026", "reviewId": "RJ57R3NR4S88I", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RJ57R3NR4S88I/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "14 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/0510359d-f319-408d-9bef-ebbbc7d8d5d0._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "Monte J. Hill", "userId": "amzn1.account.AFETK47RIGD2YHAOPOCTBTE2MXKA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFETK47RIGD2YHAOPOCTBTE2MXKA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "It\u2019s not just a fitted topper it covers the mattress completely", "reviewDescription": "This was exactly what I was looking for. I\u2019m paranoid about bed bugs & allergic to dust mites.Reasonable price, high quality. Very satisfied.Totally covers the entire mattress.", "date": "2026-07-26", "position": 3, "reviewedIn": "Reviewed in the United States on July 26, 2026", "reviewId": "R3OCQLPH1CERAG", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3OCQLPH1CERAG/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/b56e4754-c9c9-4897-934d-6a94081dc42c._CR0%2C0%2C307%2C307_UX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Charlene Deavon", "userId": "amzn1.account.AFUA4LALH5NNDAMIPAOQ66HOVYIA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFUA4LALH5NNDAMIPAOQ66HOVYIA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Mattress cover that zips all the way up so no bedbugs can get in", "reviewDescription": "Calling comfort, quality, heat noise level appearance", "date": "2026-07-15", "position": 4, "reviewedIn": "Reviewed in the United States on July 15, 2026", "reviewId": "R3LHVM225D0PRG", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3LHVM225D0PRG/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "2 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Maegan T.", "userId": "amzn1.account.AE4F35NCJDR7F4WCM6VGSYAH7CYA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE4F35NCJDR7F4WCM6VGSYAH7CYA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Protect and extend the life of your lofe of your mattress.", "reviewDescription": "Bed protector easy to place on a full size mattress. Best to have two people assist. Comfortable, quality protection for your investment.", "date": "2026-07-14", "position": 5, "reviewedIn": "Reviewed in the United States on July 14, 2026", "reviewId": "R2AUVAYZX4HTFE", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2AUVAYZX4HTFE/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Angelina M", "userId": "amzn1.account.AGRVJRYYPPZRLPSKJV3NVC563TFQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGRVJRYYPPZRLPSKJV3NVC563TFQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Held up after 2.5 years and getting another", "reviewDescription": "I just inspected my old one of 2.5 years on my daily mattress because my cat threw up on the bed. It did make it through to the top and discolored the mattress cover, but didn't touch the mattress!! I was able to scrub and disinfect it and get the stain out of the cover before I realized that some areas of the inner parts of the cover were shredded and I figured it was time for a replacement. I'm very happy with the performance so far, and will likely get another one in a few years.My cat also does a lot of playing on the bed and has perhaps scratched down to the cover layer, so that may have contributed to the fraying of the inner plastic.I've never slept on this mattress without the cover, and it is a very firm mattress so I can't speak to if it feels different on a soft mattress, but I don't notice it and I find the whole situation very comfy.Good for very tall/thick mattresses like mine, which is about 15 inches in depth. No looseness since I'm maxing out the coverage. Having it filled out to the max with no gathering or bunching probably helps minimize any plasticy noise you might hear, since I don't hear any. It's like a fabric drapey cover that has a little bit of stretch to it. Doesn't feel like starchy plastic at all, even taking it off after a few years. Good value especially for a deep pocket cover, and would buy again. TIP: put the cover on when the mattress is standing up, off the frame, and get a buddy to help. I just put on the replacement alone while it was on the frame and it was doable (mattress is extremely heavy too) although it took a while and now my back hurts. It had a slight stretch to it that help it go on", "date": "2026-04-21", "position": 6, "reviewedIn": "Reviewed in the United States on April 21, 2026", "reviewId": "RIHI7299I2YIY", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RIHI7299I2YIY/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "15 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "kathy tanasz", "userId": "amzn1.account.AHYXJAUGXX6SYGEHMGPZXXWMNMIA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHYXJAUGXX6SYGEHMGPZXXWMNMIA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Works great", "reviewDescription": "Fits great and will work well. Seems to be made good and a good price", "date": "2026-07-18", "position": 7, "reviewedIn": "Reviewed in the United States on July 18, 2026", "reviewId": "R1TCT52R3XM1FQ", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R1TCT52R3XM1FQ/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Rockytop", "userId": "amzn1.account.AETXNHUX6N5YWT75POLLSQR5YEPA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AETXNHUX6N5YWT75POLLSQR5YEPA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Encapsulated", "reviewDescription": "Our guest room, with its California King is a peaceful sanctuary for reading or watching tv, and the dog occasionally also sleeps on it. Wanted the bed to be very comfortable, but also easy to clean-up for company. The mattress has a 3\u201d topper, and microfiber/fleece blanket on top of that. That package is \u2018encapsulated\u2019 using this product and it never comes off.We seldom actually have guests, so the standard layer is yet another fleece blanket and a knit cotton bedspread. Add 4 king pillows, and I\u2019ll sometimes bring along a decorative blanket. When it\u2019s time to clean the bedding, it\u2019s usually 2 loads, fleece in one, the other has the cotton bedspread and 4 pillow cases.There\u2019s a couple unopened pillows and sheet set in the closet for a guest. Also cheap waterproof mattress covers. Takes up less room than you might think. Various blankets are available.", "date": "2026-07-21", "position": 8, "reviewedIn": "Reviewed in the United States on July 21, 2026", "reviewId": "R147DGUE4ZUDUI", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R147DGUE4ZUDUI/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/5135e39e-db2c-46d1-925b-8cc6cafe1d65._CR93%2C0%2C313%2C313_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}], "productPageReviewsFromOtherCountries": [{"username": "Rafael", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Very good cost benefit.", "reviewDescription": "Very good cost benefit.", "date": "2025-07-17", "position": 1, "reviewedIn": "Reviewed in Australia on July 17, 2025", "reviewId": "R1QV8C7F8T427X", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Annewithane", "userId": null, "userProfileLink": null, "ratingScore": 4, "reviewTitle": "It Seems Okay", "reviewDescription": "I like the properties of the material, and it seems okay, but as I was putting it on, it ripped a little near the zipper because of the design. I wish the zipper was in the middle of one side, instead of the edge of one side.", "date": "2023-08-11", "position": 2, "reviewedIn": "Reviewed in Japan on August 11, 2023", "reviewId": "R2I81TYOF4D1S4", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Sandeep", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "\u200bThe Perfect Waterproof Bedsheet.", "reviewDescription": "This is a nice bedsheet and it is waterproof.", "date": "2025-11-20", "position": 3, "reviewedIn": "Reviewed in the United Arab Emirates on November 20, 2025", "reviewId": "R3753NC0ARRN5Z", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Juan Carlos", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Agua y Microorganismos", "reviewDescription": "Si es eficiente no deja pasar micro organismos", "date": "2025-04-06", "position": 4, "reviewedIn": "Reviewed in Mexico on April 6, 2025", "reviewId": "R3LD5S5I0SBF1A", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "iMOON", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "\u062c\u064a\u062f", "reviewDescription": "\u0645\u0642\u0627\u0633\u0647 \u0645\u0646\u0627\u0633\u0628 \u0644\u0645\u0631\u062a\u0628\u0629 \u0633\u0631\u064a\u0631\u064a: 200 \u0641\u064a 200 \u0641\u064a 30 \u0633\u0645", "date": "2024-10-19", "position": 5, "reviewedIn": "Reviewed in Saudi Arabia on October 19, 2024", "reviewId": "R3SW60X4JROUCK", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B00U6HREPQ?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 22.49, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": "Wednesday, August 5", "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B00U6HREPQ?smid=", "condition": "Used - Like New", "price": {"value": 21.37, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"name": "Amazon Resale", "id": null, "url": "https://www.amazon.com/Warehouse-Deals/b?ie=UTF8&node=10158976011", "reviewsCount": null, "averageRating": null}, "position": 2, "isPinnedOffer": false, "shippingPrice": null, "delivery": "Wednesday, August 5", "fastestDelivery": "Tomorrow, August 1"}, {"url": "https://www.amazon.com/gp/product/B00U6HREPQ?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 26.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 3, "isPinnedOffer": false, "shippingPrice": null, "delivery": "August 9 - 11", "fastestDelivery": "Sunday, August 9"}, {"url": "https://www.amazon.com/gp/product/B00U6HREPQ?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 26.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 4, "isPinnedOffer": false, "shippingPrice": null, "delivery": "August 21 - September 23", "fastestDelivery": "August 21 - September 21"}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B00U6HREPQ?language=en", "input": "https://www.amazon.com/dp/B00U6HREPQ"}}, "B06X421WJ6": {"ts": 1785485051.1877604, "item": {"title": "Utopia Home Plastic Hangers 50 Pack \u2013 Space Saving Clothes Hanger White | Durable & strong | Closet organizer & storage | Ideal for apartments, dorms & stocking stuffers", "url": "https://www.amazon.com/dp/B06X421WJ6?language=en", "asin": "B06X421WJ6", "originalAsin": "B06X421WJ6", "price": {"value": 22.99, "currency": "$"}, "inStock": true, "inStockText": "", "listPrice": null, "brand": "Utopia Home", "author": null, "shippingPrice": null, "stars": 4.7, "starsBreakdown": {"5star": 0.82, "4star": 0.11, "3star": 0.05, "2star": 0.01, "1star": 0.01}, "reviewsCount": 48881, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Storage & Organization > Clothing & Closet Storage > Clothes Hangers > Coat Hangers", "videosCount": 10, "visitStoreLink": {"text": "Visit the Utopia Home Store", "url": "https://www.amazon.com/stores/UTOPIAHOME/page/25815FD4-819D-430A-B216-CBAA744E7981?lp_asin=B06X421WJ6&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/610sPm01y5L._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/412GCR5JyfL._AC_US.jpg", "https://m.media-amazon.com/images/I/41b-oKOzu0L._AC_US.jpg", "https://m.media-amazon.com/images/I/412pt34H32L._AC_US.jpg", "https://m.media-amazon.com/images/I/415A0oFStoL._AC_US.jpg", "https://m.media-amazon.com/images/I/41NkECTLZsL._AC_US.jpg", "https://m.media-amazon.com/images/I/41Mg1AN+cTL._AC_US.jpg", "https://m.media-amazon.com/images/I/41frZ1KUUfL.SS125_PKplay-button-mb-image-grid-small_.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/610sPm01y5L._AC_SL1223_.jpg", "https://m.media-amazon.com/images/I/71dtU593onL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71MXXYc-dtL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71jU+PTglpL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/718vQK6Na3L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71rYDLYMCWL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71515iJf7iL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71mwyXxLGCL._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": null, "description": null, "features": ["Space-Smart Household Essentials - These Plastic Hangers measure 16\" x 9.4\" x 0.20\" each, fitting smoothly into any closet so you can finally stop cramming clothes together and actually see every outfit you own without digging through a tangled mess.", "Holds Everything You Own - From clothes hangers for adults to everyday staples, these versatile plastic hangers work as shirts, pants hangers, coat hangers and everything in between, keeping even your heaviest jeans perfectly shaped and wrinkle-free.", "Built-In Strap Hooks - Specially designed notches work brilliantly as skirt hangers for women, gripping camisoles, tank tops and delicate straps securely so nothing slips off overnight and your most worn pieces are always ready when you need them.", "Bend-Free Durable Build - These heavy-duty hangers are molded from sturdy plastic that stays straight under daily use, making them go-to home essentials for any household, so whether you are hanging a light blouse or a thick winter coat, every garment gets hung properly.", "Sleek Closet-Ready Design - These slim shirt hangers are college essentials and apartment essentials, plus a smart addition to any home organization routine, giving your wardrobe a clean and polished look, letting clothes hang evenly so your closet goes from chaotic to neat."], "attributes": [{"key": "Brand Name", "value": "Utopia Home"}, {"key": "Manufacturer", "value": "Utopia Home"}, {"key": "UPC", "value": "817706024038"}, {"key": "Unit Count", "value": "50 Count"}, {"key": "Included Components", "value": "Clothes Hanger, Durable, Heavy Duty, Plastic Hangers, Space Saving Hangers"}, {"key": "Item Type Name", "value": "Plastic Hangers"}, {"key": "Item Weight", "value": "1.65 kg"}, {"key": "Best Sellers Rank", "value": "#72 in Home & Kitchen (See Top 100 in Home & Kitchen)#1 in Coat Hangers"}, {"key": "ASIN", "value": "B06X421WJ6"}, {"key": "Customer Reviews", "value": "4.7 4.7 out of 5 stars (48,881) 4.7 out of 5 stars"}, {"key": "Additional Features", "value": "Anti-Slip, Ergonomic, Lightweight, Plastic Hangers, Space Saving"}, {"key": "Item Dimensions W x H", "value": "16\"W x 9.4\"H"}, {"key": "Number of Items", "value": "50"}, {"key": "Size", "value": "Pack of 50"}, {"key": "Color", "value": "White"}, {"key": "Shape", "value": "Rectangular"}, {"key": "Material Type", "value": "Plastic"}, {"key": "Finish Types", "value": "Plastic"}, {"key": "Recommended Uses For Product", "value": "Clothing"}, {"key": "Maximum Weight Recommendation", "value": "50 kg"}], "productOverview": [], "variantAsins": [], "variantDetails": [], "reviewsLink": "https://www.amazon.com/product-reviews/B06X421WJ6?language=en", "hasReviews": true, "delivery": null, "fastestDelivery": null, "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 72, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 1, "category": "Coat Hangers", "url": "https://www.amazon.com/gp/bestsellers/home-garden/16353511/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nPrevious page\nNext page", "rawImages": [{"name": "plastic hangers hangers 50 pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/05a1f5f5-8eed-492e-b192-f6ee26ad00e4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "a row of clothes hanging on a black hanger.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/bcdf1914-43d4-41f1-9f6f-0f4153f29bb6.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Plain Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/933612ad-0774-44d2-b059-68cd754b7390.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/016ab3e6-47ec-4b1b-ab64-f9b478237330.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "Hangers 50 Pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d71e5924-9202-4347-9cd5-307d4e027f31.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/34ed25b6-b2f5-40ed-a986-d27f1ef3cfef.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7b4b5c58-0e34-4312-8061-ba41b6ba9d82.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/39d85a5a-e94b-4546-9931-2886955f8f56.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "White Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8ff894cf-5d35-4312-bbb9-9ae50e8f6271.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}], "rawVideos": [], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "plastic hangers hangers 50 pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/05a1f5f5-8eed-492e-b192-f6ee26ad00e4.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "a row of clothes hanging on a black hanger.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/bcdf1914-43d4-41f1-9f6f-0f4153f29bb6.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Plain Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/933612ad-0774-44d2-b059-68cd754b7390.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/016ab3e6-47ec-4b1b-ab64-f9b478237330.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "Hangers 50 Pack", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d71e5924-9202-4347-9cd5-307d4e027f31.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/34ed25b6-b2f5-40ed-a986-d27f1ef3cfef.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7b4b5c58-0e34-4312-8061-ba41b6ba9d82.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "plastic hangers, clothes hangers plastic, cloth hangers, hangers, coat hanger, clothes hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/39d85a5a-e94b-4546-9931-2886955f8f56.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "White Plasitc Hangers", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8ff894cf-5d35-4312-bbb9-9ae50e8f6271.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c4f4f469-6deb-45ab-9f26-0a3a5f1edb76.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Utopia Home Introduction", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c02f18fd-10db-42ea-8cbc-30f1f7d37613.__CR0,0,362,453_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/eda78fe8-554d-491e-b88c-903ff311d4f1.__CR0,0,362,453_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Care Global Reach, across the world - already", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62c2620f-2124-4d5b-befc-62133478336b.__CR0,0,362,453_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Plastic Hangers Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C2A852B0-73D0-43F5-A2AA-273DBC25E16A?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home Plastic Hangers 50 Pack - Perfect Space Saving Clothes Hanger - Durable and Strong - ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/23182140-e9e0-4bd1-8ba4-239c42f58557.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06X421WJ6/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Plastic Hangers 50 Pack - Perfect Space Saving Clothes Hanger - Durable and Strong - ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/23182140-e9e0-4bd1-8ba4-239c42f58557.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BSX1Z54B/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Plastic Hangers 20 Pack - Perfect Space Saving Clothes Hanger - Durable and Strong - ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a703fa40-6b99-49c8-abf9-784373c6cd41.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0B2LNQXV5/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Plastic Hangers 30 Pack - Perfect Space Saving Clothes Hanger - Durable and Strong - ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/eb667738-0bda-4231-a062-7a44b2412174.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0B3X7NG1G/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Plastic Hangers 200 Pack - Perfect Space Saving Clothes Hanger - Durable and Strong -...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/aef3cfc0-efbb-4b30-8853-694634c11ad2.__AC_SR166,182___.jpg"}}]}, {"title": "Cloth Napkins Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/94189F8F-53E3-4E37-8D86-79CAE64FF989?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home [24 Pack, White] Cloth Napkins 17x17 Inches, 100% Polyester Hemmed Edges, Washable an...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1acd47a9-e599-443e-9244-5a87c241dc00.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B07PKQCPLF/ref=emc_bcc_2_i", "image": {"name": "Utopia Home [24 Pack, White] Cloth Napkins 17x17 Inches, 100% Polyester Hemmed Edges, Washable an...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1acd47a9-e599-443e-9244-5a87c241dc00.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D25XC6X8/ref=emc_bcc_2_i", "image": {"name": "Utopia Home [288 Pack, Orange] Cloth Napkins 20x20 Inches, 100% Polyester Hemmed Edges, Washable ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/26adb8e2-b86d-4a46-a542-7a903957106a.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BHWYRSBQ/ref=emc_bcc_2_i", "image": {"name": "Utopia Home [24 Pack, Red] Cloth Napkins 18x18 Inches, 100% Polyester Hemmed Edges, Washable and ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/37a3137a-581a-478a-b9a5-338438d39eaf.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BHWY3LG6/ref=emc_bcc_2_i", "image": {"name": "Utopia Home [24 Pack, Grey] Cloth Napkins 20x20 Inches, 100% Polyester Hemmed Edges, Washable and...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7c9623b2-f20f-47f0-a8a9-8fa1a54b3db4.__AC_SR166,182___.jpg"}}]}, {"title": "Wooden Hangers Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/02D14A61-8DD9-4AF2-B0E5-74C2F553013F?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home Premium Wooden Hangers 20 Pack - Durable & Slim Coat Hanger - Suit Hanger with 360-De...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/082098d9-b25e-45fc-bf32-35b23e299c08.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01MPY18P3/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Premium Wooden Hangers 20 Pack - Durable & Slim Coat Hanger - Suit Hanger with 360-De...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/082098d9-b25e-45fc-bf32-35b23e299c08.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B09QBYCTHK/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Premium Wooden Hangers 20 Pack - Durable & Slim Coat Hanger - Suit Hanger with 360-De...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/186cc1ba-a7b7-4178-95e7-092f576f8642.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0B9S29NJ8/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Premium Wooden Hangers 20 Pack - Durable & Slim Coat Hanger - Suit Hanger with 360-De...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/cd50c8a8-3632-4e63-aac3-2a09350f04b6.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B09QC2LCPG/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Premium Wooden Hangers 20 Pack - Durable & Slim Coat Hanger - Suit Hanger with 360-De...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/5da82773-bbb9-4589-a913-43d8cdc6e9be.__AC_SR166,182___.jpg"}}]}, {"title": "Plastic Planter Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/F9D28EC3-C751-446B-92C1-A40DCEA711A7?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home - Plant Pots Indoor with Drainage - 7/6.6/6/5.3/4.8 Inches Flower Pots for Indoor Pla...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1cdb381-4dab-4165-a5dc-bfad22246429.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B9N95HL4/ref=emc_bcc_2_i", "image": {"name": "Utopia Home - Plant Pots Indoor with Drainage - 7/6.6/6/5.3/4.8 Inches Flower Pots for Indoor Pla...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1cdb381-4dab-4165-a5dc-bfad22246429.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BSV2H6R6/ref=emc_bcc_2_i", "image": {"name": "Utopia Home - Plant Pots Indoor with Drainage - 7/6.6/6/5.3/4.8 Inches Home Decor Flower Pots - P...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/c90c6300-20b5-4a96-a7c5-2810971abfb5.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0CPT3DGK7/ref=emc_bcc_2_i", "image": {"name": "Utopia Home - Plant Pots Indoor with Drainage - 7/6.6/6/5.3/4.8 Inches Home Decor Flower Pots - P...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/bd74355b-9fd0-453c-b95d-e15f486f6188.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BQR4J1QW/ref=emc_bcc_2_i", "image": {"name": "Utopia Home - Plant Pots Indoor with Drainage - 7/6.6/6/5.3/4.8 Inches Home Decor Flower Pots - P...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/9095f86e-e12d-4c8e-8f28-e1e8e4fcc96c.__AC_SR166,182___.jpg"}}]}, {"title": "Kids Folding Stool Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7FC58D2B-B916-4D83-AB58-36DCFDBDC125?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home Folding Step Stool with 9 Inch Height (Pack of 1) - Holds Up to 300 lbs - Lightweight...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6a062211-a6d5-4c11-a819-dc18c3ef267c.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B071JB9SC8/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Folding Step Stool with 9 Inch Height (Pack of 1) - Holds Up to 300 lbs - Lightweight...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6a062211-a6d5-4c11-a819-dc18c3ef267c.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0B27XD1F4/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Folding Step Stool with 9 Inch Height (Pack of 1) - Holds Up to 300 lbs - Lightweight...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b593030-be90-4764-a8bb-6137e56da8c8.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B072JJQ437/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Folding Step Stool - (Pack of 1) Foot Stool with 9 Inch Height - Holds Up to 300 lbs ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6a26f3d8-f60c-4103-899e-5f13f8a784bb.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0B1DWBTTV/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Folding Step Stool - (Pack of 1) Foot Stool with 9 Inch Height - Holds Up to 300 lbs ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f2728109-dca8-4bb4-8567-a056a39c8150.__AC_SR166,182___.jpg"}}]}, {"title": "Moving Mattress Bag Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/25815FD4-819D-430A-B216-CBAA744E7981?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Home Queen Size Mattress Bag for Moving, 4 Mil Heavy Duty Plastic Storage Bag, Mattress En...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/95865814-1113-4532-8436-7e8ce84ad5da.__AC_SR166,182___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0BDXPTFF1/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Queen Size Mattress Bag for Moving, 4 Mil Heavy Duty Plastic Storage Bag, Mattress En...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/95865814-1113-4532-8436-7e8ce84ad5da.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BDXSS63Y/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Twin Mattress Bag for Moving, 4 Mil Heavy Duty Plastic Storage Bag, Mattress Encaseme...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b8585cff-1a18-422c-b3ff-6f014adb7222.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BDYHLCYX/ref=emc_bcc_2_i", "image": {"name": "Utopia Home Full Size Mattress Bag for Moving, 4 Mil Heavy Duty Plastic Storage Bag, Mattress Enc...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/58ff942a-5b50-4f40-9d05-d0379811c52d.__AC_SR166,182___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BDXS8TX3/ref=emc_bcc_2_i", "image": {"name": "Utopia Home King Size Mattress Bag for Moving, 4 Mil Heavy Duty Plastic Storage Bag, Mattress Enc...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6969f28a-1d1a-4d63-b69b-13e9a6373529.__AC_SR166,182___.jpg"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find these hangers to be of good quality and value for money, with positive feedback about their functionality and lightweight design that works well for both shirts and sweatshirts. They appreciate the color options, with one customer mentioning they can color-code their closet. The durability receives mixed reviews - while some find them sturdy, others report they are easily breakable. The thickness is also a concern, with customers noting they seem thinner than other hangers.", "keywords": [{"name": "Quality", "sentiment": "positive", "text": "Customers find these hangers to be of good quality, describing them as decent and perfect for their needs.", "customersMentionedCount": {"total": 990, "positive": 827, "negative": 163}, "partialReviews": [{"text": "These are your standard white plastics hangers. They are of a good quality and I like the two little dips at the top to easily secure the more...", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2F3WLJ2HBHCGB"}, {"text": "Great hangers. Big and they provide great protection for all your clothes. They don't leave the sharp corners after your garment has been hung it....", "highlightedPart": "Great hangers", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RCJCZKD13MJKU"}, {"text": "Not much to say. They're good hangers and there's a lot of them. I've had a couple break but it was no big deal and mostly my fault. Pretty perfect!", "highlightedPart": "good hangers", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1Z8XKIYOUZRL6"}, {"text": "...I like the indent feature for shirts with straps. Great product. I will be purchasing more in the future.", "highlightedPart": "Great product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R11J9F34ZW8IMQ"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find these hangers to be good value for money, noting they are sturdy and offer great quality for the price.", "customersMentionedCount": {"total": 636, "positive": 552, "negative": 84}, "partialReviews": [{"text": "It\u2019s a hanger. Great price, fits clothes well, durable and won\u2019t break as easy as some other brands, rounded corners so won\u2019t make wrinkles or...", "highlightedPart": "Great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3VEUG5C4T7EIV"}, {"text": "Great value. If someone wanted to hang very heavy garments, they might want something sturdier, but for typical use, these are a great value.", "highlightedPart": "Great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R162L9CJHLYOPF"}, {"text": "I thought these hangers was a good price after reading all the reviews that the hangers were durable and strong and can hold coats and jeans....", "highlightedPart": "good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2PYNKSKG33LDV"}, {"text": "GOOD VALUE. Reasonably strong/robust. Take up less hang space than many tubular plastic hangers. I recommend them", "highlightedPart": "GOOD VALUE", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R44B404AKIP8D"}]}, {"name": "Functionality", "sentiment": "positive", "text": "Customers find that the hangers work well, with one customer noting they perform like typical wire hangers.", "customersMentionedCount": {"total": 258, "positive": 244, "negative": 14}, "partialReviews": [{"text": "They work great! We ordered these for my daughter's closet when we bought our house and she wanted to have pink accessories....", "highlightedPart": "work great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2IUDAP59JFTP"}, {"text": "They're hangers alright. They work well just as a a hanger should. They hold my clothes. The hook is very durable and stays on the pole I hang it on.", "highlightedPart": "work well", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3HM7AG4CN9A3U"}, {"text": "Good packaging. Sturdy, Does the job. Moved into new apt that actually has a closet so I needed Lots of hangers! Great quantity", "highlightedPart": "Does the job", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2DDAUKKWAJY1S"}, {"text": "Works great. Will order again when needed. My package arrived on time and it was well packed. Thank you!", "highlightedPart": "Works great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R35P7XN1RW5PY8"}]}, {"name": "Lightweight", "sentiment": "positive", "text": "Customers appreciate the hangers' lightweight design, noting they work well for both light and heavy clothing items.", "customersMentionedCount": {"total": 232, "positive": 165, "negative": 67}, "partialReviews": [{"text": "Why did you pick this product vs others?: Very light weight. Good for TShirts. Returned them, needed something a little more study...", "highlightedPart": "Very light weight", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3QWTH249MYR9T"}, {"text": "...They appear to be relatively durable. They certainly are light. And I'm hoping that they are sturdy. So far, I give them 5 stars....", "highlightedPart": "light", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R38WOWX04E4UDR"}, {"text": "Very thin, lightweight, flimsy white hangers, not black. Both the quality and the color were dealbreakers. I just gave them away. Would not recommend.", "highlightedPart": "lightweight", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R335WQ12ODZ09C"}, {"text": "Thin, lightweight hangers. Perfect for children's clothes, but may be too light for adult clothes.", "highlightedPart": "lightweight hangers", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2Q9YPS6HWZHPC"}]}, {"name": "Hanging ability", "sentiment": "positive", "text": "Customers find these hangers versatile, working well for shirts, sweatshirts, dresses, and pants.", "customersMentionedCount": {"total": 153, "positive": 141, "negative": 12}, "partialReviews": [{"text": "The hangers are very thin and bendy. Might be ok for shirts but definitely not for jackets.", "highlightedPart": "ok for shirts", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R8OWPJV1RD8TN"}, {"text": "Does exactly what they promise, hanging clothes. Third set purchased. No more wired to stain clothes, or put folds in them.", "highlightedPart": "hanging clothes", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R24VG6ZOV9NKW1"}, {"text": "They really do hang clothes well! The only thing I wish they did is hang clothes I took out of the dryer by themselves! Get on it!", "highlightedPart": "hang clothes well", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RX9L258BWSRFS"}, {"text": "Durable materials great for hanging clothes", "highlightedPart": "great for hanging clothes", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3PPCGGRBHKDLU"}]}, {"name": "Color", "sentiment": "positive", "text": "Customers like the colors of these hangers, particularly the bright pink and purple options, and appreciate that they come in multiple colors. One customer mentions being able to color-code their closet.", "customersMentionedCount": {"total": 146, "positive": 130, "negative": 16}, "partialReviews": [{"text": "Love the color and good hangers for being plastic. Only flaw is that I feel they are overpriced for plastic.", "highlightedPart": "Love the color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2ANNZNNLA2T63"}, {"text": "Hangers are great quality, great color, and big bundle", "highlightedPart": "great color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2TDMEGDZ2MES3"}, {"text": "Great to have enough hangers again! Durable and nice color (I got teal)", "highlightedPart": "nice color", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RWJROZO5XFFTR"}, {"text": "Great quality, ease of use. Love the colors and everything about them.", "highlightedPart": "Love the colors", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2SX8N8K3F4RR5"}]}, {"name": "Durability", "sentiment": "mixed", "text": "Customers have mixed opinions about the hangers' durability, with some finding them sturdier and tough, while others report they are fragile and easily breakable.", "customersMentionedCount": {"total": 968, "positive": 546, "negative": 422}, "partialReviews": [{"text": "These were pretty inexpensive for a lot of hangers and they are pretty sturdy and hold up some heavier clothing items without bending and/or breaking.", "highlightedPart": "sturdy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3BUQ76HKGCGSW"}, {"text": "These hangers are very thin and flimsy. They look like they will break pretty easily. I should\u2019ve done more research before purchasing these hangers.", "highlightedPart": "flimsy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1K01QRMKD8EKK"}, {"text": "Described as durable and strong but they are very thin and unable to hold anything heavier than a tshirt. Perfect for hanging lightweight scarves.", "highlightedPart": "durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2H7205EHKBPJR"}, {"text": "...It\u2019s not heavy duty, so just be easy on the weight. Tried to hang jeans and it sagged. Overall it\u2019s good for its purpose.", "highlightedPart": "not heavy duty", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RX4Q3SWW39XHG"}]}, {"name": "Thickness", "sentiment": "negative", "text": "Customers note that the hangers are thinner than expected, with one customer specifically mentioning that the plastic thickness is about half that of other hangers.", "customersMentionedCount": {"total": 347, "positive": 100, "negative": 247}, "partialReviews": [{"text": "Worst hangers I have ever encountered, ever. They are thin, cheap, & break easily. In the first month, 3 broke. We needed hangers so we kept them....", "highlightedPart": "thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RHPV7BAMPOLSB"}, {"text": "Very thin, lightweight, flimsy white hangers, not black. Both the quality and the color were dealbreakers. I just gave them away. Would not recommend.", "highlightedPart": "Very thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R335WQ12ODZ09C"}, {"text": "...They\u2019re sturdy, slim, and save so much closet space. We all had dressers prior to moving and decided to use closet space instead", "highlightedPart": "slim", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2KHSO39VEUHD0"}, {"text": "They're thinner than expected but serve their purpose. They're also potentially fragile, so you can't put anything of moderate weight on them.", "highlightedPart": "thinner than expected", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2NYMREM2J8NP2"}]}]}, "monthlyPurchaseVolume": "30K+ bought in past month", "productPageReviews": [{"username": "Julie Miller", "userId": "amzn1.account.AE3J2J5AYMXAHQSSP7UNQ4QHNUOQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AE3J2J5AYMXAHQSSP7UNQ4QHNUOQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Nice and sturdy. Great value.", "reviewDescription": "Great value. Nice and sturdy.", "date": "2026-07-13", "position": 1, "reviewedIn": "Reviewed in the United States on July 13, 2026", "reviewId": "R3MIPBZ7DV62L3", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3MIPBZ7DV62L3/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Dr. Peter P. Demyan", "userId": "amzn1.account.AGOR2F43TIR3GKTBJIVXJYUESZHA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGOR2F43TIR3GKTBJIVXJYUESZHA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Good ex", "reviewDescription": "Very good price. Nice item. Met my needs. Would buy from again.", "date": "2026-07-26", "position": 2, "reviewedIn": "Reviewed in the United States on July 26, 2026", "reviewId": "R15YL1FTMVBSPV", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R15YL1FTMVBSPV/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Beverly B", "userId": "amzn1.account.AGV2ENNNOO7YEAGIDHQZ5BT4DBWA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGV2ENNNOO7YEAGIDHQZ5BT4DBWA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great Hangers", "reviewDescription": "Sturdy plastic hangers. Love the blue colour! Arrived quickly and packaged well", "date": "2026-07-10", "position": 3, "reviewedIn": "Reviewed in the United States on July 10, 2026", "reviewId": "R3O94ID5KA7ZAV", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3O94ID5KA7ZAV/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Dallas", "userId": "amzn1.account.AGYE3HGH4P6KALGLKWKYET7F5U5Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGYE3HGH4P6KALGLKWKYET7F5U5Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great Value Hangers", "reviewDescription": "These are exactly what I needed. Getting 100 hangers for the price is such a good value, especially if you\u2019re organizing your closet.They\u2019re lightweight but still feel sturdy enough for everyday clothes, and I like that they\u2019re slim so they don\u2019t take up a ton of space. Everything looks way more neat and uniform now.Overall, simple, affordable, and do the job perfectly.", "date": "2026-03-21", "position": 4, "reviewedIn": "Reviewed in the United States on March 21, 2026", "reviewId": "R2TAXH7JDFAN0C", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2TAXH7JDFAN0C/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/0bd250b8-90f0-4d02-bd2d-9ecfbd52a619._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "Landon PNW", "userId": "amzn1.account.AF776YFXWHDMC2VMPRH7LTRGJ2QQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AF776YFXWHDMC2VMPRH7LTRGJ2QQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Great for lightweight clothing and closet organization, though not intended for heavy outerwear.", "reviewDescription": "These hangers feature clean, smooth molded plastic that prevents snagging on delicate fabrics. While the material feels slightly flexible, none have broken during use. They are likely not sturdy enough for heavy winter coats, but they are a reliable and effective solution for organizing shirts, blouses, and trousers.", "date": "2026-05-15", "position": 5, "reviewedIn": "Reviewed in the United States on May 15, 2026", "reviewId": "RYW4X9GDX4K26", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RYW4X9GDX4K26/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Jedata", "userId": "amzn1.account.AH3BUUS3CVX4UBB26DGSISSFSBAA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH3BUUS3CVX4UBB26DGSISSFSBAA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Basic good hangers", "reviewDescription": "Good value for the amount of hangers I got.", "date": "2026-07-23", "position": 6, "reviewedIn": "Reviewed in the United States on July 23, 2026", "reviewId": "R1EYQ5HWMXE2K7", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R1EYQ5HWMXE2K7/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Nick", "userId": "amzn1.account.AHT45GIZ7TCHN2GKWK6NEKBFYLHA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHT45GIZ7TCHN2GKWK6NEKBFYLHA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great color", "reviewDescription": "Strong hangers; hard to break. Slim profile for maximum storage. White and fresh looking. Slides easy while searching for shirts.", "date": "2026-07-18", "position": 7, "reviewedIn": "Reviewed in the United States on July 18, 2026", "reviewId": "RFAQOSI3K26XO", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RFAQOSI3K26XO/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/11e0b35a-2ad0-4ab9-a476-1a9d371daf81._CR62%2C0%2C375%2C375_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Aikener", "userId": "amzn1.account.AFNZOJPG4KCZFZ4HRWRBWKQ27QUA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFNZOJPG4KCZFZ4HRWRBWKQ27QUA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Strong effective hangers", "reviewDescription": "These hangers are my favorite for myself and gifts to others. The hangers are space savers. They are strong holding jeans, shirts and jackets. For some of my heavier coats I use two hangers. The all black color look nice in my closet.", "date": "2026-06-21", "position": 8, "reviewedIn": "Reviewed in the United States on June 21, 2026", "reviewId": "RKQ7QDTFNCUDH", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RKQ7QDTFNCUDH/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "productPageReviewsFromOtherCountries": [{"username": "Ich", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Preis Leistung super !!!\ud83d\ude00", "reviewDescription": "Werde ich wieder kaufen !Preis Leistung super !!!\ud83d\udc4d\ud83d\ude00", "date": "2025-07-22", "position": 1, "reviewedIn": "Reviewed in Germany on July 22, 2025", "reviewId": "R139DTRZGRMSY5", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Felix Boltze", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "erfekte Kleiderb\u00fcgel f\u00fcr einen organisierten Kleiderschrank!", "reviewDescription": "Perfekte Kleiderb\u00fcgel f\u00fcr einen organisierten Kleiderschrank!Ich habe den 50er-Pack der Utopia Home Kunststoffb\u00fcgel bestellt, um meinen Kleiderschrank neu zu organisieren \u2013 und ich bin absolut begeistert!\u200bDesign & Verarbeitung:Die B\u00fcgel sind schlank und dennoch stabil. Mit einer Breite von 41,5 cm und einer H\u00f6he von 23 cm passen sie perfekt f\u00fcr verschiedene Kleidungsst\u00fccke. Die integrierten Schulterrillen verhindern das Abrutschen von Tr\u00e4gern, was besonders bei Tops und Kleidern hilfreich ist. \u200bPlatzsparend & Funktional:Durch das schlanke Design nehmen die B\u00fcgel wenig Platz ein, sodass ich mehr Kleidung im Schrank unterbringen kann. Trotz ihrer Leichtigkeit sind sie robust und halten auch schwerere Kleidungsst\u00fccke problemlos.\u200bFazit:Ein hervorragendes Produkt zu einem fairen Preis. Die Utopia Home Kunststoffb\u00fcgel sind ideal f\u00fcr alle, die ihren Kleiderschrank effizient und ordentlich gestalten m\u00f6chten. Klare Kaufempfehlung!", "date": "2025-04-28", "position": 2, "reviewedIn": "Reviewed in Germany on April 28, 2025", "reviewId": "R1CTNCZDFSO4BH", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Leticia", "userId": null, "userProfileLink": null, "ratingScore": 4, "reviewTitle": "O modelo do cabide \u00e9 diferente da fotoO modelo do cabide \u00e9 diferente da foto", "reviewDescription": "o cabide \u00e9 ok mas eu queria o modelo da foto por ser igual aos que j\u00e1 tenho", "date": "2023-10-23", "position": 3, "reviewedIn": "Reviewed in Brazil on October 23, 2023Reviewed in Brazil on October 23, 2023", "reviewId": "R1JZDV78FMTE0Y", "reviewUrl": null, "reviewImages": ["https://m.media-amazon.com/images/I/31Y4m+4u0rL._SY500_.jpg"], "reviewReaction": null, "isVerified": false, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "linda van osselt", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Tr\u00e8s bonne qualit\u00e9 pour le prix", "reviewDescription": "Je suis tr\u00e8s heureuse d'avoir fait cet achat.", "date": "2024-08-14", "position": 4, "reviewedIn": "Reviewed in Belgium on August 14, 2024", "reviewId": "R1QBFOOQZO29HE", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Broke after a few weeks.", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Works like intended", "reviewDescription": "Works like intended", "date": "2025-05-01", "position": 5, "reviewedIn": "Reviewed in Sweden on May 1, 2025", "reviewId": "R1T6PVN71Q6ZFR", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B06X421WJ6?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 22.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B06X421WJ6?smid=", "condition": "Used - Very Good", "price": {"value": 22.53, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"name": "Amazon Resale", "id": null, "url": "https://www.amazon.com/Warehouse-Deals/b?ie=UTF8&node=10158976011", "reviewsCount": null, "averageRating": null}, "position": 2, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B06X421WJ6?smid=", "condition": "Used - Like New", "price": {"value": 22.76, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"name": "Amazon Resale", "id": null, "url": "https://www.amazon.com/Warehouse-Deals/b?ie=UTF8&node=10158976011", "reviewsCount": null, "averageRating": null}, "position": 3, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B06X421WJ6?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 29.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 4, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}, {"url": "https://www.amazon.com/gp/product/B06X421WJ6?smid=", "condition": "New", "price": {"value": 30.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"name": "Amazon.com", "id": null, "url": "https://www.amazon.com", "reviewsCount": null, "averageRating": null}, "position": 5, "isPinnedOffer": false, "shippingPrice": null, "delivery": null, "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B06X421WJ6?language=en", "input": "https://www.amazon.com/dp/B06X421WJ6"}}, "B00NESCOY0": {"ts": 1785485051.1877604, "item": {"title": "Quilted Fitted Mattress Pad, Queen Mattress Protector Topper with Deep Pocket Stretches up to 16 Inches, Machine Washable Mattress Cover by Utopia Bedding (White) | Soft Breathable Fitted Mattress Cover, Noiseless, Deep Pockets & Machine Washable for Home & Hotels by Utopia Bedding", "url": "https://www.amazon.com/dp/B00NESCOY0?language=en", "asin": "B00NESCOY0", "originalAsin": "B00NESCOY0", "price": {"value": 20.99, "currency": "$"}, "inStock": true, "inStockText": "", "listPrice": {"value": 21.99, "currency": "$"}, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4.6, "starsBreakdown": {"5star": 0.77, "4star": 0.13, "3star": 0.05, "2star": 0.02, "1star": 0.03}, "reviewsCount": 159099, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Mattress Pads & Toppers > Mattress Pads", "videosCount": 19, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B00NESCOY0&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/81UurBbL77L._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/41UnKywxkzL._AC_US.jpg", "https://m.media-amazon.com/images/I/41iJdUVG6gL._AC_US.jpg", "https://m.media-amazon.com/images/I/41OOYa0Q2PL._AC_US.jpg", "https://m.media-amazon.com/images/I/4113r2xIMbL._AC_US.jpg", "https://m.media-amazon.com/images/I/51N2vExRAUL._AC_US.jpg", "https://m.media-amazon.com/images/I/41iO7J017cL._AC_US.jpg", "https://m.media-amazon.com/images/I/41bxNSN1xIL.SS125_PKplay-button-mb-image-grid-small_.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/81UurBbL77L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81jff-W7eLL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81hgTJ9fiuL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/818gjIiadlL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81959W6ox8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81ANm6KGk8L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81Qj9NOdPZL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71EX8grmyRL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81XUANq3ptL._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": null, "description": null, "features": ["100% Polyester", "Mattress Pad: Our Queen size microfiber mattress pad measures 60 by 80 inches and fits mattresses up to 16 inches deep.", "Durable: The mattress pad keeps your mattress clean and comfortable. It is not liquid proof, so for spill protection, a separate waterproof cover is recommended.", "Soft and Comfortable: The super soft quilt with fiberfill has additional loft that provides extra comfortable sleep and protection; the elastic all around secures the pad into position", "An Ideal Choice: This mattress pad is a perfect choice if you're considering a comfortable, breathable and soft mattress pad", "Care Instructions: The cover is machine washable and you can tumble dry no heat; do not use bleach; easy maintenance; dry normal"], "attributes": [{"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Age Range Description", "value": "All Ages"}, {"key": "Model Name", "value": "Mattress Pad"}, {"key": "Number of Items", "value": "1"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "UPC", "value": "840143565529"}, {"key": "Unit Count", "value": "1 Count"}, {"key": "Item Weight", "value": "2.9 pounds"}, {"key": "Item Type Name", "value": "Queen Mattress pad"}, {"key": "Model Number", "value": "UB0044"}, {"key": "Manufacturer Part Number", "value": "UB0044"}, {"key": "Best Sellers Rank", "value": "#50 in Home & Kitchen (See Top 100 in Home & Kitchen)#1 in Mattress Pads"}, {"key": "ASIN", "value": "B00NESCOY0"}, {"key": "Customer Reviews", "value": "4.6 4.6 out of 5 stars (159,099) 4.6 out of 5 stars"}, {"key": "Other Special Features of the Product", "value": "Adjustable, Breathable, Deep Pocket, Quilted, Washable"}, {"key": "Closure Type", "value": "Pull On"}, {"key": "Water Resistance Level", "value": "Not Water Resistant"}, {"key": "Item Firmness Description", "value": "Soft to Medium-Soft"}, {"key": "Fabric Type", "value": "100% Polyester"}, {"key": "Product Care Instructions", "value": "Machine Wash"}, {"key": "Material Type", "value": "Polyester"}, {"key": "Size", "value": "Queen (Pack of 1)"}, {"key": "Item Display Dimensions", "value": "14.5 x 10 x 4 inches"}, {"key": "Color", "value": "White"}], "productOverview": [], "variantAsins": [], "variantDetails": [], "reviewsLink": "https://www.amazon.com/product-reviews/B00NESCOY0?language=en", "hasReviews": true, "delivery": null, "fastestDelivery": null, "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 50, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 1, "category": "Mattress Pads", "url": "https://www.amazon.com/gp/bestsellers/home-garden/10671044011/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nWhat mattress depth is this fitted pad designed to accommodate? \nDesigned with a strong elastic skirt and deep pockets, it securely fits mattresses up to 16 inches deep without slipping or shifting overnight. \nWill this mattress pad add significant cushioning? \nThis is a pad, not a thick topper. It provides a plush, quilted layer of softness, but will not drastically change your mattress's firmness or deep cushioning. \nWhat are the best washing instructions to prevent the fill from clumping or shrinking? \nMachine wash on a cold, gentle cycle. Tumble dry on no heat or air dry. Avoid bleach and do not iron, as high heat can damage the fibers. \nExplore more products` choices \n\t\nFitted Mattress Pad \nAdd to Cart \nWaterproof Mattress Pad \nAdd to Cart \nPremium Mattress Pad \nAdd to Cart \nPremium Comforter \nAdd to Cart \nAll Season Comforter \nAdd to Cart \nECO Comforter \nAdd to Cart \nKids Comforter Set \nAdd to Cart \nCustomer Reviews \n\t\n4.6 out of 5 stars 159,099 \n\t\n4.5 out of 5 stars 5,156 \n\t\n4.5 out of 5 stars 8,285 \n\t\n4.6 out of 5 stars 155,941 \n\t\n4.6 out of 5 stars 126,864 \n\t\n4.6 out of 5 stars 18,017 \n\t\n4.6 out of 5 stars 5,947 \n\t\nPrice \n\t$20.99$20.99 \t$21.99$21.99 \t$18.49$18.49 \t$29.98$29.98 \t$22.40$22.40 \t$25.64$25.64 \t$22.48$22.48 \t\nFill Material \n\t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t100% Polyester \t\nFabric Type \n\t100% Microfiber \tMicrofiber with TPU coated \t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t100% Microfiber \t\nDesign \n\tDiamond Pattern \tOnion Pattern \tBox with boarder \tBox Style \tBox Style \tBox with boarder \tSnake Pattern \t\nAvailable Sizes \n\t10 Sizes available \tTwin/Full/Queen/King \t8 sizes available \tTwin/TX/Full/Queen/King/CKing \tTwin/TX/Full/Queen/King/CKing \tTwin/TX/Full/Queen/King/CKing \tTwin \t\nAvailable Color \n\t6 Colors \tWhite/Grey/Navy \t5 Colors \t11 Colors Available \t9 Colors Available \t7 Colors \t15 Colors \t\nMachine Washable \n\t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714 \t\u2714", "rawImages": [{"name": "twin mattress cover, mattress pad, mattress cover queen size bed, mattress cover king size bed,", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/faaf7998-21a1-4880-8f36-b550de33bdb3.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "bed mattress pad topper, mattress topper, mattress cover, twin mattress pad, mattress cover full", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3b00ac0d-a37e-4870-bc8d-e4cc0e833309.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "mattress protector, california king mattress pad, king mattress pads fitted deep pocket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62de78fa-9bbe-4386-9d95-49e0a180a7e7.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "mattress covers, queen mattress pad, mattress pad queen, king mattress pad, full mattress cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed6cdf9e-bc7a-4cdc-8b15-f89ec17d1058.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "mattress cover, mattress pads twin size, king size mattress pad deep pocket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/cdb4bb64-516b-4956-9a2b-ca0c6bf2103d.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8594323d-5a23-4fa6-98a3-1e39c562755e.__CR83,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8f68c8a1-d8ac-47ce-be0f-1a9cf09ca7d6.__CR83,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/03d6b877-4c5e-4233-99f6-ca4be8463afd.__CR43,0,413,465_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/5d565779-0da0-46bd-8a2c-7c6880bf48a9.__CR84,0,1332,1499_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/5b511a49-9f1d-4733-ab78-f8101ca6590f.__CR0,0,1329,1495_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/dba3d152-7687-445c-b5b0-47fca3003d6b.__CR42,0,1333,1500_PT0_SX200_V1___.jpg"}, {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8c23997e-a992-41a7-83f8-2bfdde33e07b.__CR28,0,444,500_PT0_SX200_V1___.jpg"}], "rawVideos": [], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "twin mattress cover, mattress pad, mattress cover queen size bed, mattress cover king size bed,", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/faaf7998-21a1-4880-8f36-b550de33bdb3.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "bed mattress pad topper, mattress topper, mattress cover, twin mattress pad, mattress cover full", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3b00ac0d-a37e-4870-bc8d-e4cc0e833309.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "mattress protector, california king mattress pad, king mattress pads fitted deep pocket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62de78fa-9bbe-4386-9d95-49e0a180a7e7.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "mattress covers, queen mattress pad, mattress pad queen, king mattress pad, full mattress cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed6cdf9e-bc7a-4cdc-8b15-f89ec17d1058.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "mattress cover, mattress pads twin size, king size mattress pad deep pocket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/cdb4bb64-516b-4956-9a2b-ca0c6bf2103d.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-11-faq", "items": [{"name": "What mattress depth is this fitted pad designed to accommodate?", "text": "Designed with a strong elastic skirt and deep pockets, it securely fits mattresses up to 16 inches deep without slipping or shifting overnight."}, {"name": "Will this mattress pad add significant cushioning?", "text": "This is a pad, not a thick topper. It provides a plush, quilted layer of softness, but will not drastically change your mattress's firmness or deep cushioning."}, {"name": "What are the best washing instructions to prevent the fill from clumping or shrinking?", "text": "Machine wash on a cold, gentle cycle. Tumble dry on no heat or air dry. Avoid bleach and do not iron, as high heat can damage the fibers."}]}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": {"text": "Customers find the mattress cover fits well and stays in place, while appreciating its quality, comfort, and softness, describing it as fluffy and nice to sleep on. They consider it a good investment and like its appearance, noting it looks clean and new. The thickness receives mixed feedback, with some praising its nice thickness while others find it too thin. Durability is also mixed, with some saying it's holding up well while others report it falling apart in the wash.", "keywords": [{"name": "Fit", "sentiment": "positive", "text": "Customers find that the mattress cover fits well and stays in place.", "customersMentionedCount": {"total": 6619, "positive": 5858, "negative": 761}, "partialReviews": [{"text": "Washes up and fits well. Hopefully it will last as long as my last one did. It went on the mattress easily and I think it would fit deep mattresses....", "highlightedPart": "fits well", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1JLJFDWK45023"}, {"text": "Perfect fit for an extra deep Cal King mattress. Haven't washed it yet, so don't know how it will stand up after that, but it appears to be well made.", "highlightedPart": "Perfect fit", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2QDJIZU6O3URC"}, {"text": "...I received this pad this morning. It is nicely made and it fits perfectly. It is not wonderful, but it does everything I want it for and need it for....", "highlightedPart": "fits perfectly", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R37MKQ1PETBMTC"}, {"text": "...was looking for; good material quality, no plastic or rubber in it, fits great since it is a little bigger than my small twin mattress so it's easy...", "highlightedPart": "fits great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3QUTEJ9ASPMJI"}]}, {"name": "Quality", "sentiment": "positive", "text": "Customers find the mattress pad to be well-made and of good quality.", "customersMentionedCount": {"total": 5523, "positive": 4972, "negative": 551}, "partialReviews": [{"text": "This mattress pad was well priced and is good quality. It is soft, and fits easily on the size and depth of mattress that is described before buying....", "highlightedPart": "good quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1DNR7UVNT4IX9"}, {"text": "...Great product, delivery makes it manageable and convenient, and it\u2019s super comfortable. Add all that to the price and it\u2019s a sure bet!", "highlightedPart": "Great product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R17SY4IVWXS38T"}, {"text": "...Doesn\u2019t fall apart when you wash it. Thick cover and soft. Great quality Softness: not stiff at all Leak resistance: works great", "highlightedPart": "Great quality", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R14BLSBLERBHOV"}, {"text": "Good product. I gave it 4 stars because I expected the top to be a bit thicker. It's more like a double sheet quilted together on top kinda thin ....", "highlightedPart": "Good product", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RCMK3D0DIRXVG"}]}, {"name": "Comfort", "sentiment": "positive", "text": "Customers find the mattress cover comfortable and nice to sleep on.", "customersMentionedCount": {"total": 2877, "positive": 2750, "negative": 127}, "partialReviews": [{"text": "...My allergies flared back up- Could be unrelated. but it is comfortable? I have a pillowtop mattress so the plastic changed that comfort to begin with.", "highlightedPart": "comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R28TIV92BBLX6Q"}, {"text": "Cover was true to size and very comfortable. I have this sandwiched between two fitted sheets on a memory foam mattress. Definitely worth the price.", "highlightedPart": "very comfortable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R4TVP8CQANE7O"}, {"text": "...If you\u2019re looking for a comfy and reliable mattress cover that keeps your mattress clean and comfy, this one from Utopia Bedding is definitely worth...", "highlightedPart": "comfy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1GQDCCOS7E0SN"}, {"text": "i like it honestly needed it way before i purchased it but the comfort and everything was high quality, my baby son has leaked bottle stains on it...", "highlightedPart": "comfort", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R3UYT0097GKTXS"}]}, {"name": "Softness", "sentiment": "positive", "text": "Customers appreciate the mattress cover's softness, describing it as fluffy and padded, with one customer noting its luxurious feel.", "customersMentionedCount": {"total": 2825, "positive": 2560, "negative": 265}, "partialReviews": [{"text": "...they fit perfectly on the beds, did not bunch up and were smooth and soft. I am very pleased with this product and will order another for my own bed.", "highlightedPart": "soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1XN4KC0B4M87N"}, {"text": "The pad is great; very soft and washed well. I purchased 2 and my kids have been sleeping very comfortably on them. Very happy with the mattress pads.", "highlightedPart": "very soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R218PSCBUP75VZ"}, {"text": "This pad is super soft and add the perfect amount of cushion/coziness! It is a great addition if you\u2019re looking for just a suttle, soft mattress pad!", "highlightedPart": "super soft", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R5AN88UE0VXN7"}, {"text": "Protect mattress as it should. Is roomy and deep on corners. It's even \"fluffy\" I don't know how else to describe it.. It doesn't bunch up and is...", "highlightedPart": "fluffy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2FNOPXA861UZL"}]}, {"name": "Value for money", "sentiment": "positive", "text": "Customers find the mattress cover to be a good value, describing it as decent for the price and a good investment.", "customersMentionedCount": {"total": 2587, "positive": 2436, "negative": 151}, "partialReviews": [{"text": "...Great price, but this is a case of \u201cyou get what you pay for.\u201d Shopping for a different brand, and willing to pay more for quality and performance.", "highlightedPart": "Great price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2UOJF0L3OROUN"}, {"text": "Everything I read in previous reveiws are the same. Good price decent mattress pad. I got one for my double and twin size. They were perfectly fine.", "highlightedPart": "Good price", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R192XN42IQQU1H"}, {"text": "...Bought two for guest room twin beds. For the price, a good value. Quilted pad completely covers the mattress. Elastic sides wrap and tuck nicely ....", "highlightedPart": "good value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1ZA5H6Y5WNM1I"}, {"text": "...My friend loved it too when she visited. great value. I just don't know how it will hold up in the wash since I have not washed one yet....", "highlightedPart": "great value", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1NLY8BWUXQV3Q"}]}, {"name": "Appearance", "sentiment": "positive", "text": "Customers like the appearance of the mattress cover, noting that it looks clean, new, and has a quality finish.", "customersMentionedCount": {"total": 1096, "positive": 1034, "negative": 62}, "partialReviews": [{"text": "Soft, nice and comfortable! Plenty of room for larger than 10\" mattresses. Great price and well made... we are very satisfied with this product", "highlightedPart": "nice", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RQ2PJL9WHT94H"}, {"text": "This is a nice mattress pad. I initially bought it for a queen size bed but have also used it on a full-size bed. The elastic makes it so both work....", "highlightedPart": "nice mattress pad", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RKCFOHP4OVP48"}, {"text": "This Mattress protector has a cushion so that\u2019s an added support and looks great but it doesn\u2019t stay in itself well on the mattress and keeps...", "highlightedPart": "looks great", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R33K91P7ZD3T13"}, {"text": "...I washed it right away & it washed & dried in fine shape. Looks good. Easy to put on mattress. Very comfortable. Thank you for such a good products....", "highlightedPart": "Looks good", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RWZ2D4FT2Y8TS"}]}, {"name": "Thickness", "sentiment": "mixed", "text": "Customers have mixed opinions about the mattress pad's thickness, with some appreciating its nice padding while others find it too thin.", "customersMentionedCount": {"total": 1373, "positive": 549, "negative": 824}, "partialReviews": [{"text": "Very thin and very inexpensive, but I honestly knew that would be the case due to the low cost. Just needed it to get by until I can afford better..", "highlightedPart": "Very thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1DCZTTPA8HG11"}, {"text": "...Let me tell you this is the best mattress cover I have ever seen! It's thick, soft, easy to put in and take off, plenty long enough for the hospital...", "highlightedPart": "thick", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R38YHD2LR29CJP"}, {"text": "This \"mattress pad\" was thin, flimsy and was too big for the mattress (and yes I did order the correct size!). It was a total waste of money for me.", "highlightedPart": "thin", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RG9PWZ7RNUUW"}, {"text": "...And it helped a lot. It\u2019s not too thick though but it covered it perfectly. Good cotton material. Fits snug on the mattress won\u2019t move around....", "highlightedPart": "not too thick", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R28ZFXI9NK4OJP"}]}, {"name": "Durability", "sentiment": "mixed", "text": "Customers have mixed experiences with the mattress cover's durability, with some reporting that it holds up well and has solid sides, while others mention that it rips instantly, falls apart in the wash, and the elastic breaks.", "customersMentionedCount": {"total": 1090, "positive": 515, "negative": 575}, "partialReviews": [{"text": "This mattress cover is not worth the money. Its flimsy, the elastic is thin and doesn't fit the bed well. Its made cheaply and I would not revommend....", "highlightedPart": "flimsy", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/RJKY1HFMFBXPP"}, {"text": "Its durable. Fits a little on the big side and some times bunches up. Would much more prefer a more fitted cover. This cover is def not water proof....", "highlightedPart": "durable", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R2ZMEHF38QE6QL"}, {"text": "They are soft, but they ripped when I put them on the bed for the first time with normal application. I\u2019m hoping for a replacement.", "highlightedPart": "ripped", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R5SS5OQA26897"}, {"text": "This product comes exactly as advertised. Great fit and durability. Price was very good and as always amazon prime delivers to perfection.", "highlightedPart": "durability", "url": "https://www.amazon.com/portal/customer-reviews/srp/-/R1OQP0LMEKG3M2"}]}]}, "monthlyPurchaseVolume": "20K+ bought in past month", "productPageReviews": [{"username": "Steak-N-Eggs", "userId": "amzn1.account.AF5YNO5NVHYMD33L6ZQEYWF42XPA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AF5YNO5NVHYMD33L6ZQEYWF42XPA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Very good basic mattress pad - does not have unnecessary padding - gets the job done", "reviewDescription": "I have been very satisfied with this mattress cover. I went to several stores in my area and there were many different mattress cover options and they all seemed to be very expensive for what they were. Some were approaching $100 and had all this padding and \"cooling technology fabric\" that seemed like a lot of hype.I settled on this basic mattress pad ant is exactly what I wanted and needed without being too thin/cheap or too \"poofy\" or overdone. I have a Kind Size mattress and this mattress pad fit it perfectly and is easy to put on.I like a comfortable bed but I do not need a giant \"hotel style\" topper for my mattress.If you are looking for a \"regular\" mattress topper, this is an excellent value for the cost. I have been happy with it and it has held up well over several months.", "date": "2026-07-28", "position": 1, "reviewedIn": "Reviewed in the United States on July 28, 2026", "reviewId": "RDX7CA3HWW7GH", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RDX7CA3HWW7GH/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "One person found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "melbapatti", "userId": "amzn1.account.AFILJPRIMK2SUQPMU6GONSTW745Q", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFILJPRIMK2SUQPMU6GONSTW745Q?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great mattress pad at bargain price", "reviewDescription": "Perfect mattress pad for everyday use. Not waterproof but very soft and a perfect fit for a full, deep mattress. At less than $20 it\u2019s a bargain. Buy it!", "date": "2026-07-17", "position": 2, "reviewedIn": "Reviewed in the United States on July 17, 2026", "reviewId": "R3J4NDPRT5SDGF", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3J4NDPRT5SDGF/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "2 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Just Sparkle", "userId": "amzn1.account.AH7CXWEQ3MZQKGDFBGVQXJ2LLVYA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH7CXWEQ3MZQKGDFBGVQXJ2LLVYA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "No Bunching & Super Comfy!No Bunching & Super Comfy!", "reviewDescription": "I love this mattress pad! I have been dealing with a mattress pad that bunches up in the night for years and I finally couldn't take it anymore. I found this one and thought for the really great price, I'd give it a try. Well, I was extremely impressed with the quality. First of all, it fits my 12\" mattress like a dream, is easy to put on, and could accommodate an even thicker mattress if needed. Yes, it isn't as thick as my last mattress cover but it is very comfortable. And most importantly it doesn't bunch up during the night....not one little bit! No more moving lumps of mattress pad out from under me when I roll over. Why of why didn't I buy this sooner! I 1000% recommend it!", "date": "2026-06-15", "position": 3, "reviewedIn": "Reviewed in the United States on June 15, 2026Reviewed in the United States on June 15, 2026", "reviewId": "R3GPOZE7EW0HCO", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3GPOZE7EW0HCO/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": ["https://m.media-amazon.com/images/I/713K+zIftQL._SY500_.jpg", "https://m.media-amazon.com/images/I/71D8nEtKdHL._SY500_.jpg"], "reviewReaction": "32 people found this helpful", "isVerified": false, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/9efd5e08-cb8a-4120-8981-4c900c71659c._CR0%2C0%2C500%2C500_SX460_SX48_.jpg", "variant": "", "variantAttributes": []}, {"username": "Jo F.", "userId": "amzn1.account.AFIIBKROYZA4LRE6R5Q7AVRXTAIA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AFIIBKROYZA4LRE6R5Q7AVRXTAIA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 4, "reviewTitle": "Water resistant? No\u2026 comfy? Yes", "reviewDescription": "It wasn\u2019t bad. We did get rid of it however because we got a new bed and it\u2019s a different size and all, but it wasn\u2019t too awful for the year we had it. I wouldn\u2019t say it was very water resistant, but it did have some pretty decent padding. It was a bit noisy at first but after washing it it was just like a regular sheet just plus padding. I\u2019m not sure it did much besides keep dirt off the mattress which our normal sheet also did. Our mattress would get wet though from spilled cups of water or if we sat on it after a shower, it\u2019s just not that water resistant. It was comfy though and it did fit well.", "date": "2026-06-14", "position": 4, "reviewedIn": "Reviewed in the United States on June 14, 2026", "reviewId": "R3F5WQQWVW3ISP", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3F5WQQWVW3ISP/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "9 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Wesley Pinkham", "userId": "amzn1.account.AHML4RNJWSB3EJNAN4I4ASRL5MEA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHML4RNJWSB3EJNAN4I4ASRL5MEA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Sleeping better", "reviewDescription": "Makes the mattress more comfortable in ways you notice immediately. The quilted design adds cushioning without being too soft or feeling like you're sinking in. Doesn't slip around on the mattress like cheap pads do. Washes easy, which matters because you actually want to keep it clean. Temperature regulation is real and noticeable, especially if you sleep hot. Wake up without that stiff feeling you get from an uncomfortable mattress. Quality bedding makes a difference in how you sleep and how rested you feel. The fit is secure so it stays in place all night. Worth upgrading your sleep comfort for the price.", "date": "2026-06-17", "position": 5, "reviewedIn": "Reviewed in the United States on June 17, 2026", "reviewId": "RJF7T0YCLVK2T", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/RJF7T0YCLVK2T/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "26 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Alesha", "userId": "amzn1.account.AH2BEPE7JJC5ZF7P72HFHBHBRIIA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AH2BEPE7JJC5ZF7P72HFHBHBRIIA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "A great mattress topper/pad cover", "reviewDescription": "Perfect fit on my full mattress with a 3 inch foam topper on it. It\u2019s nice and tight and very comfortable. Washed perfectly also.", "date": "2026-06-30", "position": 6, "reviewedIn": "Reviewed in the United States on June 30, 2026", "reviewId": "R7T9I5MWMLUDE", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R7T9I5MWMLUDE/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "4 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Cheryl Miller", "userId": "amzn1.account.AGONNCTW7VY3CM3WM25LBY6EHXGQ", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AGONNCTW7VY3CM3WM25LBY6EHXGQ?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Nice mattress pad", "reviewDescription": "100% polyester but made well. It fits the king memory foam 10 inch easily. The thickness is just right. It is not plush and it is not thin. This item is easily washed on a delicate cycle. It can be air dried or on the very lowest heat for a short time. The price is right for this pad.", "date": "2026-06-18", "position": 7, "reviewedIn": "Reviewed in the United States on June 18, 2026", "reviewId": "R2AYJX5GQ17W1V", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R2AYJX5GQ17W1V/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": [], "reviewReaction": "9 people found this helpful", "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Ed Runnion", "userId": "amzn1.account.AHQDBWVW55QJ6E5E5TA4YDP2CJPA", "userProfileLink": "https://www.amazon.com/gp/profile/amzn1.account.AHQDBWVW55QJ6E5E5TA4YDP2CJPA?ref=cm_cr_dp_d_bdcrb_top", "ratingScore": 5, "reviewTitle": "Great qualityGreat quality", "reviewDescription": "Love this mattress pad for the reasonable price it washed and dried nicely and fits the bed perfectlyQuality product!Would definitely recommend this mattress pad !", "date": "2026-07-01", "position": 8, "reviewedIn": "Reviewed in the United States on July 1, 2026Reviewed in the United States on July 1, 2026", "reviewId": "R3EX3OYYSIDYOU", "reviewUrl": "https://www.amazon.com/portal/customer-reviews/srp/-/R3EX3OYYSIDYOU/ref=cm_cr_dp_d_rvw_ttl?_encoding=UTF8&ie=UTF8", "reviewImages": ["https://m.media-amazon.com/images/I/61BrQCIv0zL._SY500_.jpg", "https://m.media-amazon.com/images/I/61DykTHGn2L._SY500_.jpg"], "reviewReaction": "4 people found this helpful", "isVerified": false, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "productPageReviewsFromOtherCountries": [{"username": "Anna P", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Rewelacyjny pokrowiec na gruby materac", "reviewDescription": "\u015awietny pokrowiec na grubszy materac, zak\u0142ada si\u0119 go bez problemu, nie zsuwa si\u0119 z grubego materaca, Powierzchnia jest pokryta mi\u0119kkim, pikowanym materia\u0142em.", "date": "2025-08-04", "position": 1, "reviewedIn": "Reviewed in Poland on August 4, 2025", "reviewId": "R117ZNHNTFQD8M", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Hans", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Bedding Gewatteerde", "reviewDescription": "perfect ook pasvorm", "date": "2026-07-26", "position": 2, "reviewedIn": "Reviewed in the Netherlands on July 26, 2026", "reviewId": "R214EZNZDCVXVF", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Ian Chan", "userId": null, "userProfileLink": null, "ratingScore": 1, "reviewTitle": "Disappointed by Utopia", "reviewDescription": "It's simply uncomfortable and traps heat. I ordered this mattress pad over the others because I think Utopia's donw-alternative duvet is truly excellent, regardless of price. This piece of cloth is breathable, but I immediately start sweating with this pad over my mattress and under my 100% percale cotton sheets", "date": "2023-03-01", "position": 3, "reviewedIn": "Reviewed in Singapore on March 1, 2023", "reviewId": "R23GXL5UGW81TD", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}, {"username": "Andy", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Great quality mattress pad at a genuine price", "reviewDescription": "This is a great mattress pad for the price. In fact, this is the second time I have purchased this product, this time for our guest room, because my experience with the first one has been very positive.I have been using this mattress pad for years on another bed, and it has held up really well with no issues at all. The quilted padding adds a nice layer of comfort without making the mattress feel overly soft, and the elastic fitted design stretches nicely over the mattress and stays in place.Another thing I like is that it\u2019s machine washable, which makes maintenance easy. After washing, it still keeps its shape and fits well on the mattress.Overall, this is a reliable, well-priced mattress pad that does exactly what it\u2019s supposed to do. Based on my long-term experience, I would definitely buy it again.", "date": "2026-03-10", "position": 4, "reviewedIn": "Reviewed in Canada on March 10, 2026", "reviewId": "R2FW5L1X2AVQA9", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": "https://m.media-amazon.com/images/S/amazon-avatars-global/1d5f94d3-2272-4ec3-a47f-f6f1009eb095._CR0%2C0%2C500%2C500_SX460_SX48_.jpeg", "variant": "", "variantAttributes": []}, {"username": "Kindle Customer", "userId": null, "userProfileLink": null, "ratingScore": 5, "reviewTitle": "Good fit!", "reviewDescription": "Fit was good, and washes well.", "date": "2026-01-23", "position": 5, "reviewedIn": "Reviewed in Australia on January 23, 2026", "reviewId": "R2Q5UKDYMY7R37", "reviewUrl": null, "reviewImages": [], "reviewReaction": null, "isVerified": true, "isAmazonVine": false, "avatar": null, "variant": "", "variantAttributes": []}], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B00NESCOY0?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 20.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": "Wednesday, August 5", "fastestDelivery": null}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B00NESCOY0?language=en", "input": "https://www.amazon.com/dp/B00NESCOY0"}}, "B0FV2Y5STT": {"ts": 1785486301.6078985, "item": {"title": "Utopia Bedding 4 Piece Bedding Set - 1 Fitted Sheet (54x75), 1 Duvet Cover Full Size, 2 Pillow Case (20x30) - All Season Luxury Bed in a Bag with Zippered Full Duvet Cover - No Comforter (White)", "url": "https://www.amazon.com/dp/B0FV2Y5STT?language=en", "asin": "B0FV2Y5STT", "originalAsin": "B0FV2Y5STT", "price": {"value": 40.99, "currency": "$"}, "inStock": true, "inStockText": "In Stock", "listPrice": null, "brand": "Utopia Bedding", "author": null, "shippingPrice": null, "stars": 4, "starsBreakdown": {"5star": 0, "4star": 1, "3star": 0, "2star": 0, "1star": 0}, "reviewsCount": 2, "answeredQuestions": null, "breadCrumbs": "Home & Kitchen > Bedding > Duvet Covers & Sets > Duvet Cover Sets", "videosCount": 0, "visitStoreLink": {"text": "Visit the Utopia Bedding Store", "url": "https://www.amazon.com/stores/UTOPIABEDDING/page/79964788-E114-433C-9E86-CE0136B7D494?lp_asin=B0FV2Y5STT&ref_=ast_bln&store_ref=bl_ast_dp_brandlogo_sto"}, "thumbnailImage": "https://m.media-amazon.com/images/I/81YOio4y4kL._AC_SY300_SX300_QL70_FMwebp_.jpg", "galleryThumbnails": ["https://m.media-amazon.com/images/I/41ozw65LXKL._AC_US.jpg", "https://m.media-amazon.com/images/I/41nUaGP6ESL._AC_US.jpg", "https://m.media-amazon.com/images/I/41NkyrLxqYL._AC_US.jpg", "https://m.media-amazon.com/images/I/41JR9-ilsxL._AC_US.jpg", "https://m.media-amazon.com/images/I/41ZY2bkQf8L._AC_US.jpg", "https://m.media-amazon.com/images/I/51RAi1zuW3L._AC_US.jpg", "https://m.media-amazon.com/images/I/41WS8GJhyjL._AC_US.jpg"], "highResolutionImages": ["https://m.media-amazon.com/images/I/81YOio4y4kL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71a+BCv79gL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71ZLqZkeDQL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81Fes3TyqfL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/81a0NxLZYJL._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/A1JrHIgDx7L._AC_SL1500_.jpg", "https://m.media-amazon.com/images/I/71DQDraSXWL._AC_SL1500_.jpg"], "importantInformation": null, "sustainabilityFeatures": [{"title": "Safer chemicals", "description": "Made with chemicals safer for human health and the environment.", "certifiedBy": [{"name": "OEKO-TEX STANDARD 100", "image": "https://m.media-amazon.com/images/I/51YvKwF01yL._SS144_.jpg", "description": " OEKO-TEX\u00ae STANDARD 100 certified products require every component of a textiles production including all thread, buttons, and trims to be tested against a list of more than 1,000 regulated and unregulated chemicals which may be harmful to human health. The assessment process is globally standardised, independently conducted, and updated at least once per year based on new scientific information or regulatory requirements. Learn more about this certification", "url": "https://www.oeko-tex.com/en/our-standards/standard-100-by-oeko-tex", "data": [{"key": "Certification Number", "value": "2023OK1318"}]}]}], "description": null, "features": ["What You Get - Made from soft brushed microfiber (100% polyester), this full bedding set 4 piece includes 1 duvet cover full size (80 x 90 inches), 1 fitted sheet full (54 x 75 inches), and 2 pillowcases (20 x 30 inches), designed for a complete twin bed frame setup.", "Deep Pocket Fitted Sheet \u2013 Features strong all-around elastic with a 15-inch deep pocket to ensure a snug, secure fit on most full mattresses, staying smooth and in place for a neat, tailored bed look that complements your bedroom decor.", "Duvet Cover and 2 Pillow Covers - This full size bedding set features a zipper duvet cover with 8 corner ties to keep the comforter secure. The (20 x 30 inches) pillow case envelope closure comprises of a 4-inch hem with piping.", "OEKO-TEX Certified Sheets & Covers - This bed set features OEKO-TEX certified full duvet cover set, and a fitted sheet, tested for harmful substances to ensure safe, skin-friendly comfort and restful sleep on your bed.", "Easy Care Low Maintenance Bedding Set \u2013 Crafted for everyday convenience, this Utopia Bedding bed set is machine washable and easy to care for. Wash in cold water, tumble dry on low, and use low heat for ironing. Bleach not recommended."], "attributes": [{"key": "Brand Name", "value": "Utopia Bedding"}, {"key": "Number of Pieces", "value": "4"}, {"key": "Included Components", "value": "Duvet Cover, Fitted Sheet, Pillowcase"}, {"key": "Pillowcase Quantity", "value": "2"}, {"key": "Unit Count", "value": "1 Count"}, {"key": "Sham Quantity", "value": "2"}, {"key": "Set Name", "value": "Luxury Bed in a Bag"}, {"key": "Manufacturer", "value": "Utopia Bedding"}, {"key": "UPC", "value": "197703214862"}, {"key": "Best Sellers Rank", "value": "#1,136 in Home & Kitchen (See Top 100 in Home & Kitchen)#2 in Bedding Duvet Cover Sets"}, {"key": "ASIN", "value": "B0FV2Y5STT"}, {"key": "Customer Reviews", "value": "4.0 4.0 out of 5 stars (2) 4.0 out of 5 stars"}, {"key": "Size", "value": "Full - Duvet Cover with Fitted Sheet"}, {"key": "Fitted Sheet Pocket Depth", "value": "38 centimeters"}, {"key": "Pillowcase Width", "value": "20 inches"}, {"key": "Pillowcase Length", "value": "30 inches"}, {"key": "Color", "value": "Grey"}, {"key": "Pattern", "value": "Solid"}, {"key": "Style Name", "value": "English"}, {"key": "Material Type", "value": "Microfiber"}, {"key": "Product Care Instructions", "value": "Machine wash on cold, Tumble dry on low. Bleach not recommended."}, {"key": "Other Special Features of the Product", "value": "Deep Pocket"}], "productOverview": [], "variantAsins": ["B0854JMK3W", "B0CX5J6DHW", "B0BYDXPY1T", "B09LMDNTQ4", "B0GFNWN99R", "B098DVZV31", "B0GFNT5MN7", "B0GFNWY46M", "B0CX5KG7RC", "B09RWWQJ5T", "B0GFPF48KZ", "B00NWPV1S0", "B0CX5J6DHP", "B0GFP5VCRK", "B0BYF1KF9G", "B0FV2Y3PNM", "B0FV2YZG31", "B0BYDXZCHQ", "B0GFP4NHDZ", "B0GFP3Q2N4", "B0CX5JV31V", "B0FV31KR5C", "B0CX5KDQ6P", "B0GFP82495", "B0GFPGGB4H", "B09RWXDSTZ", "B0GFP9FSX6", "B0BYDX87D8", "B00NWPV2VG", "B0GFP1FTBZ", "B0FV2Y5STT", "B0GFP8R3QQ", "B0GFP8XR45", "B0FV2XNQRK", "B0GFP1VWR7", "B0GFPJKCL2", "B09LMDGXHD", "B0GC6GYZDY", "B0854JJZCT", "B0CX5HCBTY", "B0CX5JJLZX", "B0BYF15S5X", "B09RWWW66P", "B0FV2Y8MNB", "B0CNHBN44J", "B0GFP13LMT", "B0GFNTD79R", "B09RWY127Q", "B0BYDYL1GW", "B09RWXK67W", "B0GFPJHSJY", "B0854D426F", "B07PQL9NQQ", "B0GFNZY376", "B0BYDZ6NVD", "B0GFPCYRHN", "B0CNH8LW38", "B098DWSSZC", "B0GFP4L7WS", "B0CX5J818S", "B0GFP9G1P9", "B0FV2ZSM28", "B0FV2YR928", "B0B39HV6FK", "B0GFP461BW", "B0GFP6B4XT", "B00NWPUYFG", "B0GFNSZRL3", "B0GFNYMX6W", "B09LMF8G4W", "B098DVWVWC", "B0GFNW7YXR", "B09NY92QSL", "B09LMBS294", "B0GFNYKV9G", "B0CX5KX22Q", "B0BYF232C6", "B00NWPUKMS", "B0BYDYXXWP", "B0854CQVR8", "B09RWXK2SB", "B0FV2Y5ZT2", "B09RWY6DY2", "B0FV2XGPQL", "B0CX5JTJ24", "B0GFP6XY4H", "B0FV2YQQFR", "B085443LSP", "B0B39FFJHF", "B00NWPV6Q2", "B0CX5G5YC8", "B0GFP9NP4Z", "B0CNH8XQ92", "B0BYDYRVT8", "B098DVLTVK", "B0854755CR", "B0GC6H9WF3", "B09NY8ZYZL", "B0GFP62YP5"], "variantDetails": [{"name": "Black Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51oAYQhhzKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81dZCM0CgtL._AC_SL1500_.jpg"], "asin": "B0854JMK3W", "price": null}, {"name": "Black California King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51oAYQhhzKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81dZCM0CgtL._AC_SL1500_.jpg"], "asin": "B0CX5J6DHW", "price": null}, {"name": "Black Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51LGRV2pvDL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81yUNoP8SlL._AC_SL1500_.jpg"], "asin": "B0BYDXPY1T", "price": null}, {"name": "Brown Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51XamQRAzJL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81h9uSm7QuL._AC_SL1500_.jpg"], "asin": "B09LMDNTQ4", "price": null}, {"name": "Dutch Blue Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WgSE38s3L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/91wLkZLUhFL._AC_SL1500_.jpg"], "asin": "B0GFNWN99R", "price": null}, {"name": "Spa Blue Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WKpImxG9L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81bYv0JMWDL._AC_SL1500_.jpg"], "asin": "B098DVZV31", "price": null}, {"name": "Blush Pink Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51GtabLlA0L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71VHIKiCJXL._AC_SL1254_.jpg"], "asin": "B0GFNT5MN7", "price": null}, {"name": "Sand Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51P0yUYLySL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81z5hDzoi7L._AC_SL1500_.jpg"], "asin": "B0GFNWY46M", "price": null}, {"name": "Emerald King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51Cxwn+iP+L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81MwJ6Wh4GL._AC_SL1500_.jpg"], "asin": "B0CX5KG7RC", "price": null}, {"name": "White California King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51iWUQgfLYL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/814QNNcuAeL._AC_SL1500_.jpg"], "asin": "B09RWWQJ5T", "price": null}, {"name": "Eggplant Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ZNLez+9LL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71S-HGc423L._AC_SL1254_.jpg"], "asin": "B0GFPF48KZ", "price": null}, {"name": "Grey King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51o1icRZmmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81FZmgiGPuL._AC_SL1500_.jpg"], "asin": "B00NWPV1S0", "price": null}, {"name": "Royal Blue King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hp2CPJyjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81OGmU7833L._AC_SL1500_.jpg"], "asin": "B0CX5J6DHP", "price": null}, {"name": "Ice Blue Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51SMdi-uRSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71cOe9oxbUL._AC_SL1254_.jpg"], "asin": "B0GFP5VCRK", "price": null}, {"name": "Black Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51oAYQhhzKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81dZCM0CgtL._AC_SL1500_.jpg"], "asin": "B0BYF1KF9G", "price": null}, {"name": "Black King - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41GhbCtTIHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81pLqjcmH4L._AC_SL1500_.jpg"], "asin": "B0FV2Y3PNM", "price": null}, {"name": "White Full - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41HQUBYHJGL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/713+Ja239rL._AC_SL1500_.jpg"], "asin": "B0FV2YZG31", "price": null}, {"name": "Beige Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ssgEybfWL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/818wkBofOjL._AC_SL1500_.jpg"], "asin": "B0BYDXZCHQ", "price": null}, {"name": "Blue Heaven King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51VqB-o-0jL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71uhXVu61qL._AC_SL1254_.jpg"], "asin": "B0GFP4NHDZ", "price": null}, {"name": "Blue Heaven Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51VqB-o-0jL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71uhXVu61qL._AC_SL1254_.jpg"], "asin": "B0GFP3Q2N4", "price": null}, {"name": "Denim Blue Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51kSE+XItNL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81iaPp-hauL._AC_SL1500_.jpg"], "asin": "B0CX5JV31V", "price": null}, {"name": "Black Full - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41GhbCtTIHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81pLqjcmH4L._AC_SL1500_.jpg"], "asin": "B0FV31KR5C", "price": null}, {"name": "Lavender Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51marfdJb7L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/919k4M1s4WL._AC_SL1500_.jpg"], "asin": "B0CX5KDQ6P", "price": null}, {"name": "Ice Blue Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/413mTNFmvIL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/719QGx2seWL._AC_SL1254_.jpg"], "asin": "B0GFP82495", "price": null}, {"name": "Blush Pink King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51GtabLlA0L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71VHIKiCJXL._AC_SL1254_.jpg"], "asin": "B0GFPGGB4H", "price": null}, {"name": "Grey California King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51o1icRZmmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81FZmgiGPuL._AC_SL1500_.jpg"], "asin": "B09RWXDSTZ", "price": null}, {"name": "Eggplant Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hsTZsmsbL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81Y2gehP4IL._AC_SL1500_.jpg"], "asin": "B0GFP9FSX6", "price": null}, {"name": "Sage Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51MeFxBLOLL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81KtgM5WcvL._AC_SL1500_.jpg"], "asin": "B0BYDX87D8", "price": null}, {"name": "Navy Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51+Ed3rSNAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81hQkpM4HUL._AC_SL1500_.jpg"], "asin": "B00NWPV2VG", "price": null}, {"name": "Sand King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ouY2DydUL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81xH1WaQuyL._AC_SL1500_.jpg"], "asin": "B0GFP1FTBZ", "price": null}, {"name": "Grey Full - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41ozw65LXKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YOio4y4kL._AC_SL1500_.jpg"], "asin": "B0FV2Y5STT", "price": null}, {"name": "Peri King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/510RwSF7qfL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81taUcgtlvL._AC_SL1500_.jpg"], "asin": "B0GFP8R3QQ", "price": null}, {"name": "Ice Blue King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51SMdi-uRSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71cOe9oxbUL._AC_SL1254_.jpg"], "asin": "B0GFP8XR45", "price": null}, {"name": "White King - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41HQUBYHJGL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/713+Ja239rL._AC_SL1500_.jpg"], "asin": "B0FV2XNQRK", "price": null}, {"name": "Ivory Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hTNpBMnvL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/819FTns+QKL._AC_SL1500_.jpg"], "asin": "B0GFP1VWR7", "price": null}, {"name": "Ivory Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51Ql72qj-WL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81JGZ9WNAIL._AC_SL1500_.jpg"], "asin": "B0GFPJKCL2", "price": null}, {"name": "Lavender King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51marfdJb7L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/919k4M1s4WL._AC_SL1500_.jpg"], "asin": "B09LMDGXHD", "price": null}, {"name": "Brown Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/518nUaTgoeL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81Wv6Ms9pwL._AC_SL1500_.jpg"], "asin": "B0GC6GYZDY", "price": null}, {"name": "Burgundy King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51lu6kOwx8L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81tXRojWGxL._AC_SL1500_.jpg"], "asin": "B0854JJZCT", "price": null}, {"name": "Purple Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WOT9D2hAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811QBqukwnL._AC_SL1500_.jpg"], "asin": "B0CX5HCBTY", "price": null}, {"name": "Burgundy Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51lu6kOwx8L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81tXRojWGxL._AC_SL1500_.jpg"], "asin": "B0CX5JJLZX", "price": null}, {"name": "Beige Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51YWJLWNb5L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81-qA2fNo-L._AC_SL1500_.jpg"], "asin": "B0BYF15S5X", "price": null}, {"name": "Grey Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51o1icRZmmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81FZmgiGPuL._AC_SL1500_.jpg"], "asin": "B09RWWW66P", "price": null}, {"name": "Ivory Queen - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41wVk40+sTL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/815HozSVpFL._AC_SL1500_.jpg"], "asin": "B0FV2Y8MNB", "price": null}, {"name": "Royal Blue Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hp2CPJyjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81OGmU7833L._AC_SL1500_.jpg"], "asin": "B0CNHBN44J", "price": null}, {"name": "Ivory Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hTNpBMnvL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/819FTns+QKL._AC_SL1500_.jpg"], "asin": "B0GFP13LMT", "price": null}, {"name": "Peri Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/510RwSF7qfL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81taUcgtlvL._AC_SL1500_.jpg"], "asin": "B0GFNTD79R", "price": null}, {"name": "Sage King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51MeFxBLOLL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81KtgM5WcvL._AC_SL1500_.jpg"], "asin": "B09RWY127Q", "price": null}, {"name": "Navy Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51XUO31EnWL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81+nGB3MlPL._AC_SL1500_.jpg"], "asin": "B0BYDYL1GW", "price": null}, {"name": "Sage Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51MeFxBLOLL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81KtgM5WcvL._AC_SL1500_.jpg"], "asin": "B09RWXK67W", "price": null}, {"name": "Tea Pink Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51mV3m3gyHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71NHDv9F1ZL._AC_SL1254_.jpg"], "asin": "B0GFPJHSJY", "price": null}, {"name": "Burgundy Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51lu6kOwx8L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81tXRojWGxL._AC_SL1500_.jpg"], "asin": "B0854D426F", "price": null}, {"name": "White King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51iWUQgfLYL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/814QNNcuAeL._AC_SL1500_.jpg"], "asin": "B07PQL9NQQ", "price": null}, {"name": "Peri Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/510RwSF7qfL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81taUcgtlvL._AC_SL1500_.jpg"], "asin": "B0GFNZY376", "price": null}, {"name": "Pink Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51EAxHcuBLL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81q7T2oBtWL._AC_SL1500_.jpg"], "asin": "B0BYDZ6NVD", "price": null}, {"name": "Blue Heaven Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/512uaEtoiHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71Yy3NnjoxL._AC_SL1254_.jpg"], "asin": "B0GFPCYRHN", "price": null}, {"name": "Olive Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/5131uz2JDBL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81k9L-lPO3L._AC_SL1500_.jpg"], "asin": "B0CNH8LW38", "price": null}, {"name": "Spa Blue King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WKpImxG9L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81bYv0JMWDL._AC_SL1500_.jpg"], "asin": "B098DWSSZC", "price": null}, {"name": "Blush Pink Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51M9FrAOo4L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71bsdQy4roL._AC_SL1254_.jpg"], "asin": "B0GFP4L7WS", "price": null}, {"name": "Burgundy Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/514Xx8NnB2L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81tH9jIYc3L._AC_SL1500_.jpg"], "asin": "B0CX5J818S", "price": null}, {"name": "Ice Blue Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51SMdi-uRSL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71cOe9oxbUL._AC_SL1254_.jpg"], "asin": "B0GFP9G1P9", "price": null}, {"name": "Grey Queen - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41ozw65LXKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81YOio4y4kL._AC_SL1500_.jpg"], "asin": "B0FV2ZSM28", "price": null}, {"name": "White Queen - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/411wGxoKYdL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81lahNuBnAL._AC_SL1500_.jpg"], "asin": "B0FV2YR928", "price": null}, {"name": "Teal King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51AFiFSXGNL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81M1zmJsiFL._AC_SL1500_.jpg"], "asin": "B0B39HV6FK", "price": null}, {"name": "Dutch Blue Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51LZcprfheL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81qs66BP0gL._AC_SL1500_.jpg"], "asin": "B0GFP461BW", "price": null}, {"name": "Blush Pink Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51GtabLlA0L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71VHIKiCJXL._AC_SL1254_.jpg"], "asin": "B0GFP6B4XT", "price": null}, {"name": "Navy King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51tKTvL9ITL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81m5hxHtoBL._AC_SL1500_.jpg"], "asin": "B00NWPUYFG", "price": null}, {"name": "Blue Heaven Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51VqB-o-0jL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71uhXVu61qL._AC_SL1254_.jpg"], "asin": "B0GFNSZRL3", "price": null}, {"name": "Eggplant Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hsTZsmsbL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81Y2gehP4IL._AC_SL1500_.jpg"], "asin": "B0GFNYMX6W", "price": null}, {"name": "Lavender Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51marfdJb7L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/919k4M1s4WL._AC_SL1500_.jpg"], "asin": "B09LMF8G4W", "price": null}, {"name": "Beige King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ssgEybfWL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/818wkBofOjL._AC_SL1500_.jpg"], "asin": "B098DVWVWC", "price": null}, {"name": "Tea Pink Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/513iVtnFmxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71vv2q4LY8L._AC_SL1254_.jpg"], "asin": "B0GFNW7YXR", "price": null}, {"name": "White Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51w4wHxHQGL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81znTb3AbzL._AC_SL1500_.jpg"], "asin": "B09NY92QSL", "price": null}, {"name": "Brown King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51XamQRAzJL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81h9uSm7QuL._AC_SL1500_.jpg"], "asin": "B09LMBS294", "price": null}, {"name": "Eggplant King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51hsTZsmsbL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81Y2gehP4IL._AC_SL1500_.jpg"], "asin": "B0GFNYKV9G", "price": null}, {"name": "Spa Blue Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WKpImxG9L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81bYv0JMWDL._AC_SL1500_.jpg"], "asin": "B0CX5KX22Q", "price": null}, {"name": "Sage Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51f5qPJ2-nL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81VwQgCvlFL._AC_SL1500_.jpg"], "asin": "B0BYF232C6", "price": null}, {"name": "White Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51iWUQgfLYL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/814QNNcuAeL._AC_SL1500_.jpg"], "asin": "B00NWPUKMS", "price": null}, {"name": "Spa Blue Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51nhmLrUd9L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81t3N8xGRbL._AC_SL1500_.jpg"], "asin": "B0BYDYXXWP", "price": null}, {"name": "Black King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51oAYQhhzKL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81dZCM0CgtL._AC_SL1500_.jpg"], "asin": "B0854CQVR8", "price": null}, {"name": "White Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51iWUQgfLYL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/814QNNcuAeL._AC_SL1500_.jpg"], "asin": "B09RWXK2SB", "price": null}, {"name": "White Twin - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41HQUBYHJGL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/713+Ja239rL._AC_SL1500_.jpg"], "asin": "B0FV2Y5ZT2", "price": null}, {"name": "Pink Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51H5ZJVwleL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81LPnwDYEbL._AC_SL1500_.jpg"], "asin": "B09RWY6DY2", "price": null}, {"name": "Grey King - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/416aRNTinQL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71by2K1d++L._AC_SL1500_.jpg"], "asin": "B0FV2XGPQL", "price": null}, {"name": "Lavender Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51Lvhbg5H-L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/814ON9jEurL._AC_SL1500_.jpg"], "asin": "B0CX5JTJ24", "price": null}, {"name": "Sand Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ouY2DydUL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81xH1WaQuyL._AC_SL1500_.jpg"], "asin": "B0GFP6XY4H", "price": null}, {"name": "Black Queen - Duvet Cover with Fitted Sheet", "thumbnail": "https://m.media-amazon.com/images/I/41GhbCtTIHL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81pLqjcmH4L._AC_SL1500_.jpg"], "asin": "B0FV2YQQFR", "price": null}, {"name": "Purple King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WOT9D2hAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811QBqukwnL._AC_SL1500_.jpg"], "asin": "B085443LSP", "price": null}, {"name": "Teal Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51AFiFSXGNL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81M1zmJsiFL._AC_SL1500_.jpg"], "asin": "B0B39FFJHF", "price": null}, {"name": "Grey Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51o1icRZmmL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81FZmgiGPuL._AC_SL1500_.jpg"], "asin": "B00NWPV6Q2", "price": null}, {"name": "Mocha Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/512uboU01gL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/91ANUWO4CjL._AC_SL1500_.jpg"], "asin": "B0CX5G5YC8", "price": null}, {"name": "Tea Pink King - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/513iVtnFmxL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/71vv2q4LY8L._AC_SL1254_.jpg"], "asin": "B0GFP9NP4Z", "price": null}, {"name": "Emerald Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51Cxwn+iP+L._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81MwJ6Wh4GL._AC_SL1500_.jpg"], "asin": "B0CNH8XQ92", "price": null}, {"name": "Navy Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51+Ed3rSNAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81hQkpM4HUL._AC_SL1500_.jpg"], "asin": "B0BYDYRVT8", "price": null}, {"name": "Beige Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51ssgEybfWL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/818wkBofOjL._AC_SL1500_.jpg"], "asin": "B098DVLTVK", "price": null}, {"name": "Purple Queen - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51WOT9D2hAL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/811QBqukwnL._AC_SL1500_.jpg"], "asin": "B0854755CR", "price": null}, {"name": "Brown Full - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51XamQRAzJL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81h9uSm7QuL._AC_SL1500_.jpg"], "asin": "B0GC6H9WF3", "price": null}, {"name": "Grey Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51b3vw1HNaL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81ClbDCYJEL._AC_SL1500_.jpg"], "asin": "B09NY8ZYZL", "price": null}, {"name": "Peri Twin/Twin XL - Duvet Cover Set", "thumbnail": "https://m.media-amazon.com/images/I/51DjieKAgjL._AC_US.jpg", "images": ["https://m.media-amazon.com/images/I/81wteooO61L._AC_SL1500_.jpg"], "asin": "B0GFP62YP5", "price": null}], "reviewsLink": "https://www.amazon.com/product-reviews/B0FV2Y5STT?language=en", "hasReviews": true, "delivery": "Wednesday, August 5", "fastestDelivery": "Tomorrow, August 1", "condition": null, "returnPolicy": null, "support": null, "variantAttributes": [{"key": "Color", "value": "Grey"}, {"key": "Size", "value": "Full - Duvet Cover with Fitted Sheet"}], "manufacturerAttributes": [], "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "bestsellerRanks": [{"rank": 1136, "category": "Home & Kitchen", "url": "https://www.amazon.com/gp/bestsellers/home-garden/ref=pd_zg_ts_home-garden"}, {"rank": 2, "category": "Bedding Duvet Cover Sets", "url": "https://www.amazon.com/gp/bestsellers/home-garden/14053331/ref=pd_zg_hrsr_home-garden"}], "isAmazonChoice": false, "amazonChoiceText": null, "bookDescription": null, "priceRange": null, "aPlusContent": {"title": "Product description", "rawText": "Product description\nPrevious page\nNext page\nPrevious page\nNext page\nPrevious page\nNext page", "rawImages": [{"name": "queen sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/da4f6179-0ef3-45af-9c2d-0e02becbfda8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "bed sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1689746-bc66-42f1-908e-3b4fb2667351.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7975fe96-1078-4cdf-ad6d-a32b1f04bbc1.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6edc52d4-bc3e-43c7-9fb4-01e389df61d2.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "duvet cover queen size", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f123d001-47c9-46a0-a630-062380d16078.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "sheets queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2c707638-df3f-474f-b5d7-d043f2633700.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "full sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/06699196-0e5e-4efa-97f3-772730361b1e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f122facd-5acc-4f8f-812f-edd737e23b04.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "sheets", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/36586ebb-218a-43db-9d0c-2751ee31443d.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "king duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3d37bf1c-1c12-4913-a72b-6c922d5a1efd.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6a22044e-8783-4550-b6cc-811a1e8eabd8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "bed sheets", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/622de54d-6afd-49ec-9d89-eacbae4950d6.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}, {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8686d1ad-6ba5-4b33-bafe-f5468e906875.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}], "rawVideos": [], "modules": [{"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "queen sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/da4f6179-0ef3-45af-9c2d-0e02becbfda8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "bed sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d1689746-bc66-42f1-908e-3b4fb2667351.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7975fe96-1078-4cdf-ad6d-a32b1f04bbc1.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6edc52d4-bc3e-43c7-9fb4-01e389df61d2.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "duvet cover queen size", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f123d001-47c9-46a0-a630-062380d16078.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "sheets queen", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2c707638-df3f-474f-b5d7-d043f2633700.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "full sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/06699196-0e5e-4efa-97f3-772730361b1e.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/f122facd-5acc-4f8f-812f-edd737e23b04.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "sheets", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/36586ebb-218a-43db-9d0c-2751ee31443d.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-13-carousel", "text": null, "items": [{"title": null, "text": null, "url": null, "image": {"name": "king duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3d37bf1c-1c12-4913-a72b-6c922d5a1efd.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "duvet cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6a22044e-8783-4550-b6cc-811a1e8eabd8.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}, {"title": null, "text": null, "url": null, "image": {"name": "bed sheets", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/622de54d-6afd-49ec-9d89-eacbae4950d6.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, {"type": "premium-module-2-fullbackground-image", "title": null, "subtitle": null, "text": null, "image": {"name": "sheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8686d1ad-6ba5-4b33-bafe-f5468e906875.__CR0,0,1464,600_PT0_SX1464_V1___.jpg"}}]}, "brandStory": {"title": "From the brand", "image": {"name": null, "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/640355bd-3ee3-4940-b6e5-acf8beaed255.__CR0,0,1464,625_PT0_SX1464_V1___.jpg"}, "items": [{"title": null, "text": null, "url": null, "image": {"name": "Text reads 'UTOPIA BEDDING', 'Comfort Within Reach!'. Extended product description text about bedding comfort and company philosophy on blue rounded background. Marketing content for bedding brand.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/e3ff2ce1-c9a6-4ab2-9cd7-24610d48a693.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Utopia Bedding Recognitions in Publications, Global Endorsements", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/25fcf4d6-1afb-4ce2-a622-3f9cddd046a1.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": null, "text": null, "url": null, "image": {"name": "Text reads 'Across the world -already!'. World map in dark blue on light blue background, mounted in navy blue frame or border.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/fe056c35-dbae-49e7-92cb-5c1b03bcd297.__CR0,0,919,1150_PT0_SX362_V1___.jpg"}, "items": []}, {"title": "Comforters Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7A78C167-A51A-4F75-8CD5-9C1EA91B112F?store_ref=storeRecs_dp_aplus", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B06WVV93R3/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/60de7f42-bab8-4552-9e94-237c83d11a2c.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B06WW5YJJN/ref=emc_bcc_2_i", "image": {"name": "Duvet Cover, Comforter, Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/248366a5-6856-47a8-9e46-ec4e42041972.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07DK77PYL/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2e425f83-0fc6-4bec-bc67-24f39eda4eb6.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DNQZLNRQ/ref=emc_bcc_2_i", "image": {"name": "Comforter, Duvet Cover", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/22c19055-496b-4988-9cab-e45b1dcb09e4.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Encasements & Protectors", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/CA809F05-46B2-4240-BB7A-E4D1706CBF50?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B01E02DU1O/ref=emc_bcc_2_i", "image": {"name": "Bed Pillow, Throw Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/82e5745c-4d90-4c6d-b628-ef45ef18d675.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU6E/ref=emc_bcc_2_i", "image": {"name": "Throw Pillow, Bed Pillow, Bamboo Mattress Protector", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/ed78ef18-4455-4774-b3da-d31a8ad9e6bc.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU1Y/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/50edee12-263c-4bed-84f0-a815cd655158.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B01E02DU0K/ref=emc_bcc_2_i", "image": {"name": "Bamboo Mattress Protector, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/2749f98c-2ee6-430a-880d-ff0f05b2d022.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Pillows Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/7DE87735-C5C6-45B6-8E08-51A4EF881C80?store_ref=storeRecs_dp_aplus", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B0B2WV1J4Y/ref=emc_bcc_2_i", "image": {"name": "Gusetted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/7db555fc-abbe-4f17-869c-ca588004ae1a.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B092M45BCD/ref=emc_bcc_2_i", "image": {"name": "Gusseted Pillows, Bed Pillows", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/290c9cfd-adb1-4a37-aa2d-d17b124ca1a7.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0D23NY7NQ/ref=emc_bcc_2_i", "image": {"name": "Fluffy gussetted pillows, flat sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8559ff98-ed83-4f70-aa65-eca3b8f23e05.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B07N7FLYXY/ref=emc_bcc_2_i", "image": {"name": "Flat sheet, comforter", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/a96a1fd1-1bda-498b-b930-74cc9f093459.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Bedsheet Sets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/541340C3-6B45-45C9-B574-4116146D94F5?store_ref=storeRecs_dp_aplus", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B00NX0WXQI/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/54149f30-269b-48fc-82a9-f3a160ded1f5.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B084MFBTT4/ref=emc_bcc_2_i", "image": {"name": "Bed Pillows, Bedsheet Set", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/61a8ff60-99b9-4ac8-be00-743af7803475.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0DGKWGQXR/ref=emc_bcc_2_i", "image": {"name": "Flat Sheet, Fitted Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3c93ba8a-6b86-4b7a-b1ce-4a4e4e048e92.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B00J602ECW/ref=emc_bcc_2_i", "image": {"name": "Fitted Sheet, Flat Sheet", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/62625ead-1368-4904-8e2d-20db84a25048.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Blankets Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/C07F3E93-218A-4AC0-9025-4D3E4B3E986F?store_ref=storeRecs_dp_aplus", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}, "items": [{"url": "https://www.amazon.com/dp/B084RGS1TH/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Thermal Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/6b709618-c919-431a-b23c-06d603ac1456.__CR0,14,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09STB87DM/ref=emc_bcc_2_i", "image": {"name": "Bed Blanket, Thermal Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/b91cc767-7887-4aa4-b77e-1934a2ae216b.__CR0,17,178,195_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B09ST8MVSC/ref=emc_bcc_2_i", "image": {"name": "Drapped Bed Blanket, Fleece Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/4608f342-3d3a-4aaa-82eb-68eca5ac58bc.__CR0,11,184,202_PT0_SX166_V1___.jpg"}}, {"url": "https://www.amazon.com/dp/B0BWMYDBVB/ref=emc_bcc_2_i", "image": {"name": "Fleece Blanket, Bed Blanket", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/d722c891-9aa9-4d66-87d9-d555d5a60eb1.__CR0,14,178,195_PT0_SX166_V1___.jpg"}}]}, {"title": "Mattress Pad Collection", "text": "Visit the Store", "url": "https://www.amazon.com/stores/page/E58CD570-E3C4-47DB-9D7D-E82C2BC8ADFF?store_ref=storeRecs_dp_aplus", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}, "items": [{"url": "https://www.amazon.com/dp/B0F2FZT6Z1/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding 2 Pack Waterproof Mattress Protector, King Mattress Pad, Noiseless Quilted Fitted ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/318088cc-acb6-423e-ae14-0fbc590b8c99.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYL44RR/ref=emc_bcc_2_i", "image": {"name": "White gridded fabric or mesh material partially shown, appears to be textile or protective covering surface.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/3ee37ec1-14c5-4846-bae3-81bf705b7918.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0CLYJ7Q38/ref=emc_bcc_2_i", "image": {"name": "Utopia Bedding Waterproof Mattress Protector, Queen Mattress Pad, Noiseless Quilted Fitted Cover ...", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/8567d05f-fae5-452e-bacf-cd8f0b0c58da.__AC_SR166,182___.png"}}, {"url": "https://www.amazon.com/dp/B0F2G5QMCW/ref=emc_bcc_2_i", "image": {"name": "Close-up of clear protective sheet showing quilted mattress surface pattern, demonstrating protective covering application.", "url": "https://m.media-amazon.com/images/S/aplus-media-library-service-media/1ef49fa2-77fd-42a8-99d1-896900faf41c.__AC_SR166,182___.png"}}]}]}, "productComparison": null, "aiReviewsSummary": null, "monthlyPurchaseVolume": null, "productPageReviews": [], "productPageReviewsFromOtherCountries": [], "locationText": "Deliver to New York 10001\u200c", "loadedCountryCode": "US", "offers": [{"url": "https://www.amazon.com/gp/product/B0FV2Y5STT?smid=A3AQP8TDYVYCGL", "condition": "New", "price": {"value": 40.99, "currency": "$"}, "shipsFrom": "Amazon.com", "seller": {"id": "A3AQP8TDYVYCGL", "url": "https://www.amazon.com/sp?seller=A3AQP8TDYVYCGL&language=en", "name": "Utopia Brands", "businessName": "UTOPIA BRANDS INC.", "VAT": null, "address": ["1151 BEAVER ST", "BRISTOL", "PA", "19007", "US"], "phone": "+16462130007", "email": null, "storefrontUrl": "https://www.amazon.com/s?ie=UTF8&marketplaceID=ATVPDKIKX0DER&me=A3AQP8TDYVYCGL", "rating30Days": {"starsOutOf5": 4.9, "ratingCount": 2426, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 15, "absolute2Star": 6, "absolute3Star": 1, "absolute4Star": 200, "absolute5Star": 2204}, "rating90Days": {"starsOutOf5": 4.9, "ratingCount": 5366, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 49, "absolute2Star": 14, "absolute3Star": 4, "absolute4Star": 440, "absolute5Star": 4859}, "rating12Months": {"starsOutOf5": 4.9, "ratingCount": 25390, "percentage1Star": 1, "percentage2Star": 0, "percentage3Star": 0, "percentage4Star": 8, "percentage5Star": 91, "absolute1Star": 161, "absolute2Star": 48, "absolute3Star": 26, "absolute4Star": 1949, "absolute5Star": 23206}, "ratingLifetime": {"starsOutOf5": 4.9, "ratingCount": 595817, "percentage1Star": 0, "percentage2Star": 0, "percentage3Star": 1, "percentage4Star": 9, "percentage5Star": 90, "absolute1Star": 2873, "absolute2Star": 1488, "absolute3Star": 3313, "absolute4Star": 54797, "absolute5Star": 533346}}, "position": 1, "isPinnedOffer": true, "shippingPrice": null, "delivery": "Wednesday, August 5", "fastestDelivery": "Tomorrow, August 1"}], "unNormalizedProductUrl": "https://www.amazon.com/dp/B0FV2Y5STT?language=en", "input": "https://www.amazon.com/dp/B0FV2Y5STT"}}} \ No newline at end of file diff --git a/data/live_validation_item3.json b/data/live_validation_item3.json new file mode 100644 index 0000000..e53f182 --- /dev/null +++ b/data/live_validation_item3.json @@ -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" + } +] \ No newline at end of file diff --git a/data/sample_skus.csv b/data/sample_skus.csv new file mode 100644 index 0000000..2ea9056 --- /dev/null +++ b/data/sample_skus.csv @@ -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 diff --git a/legacy_app.py b/legacy_app.py new file mode 100644 index 0000000..698e970 --- /dev/null +++ b/legacy_app.py @@ -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 = """ + +""" + + +# ── 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'
{title}
') + + +def _note(kind: str, body: str) -> None: + _html(f'
{body}
') + + +def _pill(rating: str) -> str: + return f'{rating.title()}' + + +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'
{sub}
' if sub else "" + cells += f'
{label}
{value}
{s}
' + _html(f'
{cells}
') + + +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'{hint}' if hint else "" + out += (f'
{label}{h}' + f'{_money(value)}
') + return f'
{out}
' + + +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'
{label}{disp}
' + cb = detail.get("costBreakdown") + if isinstance(cb, dict) and cb: + parts = " · ".join(f"{k} {_money(v)}" for k, v in cb.items()) + rows += (f'
Cost breakdown' + f'{_money(sum(cb.values()))}
' + f'
{parts}
') + 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'
{label}' + f'{_money(v)}
') + return f'
{rows}
' + + +# ── 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'
{r.sku}{_pill(r.rating.value)}
' + f'
' + + (f"ASIN {r.asin}  ·  " if r.asin else "") + + f"Evaluated at {_money(r.price)}
") + + # 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"Actually losing money — {_money(r.actual_profit_per_unit)} per unit. " + "Real COSMOS profit over the last 3 months, including storage, refunds, promo " + "and ads." + + (f" Lost money in {r.unprofitable_months} of {n_months} observed months." + if r.unprofitable_months else "")) + elif r.unprofitable_months >= 3: + _note("bad", f"Unprofitable in {r.unprofitable_months} of the last {n_months} months " + "on actual profit.") + elif r.is_losing_money: + _note("bad", f"Losing money after ads. 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"Verdict. {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"Price mismatch. 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 Test a specific price 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"Gate. {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('
Sellers and prices scraped from the ASIN product page ' + '(Apify). Same listing — other merchants competing for the Buy Box — not rival ' + 'product ASINs.
') + 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 overstates profit by {_money(r.unmodeled_cost_gap)} " + "per unit (unmodeled storage, refunds, promo, logistics). Trust the " + "actual figures.") + + if r.reasons: + _sec("Why this verdict — every reason") + rows = "".join(f'
{i}{reason}
' + for i, reason in enumerate(r.reasons, 1)) + _html(f'
{rows}
' + '
These are the exact deterministic rule outputs that produced ' + 'the verdict — no AI involved. The AI analysis only explains them.
') + + 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('
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.
') + + _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'
Estimated monthly take-home {_money(r.monthly_take_home)}, ' + f'after {_money(r.storage_charges)} of storage on current stock.
') + + 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('
At current volume — elasticity is not available for this SKU.
') + + 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('
This “take home” excludes ad spend, refunds, promo and ' + 'logistics — see the profit bridge for real per-unit profit.
') + 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('
Worst first. Every row states why it loses money and how to fix ' + 'it. To verify: if profit/unit + ad/unit > 0, ads caused the loss.
') + 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 cutting ad spend alone — 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('
Read-only — nothing is written anywhere. Open any SKU under ' + 'Single product for its full evidence-based report.
') + + +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"{len(losing)} of {len(rows)} SKUs are actually losing money 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'actual {_money(r.actual_profit_per_unit)}/unit ' + 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'
{_pill(r.rating.value)}' + f'{r.sku}' + f'now {_money(r.current_price)}, suggest ' + f'{_money(r.suggested_price)}{move} · {econ} · ' + f'6-mo {(r.avg_6m or 0):,.0f}/day ({r.trend}) — {r.headline}
') + _html(body) + + +# ── Page ─────────────────────────────────────────────────────────────── +_html(CSS) +_html('
Pricing & Profitability Analyst
' + '
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 ' + 'APIFY_TOKEN is set (product-line bulk skips Apify for speed).
') + +if not get_settings().openai_api_key: + _note("warn", "No OPENAI_API_KEY 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 single product for the full evidence-based report, scan a " + "product line, or run the catalog money-at-risk scan 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") diff --git a/ppt.md b/ppt.md new file mode 100644 index 0000000..74cf211 --- /dev/null +++ b/ppt.md @@ -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?* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9aa1a9e --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22c191b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/backtest_competitor_rules.py b/scripts/backtest_competitor_rules.py new file mode 100644 index 0000000..fc6c621 --- /dev/null +++ b/scripts/backtest_competitor_rules.py @@ -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()) diff --git a/scripts/e2e_pipeline_validate.py b/scripts/e2e_pipeline_validate.py new file mode 100644 index 0000000..a22583f --- /dev/null +++ b/scripts/e2e_pipeline_validate.py @@ -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()) diff --git a/scripts/serve_lan.ps1 b/scripts/serve_lan.ps1 new file mode 100644 index 0000000..43e38ab --- /dev/null +++ b/scripts/serve_lan.ps1 @@ -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 ." +} +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 = "" } + +$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 diff --git a/src/pricing_agent/__init__.py b/src/pricing_agent/__init__.py new file mode 100644 index 0000000..3b04d23 --- /dev/null +++ b/src/pricing_agent/__init__.py @@ -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" diff --git a/src/pricing_agent/analyze.py b/src/pricing_agent/analyze.py new file mode 100644 index 0000000..64f4860 --- /dev/null +++ b/src/pricing_agent/analyze.py @@ -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 diff --git a/src/pricing_agent/backtest.py b/src/pricing_agent/backtest.py new file mode 100644 index 0000000..a59342e --- /dev/null +++ b/src/pricing_agent/backtest.py @@ -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"} diff --git a/src/pricing_agent/cli.py b/src/pricing_agent/cli.py new file mode 100644 index 0000000..7513a2f --- /dev/null +++ b/src/pricing_agent/cli.py @@ -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 and --price ") + 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 [--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()) diff --git a/src/pricing_agent/competitive_state.py b/src/pricing_agent/competitive_state.py new file mode 100644 index 0000000..ec25130 --- /dev/null +++ b/src/pricing_agent/competitive_state.py @@ -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 `@` 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 diff --git a/src/pricing_agent/competitor_sheet.py b/src/pricing_agent/competitor_sheet.py new file mode 100644 index 0000000..75b5d05 --- /dev/null +++ b/src/pricing_agent/competitor_sheet.py @@ -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__.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 ' 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 diff --git a/src/pricing_agent/cosmos/__init__.py b/src/pricing_agent/cosmos/__init__.py new file mode 100644 index 0000000..d4fede8 --- /dev/null +++ b/src/pricing_agent/cosmos/__init__.py @@ -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", +] diff --git a/src/pricing_agent/cosmos/client.py b/src/pricing_agent/cosmos/client.py new file mode 100644 index 0000000..efc9539 --- /dev/null +++ b/src/pricing_agent/cosmos/client.py @@ -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 {}) diff --git a/src/pricing_agent/cosmos/errors.py b/src/pricing_agent/cosmos/errors.py new file mode 100644 index 0000000..9419754 --- /dev/null +++ b/src/pricing_agent/cosmos/errors.py @@ -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.""" diff --git a/src/pricing_agent/cosmos/models.py b/src/pricing_agent/cosmos/models.py new file mode 100644 index 0000000..cfe0cc3 --- /dev/null +++ b/src/pricing_agent/cosmos/models.py @@ -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") diff --git a/src/pricing_agent/cosmos/service.py b/src/pricing_agent/cosmos/service.py new file mode 100644 index 0000000..4707650 --- /dev/null +++ b/src/pricing_agent/cosmos/service.py @@ -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] diff --git a/src/pricing_agent/elasticity.py b/src/pricing_agent/elasticity.py new file mode 100644 index 0000000..dc23fbe --- /dev/null +++ b/src/pricing_agent/elasticity.py @@ -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 diff --git a/src/pricing_agent/fmt.py b/src/pricing_agent/fmt.py new file mode 100644 index 0000000..3d2a79f --- /dev/null +++ b/src/pricing_agent/fmt.py @@ -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}" diff --git a/src/pricing_agent/graph.py b/src/pricing_agent/graph.py new file mode 100644 index 0000000..f4c00f8 --- /dev/null +++ b/src/pricing_agent/graph.py @@ -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()) diff --git a/src/pricing_agent/llm.py b/src/pricing_agent/llm.py new file mode 100644 index 0000000..a7a6b8c --- /dev/null +++ b/src/pricing_agent/llm.py @@ -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) diff --git a/src/pricing_agent/logging_config.py b/src/pricing_agent/logging_config.py new file mode 100644 index 0000000..a66e6de --- /dev/null +++ b/src/pricing_agent/logging_config.py @@ -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 diff --git a/src/pricing_agent/nodes/__init__.py b/src/pricing_agent/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pricing_agent/nodes/compute.py b/src/pricing_agent/nodes/compute.py new file mode 100644 index 0000000..642489c --- /dev/null +++ b/src/pricing_agent/nodes/compute.py @@ -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 {} diff --git a/src/pricing_agent/nodes/writeback.py b/src/pricing_agent/nodes/writeback.py new file mode 100644 index 0000000..1be1266 --- /dev/null +++ b/src/pricing_agent/nodes/writeback.py @@ -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} diff --git a/src/pricing_agent/performance.py b/src/pricing_agent/performance.py new file mode 100644 index 0000000..f6820af --- /dev/null +++ b/src/pricing_agent/performance.py @@ -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 diff --git a/src/pricing_agent/providers.py b/src/pricing_agent/providers.py new file mode 100644 index 0000000..9bed120 --- /dev/null +++ b/src/pricing_agent/providers.py @@ -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}") diff --git a/src/pricing_agent/schemas.py b/src/pricing_agent/schemas.py new file mode 100644 index 0000000..475a7a8 --- /dev/null +++ b/src/pricing_agent/schemas.py @@ -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 diff --git a/src/pricing_agent/state.py b/src/pricing_agent/state.py new file mode 100644 index 0000000..2a8c8e8 --- /dev/null +++ b/src/pricing_agent/state.py @@ -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 diff --git a/src/pricing_agent/tools/__init__.py b/src/pricing_agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pricing_agent/tools/amazon/__init__.py b/src/pricing_agent/tools/amazon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pricing_agent/tools/amazon/apify.py b/src/pricing_agent/tools/amazon/apify.py new file mode 100644 index 0000000..03c2160 --- /dev/null +++ b/src/pricing_agent/tools/amazon/apify.py @@ -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/ 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) diff --git a/src/pricing_agent/tools/amazon/base.py b/src/pricing_agent/tools/amazon/base.py new file mode 100644 index 0000000..6dd4fea --- /dev/null +++ b/src/pricing_agent/tools/amazon/base.py @@ -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.""" + ... diff --git a/src/pricing_agent/tools/amazon/mock.py b/src/pricing_agent/tools/amazon/mock.py new file mode 100644 index 0000000..fc4d064 --- /dev/null +++ b/src/pricing_agent/tools/amazon/mock.py @@ -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", ""), + ) diff --git a/src/pricing_agent/tools/amazon/sp_api.py b/src/pricing_agent/tools/amazon/sp_api.py new file mode 100644 index 0000000..7f8b1da --- /dev/null +++ b/src/pricing_agent/tools/amazon/sp_api.py @@ -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)." + ) diff --git a/src/pricing_agent/tools/gate.py b/src/pricing_agent/tools/gate.py new file mode 100644 index 0000000..b073a16 --- /dev/null +++ b/src/pricing_agent/tools/gate.py @@ -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 diff --git a/src/pricing_agent/tools/margin_engine.py b/src/pricing_agent/tools/margin_engine.py new file mode 100644 index 0000000..c4e654a --- /dev/null +++ b/src/pricing_agent/tools/margin_engine.py @@ -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) diff --git a/src/pricing_agent/tools/tracker.py b/src/pricing_agent/tools/tracker.py new file mode 100644 index 0000000..43e18f6 --- /dev/null +++ b/src/pricing_agent/tools/tracker.py @@ -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") diff --git a/tests/fixtures/amazon_mock.json b/tests/fixtures/amazon_mock.json new file mode 100644 index 0000000..a10fec0 --- /dev/null +++ b/tests/fixtures/amazon_mock.json @@ -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." + } + } +} diff --git a/tests/fixtures/apify_amazon_item.json b/tests/fixtures/apify_amazon_item.json new file mode 100644 index 0000000..67276ee --- /dev/null +++ b/tests/fixtures/apify_amazon_item.json @@ -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" } + } + ] + } +} diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..05ef4fe --- /dev/null +++ b/tests/test_analyze.py @@ -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 diff --git a/tests/test_apify_batch.py b/tests/test_apify_batch.py new file mode 100644 index 0000000..fa6e6c4 --- /dev/null +++ b/tests/test_apify_batch.py @@ -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/ 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 diff --git a/tests/test_apify_mapper.py b/tests/test_apify_mapper.py new file mode 100644 index 0000000..d158d50 --- /dev/null +++ b/tests/test_apify_mapper.py @@ -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 diff --git a/tests/test_backtest.py b/tests/test_backtest.py new file mode 100644 index 0000000..c55b944 --- /dev/null +++ b/tests/test_backtest.py @@ -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" diff --git a/tests/test_competitor_rules.py b/tests/test_competitor_rules.py new file mode 100644 index 0000000..8b00520 --- /dev/null +++ b/tests/test_competitor_rules.py @@ -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 ' 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 diff --git a/tests/test_cosmos.py b/tests/test_cosmos.py new file mode 100644 index 0000000..b033caa --- /dev/null +++ b/tests/test_cosmos.py @@ -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) diff --git a/tests/test_cover_bands.py b/tests/test_cover_bands.py new file mode 100644 index 0000000..89b9b57 --- /dev/null +++ b/tests/test_cover_bands.py @@ -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") diff --git a/tests/test_dependencies_declared.py b/tests/test_dependencies_declared.py new file mode 100644 index 0000000..ba7b548 --- /dev/null +++ b/tests/test_dependencies_declared.py @@ -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" diff --git a/tests/test_evaluate_gate.py b/tests/test_evaluate_gate.py new file mode 100644 index 0000000..df5ec3f --- /dev/null +++ b/tests/test_evaluate_gate.py @@ -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() diff --git a/tests/test_graph_e2e.py b/tests/test_graph_e2e.py new file mode 100644 index 0000000..60a348a --- /dev/null +++ b/tests/test_graph_e2e.py @@ -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 diff --git a/tests/test_impact_baseline.py b/tests/test_impact_baseline.py new file mode 100644 index 0000000..df7132c --- /dev/null +++ b/tests/test_impact_baseline.py @@ -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"]) diff --git a/tests/test_inventory_outlook.py b/tests/test_inventory_outlook.py new file mode 100644 index 0000000..48c519b --- /dev/null +++ b/tests/test_inventory_outlook.py @@ -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() diff --git a/tests/test_inventory_reasons.py b/tests/test_inventory_reasons.py new file mode 100644 index 0000000..d6a9382 --- /dev/null +++ b/tests/test_inventory_reasons.py @@ -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"] diff --git a/tests/test_invp_header_matches_cosmos.py b/tests/test_invp_header_matches_cosmos.py new file mode 100644 index 0000000..257d527 --- /dev/null +++ b/tests/test_invp_header_matches_cosmos.py @@ -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") diff --git a/tests/test_line_picker.py b/tests/test_line_picker.py new file mode 100644 index 0000000..bbd796c --- /dev/null +++ b/tests/test_line_picker.py @@ -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) diff --git a/tests/test_margin_engine.py b/tests/test_margin_engine.py new file mode 100644 index 0000000..e1f7be0 --- /dev/null +++ b/tests/test_margin_engine.py @@ -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 + ) diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..6c364de --- /dev/null +++ b/tests/test_performance.py @@ -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 diff --git a/tests/test_pricing_safety.py b/tests/test_pricing_safety.py new file mode 100644 index 0000000..f426126 --- /dev/null +++ b/tests/test_pricing_safety.py @@ -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") diff --git a/tests/test_reproject_inventory.py b/tests/test_reproject_inventory.py new file mode 100644 index 0000000..7631d9f --- /dev/null +++ b/tests/test_reproject_inventory.py @@ -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 diff --git a/tests/test_storage_units.py b/tests/test_storage_units.py new file mode 100644 index 0000000..6d14a84 --- /dev/null +++ b/tests/test_storage_units.py @@ -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