Skip to content
SlashClone
Made by@clone

Brand test

@clone/brand-test

Turn a CSV, TSV or Excel file into a dark, trading-dashboard-styled chart (PNG + self-contained HTML card) in the Recon brand.

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.