679 lines
32 KiB
Python
679 lines
32 KiB
Python
"""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]
|