- 1---
- 2name: brand-test
- 3description: Turn a CSV, TSV or Excel file (or a chart screenshot's data) into a chart in the Recon trading-app brand. Use when the user runs /brand-test or asks for an on-brand / Recon-style chart.
- 4---
- 6# /brand-test — on-brand charts from a spreadsheet
- 8Renders a chart from tabular data in the Recon brand (dark canvas, single blue data line,
- 9status colours only where they mean something) and hands back a PNG plus an HTML card that
- 10matches the app's "Portfolio performance" panel.
- 12The brand rules are listed below and built into the script, so the skill is self-contained.
- 14## Steps
- 161. **Find the data.** Use the file the user attached or names (.csv, .tsv, .xlsx, .xls).
- 17 If they gave a chart *image* instead of data, read the values off it, write them to a CSV
- 18 (one column per series, first column the x-axis) and say the numbers were read by eye.
- 19 No file at all: ask for one in one sentence.
- 202. **Install the script** if `brand_chart.py` isn't already in the working directory: write the
- 21 code block at the end of this skill to `brand_chart.py` exactly as given.
- 223. **Profile:** `python3 brand_chart.py <file> --profile` (add `--sheet NAME` for Excel). It lists
- 23 column kinds (date / number / category) and suggests commands.
- 244. **Choose the chart.** If the user said what they want, map it to flags. Otherwise pick the most
- 25 useful view from the profile and say which you chose:
- 26 - something over time → `--x <date col>` (+ `--y <number col> --agg sum`, or `--agg count`), `--freq D|W|M`
- 27 - level series (equity, price, balance) → `--agg last` so the headline shows latest value and change
- 28 - breakdown → `--x <category col> --agg count` (ranked horizontal bars, top 12 + Other)
- 29 - split over time → add `--by <category col>` and `--kind bar` (stacked) or `line`
- 30 - share of a whole with ≤5 parts → `--kind donut`
- 31 - filter rows → `--where "status == 'filled'"`
- 32 - money columns are detected by name (price, notional, pnl, equity, amount…); force with `--money`
- 33 Always pass a human `--title` in sentence case ("Daily orders by status"), never a column name.
- 345. **Render:** `python3 brand_chart.py <file> [flags] --out out/<slug>`. It writes `out/<slug>.png`
- 35 (2×, for decks/Slack) and `out/<slug>.html` (self-contained card).
- 366. **Check it:** open the PNG with Read. Fix and re-run if labels overlap, a single giant "Other"
- 37 bar hides the story (raise `--top`), dates are unreadable (coarser `--freq`), or the colours
- 38 don't match meaning.
- 397. **Deliver:** send the PNG and the HTML with SendUserFile (HTML with display "render"). One line
- 40 on what the chart shows and the single most notable thing in it. Offer one alternative view.
- 42## Brand rules the script already applies (keep them if you hand-edit)
- 44- Canvas `#0B0E13`, card `#11161E`, border `#222B37`, gridlines `#1C2430` horizontal only, no axis lines.
- 45- Text `#F2F4F7`, labels `#A3ABB8`, ticks/timestamps `#7D8695`. Font Plus Jakarta Sans (Inter fallback), tabular numbers.
- 46- Data: `#7BA4FA` line, 2px, with a 16% area fill; second series `#B5CCFC`, then greys.
- 47- Status words map to status colours: filled/running → `#5DCB7E`; held/paused/pending → `#F2B84B`;
- 48 canceled/stopped/failed → `#F07070`; expired/new → greys. P&L bars green above zero, red below.
- 49- Headline value large and bold; changes always signed (+/−) and coloured.
- 50- Never: 3D, gradients beyond the area fill, rainbow palettes, dual axes, labels on pie slices.
- 52## brand_chart.py
- 54```python
- 55#!/usr/bin/env python3
- 56"""brand_chart.py — turn a CSV / TSV / Excel file into a Recon-branded chart.
- 58Outputs <out>.png (2x, for decks/Slack) and <out>.html (a self-contained card
- 59styled like the app's "Portfolio performance" panel).
- 61Usage:
- 62 python3 brand_chart.py FILE --profile # list columns + suggestions
- 63 python3 brand_chart.py FILE --x created_at --agg count --freq D
- 64 python3 brand_chart.py FILE --x symbol --y filled_qty --agg sum --kind hbar
- 65 python3 brand_chart.py FILE --x created_at --by status --agg count --freq W
- 66Options: --sheet NAME --y COL --by COL --agg sum|count|mean|last
- 67 --kind auto|area|line|bar|hbar|donut|scatter --freq D|W|M|H
- 68 --top N --title TEXT --subtitle TEXT --out PATH --where "pandas query"
- 69"""
- 70import argparse, base64, html, io, re, sys
- 71from pathlib import Path
- 72import pandas as pd
- 73import matplotlib
- 74matplotlib.use("Agg")
- 75import matplotlib.pyplot as plt
- 76from matplotlib import font_manager
- 78# ── Recon tokens (dark-only brand) ──────────────────────────────────────────
- 79T = {
- 80 "bg": "#0B0E13", "surface": "#11161E", "raised": "#1A2230", "border": "#222B37",
- 81 "grid": "#1C2430", "track": "#2A3342",
- 82 "text": "#F2F4F7", "text2": "#A3ABB8", "muted": "#7D8695",
- 83 "accent": "#7BA4FA", "accent_soft": "#B5CCFC",
- 84 "pos": "#5DCB7E", "warn": "#F2B84B", "neg": "#F07070",
- 85}
- 86SERIES = [T["accent"], T["accent_soft"], "#5B6B85", "#3E5A99", "#8E97A6", "#C9D6F2"]
- 87SEMANTIC = { # status words get the app's status colours; extra words in a role get its tints
- 88 "pos": {"filled", "running", "open", "active", "done", "success", "complete", "completed", "win", "profit"},
- 89 "warn": {"paused", "held", "pending", "waiting", "partially_filled", "accepted", "paper"},
- 90 "neg": {"canceled", "cancelled", "stopped", "failed", "rejected", "error", "loss", "killed"},
- 91 "neutral": {"expired", "new", "queued", "replaced", "done_for_day", "inactive"},
- 92}
- 93TINTS = {"pos": ["#5DCB7E", "#2F8A4C"], "warn": ["#F2B84B", "#A9772A"],
- 94 "neg": ["#F07070", "#A84848"], "neutral": ["#8E97A6", "#5B6B85", "#C9D0DB"]}
- 95FONT = "Inter" if any("Inter" in f.name for f in font_manager.fontManager.ttflist) else "DejaVu Sans"
- 96WEB_FONT = "'Plus Jakarta Sans', Inter, system-ui, sans-serif"
- 99def semantic_role(label):
- 100 k = str(label).strip().lower().replace(" ", "_")
- 101 for role, words in SEMANTIC.items():
- 102 if k in words:
- 103 return role
- 104 return None
- 107def palette_for(labels, fallback=None):
- 108 """Status-like labels -> app status colours (tinted when a role repeats);
- 109 everything else -> the blue series palette. Returns None if no label is status-like."""
- 110 labels = [str(l) for l in labels]
- 111 if not any(semantic_role(l) for l in labels):
- 112 return None
- 113 used, out, i = {}, [], 0
- 114 for l in labels:
- 115 r = semantic_role(l)
- 116 if r:
- 117 n = used.get(r, 0); used[r] = n + 1
- 118 out.append(TINTS[r][min(n, len(TINTS[r]) - 1)])
- 119 else:
- 120 out.append(SERIES[i % len(SERIES)]); i += 1
- 121 return out
- 124def load(path, sheet=None):
- 125 p = Path(path)
- 126 ext = p.suffix.lower()
- 127 if ext in (".xlsx", ".xlsm", ".xls"):
- 128 df = pd.read_excel(p, sheet_name=sheet or 0)
- 129 elif ext == ".tsv":
- 130 df = pd.read_csv(p, sep="\t")
- 131 else:
- 132 df = pd.read_csv(p, sep=None, engine="python")
- 133 df.columns = [str(c).strip() for c in df.columns]
- 134 for c in df.columns: # parse date-like text columns
- 135 if (df[c].dtype == object or pd.api.types.is_string_dtype(df[c])) and re.search(r"(date|time|_at$|day|month|period)", c, re.I):
- 136 parsed = pd.to_datetime(df[c], errors="coerce", utc=True, format="ISO8601")
- 137 if parsed.notna().mean() < 0.8:
- 138 parsed = pd.to_datetime(df[c], errors="coerce", utc=True, format="mixed")
- 139 if parsed.notna().mean() > 0.8 or (df[c].notna().mean() < 0.8 and parsed.notna().sum() == df[c].notna().sum()):
- 140 df[c] = parsed.dt.tz_convert(None)
- 141 return df
- 144def profile(df):
- 145 rows = []
- 146 for c in df.columns:
- 147 s = df[c]
- 148 nn = s.notna().mean()
- 149 if nn == 0:
- 150 continue
- 151 kind = ("date" if pd.api.types.is_datetime64_any_dtype(s) else
- 152 "number" if pd.api.types.is_numeric_dtype(s) and s.nunique() > 2 else
- 153 "category" if s.nunique() <= 40 else "id/text")
- 154 rows.append((c, kind, s.nunique(), f"{nn:.0%}"))
- 155 print(f"{len(df)} rows × {len(df.columns)} cols (empty columns hidden)\n")
- 156 print(f"{'column':28} {'kind':9} {'unique':>7} {'filled':>7}")
- 157 for r in rows:
- 158 print(f"{r[0]:28} {r[1]:9} {r[2]:>7} {r[3]:>7}")
- 159 dates = [r[0] for r in rows if r[1] == "date"]
- 160 nums = [r[0] for r in rows if r[1] == "number"]
- 161 cats = sorted([r for r in rows if r[1] == "category" and 1 < r[2] <= 25], key=lambda r: r[2])
- 162 cats = [r[0] for r in cats if not r[0].endswith("_id")]
- 163 print("\nSuggestions:")
- 164 if dates:
- 165 print(f" --x {dates[0]} --agg count --freq D # activity over time")
- 166 if nums: print(f" --x {dates[0]} --y {nums[0]} --agg sum --freq D # value over time")
- 167 if cats: print(f" --x {dates[0]} --by {cats[0]} --agg count --freq W # split by {cats[0]}")
- 168 for c in cats[:3]:
- 169 print(f" --x {c} --agg count # breakdown by {c}")
- 172def fmt_num(v, money=False):
- 173 if pd.isna(v):
- 174 return "–"
- 175 sign = "-" if v < 0 else ""
- 176 a = abs(v)
- 177 pre = "$" if money else ""
- 178 if a >= 1e6: return f"{sign}{pre}{a/1e6:.2f}M"
- 179 if a >= 1e4: return f"{sign}{pre}{a/1e3:.1f}k"
- 180 if money or a % 1: return f"{sign}{pre}{a:,.2f}"
- 181 return f"{sign}{pre}{a:,.0f}"
- 184def aggregate(df, x, y, by, agg, freq, top):
- 185 d = df.copy()
- 186 is_time = pd.api.types.is_datetime64_any_dtype(d[x])
- 187 if is_time:
- 188 d[x] = d[x].dt.to_period(freq).dt.to_timestamp() if freq != "H" else d[x].dt.floor("h")
- 189 keys = [x] + ([by] if by else [])
- 190 if agg == "count" or y is None:
- 191 g = d.groupby(keys).size()
- 192 else:
- 193 d[y] = pd.to_numeric(d[y], errors="coerce")
- 194 g = getattr(d.groupby(keys)[y], {"sum": "sum", "mean": "mean", "last": "last"}[agg])()
- 195 if by:
- 196 g = g.unstack(by).fillna(0)
- 197 if len(g.columns) > top:
- 198 keep = g.sum().sort_values(ascending=False).index[:top - 1]
- 199 g = pd.concat([g[keep], g.drop(columns=keep).sum(axis=1).rename("Other")], axis=1)
- 200 else:
- 201 g = g.to_frame(y or "count")
- 202 if not is_time:
- 203 g = g.sort_values(g.columns[0], ascending=False)
- 204 if len(g) > top:
- 205 rest = g.iloc[top - 1:].sum()
- 206 g = pd.concat([g.iloc[:top - 1], pd.DataFrame([rest], index=["Other"])])
- 207 return g, is_time
- 210def style_axes(ax):
- 211 ax.set_facecolor(T["surface"])
- 212 for s in ax.spines.values():
- 213 s.set_visible(False)
- 214 ax.tick_params(colors=T["muted"], labelsize=10, length=0, pad=8)
- 215 ax.grid(axis="y", color=T["grid"], linewidth=1)
- 216 ax.set_axisbelow(True)
- 219def draw(g, is_time, kind, money, x_label):
- 220 plt.rcParams.update({"font.family": FONT, "svg.fonttype": "none"})
- 221 fig, ax = plt.subplots(figsize=(10, 4.2), dpi=200)
- 222 fig.patch.set_facecolor(T["surface"])
- 223 style_axes(ax)
- 224 cols = list(g.columns)
- 225 colors = palette_for(cols) or [SERIES[i % len(SERIES)] for i in range(len(cols))]
- 226 if kind == "auto":
- 227 kind = ("area" if len(cols) == 1 else "line") if is_time else "hbar"
- 229 if kind in ("area", "line"):
- 230 for c, col in zip(cols, colors):
- 231 ax.plot(g.index, g[c], color=col, linewidth=2.2, solid_capstyle="round", label=str(c))
- 232 if kind == "area":
- 233 base = min(0, g[c].min())
- 234 ax.fill_between(g.index, g[c], base, color=col, alpha=0.16, linewidth=0)
- 235 ax.margins(x=0)
- 236 if is_time:
- 237 import matplotlib.dates as mdates
- 238 loc = mdates.AutoDateLocator(maxticks=6)
- 239 ax.xaxis.set_major_locator(loc)
- 240 ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc, show_offset=False))
- 241 elif kind == "bar":
- 242 labels = [f"{i:%b %d}" if hasattr(i, "strftime") else str(i) for i in g.index]
- 243 if len(cols) == 1:
- 244 v = g[cols[0]]
- 245 bc = palette_for(labels) or [T["neg"] if val < 0 else T["accent"] for val in v]
- 246 ax.bar(labels, v, color=bc, width=0.62)
- 247 else:
- 248 bottom = pd.Series(0.0, index=g.index)
- 249 for c, col in zip(cols, colors):
- 250 ax.bar(labels, g[c], bottom=bottom.values, color=col, width=0.62, label=str(c))
- 251 bottom += g[c]
- 252 step = max(1, len(labels) // 8)
- 253 ax.set_xticks(range(0, len(labels), step), [labels[k] for k in range(0, len(labels), step)])
- 254 ax.margins(x=0.01)
- 255 elif kind == "hbar":
- 256 g = g.iloc[::-1]
- 257 labels = [str(i) for i in g.index]
- 258 v = g[cols[0]]
- 259 has_neg = (v < 0).any()
- 260 bc = palette_for(labels) or [(T["neg"] if val < 0 else T["pos"]) if has_neg else T["accent"] for val in v]
- 261 bc = ["#3A4454" if l == "Other" else c for l, c in zip(labels, bc)] # remainder stays quiet
- 262 ax.barh(labels, v, color=bc, height=0.56)
- 263 ax.grid(axis="y", visible=False)
- 264 ax.grid(axis="x", color=T["grid"], linewidth=1)
- 265 ax.tick_params(axis="y", colors=T["text2"], labelsize=11)
- 266 for i, val in enumerate(v):
- 267 ax.text(val, i, " " + fmt_num(val, money), va="center",
- 268 ha="left" if val >= 0 else "right", color=T["text"], fontsize=10, fontweight="bold")
- 269 ax.margins(x=0.12)
- 270 elif kind == "donut":
- 271 v = g[cols[0]].clip(lower=0)
- 272 dc = palette_for(g.index) or [SERIES[i % len(SERIES)] for i in range(len(g))]
- 273 ax.pie(v, colors=dc, startangle=90, counterclock=False,
- 274 wedgeprops={"width": 0.16, "edgecolor": T["surface"], "linewidth": 2})
- 275 ax.text(0, 0.06, fmt_num(v.sum(), money), ha="center", va="center", color=T["text"], fontsize=26, fontweight="bold")
- 276 ax.text(0, -0.2, "Total", ha="center", color=T["text2"], fontsize=11)
- 277 ax.legend([str(i) for i in g.index], loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False,
- 278 labelcolor=T["text2"], fontsize=10)
- 279 ax.set_aspect("equal")
- 280 elif kind == "scatter":
- 281 ax.scatter(g.index, g[cols[0]], s=22, color=T["accent"], alpha=0.85, linewidths=0)
- 283 if len(cols) > 1 and kind != "donut":
- 284 ax.legend(loc="upper left", bbox_to_anchor=(0, 1.12), ncol=min(len(cols), 6), frameon=False,
- 285 labelcolor=T["text2"], fontsize=10, handlelength=1.0, handleheight=0.6, columnspacing=1.4)
- 286 if kind not in ("hbar", "donut"):
- 287 ax.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda v, _: fmt_num(v, money)))
- 288 if kind == "hbar":
- 289 ax.xaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda v, _: fmt_num(v, money)))
- 290 fig.tight_layout(pad=1.2)
- 291 return fig, kind
- 294def headline(g, is_time, money):
- 295 total = g.sum(axis=1)
- 296 if is_time and len(total) > 1:
- 297 last, first = total.iloc[-1], total.iloc[0]
- 298 delta = last - first
- 299 pct = (delta / abs(first) * 100) if first else None
- 300 return fmt_num(last, money), delta, pct, "latest"
- 301 return fmt_num(total.sum(), money), None, None, "total"
- 304def card_html(title, subtitle, big, delta, pct, big_label, svg, money, n_rows):
- 305 dcls = "pos" if (delta or 0) >= 0 else "neg"
- 306 delta_html = ""
- 307 if delta is not None:
- 308 sign = "+" if delta >= 0 else ""
- 309 p = f" ({sign}{pct:.2f}%)" if pct is not None else ""
- 310 delta_html = f'<span class="delta {dcls}">{sign}{fmt_num(delta, money)}{p}</span>'
- 311 return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
- 312<meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title>
- 313<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
- 314<style>
- 315:root{{--bg:{T['bg']};--surface:{T['surface']};--border:{T['border']};--text:{T['text']};--text2:{T['text2']};--muted:{T['muted']};--accent:{T['accent']};--pos:{T['pos']};--neg:{T['neg']}}}
- 316*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--text);font-family:{WEB_FONT};padding:32px 16px;font-variant-numeric:tabular-nums}}
- 317.card{{max-width:1040px;margin:0 auto;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:32px 36px}}
- 318.head{{display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap}}
- 319h1{{font-size:24px;font-weight:600;margin:0;letter-spacing:-.01em}}.meta{{color:var(--muted);font-size:14px}}
- 320.value{{display:flex;align-items:baseline;gap:16px;margin:28px 0 8px;flex-wrap:wrap}}
- 321.big{{font-size:44px;font-weight:700;letter-spacing:-.02em}}.big small{{font-size:14px;color:var(--text2);font-weight:500;margin-left:10px;letter-spacing:0}}
- 322.delta{{font-size:20px;font-weight:500}}.pos{{color:var(--pos)}}.neg{{color:var(--neg)}}
- 323.chart svg{{width:100%;height:auto;display:block}}.chart svg text{{font-family:{WEB_FONT}!important}}
- 324.foot{{margin-top:12px;color:var(--muted);font-size:13px;border-top:1px solid var(--border);padding-top:14px}}
- 325@media (max-width:600px){{.card{{padding:20px}}.big{{font-size:32px}}}}
- 326</style></head><body><div class="card">
- 327<div class="head"><h1>{html.escape(title)}</h1><span class="meta">{html.escape(subtitle)}</span></div>
- 328<div class="value"><span class="big">{big}<small>{big_label}</small></span>{delta_html}</div>
- 329<div class="chart">{svg}</div>
- 330<div class="foot">Source: {n_rows:,} rows · generated with /brand-test</div>
- 331</div></body></html>"""
- 334def main():
- 335 ap = argparse.ArgumentParser()
- 336 ap.add_argument("file"); ap.add_argument("--sheet"); ap.add_argument("--profile", action="store_true")
- 337 ap.add_argument("--x"); ap.add_argument("--y"); ap.add_argument("--by")
- 338 ap.add_argument("--agg", default="sum", choices=["sum", "count", "mean", "last"])
- 339 ap.add_argument("--kind", default="auto", choices=["auto", "area", "line", "bar", "hbar", "donut", "scatter"])
- 340 ap.add_argument("--freq", default="D"); ap.add_argument("--top", type=int, default=12)
- 341 ap.add_argument("--where"); ap.add_argument("--title"); ap.add_argument("--subtitle")
- 342 ap.add_argument("--money", action="store_true", help="format values as $")
- 343 ap.add_argument("--out", default="brand_chart")
- 344 a = ap.parse_args()
- 346 df = load(a.file, a.sheet)
- 347 if a.profile or not a.x:
- 348 profile(df); return
- 349 if a.where:
- 350 df = df.query(a.where)
- 351 for c in [a.x, a.y, a.by]:
- 352 if c and c not in df.columns:
- 353 sys.exit(f"Column not found: {c}. Run with --profile to list columns.")
- 354 agg = "count" if a.y is None else a.agg
- 355 money = a.money or bool(a.y and re.search(r"(price|notional|pnl|p&l|equity|amount|value|cost|usd|\$)", a.y, re.I))
- 356 g, is_time = aggregate(df, a.x, a.y, a.by, agg, a.freq, a.top)
- 357 fig, kind = draw(g, is_time, a.kind, money, a.x)
- 359 out = Path(a.out)
- 360 out.parent.mkdir(parents=True, exist_ok=True)
- 361 fig.savefig(out.with_suffix(".png"), facecolor=T["surface"])
- 362 buf = io.StringIO(); fig.savefig(buf, format="svg", facecolor=T["surface"])
- 363 svg = re.sub(r"^.*?(<svg)", r"\1", buf.getvalue(), flags=re.S)
- 364 svg = re.sub(r'(<svg[^>]*?) width="[^"]*" height="[^"]*"', r"\1", svg, count=1)
- 366 what = f"{a.agg} of {a.y}" if a.y and agg != "count" else "count"
- 367 title = a.title or (f"{a.y or 'Rows'} by {a.x}" + (f" · split by {a.by}" if a.by else ""))
- 368 if is_time:
- 369 idx = g.index
- 370 sub = a.subtitle or f"{idx.min():%b %d, %Y} — {idx.max():%b %d, %Y} · {what}"
- 371 else:
- 372 sub = a.subtitle or what
- 373 # levels (equity, price) show latest + change; flows (sum/count per period) show the total
- 374 big, delta, pct, lab = headline(g, is_time and agg in ("last", "mean") and kind in ("area", "line", "bar"), money)
- 375 out.with_suffix(".html").write_text(card_html(title, sub, big, delta, pct, lab, svg, money, len(df)))
- 376 print(f"chart: {kind}\nwrote {out.with_suffix('.png')}\nwrote {out.with_suffix('.html')}")
- 379if __name__ == "__main__":
- 380 main()
- 381```
