Skip to content

Commit 7239b6a

Browse files
GroverChorizoclaude
andcommitted
feat(viz): real terminal visualizations — sparklines, gauges, heatmaps, depth/CVD
Implements the Vision MVP visual layer on keyless data. New primitives in tui/render/sparkline.py: heat_cell / heat_row (2D-heatmap intensity) and gauge (min..max marker track), joining the existing sparkline / heat_bar / diverging_bar / stacked_bar / histogram. The Command Center now renders them live: - Regime: price sparkline + volatility gauge + a watchlist of per-coin price sparklines with % change (the "Price Action / Sparklines" MVP feature). - Correlation: colored block-intensity heatmap (green +, red -, intensity = |r|). - Funding/Flow: funding heatmap strip across symbols + open-interest depth bars. Pure renderers unit-tested (tests/test_render_sparkline.py); the Command Center smoke test asserts the glyphs render. Gate green (309 files). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8aa34c0 commit 7239b6a

4 files changed

Lines changed: 163 additions & 33 deletions

File tree

tests/test_render_sparkline.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Deterministic tests for terminal-viz primitives (tui/render/sparkline.py)."""
2+
3+
from __future__ import annotations
4+
5+
from tui.render import sparkline as sp
6+
7+
8+
def test_sparkline_low_to_high():
9+
line = sp.sparkline([1, 2, 3, 4, 5, 6, 7, 8])
10+
assert line[0] == "▁" and line[-1] == "█"
11+
12+
13+
def test_sparkline_constant_is_flat():
14+
assert set(sp.sparkline([5, 5, 5])) == {"▁"}
15+
16+
17+
def test_sparkline_empty():
18+
assert sp.sparkline([]) == ""
19+
20+
21+
def test_heat_bar_half():
22+
bar = sp.heat_bar(5, 10, width=10)
23+
assert bar.count("█") == 5 and bar.count("░") == 5
24+
25+
26+
def test_heat_cell_scale():
27+
assert sp.heat_cell(0, 10) in {" ", "·"}
28+
assert sp.heat_cell(10, 10) == "█"
29+
30+
31+
def test_heat_row_length():
32+
row = sp.heat_row([1, 2, 3, 4], max_abs=4)
33+
assert len(row) == 4 and row[-1] == "█"
34+
35+
36+
def test_gauge_marker_position():
37+
assert sp.gauge(0, 0, 10, width=11)[0] == "●" # low end
38+
assert sp.gauge(10, 0, 10, width=11)[-1] == "●" # high end
39+
assert sp.gauge(5, 0, 10, width=11)[5] == "●" # middle
40+
41+
42+
def test_diverging_bar_sign():
43+
pos = sp.diverging_bar(1, 1, width=11)
44+
neg = sp.diverging_bar(-1, 1, width=11)
45+
# positive fills to the right of the centre marker, negative to the left
46+
assert pos.index("│") < pos.rindex("█")
47+
assert neg.index("█") < neg.index("│")

tests/ui/test_command_center.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def after(app):
103103
seen["flow"] = _text(app.screen._flow.renderable)
104104

105105
_run(CommandCenterScreen(), after)
106-
assert "Trend" in seen["regime"]
106+
# regime stats + watchlist + a rendered price sparkline
107+
assert "Trend" in seen["regime"] and "Watchlist" in seen["regime"]
108+
assert any(ch in seen["regime"] for ch in "▁▂▃▄▅▆▇█")
109+
# correlation rendered as a heatmap (intensity glyphs), symbols labelled
107110
assert "BTC" in seen["corr"] and "ETH" in seen["corr"]
108-
assert "Funding" in seen["flow"]
111+
assert "█" in seen["corr"]
112+
# funding heatmap strip present
113+
assert "Funding" in seen["flow"] and "heatmap" in seen["flow"].lower()

tui/render/sparkline.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,43 @@ def histogram(values: Iterable[float], *, width: int = 18) -> list[str]:
7373
series = [float(v or 0.0) for v in values]
7474
max_value = max(series) if series else 0.0
7575
return [heat_bar(v, max_value, width=width) for v in series]
76+
77+
78+
HEAT_CELLS = " ·░▒▓█"
79+
80+
81+
def heat_cell(value: float, max_abs: float) -> str:
82+
"""Map a magnitude to a single intensity glyph (for 2D heatmaps)."""
83+
try:
84+
v = abs(float(value))
85+
m = abs(float(max_abs))
86+
except (TypeError, ValueError):
87+
return HEAT_CELLS[0]
88+
if m <= 0:
89+
return HEAT_CELLS[1]
90+
idx = int(round(min(v / m, 1.0) * (len(HEAT_CELLS) - 1)))
91+
return HEAT_CELLS[idx]
92+
93+
94+
def heat_row(values: Iterable[float], *, max_abs: float | None = None) -> str:
95+
"""A 1-D heatmap row of intensity glyphs (one per value)."""
96+
series = [float(v) for v in values if v is not None]
97+
if not series:
98+
return ""
99+
m = max_abs if max_abs is not None else max((abs(v) for v in series), default=0.0)
100+
return "".join(heat_cell(v, m) for v in series)
101+
102+
103+
def gauge(value: float, low: float, high: float, *, width: int = 12) -> str:
104+
"""A min..max gauge track with a marker, e.g. ``──●───────`` for low-ish."""
105+
if width <= 1:
106+
return "●"
107+
try:
108+
v, lo, hi = float(value), float(low), float(high)
109+
except (TypeError, ValueError):
110+
v, lo, hi = 0.0, 0.0, 1.0
111+
ratio = 0.5 if hi <= lo else min(max((v - lo) / (hi - lo), 0.0), 1.0)
112+
pos = int(round(ratio * (width - 1)))
113+
track = ["─"] * width
114+
track[pos] = "●"
115+
return "".join(track)

tui/ui/screens/command_center.py

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from textual.widgets import Static, TabbedContent, TabPane
2424

2525
from tui import analytics
26+
from tui.render import sparkline as sp
2627
from tui.ui.screens.base import BaseScreen
2728

2829
MAJORS = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"]
@@ -159,60 +160,96 @@ def _render(
159160
self._health.update(self._render_health(health or {}))
160161

161162
def _render_regime(self, selected, closes, snapshot):
162-
series = closes.get(selected) or next(iter(closes.values()), [])
163-
sym = selected if closes.get(selected) else (
164-
next(iter(closes), selected)
165-
)
166-
if not series:
163+
if not closes:
167164
return Text("No candle data yet.", style="dim")
165+
sym = selected if closes.get(selected) else next(iter(closes), selected)
166+
series = closes.get(sym, [])
168167
r = analytics.regime(series)
169168
trend_color = {"up": "green", "down": "red", "flat": "yellow"}.get(
170169
r["trend"], "white"
171170
)
172-
t = Table.grid(padding=(0, 2))
173-
t.add_column(justify="right", style="bold")
174-
t.add_column()
175-
t.add_row("Symbol", sym)
176-
t.add_row("Trend", Text(str(r["trend"]).upper(), style=trend_color))
177-
t.add_row("Volatility", f"{r['vol_state']} (pct {r['vol_pct']:.0%})")
178-
t.add_row("Realized vol", f"{r['vol']:.4%}")
179-
t.add_row("Drift vs SMA", f"{r.get('drift', 0.0):+.2%}")
180-
return t
171+
stats = Table.grid(padding=(0, 2))
172+
stats.add_column(justify="right", style="bold")
173+
stats.add_column()
174+
stats.add_row("Symbol", sym)
175+
stats.add_row("Price 48×1h", Text(sp.sparkline(series, width=48), style=trend_color))
176+
stats.add_row("Trend", Text(str(r["trend"]).upper(), style=trend_color))
177+
stats.add_row(
178+
"Volatility",
179+
f"{r['vol_state']:<7}{sp.gauge(r['vol_pct'], 0, 1, width=14)} {r['vol_pct']:.0%}",
180+
)
181+
stats.add_row("Realized vol", f"{r['vol']:.4%}")
182+
stats.add_row("Drift vs SMA", Text(f"{r.get('drift', 0.0):+.2%}", style=trend_color))
183+
184+
# Watchlist: price sparkline per major (the MVP "Price Action" feature).
185+
watch = Table(title="Watchlist · 48×1h", box=box.ASCII, expand=False)
186+
watch.add_column("sym", style="bold")
187+
watch.add_column("last", justify="right")
188+
watch.add_column("chg", justify="right")
189+
watch.add_column("spark")
190+
for s in sorted(closes):
191+
cs = closes[s]
192+
chg = (cs[-1] - cs[0]) / cs[0] if cs and cs[0] else 0.0
193+
col = "green" if chg >= 0 else "red"
194+
watch.add_row(
195+
s, f"{cs[-1]:,.6g}",
196+
Text(f"{chg:+.2%}", style=col),
197+
Text(sp.sparkline(cs, width=32), style=col),
198+
)
199+
grid = Table.grid(padding=(1, 0))
200+
grid.add_row(stats)
201+
grid.add_row(watch)
202+
return grid
181203

182204
def _render_correlation(self, closes):
183205
if len(closes) < 2:
184206
return Text("Need ≥2 symbols with candles for correlation.", style="dim")
185207
symbols, matrix = analytics.correlation_matrix(closes)
186208
table = Table(
187-
title="1h return correlation",
188-
show_lines=False,
189-
expand=False,
190-
box=box.ASCII,
209+
title="1h return correlation · heatmap (green=+ red=- intensity=|r|)",
210+
show_lines=False, expand=False, box=box.ASCII,
191211
)
192212
table.add_column("")
193213
for s in symbols:
194-
table.add_column(s, justify="right")
214+
table.add_column(s, justify="center")
195215
for i, a in enumerate(symbols):
196216
cells = [Text(a, style="bold")]
197217
for j, _ in enumerate(symbols):
198218
v = matrix[i][j]
199219
if v is None:
200-
cells.append(Text("·", style="dim"))
220+
cells.append(Text(" ", style="dim"))
201221
else:
202-
style = "green" if v > 0.5 else "red" if v < -0.5 else "white"
203-
cells.append(Text(f"{v:+.2f}", style=style))
222+
color = "green" if v > 0 else "red" if v < 0 else "white"
223+
cells.append(Text(sp.heat_cell(v, 1.0) * 2, style=color))
204224
table.add_row(*cells)
205225
return table
206226

207227
def _render_flow(self, snapshot):
208228
if not snapshot:
209229
return Text("No market snapshot yet.", style="dim")
210-
ext = analytics.funding_extremes(snapshot, n=5)
211-
oi = analytics.oi_leaders(snapshot, n=5)
212-
table = Table.grid(padding=(0, 3))
213-
table.add_column()
214-
table.add_column()
215-
table.add_column()
230+
ext = analytics.funding_extremes(snapshot, n=6)
231+
oi = analytics.oi_leaders(snapshot, n=6)
232+
oi_max = max((v for _, v in oi), default=1.0)
233+
out = Table.grid(padding=(1, 0))
234+
235+
# Funding heatmap strip across all symbols (intensity=|funding|, sign-colored).
236+
fr: list[tuple[str, float]] = []
237+
for r in snapshot:
238+
try:
239+
fr.append((str(r.get("symbol") or "?"), float(r.get("funding"))))
240+
except (TypeError, ValueError):
241+
continue
242+
if fr:
243+
fmax = max(abs(f) for _, f in fr) or 1.0
244+
strip = Text("Funding heatmap ", style="dim")
245+
for _s, f in fr[:48]:
246+
strip.append(sp.heat_cell(f, fmax), style="green" if f >= 0 else "red")
247+
out.add_row(strip)
248+
249+
cols = Table.grid(padding=(0, 3))
250+
cols.add_column()
251+
cols.add_column()
252+
cols.add_column()
216253

217254
def col(title, pairs, fmt):
218255
inner = Table(title=title, show_edge=False, box=box.ASCII)
@@ -222,12 +259,13 @@ def col(title, pairs, fmt):
222259
inner.add_row(sym, fmt(val))
223260
return inner
224261

225-
table.add_row(
262+
cols.add_row(
226263
col("Funding +", ext["most_positive"], lambda v: Text(f"{v:+.4%}", style="green")),
227264
col("Funding -", ext["most_negative"], lambda v: Text(f"{v:+.4%}", style="red")),
228-
col("Open interest", oi, lambda v: f"{v:,.0f}"),
265+
col("Open interest", oi, lambda v: Text(f"{sp.heat_bar(v, oi_max, width=10)} {v:,.0f}")),
229266
)
230-
return table
267+
out.add_row(cols)
268+
return out
231269

232270
def _render_health(self, health):
233271
if not health:

0 commit comments

Comments
 (0)