2211 lines
120 KiB
Python
2211 lines
120 KiB
Python
"""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(
|
||
'<div class="loadcard"><div class="lc-t">🔒 Utopia Pricing Agent</div>'
|
||
'<div class="lc-s">This dashboard shows live cost and margin data. '
|
||
'Enter the shared password to continue.</div></div>',
|
||
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("""
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||
.stApp *:not([data-testid="stIconMaterial"]):not([class*="material-symbols"]) {
|
||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||
}
|
||
[data-testid="stSidebar"] { background:#fffdf9; border-right:1px solid #e3ddd0; }
|
||
div[data-testid="stExpander"] {
|
||
background:#fffdf9; border:1.5px solid #d6cfbf; border-radius:14px;
|
||
box-shadow:0 1px 2px rgba(27,32,48,.05), 0 8px 24px rgba(27,32,48,.06);
|
||
margin-bottom:14px; overflow:hidden;
|
||
}
|
||
div[data-testid="stExpander"]:hover { border-color:#a89f8a; }
|
||
div[data-testid="stExpander"]:has(details[open]) {
|
||
border:2px solid #0c8276; box-shadow:0 6px 20px rgba(12,130,118,.16);
|
||
}
|
||
div[data-testid="stExpander"] details[open] > summary {
|
||
background:#eef6f3; border-bottom:1px solid #d9eee9;
|
||
}
|
||
div[data-testid="stExpander"] > details { border:none; background:transparent; }
|
||
div[data-testid="stExpander"] summary { padding:.9rem 1.1rem; }
|
||
div[data-testid="stExpander"] summary p { font-size:.95rem !important; }
|
||
div[data-testid="stExpander"] summary:hover { background:#f2f8f5; }
|
||
.stButton > button { border-radius:10px; font-weight:600; }
|
||
.stButton > button p { font-size:0.86rem; line-height:1.25; }
|
||
.stButton > button:hover { border-color:#0c8276; color:#0a6f65; }
|
||
div[data-testid="stPopover"] > button, button[data-testid="stPopoverButton"] { border-radius:10px; font-weight:600; }
|
||
.stTabs [data-baseweb="tab"] { border-radius:10px; padding:4px 12px; }
|
||
h1, h2, h3, h4 { letter-spacing:-.01em; }
|
||
/* Streamlit's :green[]/:red[] defaults sit around 3:1 on this cream paper — under
|
||
the 4.5:1 bar for the small text used in the queue row headers. */
|
||
[data-testid="stExpander"] summary [data-testid="stMarkdownContainer"] span[style*="color: rgb(9, 171, 59)"],
|
||
[data-testid="stExpander"] summary span.green { color:#0a6f65 !important; }
|
||
[data-testid="stExpander"] summary [data-testid="stMarkdownContainer"] span[style*="color: rgb(255, 43, 43)"],
|
||
[data-testid="stExpander"] summary span.red { color:#b23a22 !important; }
|
||
/* Stat tiles live in a real auto-fit grid, so they reflow 5 → 4 → 3 → 2 → 1
|
||
across the window width instead of being squeezed into unreadable slivers. */
|
||
.tiles { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(11.5rem, 1fr)); }
|
||
.tl { background:#fffdf9; border:1px solid #e3ddd0; border-radius:14px; padding:16px 18px;
|
||
box-shadow:0 1px 2px rgba(27,32,48,.05), 0 8px 24px rgba(27,32,48,.06); }
|
||
.tl-ic { width:36px; height:36px; border-radius:10px; background:#d9eee9; display:flex;
|
||
align-items:center; justify-content:center; font-size:17px; margin-bottom:10px; }
|
||
.tl-v { font-size:clamp(1.2rem, 1.6vw, 1.6rem); font-weight:800; color:#1b2030;
|
||
line-height:1.15; letter-spacing:-.01em; overflow-wrap:anywhere; }
|
||
.tl-l { font-size:.78rem; color:#4a4f60; margin-top:2px; font-weight:500; }
|
||
.tl-n { font-size:.72rem; color:#7c8092; margin-top:6px; }
|
||
.loadcard { max-width:620px; margin:14vh auto 0; text-align:center; background:#fffdf9;
|
||
border:1px solid #e3ddd0; border-radius:16px; padding:30px 34px;
|
||
box-shadow:0 8px 24px rgba(27,32,48,.08); }
|
||
.loadcard .lc-t { font-size:1.15rem; font-weight:800; color:#1b2030; }
|
||
.loadcard .lc-s { font-size:.86rem; color:#7c8092; margin:4px 0 18px; }
|
||
.loadcard .lc-b { font-family:monospace; font-size:1.1rem; letter-spacing:-1px; line-height:1;
|
||
overflow:hidden; white-space:nowrap; }
|
||
.loadcard .lc-p { font-size:2rem; font-weight:800; color:#0c8276; margin:10px 0 2px; }
|
||
.loadcard .lc-m { font-size:.85rem; color:#4a4f60; overflow-wrap:anywhere; }
|
||
.brandlogo { display:flex; align-items:center; gap:10px; padding:2px 0 6px; }
|
||
.brandlogo .bx { width:38px; height:38px; border-radius:11px;
|
||
background:linear-gradient(135deg,#22304e,#df4f33); display:flex; align-items:center;
|
||
justify-content:center; color:#fff; font-weight:900; font-size:17px;
|
||
letter-spacing:.02em; box-shadow:0 1px 2px rgba(27,32,48,.05), 0 8px 24px rgba(27,32,48,.06); }
|
||
.brandlogo .t1 { font-weight:800; font-size:1.02rem; line-height:1.15; color:#1b2030; }
|
||
.brandlogo .t2 { font-size:.72rem; color:#7c8092; }
|
||
.invt-wrap { overflow-x:auto; border:1px solid #e3ddd0; border-radius:12px; background:#fffdf9; }
|
||
.invt { border-collapse:collapse; width:100%; font-size:.78rem; }
|
||
.invt th { background:#faf7f1; padding:9px 10px; color:#4a4f60; font-weight:600;
|
||
border-bottom:1px solid #e3ddd0; white-space:nowrap; text-align:center; }
|
||
.invt th:first-child, .invt td:first-child { text-align:left; position:sticky; left:0;
|
||
background:#fffdf9; box-shadow:1px 0 0 #e3ddd0; min-width:215px; }
|
||
.invt th:first-child { background:#faf7f1; }
|
||
.invt td { padding:9px 10px; border-left:1px solid #ece6d9; text-align:center;
|
||
min-width:98px; vertical-align:middle; }
|
||
.invt .p-t { font-weight:700; color:#1b2030; font-size:.82rem; }
|
||
.invt .p-s { color:#7c8092; font-size:.71rem; margin-top:2px; white-space:nowrap; }
|
||
.invt .cell-main { font-weight:800; font-size:.93rem; color:#1b2030; }
|
||
.invt .cell-val { color:#7a4a8c; font-size:.71rem; margin-top:1px; font-weight:700; }
|
||
.invt .cell-sub { display:flex; justify-content:space-between; gap:8px; margin-top:3px;
|
||
font-size:.7rem; }
|
||
.invt .cov { color:#22304e; font-weight:800; }
|
||
.invt .arr { color:#4a4f60; font-weight:600; }
|
||
|
||
/* ------------------------------------------------------------------ responsive
|
||
Streamlit only stacks st.columns below a 640px viewport; between 640px and
|
||
~1500px it simply squeezes them, which clips stat tiles, metric strips,
|
||
button labels and section pills — very visible on 13"/14" MacBook screens.
|
||
The rules below let column rows WRAP, and let text reflow rather than be cut. */
|
||
[data-testid="stHorizontalBlock"] { flex-wrap:wrap !important; row-gap:.6rem; }
|
||
[data-testid="stColumn"] { min-width:15rem; }
|
||
/* a column holding a single metric is small by nature (no nested row inside),
|
||
so several may share a line before wrapping */
|
||
[data-testid="stColumn"]:has([data-testid="stMetric"]):not(:has([data-testid="stHorizontalBlock"])) {
|
||
min-width:6.5rem;
|
||
}
|
||
/* decision card (summary · metrics · actions) and short-label button rows.
|
||
Descendant selectors — Streamlit nests the keyed container's own wrapper
|
||
between the class and the column row. Metric cells inside keep their own
|
||
(more specific) minimum from the rule above. */
|
||
[class*="st-key-deccard"] [data-testid="stColumn"] { min-width:12rem; }
|
||
[class*="st-key-pillrow"] [data-testid="stColumn"],
|
||
[class*="st-key-optrow"] [data-testid="stColumn"] { min-width:8rem; }
|
||
|
||
/* Never ellipsize metric text — Streamlit truncates label/value/delta by
|
||
default, which is what "cuts off" the numbers on a narrow window. */
|
||
[data-testid="stMetricValue"], [data-testid="stMetricLabel"], [data-testid="stMetricDelta"],
|
||
[data-testid="stMetricValue"] *, [data-testid="stMetricLabel"] *, [data-testid="stMetricDelta"] * {
|
||
white-space:normal !important; overflow:visible !important; text-overflow:clip !important;
|
||
-webkit-line-clamp:unset !important; max-width:none !important;
|
||
}
|
||
[data-testid="stMetricValue"] { font-size:clamp(1.05rem, 1.5vw, 1.5rem); }
|
||
[data-testid="stMetricLabel"] p { font-size:.8rem; }
|
||
/* Allowing metric text to wrap must not let a token with no spaces (a money range,
|
||
a long SKU) shatter one character per line. Wrap between words only. */
|
||
[data-testid="stMetricValue"], [data-testid="stMetricValue"] *,
|
||
[data-testid="stMetricDelta"], [data-testid="stMetricDelta"] * {
|
||
overflow-wrap:normal !important; word-break:keep-all !important;
|
||
}
|
||
|
||
/* segmented controls (7 per-SKU sections, 5 averaging windows) wrap instead of
|
||
overflowing their card */
|
||
[data-testid="stButtonGroup"] { flex-wrap:wrap; max-width:100%; }
|
||
[data-testid="stButtonGroup"] button { white-space:normal; }
|
||
|
||
/* wide content (scenario / inventory tables) scrolls inside its own box */
|
||
.invt-wrap { -webkit-overflow-scrolling:touch; max-width:100%; }
|
||
[data-testid="stExpanderDetails"] { max-width:100%; }
|
||
|
||
@media (max-width: 1100px) {
|
||
/* stack the decision card so its metric strip gets the full card width
|
||
(3 squeezed columns force the 4 metrics 1-up, which is a lot of scrolling) */
|
||
[class*="st-key-deccard"] [data-testid="stColumn"] { min-width:100% !important; }
|
||
[data-testid="stColumn"]:has([data-testid="stMetric"]):not(:has([data-testid="stHorizontalBlock"])) {
|
||
min-width:6.5rem !important;
|
||
}
|
||
div[data-testid="stExpander"] summary p { font-size:.86rem !important; }
|
||
div[data-testid="stExpander"] summary { padding:.75rem .85rem; }
|
||
.stButton > button p { font-size:.8rem; }
|
||
.invt th:first-child, .invt td:first-child { min-width:9.5rem; }
|
||
.invt td { min-width:82px; }
|
||
}
|
||
@media (max-width: 820px) {
|
||
[data-testid="stMainBlockContainer"] { padding-left:1.1rem; padding-right:1.1rem;
|
||
padding-top:2.6rem; }
|
||
[data-testid="stColumn"] { min-width:100% !important; }
|
||
/* keep metrics 2-up even fully stacked — 1-up wastes a lot of vertical space */
|
||
[data-testid="stColumn"]:has([data-testid="stMetric"]):not(:has([data-testid="stHorizontalBlock"])) {
|
||
min-width:42% !important;
|
||
}
|
||
[class*="st-key-pillrow"] [data-testid="stColumn"] { min-width:8rem !important; }
|
||
.tiles { grid-template-columns:repeat(auto-fit, minmax(9.5rem, 1fr)); gap:10px; }
|
||
.loadcard { margin-top:6vh; padding:22px 18px; }
|
||
.loadcard .lc-b { font-size:.72rem; letter-spacing:-.5px; }
|
||
}
|
||
</style>
|
||
""", 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 '<span style="color:#7c8092;font-weight:600">→ 0.0%</span>'
|
||
if delta_pct > 0:
|
||
return (f'<span style="color:#0a6f65;font-weight:700">▲ +{delta_pct:.1f}%</span>')
|
||
return (f'<span style="color:#c9442b;font-weight:700">▼ {delta_pct:.1f}%</span>')
|
||
|
||
|
||
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 <b>${d['current_price']:.2f}</b> — 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"<b>${d['rec_price']:.2f}</b> ({d['delta_pct']:+.1f}%)")
|
||
if d.get("stepped") and abs(tgt - d["rec_price"]) > 0.01:
|
||
body += (f", stepping toward <b>${tgt:.2f}</b> — 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 <b>{d['impact_30d']:+,.0f}/30d</b> 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'<div style="background:#d9eee9;border:1px solid #0c8276;border-radius:12px;'
|
||
f'padding:12px 16px;margin-bottom:10px">'
|
||
f'<span style="font-weight:800;color:#0a6f65">💡 Recommendation:</span> '
|
||
f'{body}</div>',
|
||
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'<div style="font-size:.72em;color:#7c8092">avg sold · '
|
||
f'{days}d</div>'
|
||
f'<div style="font-size:.72em;color:#7c8092">list '
|
||
f'${x.get("list_price", x["price"]):.2f}</div>')
|
||
move_cell = '<span style="color:#7c8092;font-weight:600">actual</span>'
|
||
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'<div style="font-size:.7em;color:#0a6f65;font-weight:700">'
|
||
f'✓ ran {obs}d</div>' if obs else
|
||
'<div style="font-size:.7em;color:#a89f8a">never tested</div>')
|
||
price_cell = f'${x["price"]:.2f}{tag}'
|
||
move_cell = _price_move_html(x["delta_pct"])
|
||
rows_html.append(
|
||
f'<tr style="background:{row_bg}">'
|
||
f'<td style="text-align:left;font-weight:600">{emoji} {esc(x["label"])}{star}</td>'
|
||
f'<td>{price_cell}</td>'
|
||
f'<td>{move_cell}</td>'
|
||
f'<td>{x["units_day"]:,}</td>'
|
||
f'<td>${x["revenue_30d"]:,.0f}</td>'
|
||
f'<td>${x["ad_30d"]:,.0f}</td>'
|
||
f'<td style="color:{net_color};font-weight:700">${x["net_30d"]:,.0f}</td>'
|
||
f'<td>{x["net_margin_pct"]:.1f}%</td>'
|
||
# 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'<td style="text-align:left;color:#4a4f60;font-size:.82em">{x["advice"]}</td>')
|
||
+ '</tr>'
|
||
)
|
||
# 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'<th style="text-align:{align}" title="{tip}">'
|
||
f'<span style="border-bottom:1px dotted #a89f8a;cursor:help">{label}</span>'
|
||
f' <span style="color:#a89f8a;font-size:.85em">ⓘ</span></th>')
|
||
|
||
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(
|
||
'<div class="invt-wrap"><table class="invt" style="font-size:.82rem">'
|
||
'<thead><tr>' + headers + '</tr></thead><tbody>'
|
||
+ "".join(rows_html) + '</tbody></table></div>',
|
||
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
|
||
|
||
|
||
def inventory_reprice_table_html(d, rows: list, u_now: float, u_new: float) -> str:
|
||
"""The same weekly grid as `inventory_table_html`, re-run at the recommended price.
|
||
|
||
Deliberately identical markup and identical cover-band shading, so the two tables can be
|
||
read against each other cell for cell — a different layout for the same quantities would
|
||
make the comparison work the reader has to do.
|
||
|
||
A third row carries the CHANGE, because the interesting number is the difference and
|
||
making someone subtract two grids by eye is how a stock risk gets missed.
|
||
"""
|
||
if not rows:
|
||
return ""
|
||
cfg = d["cfg"]
|
||
cls = cfg["inv_class"]
|
||
heads = "".join(f'<th>{pd.Timestamp(r["date"]):%m/%d/%Y}</th>' for r in rows)
|
||
|
||
at_new, delta = [], []
|
||
for r in rows:
|
||
cover = float(r["cover_new"] or 0)
|
||
at_new.append(
|
||
f'<td style="background:{_bucket_bg(cover, cls)}">'
|
||
f'<div class="cell-main">{r["units_new"]:,.0f}</div>'
|
||
f'<div class="cell-val">${r["value_new"]:,.0f}</div>'
|
||
f'<div class="cell-sub"><span class="cov">{cover:,.0f}</span>'
|
||
f'<span class="arr">{r["arrival"]:,.0f}</span></div></td>'
|
||
)
|
||
# The delta row is intentionally UNSHADED: colour here would compete with the cover
|
||
# bands above and imply a second, unrelated scale.
|
||
du, dc = r["delta_units"], (r["cover_new"] or 0) - (r["cover_now"] or 0)
|
||
colour = (theme.DELTA_GOOD if du > 0 else
|
||
("#c9442b" if du < 0 else theme.MUTED))
|
||
delta.append(
|
||
f'<td><div class="cell-main" style="color:{colour}">{du:+,.0f}</div>'
|
||
f'<div class="cell-sub"><span class="cov">{dc:+,.0f} d</span></div></td>'
|
||
)
|
||
|
||
return (
|
||
'<div class="invt-wrap"><table class="invt">'
|
||
f'<thead><tr><th>At the recommended price</th><th>Daily Sale</th>{heads}</tr></thead>'
|
||
"<tbody>"
|
||
# --- row 1: the shelf at the new price
|
||
"<tr>"
|
||
f'<td><div class="p-t">${d["rec_price"]:,.2f}</div>'
|
||
f'<div class="p-s">was ${d["current_price"]:,.2f} · {d["delta_pct"]:+.1f}%</div>'
|
||
f'<div class="p-s">our projection · class {cls.capitalize()}</div></td>'
|
||
f'<td><div class="cell-main">{u_new:,.0f}</div>'
|
||
f'<div class="p-s">was {u_now:,.0f}/day</div>'
|
||
f'<div class="p-s">{(u_new / u_now - 1) if u_now else 0:+.1%} demand</div></td>'
|
||
f'{"".join(at_new)}'
|
||
"</tr>"
|
||
# --- row 2: the difference against COSMOS's own projection
|
||
"<tr>"
|
||
'<td><div class="p-t">Change</div>'
|
||
'<div class="p-s">vs today\'s price</div>'
|
||
'<div class="p-s">units · cover days</div></td>'
|
||
'<td><div class="p-s"> </div></td>'
|
||
f'{"".join(delta)}'
|
||
"</tr>"
|
||
"</tbody></table></div>"
|
||
)
|
||
|
||
|
||
def inventory_reprice_chart(d, rows: list) -> go.Figure:
|
||
"""The same shelf under BOTH prices — today's, and the one being recommended.
|
||
|
||
Sits under the projection above so the consequence of the price move is read in the same
|
||
glance as the move itself. COSMOS's own line is the solid one; ours is dashed, because one
|
||
is their forecast and the other is our model of it and the chart should not pretend
|
||
otherwise. The gap between the two IS the price effect.
|
||
"""
|
||
fig = go.Figure()
|
||
if not rows:
|
||
return fig
|
||
dates = [pd.Timestamp(r["date"]) for r in rows]
|
||
now = [r["units_now"] for r in rows]
|
||
new = [r["units_new"] for r in rows]
|
||
|
||
# Shaded band between the two lines: green when the move leaves more stock on the shelf,
|
||
# red when it burns through faster. Colour is never the only cue — the legend names both
|
||
# lines and the caption states which is which.
|
||
#
|
||
# NOT shaded when the two lines coincide. COSMOS returns a flat projection for some very
|
||
# low-velocity SKUs (the same units every week), so there is no draw to re-scale and the
|
||
# lines sit on top of each other. A band there would colour a difference that does not
|
||
# exist — and with `>=` it painted a 5% price CUT green, implying stock gained.
|
||
if any(a != b for a, b in zip(now, new)):
|
||
gained = new[-1] > now[-1]
|
||
fig.add_trace(go.Scatter(
|
||
x=dates + dates[::-1], y=new + now[::-1], fill="toself",
|
||
fillcolor=("rgba(10,111,101,0.10)" if gained else "rgba(201,68,43,0.10)"),
|
||
line=dict(width=0), hoverinfo="skip", showlegend=False))
|
||
|
||
fig.add_trace(go.Scatter(
|
||
x=dates, y=now, name=f"At ${d['current_price']:,.2f} (today)",
|
||
mode="lines+markers", line=dict(color=theme.SERIES["us"], width=2),
|
||
hovertemplate="%{x|%d %b}<br>%{y:,.0f} units<extra>today's price</extra>"))
|
||
fig.add_trace(go.Scatter(
|
||
x=dates, y=new, name=f"At ${d['rec_price']:,.2f} (recommended)",
|
||
mode="lines+markers", line=dict(color=theme.SERIES["alt"], width=2, dash="dash"),
|
||
hovertemplate="%{x|%d %b}<br>%{y:,.0f} units<extra>recommended price</extra>"))
|
||
|
||
fig.add_hline(y=0, line_color=theme.STATUS["critical"], line_dash="dot", line_width=1)
|
||
# Arrivals move the shelf on BOTH lines, so they are annotated once, on the axis.
|
||
for r, dt in zip(rows, dates):
|
||
if r["arrival"] > 0:
|
||
fig.add_annotation(x=dt, y=0, yref="y", text=f"🚚 +{r['arrival']:,.0f}",
|
||
showarrow=False, yshift=-14,
|
||
font=dict(size=10, color=theme.INK2))
|
||
# The first week the shelf empties under the new price — the whole point of the chart.
|
||
out = next((r for r in rows if r["stockout"]), None)
|
||
if out:
|
||
fig.add_vline(x=pd.Timestamp(out["date"]), line_color=theme.STATUS["critical"],
|
||
line_dash="dot", line_width=1)
|
||
fig.add_annotation(x=pd.Timestamp(out["date"]), y=max(now + new),
|
||
text="empty at the new price", showarrow=False,
|
||
font=dict(size=10, color=theme.STATUS["critical"]))
|
||
fig.update_yaxes(rangemode="tozero", title_text="units")
|
||
fig.update_layout(height=300, legend=dict(orientation="h", y=1.12, x=0))
|
||
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'<a href="{asin_url}" target="_blank">{asin}</a>' 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 ('<div class="invt-wrap" style="padding:16px;color:#7c8092">'
|
||
'No INVP projection available for this product from COSMOS.</div>')
|
||
|
||
# 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'<th>{pd.Timestamp(p["date"]):%m/%d/%Y}</th>' 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'<td style="background:{_bucket_bg(cover, cfg["inv_class"])}">'
|
||
f'<div class="cell-main">{units:,.0f}</div>'
|
||
f'<div class="cell-val">${value:,.0f}</div>'
|
||
f'<div class="cell-sub"><span class="cov">{cover:,.0f}</span>'
|
||
f'<span class="arr">{arr:,.0f}</span></div></td>'
|
||
)
|
||
return (
|
||
'<div class="invt-wrap"><table class="invt">'
|
||
f'<thead><tr><th>Product Description</th><th>Daily Sale</th>{heads}</tr></thead>'
|
||
"<tbody><tr>"
|
||
f'<td><div class="p-t">{cfg["title"]}</div>'
|
||
f'<div class="p-s">{cfg["sku"]} · {asin_html}{reviews}</div>'
|
||
f'<div class="p-s">📦 {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()}</div></td>'
|
||
f'<td><div class="cell-main">{d7:,.0f}</div>'
|
||
+ (f'<div class="p-s">{d_pot:,.0f} · potential</div>' if d_pot else '')
|
||
+ f'<div class="p-s">{d6m:,.0f} · 6mo avg</div></td>'
|
||
f'{"".join(cells)}'
|
||
"</tr></tbody></table></div>"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------- 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(
|
||
'<div class="brandlogo"><div class="bx">U</div>'
|
||
'<div><div class="t1">Utopia Pricing</div>'
|
||
'<div class="t2">AI Pricing Agent</div></div></div>',
|
||
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 = ("<span style='color:#0c8276'>" + "█" * fill + "</span>"
|
||
+ "<span style='color:#e3ddd0'>" + "█" * (34 - fill) + "</span>")
|
||
try:
|
||
_loader.markdown(
|
||
f"""<div class="loadcard">
|
||
<div class="lc-t">⚙️ Analyzing against COSMOS</div>
|
||
<div class="lc-s">{n} product{'s' if n > 1 else ''} · live fees, 6-month
|
||
history, elasticity & bulk economics</div>
|
||
<div class="lc-b">{bar}</div>
|
||
<div class="lc-p">{pct}%</div>
|
||
<div class="lc-m">{esc(message)}</div>
|
||
</div>""",
|
||
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('<div class="tiles">'
|
||
# 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)
|
||
+ '</div>', 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}")
|
||
|
||
# ---- and the same shelf at the recommended price, directly below ----
|
||
# The projection above is COSMOS's, at TODAY's price, so the price move is
|
||
# otherwise shown with no stock consequence at all. Raising slows the burn;
|
||
# cutting empties the shelf sooner, which on a SKU near a stockout is the
|
||
# difference between a sound move and an expensive one.
|
||
_ed = d.get("elasticity_detail") or {}
|
||
_el = _ed.get("elasticity") or FALLBACK_ELASTICITY
|
||
# COSMOS's OWN daily sale, the same figure the grid above prints — not our 30-day
|
||
# mean from sales history. They differ (64 vs 63.3 on the audited SKU), and using
|
||
# ours put two different "daily sale" numbers in adjacent tables AND shifted every
|
||
# cover day: 5,029 / 63.3 = 79 where COSMOS says 78.
|
||
#
|
||
# Driving both off COSMOS's rate makes the cover column reproduce COSMOS's own
|
||
# coverDays exactly, so the two grids reconcile line for line.
|
||
_u_now = float((d.get("invp_stats") or {}).get("avg_sale")
|
||
or d.get("units_day") or 0.0)
|
||
_ratio = ((d["rec_price"] / d["current_price"]) ** _el
|
||
if d.get("current_price") else 1.0)
|
||
_rows = reproject_inventory(d.get("inv_projection") or [],
|
||
_u_now, _u_now * _ratio)
|
||
_moved = abs(d["rec_price"] - d["current_price"]) >= 0.005
|
||
|
||
st.markdown("---")
|
||
if not _moved:
|
||
st.caption("**No price change is recommended**, so the shelf above is the "
|
||
"outlook. Nothing to re-project.")
|
||
elif not _rows:
|
||
st.caption(esc(
|
||
"Cannot re-project the shelf at the new price for this SKU — that needs "
|
||
"COSMOS's weekly projection and a usable demand response, and one of "
|
||
"them is missing."))
|
||
else:
|
||
_last = _rows[-1]
|
||
st.markdown(esc(
|
||
f"**At the recommended ${d['rec_price']:,.2f}** "
|
||
f"({d['delta_pct']:+.1f}%), demand moves {_ratio - 1:+.1%} — "
|
||
f"**{_last['units_new']:,} units** left at the end of the horizon "
|
||
f"against {_last['units_now']:,} at today's price "
|
||
f"(**{_last['delta_units']:+,}**)."))
|
||
if all(r["delta_units"] == 0 for r in _rows):
|
||
st.caption(esc(
|
||
f"COSMOS projects no depletion for this SKU — the same "
|
||
f"{_rows[0]['units_now']:,} units in every snapshot — so there is no "
|
||
f"draw to re-scale and the two lines coincide. Only cover days move."))
|
||
# The same grid as above, at the new price, so the two read cell for cell.
|
||
st.markdown(
|
||
inventory_reprice_table_html(d, _rows, _u_now, _u_now * _ratio),
|
||
unsafe_allow_html=True)
|
||
st.caption(
|
||
"Same layout as the COSMOS grid above and the same Alpha/Beta cover "
|
||
"shading, so the two compare cell for cell. **Change** is this projection "
|
||
"minus COSMOS's — green = more stock left, red = burns through faster.")
|
||
st.plotly_chart(inventory_reprice_chart(d, _rows),
|
||
use_container_width=True,
|
||
config={"displayModeBar": False}, key=f"invrp_{sku}")
|
||
st.caption(
|
||
"Solid = COSMOS's projection at today's price. Dashed = **our** model of "
|
||
"the same shelf at the recommended price, scaling each week's SALES by "
|
||
"the elasticity the Scenarios tab uses. Arrivals are unchanged — a PO "
|
||
"already placed does not move because we re-priced — and stock is floored "
|
||
"at zero. Full week-by-week numbers are on **🔁 Stock at the new price**.")
|
||
|
||
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
|
||
# COSMOS's own daily sale — the figure its Inventory Planning grid prints — so the
|
||
# cover days here reproduce COSMOS's coverDays rather than sitting a day or two off.
|
||
u_now = float((d.get("invp_stats") or {}).get("avg_sale")
|
||
or 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:,.0f}", f"{pct:+.1f}% vs {u_now:,.0f} 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`")
|