"""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]