AI-Pricing-Agent/src/pricing_agent/cosmos/service.py

679 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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