Compare commits

...

21 commits

Author SHA1 Message Date
d59555bf92
global theme flag/theme resolution themes all the things
All checks were successful
Generic zig build / build (push) Successful in 4m45s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 17s
2026-06-27 10:20:59 -07:00
ef7f8c2bd1
apply theme to CLI 2026-06-27 10:20:58 -07:00
55cacbe763
example themes 2026-06-27 10:20:57 -07:00
ec96b60eb4
add theming (chart graphics only) 2026-06-27 10:20:56 -07:00
3fda9eabcf
bump coverage to 79% 2026-06-27 10:20:55 -07:00
76d00b8dfe
projections convergence graph axis labels 2026-06-27 10:20:54 -07:00
243f3c40bf
add axis labels to return backtest 2026-06-27 10:20:53 -07:00
f107002b93
provide chart for the projections --vs command 2026-06-27 10:20:52 -07:00
a70e61d873
projections CLI kitty-chart for return-backtest and convergence (if terminal supported) 2026-06-27 10:20:51 -07:00
42a16cbbd3
zoom overlay the same way it gets zoomed in the TUI (default zoom only) 2026-06-27 10:20:51 -07:00
98f0f96bf5
chart export for return backtest and convergence projection charts 2026-06-27 10:20:50 -07:00
6827b70f85
draw vertical line on projections chart for projected retirement date 2026-06-27 10:20:49 -07:00
acf5f723f8
move braille charts to its own space 2026-06-27 10:20:48 -07:00
8ca673c8e3
better quote name resolution 2026-06-27 09:49:32 -07:00
3abce454dc
add edgar guard for recycled ticker symbols (SPCX IPO reused ticker from an ETF) 2026-06-27 09:25:44 -07:00
2bd49af8f3
clean remaining tui.zig "reach-ins" 2026-06-27 07:57:43 -07:00
bfabd66866
fetch live on quote tab 2026-06-27 00:04:25 -07:00
fa4ec246c2
quiet tests 2026-06-26 23:36:39 -07:00
aa8dafa3ab
add prev close to cli/tui 2026-06-26 23:22:06 -07:00
b7bb2c85d7
fix yahoo provider prev close computation 2026-06-26 22:35:08 -07:00
46375fed5a
update README 2026-06-26 17:01:37 -07:00
40 changed files with 3249 additions and 972 deletions

View file

@ -46,7 +46,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=78"]
args: ["build", "coverage", "-Dcoverage-threshold=79"]
language: system
types: [file]
pass_filenames: false

157
README.md
View file

@ -1,6 +1,6 @@
# zfin
A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains, earnings history, and more -- all from the terminal.
A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains and earnings history, and renders price and projection charts (inline Kitty graphics or PNG export) -- all from the terminal.
## Quick start
@ -102,60 +102,124 @@ caching/TTL model, and the complete environment-variable list, see
```
src/
root.zig Library root, exports all public types
format.zig Shared formatters, braille engine, ANSI helpers
config.zig Configuration from env vars / .env files
service.zig DataService: cache-check -> fetch -> cache -> return
main.zig CLI dispatch + TUI entry point
root.zig Library root, exports all public types
Config.zig Configuration from env vars / .env files
service.zig DataService: cache-check -> fetch -> cache -> return
Date.zig Date type (i32 days since epoch); arithmetic, formatting
Money.zig Money formatting ($1,234.56; whole/trim/signed variants)
format.zig Shared formatters, braille engine, ANSI helpers
padded.zig Generic width/alignment wrapper for any formattable type
PortfolioData.zig Per-portfolio parse, price fetch, summary, background workers
portfolio_loader.zig Loads + union-merges portfolio_*.srf for CLI and TUI
compare.zig Composes two points in time (snapshot vs live) for `compare`
history.zig Reads history/<date>-portfolio.srf snapshots; aggregation
market.zig Market calendar; close-anchored cache freshness
git.zig Thin git subprocess wrappers (diff/walk tracked portfolios)
atomic.zig Crash-safe atomic file writes (tmp + fsync + rename)
stderr.zig Best-effort (non-throwing) stderr hint/progress writers
term_graphics.zig Kitty graphics emission for plain (non-TUI) CLI charts
term_query.zig Terminal capability probing for inline CLI graphics
chart_export.zig Chart-to-PNG export (`--export-chart <path>`)
comptime_validator.zig Shared comptime contract-validation helpers
models/
candle.zig OHLCV price bars
date.zig Date type with arithmetic, snapping, formatting
dividend.zig Dividend records with type classification
split.zig Stock splits
option.zig Option contracts and chains
earnings.zig Earnings events with surprise calculation
etf_profile.zig ETF profiles with holdings and sectors
portfolio.zig Lots, positions, and portfolio aggregation
classification.zig Classification metadata parser
quote.zig Real-time quote data
candle.zig OHLCV price bars
quote.zig Real-time quote data
dividend.zig Dividend records with type classification
split.zig Stock splits
option.zig Option contracts and chains
earnings.zig Earnings events with surprise calculation
etf_profile.zig ETF profiles with holdings and sectors
portfolio.zig Lots, positions, and portfolio aggregation
classification.zig Classification metadata parser
snapshot.zig Snapshot record types (history/*-portfolio.srf wire format)
transaction_log.zig transaction_log.srf wire format (transfer:: records)
shiller_year.zig One year of Shiller returns (S&P / bond / CPI)
providers/
tiingo.zig Tiingo: daily candles (primary), supplementary div/split merge
twelvedata.zig TwelveData: quote fallback
polygon.zig Polygon: dividends, splits (primary, with forward-looking entries)
fmp.zig FMP: earnings (actuals + estimates)
cboe.zig CBOE: options chains (no API key)
Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), mutual-fund ticker map, XBRL company facts
Wikidata.zig Wikidata SPARQL: classification metadata for `enrich`
yahoo.zig Yahoo Finance: quotes (primary), candles (Tiingo fallback)
openfigi.zig OpenFIGI: CUSIP to ticker lookup
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code")
tiingo.zig Tiingo: daily candles (primary)
yahoo.zig Yahoo Finance: quotes (primary), candle fallback
twelvedata.zig TwelveData: quote fallback
polygon.zig Polygon: dividends, splits (with forward-looking entries)
fmp.zig FMP: earnings (actuals + estimates)
cboe.zig CBOE: options chains (no API key)
Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), ticker map, XBRL facts
Wikidata.zig Wikidata SPARQL: classification metadata for `enrich`
openfigi.zig OpenFIGI: CUSIP to ticker lookup
json_utils.zig Shared JSON parsing helpers
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code")
analytics/
indicators.zig SMA, Bollinger Bands, RSI
performance.zig Trailing returns (as-of-date + month-end)
risk.zig Volatility, Sharpe, drawdown
valuation.zig Portfolio summary, allocations, covered call adjustments
analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax)
indicators.zig SMA, Bollinger Bands, RSI
performance.zig Trailing returns (as-of-date + month-end)
risk.zig Per-symbol volatility, Sharpe, drawdown
portfolio_risk.zig Correlation-aware portfolio-level risk
valuation.zig Portfolio summary, allocations, covered-call adjustments
analysis.zig Breakdowns by class/sector/geo/account/tax
benchmark.zig Per-position benchmark returns
exposure.zig Look-through exposure (direct + ETF holdings)
milestones.zig Retirement-attainment grid
projections.zig Monte Carlo + percentile bands
forecast_evaluation.zig Forecast-vs-actual (convergence + return back-test)
observations.zig Portfolio sanity checks for the review surface
timeline.zig History-tab tier rollup
data/
ie_data.csv Raw Shiller S&P 500 + CPI dataset (CSV)
shiller.zig Shiller series, comptime-generated from ie_data.csv
imported_values.zig Back-history portfolio values (imported from spreadsheet)
staleness.zig Account-cadence staleness checks
Journal.zig acknowledgments.srf journal (acknowledged findings)
brokerage/
types.zig Normalized BrokeragePosition record + dollar parser
fidelity.zig Fidelity "Download Positions" CSV parser
schwab.zig Schwab export parser
wells_fargo.zig Wells Fargo export parser
views/
portfolio_sections.zig Portfolio view model (renderer-agnostic StyleIntent)
compare.zig Compare view model
history.zig History view model
projections.zig Projections view model
review.zig Per-holding performance + risk dashboard view model
observations_view.zig Findings list (engine output + acknowledgments)
cache/
store.zig SRF file cache with TTL freshness checks
net/
http.zig HTTP client with retries and server error retry
rate_limiter.zig Token-bucket rate limiter
store.zig SRF file cache with TTL freshness checks
net/
http.zig HTTP client with retries and server-error retry
RateLimiter.zig Token-bucket rate limiter
commands/
common.zig Shared CLI helpers (progress, formatting)
perf.zig Trailing returns command
quote.zig Quote command
... (14 command files)
tui.zig Interactive TUI application
common.zig Shared CLI helpers (progress, formatting, color)
framework.zig CLI command framework + portfolio pattern resolution
TimeRange.zig Shared --range / time-window parsing
audit/ Brokerage reconciliation helpers for `audit`
(one .zig per CLI command: perf, quote, portfolio, analysis, history, ...)
charts/
chart.zig Price + Bollinger + volume + RSI renderer (z2d)
projection_chart.zig Percentile-band + median projection renderer
forecast_chart.zig Convergence / back-test line renderer
line_chart.zig Single-series value-over-time line renderer
axis.zig Shared axis-label helpers
draw.zig Shared z2d drawing primitives
text.zig 5x7 bitmap font for axis labels
tui.zig Interactive TUI App orchestrator
tui/
chart.zig z2d pixel chart renderer (Kitty graphics)
keybinds.zig Configurable keybinding system
theme.zig Configurable color theme
tab_framework.zig Tab contract + comptime validator
keybinds.zig Configurable keybinding system
theme.zig Configurable color theme
input_buffer.zig Modal text-input state machine
(nine *_tab.zig tabs: portfolio, analysis, review, projections,
history, quote, performance, earnings, options)
version.zig Build version string
```
Data files (user-managed, in project root):
Data files (user-managed, auto-detected in cwd or `$ZFIN_HOME`):
```
portfolio.srf Portfolio lots
metadata.srf Classification metadata for analysis
accounts.srf Account to tax type mapping for analysis
portfolio.srf Portfolio lots (positions and cost basis)
accounts.srf Account -> tax-type mapping for analysis
metadata.srf Per-symbol classification (class/sector/geo) for analysis
watchlist.srf Symbols to track in the TUI watchlist
transaction_log.srf Declared transfers so contributions math skips internal moves
projections.srf Retirement / Monte Carlo projection assumptions
theme.srf TUI color theme overrides
keys.srf TUI keybinding overrides
acknowledgments.srf Acknowledged review-tab findings (app-maintained)
```
### Dependencies
@ -165,6 +229,7 @@ accounts.srf Account to tax type mapping for analysis
| [SRF](https://git.lerch.org/lobo/srf) | Git | Cache file format and portfolio/watchlist parsing |
| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
| [zeit](https://github.com/rockorager/zeit) | Git (v0.9.0) | Calendar arithmetic + timezone conversion (ET) |
## Building

33
TODO.md
View file

@ -7,13 +7,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
## Projections: future enhancements
- **Chart vertical line at retirement boundary - priority LOW.**
The accumulation-phase spec called this "mandatory" but it was
explicitly deferred during implementation. The chart currently
shows the full `accumulation_years + horizon` span without a
visual marker for where accumulation ends and distribution
begins. Easier to add to the kitty-graphics chart than the braille
one.
- **Goal-seek over distribution horizon for W1 - priority LOW.**
Today the W1 ("set spending, find date") workflow reports the
earliest retirement at each user-configured `(horizon, confidence)`
@ -80,14 +73,9 @@ ranking; unlabeled items are "someday, if the mood strikes."
## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
(default bands mode only), and `history`. Several adjacent surfaces
still don't have PNG export and were deferred:
(bands, `--convergence`, and `--return-backtest` modes), and
`history`. Two adjacent surfaces still don't have PNG export:
- **`projections --convergence` / `--return-backtest`.** Both
render forecast-evaluation charts via `tui/forecast_chart.zig`.
Not refactored to expose a `renderToSurface` seam yet -
parser rejects `--export-chart` in those modes today. Low
effort to add (mirror the `tui/chart.zig` pattern).
- **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
@ -97,24 +85,21 @@ still don't have PNG export and were deferred:
time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when
someone asks for it.
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
exports PNG natively today; would need an external dependency
or a pixel-buffer-to-format conversion.
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
`src/format.zig` is still a ~1600-line grab-bag, but the money- and
date-shaped helpers that used to live there have been moved out:
money formatting now lives in `src/Money.zig` (with `{f}` /
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
and date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`). What's left in `format.zig` is the
genuinely-format-domain stuff: braille charts, return formatters,
allocation notes, signed-percent rendering.
date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
lives in `src/charts/braille.zig`. What's left in `format.zig` is
the genuinely-format-domain stuff: return formatters, allocation
notes, signed-percent rendering.
If the file ever grows enough to be annoying again, consider
renaming to `src/render.zig` to better describe what's left, or
splitting the braille chart out (it's ~600 lines on its own).
renaming to `src/render.zig` to better describe what's left.
Not blocking - file it as cleanup if and when it bites.
## Investigate: detailed 401(k) contributions data source

View file

@ -0,0 +1,30 @@
#!srfv1
# Catppuccin Frappe - https://catppuccin.com (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#303446
bg_panel::#292c3c
bg_element::#414559
tab_bg::#292c3c
tab_fg::#838ba7
tab_active_bg::#ca9ee6
tab_active_fg::#303446
text::#c6d0f5
text_muted::#a5adce
text_dim::#737994
status_bg::#292c3c
status_fg::#a5adce
input_bg::#414559
input_fg::#ca9ee6
input_hint::#737994
accent::#ca9ee6
positive::#a6d189
negative::#e78284
warning::#e5c890
info::#99d1db
select_bg::#51576d
select_fg::#f2d5cf
border::#51576d
bar_fill::#8caaee

View file

@ -0,0 +1,30 @@
#!srfv1
# Catppuccin Macchiato - https://catppuccin.com (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#24273a
bg_panel::#1e2030
bg_element::#363a4f
tab_bg::#1e2030
tab_fg::#8087a2
tab_active_bg::#c6a0f6
tab_active_fg::#24273a
text::#cad3f5
text_muted::#a5adcb
text_dim::#6e738d
status_bg::#1e2030
status_fg::#a5adcb
input_bg::#363a4f
input_fg::#c6a0f6
input_hint::#6e738d
accent::#c6a0f6
positive::#a6da95
negative::#ed8796
warning::#eed49f
info::#91d7e3
select_bg::#494d64
select_fg::#f4dbd6
border::#494d64
bar_fill::#8aadf4

View file

@ -0,0 +1,30 @@
#!srfv1
# Catppuccin Mocha - https://catppuccin.com (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#1e1e2e
bg_panel::#181825
bg_element::#313244
tab_bg::#181825
tab_fg::#7f849c
tab_active_bg::#cba6f7
tab_active_fg::#1e1e2e
text::#cdd6f4
text_muted::#a6adc8
text_dim::#6c7086
status_bg::#181825
status_fg::#a6adc8
input_bg::#313244
input_fg::#cba6f7
input_hint::#6c7086
accent::#cba6f7
positive::#a6e3a1
negative::#f38ba8
warning::#f9e2af
info::#89dceb
select_bg::#45475a
select_fg::#f5e0dc
border::#45475a
bar_fill::#89b4fa

View file

@ -0,0 +1,30 @@
#!srfv1
# Dracula - https://draculatheme.com (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#282a36
bg_panel::#21222c
bg_element::#343746
tab_bg::#21222c
tab_fg::#6272a4
tab_active_bg::#bd93f9
tab_active_fg::#282a36
text::#f8f8f2
text_muted::#6272a4
text_dim::#4a4d63
status_bg::#21222c
status_fg::#6272a4
input_bg::#343746
input_fg::#bd93f9
input_hint::#6272a4
accent::#bd93f9
positive::#50fa7b
negative::#ff5555
warning::#f1fa8c
info::#8be9fd
select_bg::#44475a
select_fg::#f8f8f2
border::#44475a
bar_fill::#8be9fd

View file

@ -0,0 +1,30 @@
#!srfv1
# Gruvbox Dark - morhetz/gruvbox (dark, medium contrast)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#282828
bg_panel::#1d2021
bg_element::#3c3836
tab_bg::#1d2021
tab_fg::#928374
tab_active_bg::#fe8019
tab_active_fg::#282828
text::#ebdbb2
text_muted::#a89984
text_dim::#928374
status_bg::#1d2021
status_fg::#a89984
input_bg::#3c3836
input_fg::#fe8019
input_hint::#928374
accent::#d3869b
positive::#b8bb26
negative::#fb4934
warning::#fabd2f
info::#8ec07c
select_bg::#504945
select_fg::#fbf1c7
border::#504945
bar_fill::#83a598

View file

@ -0,0 +1,30 @@
#!srfv1
# Molokai - Tomas Restrepo's Vim palette, a darker Monokai variant
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#1b1d1e
bg_panel::#131415
bg_element::#232526
tab_bg::#131415
tab_fg::#7e8e91
tab_active_bg::#fd971f
tab_active_fg::#1b1d1e
text::#f8f8f2
text_muted::#7e8e91
text_dim::#465457
status_bg::#131415
status_fg::#7e8e91
input_bg::#232526
input_fg::#fd971f
input_hint::#465457
accent::#ae81ff
positive::#a6e22e
negative::#f92672
warning::#e6db74
info::#66d9ef
select_bg::#403d3d
select_fg::#f8f8f2
border::#403d3d
bar_fill::#66d9ef

View file

@ -0,0 +1,30 @@
#!srfv1
# Monokai - classic Sublime Text palette (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#272822
bg_panel::#21221c
bg_element::#3e3d32
tab_bg::#21221c
tab_fg::#75715e
tab_active_bg::#fd971f
tab_active_fg::#272822
text::#f8f8f2
text_muted::#75715e
text_dim::#565449
status_bg::#21221c
status_fg::#75715e
input_bg::#3e3d32
input_fg::#fd971f
input_hint::#75715e
accent::#ae81ff
positive::#a6e22e
negative::#f92672
warning::#e6db74
info::#66d9ef
select_bg::#49483e
select_fg::#f8f8f2
border::#49483e
bar_fill::#66d9ef

30
examples/themes/nord.srf Normal file
View file

@ -0,0 +1,30 @@
#!srfv1
# Nord - https://nordtheme.com (dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#2e3440
bg_panel::#272c36
bg_element::#3b4252
tab_bg::#272c36
tab_fg::#4c566a
tab_active_bg::#88c0d0
tab_active_fg::#2e3440
text::#d8dee9
text_muted::#7b88a1
text_dim::#4c566a
status_bg::#272c36
status_fg::#7b88a1
input_bg::#3b4252
input_fg::#88c0d0
input_hint::#4c566a
accent::#b48ead
positive::#a3be8c
negative::#bf616a
warning::#ebcb8b
info::#88c0d0
select_bg::#434c5e
select_fg::#eceff4
border::#4c566a
bar_fill::#81a1c1

View file

@ -0,0 +1,30 @@
#!srfv1
# Solarized Dark - Ethan Schoonover (https://ethanschoonover.com/solarized)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#002b36
bg_panel::#073642
bg_element::#073642
tab_bg::#073642
tab_fg::#586e75
tab_active_bg::#268bd2
tab_active_fg::#002b36
text::#839496
text_muted::#657b83
text_dim::#586e75
status_bg::#073642
status_fg::#657b83
input_bg::#073642
input_fg::#268bd2
input_hint::#586e75
accent::#268bd2
positive::#859900
negative::#dc322f
warning::#b58900
info::#2aa198
select_bg::#094e5c
select_fg::#93a1a1
border::#586e75
bar_fill::#268bd2

View file

@ -0,0 +1,30 @@
#!srfv1
# Tokyo Night - enkia/tokyo-night (the "night" variant, dark)
# Install: cp this file to ~/.config/zfin/theme.srf
# Used by both the TUI and `--export-chart` PNG export.
#
# All values are hex RGB: #rrggbb
bg::#1a1b26
bg_panel::#16161e
bg_element::#292e42
tab_bg::#16161e
tab_fg::#565f89
tab_active_bg::#7aa2f7
tab_active_fg::#1a1b26
text::#c0caf5
text_muted::#565f89
text_dim::#414868
status_bg::#16161e
status_fg::#565f89
input_bg::#292e42
input_fg::#7aa2f7
input_hint::#565f89
accent::#bb9af7
positive::#9ece6a
negative::#f7768e
warning::#e0af68
info::#7dcfff
select_bg::#33384d
select_fg::#c0caf5
border::#3b4261
bar_fill::#7aa2f7

View file

@ -142,6 +142,18 @@ pub const ResolvedRetirement = struct {
/// The line renders "not feasible" instead of a date.
promoted_infeasible,
},
/// The accumulation/distribution boundary as a year-offset for the
/// projection chart's x-axis. `bands[i].year == i` in production,
/// so this offset doubles as the band index. Returns `null` when
/// there's no accumulation phase to mark (`accumulation_years == 0`:
/// already retired or distribution-only), in which case the chart
/// draws no divider. Otherwise the chart draws a vertical line at
/// this offset separating the saving phase (left) from the
/// withdrawal phase (right).
pub fn boundaryYear(self: ResolvedRetirement) ?u16 {
return if (self.accumulation_years == 0) null else self.accumulation_years;
}
};
/// User-configurable projection parameters, loaded from projections.srf.
@ -2594,6 +2606,18 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
try std.testing.expect(r1.date.?.eql(r2.date.?));
}
test "ResolvedRetirement.boundaryYear: zero accumulation -> null, positive -> offset" {
// No accumulation phase (already retired / distribution-only):
// no divider to draw.
const none_r: ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none };
try std.testing.expectEqual(@as(?u16, null), none_r.boundaryYear());
// An accumulation phase: the boundary offset equals
// accumulation_years (which doubles as the band index).
const acc_r: ResolvedRetirement = .{ .accumulation_years = 12, .date = Date.fromYmd(2038, 1, 1), .source = .at_age };
try std.testing.expectEqual(@as(?u16, 12), acc_r.boundaryYear());
}
// Two-phase simulation regression tests
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {

View file

@ -17,10 +17,10 @@
//! The TUI's adaptive chart sizing isn't used here because the
//! export target is a file, not a cell grid.
//!
//! The module deliberately reuses the TUI's `default_theme` for
//! consistent visual identity across the live TUI surface and
//! exported images. A `--theme` override at export time is out of
//! scope for V1 (see TODO follow-up).
//! Each `export*` takes the `theme.Theme` to render with - the CLI
//! resolves it from `--theme <PATH>` (a `theme.srf`), defaulting to
//! the built-in theme so exports keep the TUI's visual identity unless
//! the user opts into a custom or presentation-friendly palette.
const std = @import("std");
const z2d = @import("z2d");
@ -29,6 +29,9 @@ const chart = @import("charts/chart.zig");
const projection_chart = @import("charts/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig");
const forecast = @import("analytics/forecast_evaluation.zig");
const forecast_chart = @import("charts/forecast_chart.zig");
const compare_chart = @import("charts/compare_chart.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `charts/chart.zig`'s
@ -47,6 +50,7 @@ pub fn exportSymbolChart(
alloc: std.mem.Allocator,
candles: []const zfin.Candle,
display_count: usize,
th: theme.Theme,
path: []const u8,
) !void {
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
@ -64,7 +68,7 @@ pub fn exportSymbolChart(
null,
default_width,
default_height,
theme.default_theme,
th,
&cached,
true,
) catch |err| switch (err) {
@ -78,12 +82,16 @@ pub fn exportSymbolChart(
/// Export a projection percentile-band chart with optional actuals
/// overlay. Wraps `projection_chart.renderToSurface` +
/// `writeToPNGFile`.
/// `writeToPNGFile`. `retirement_boundary_year` (pass
/// `ResolvedRetirement.boundaryYear()`) draws the
/// accumulation/distribution divider when set.
pub fn exportProjectionChart(
io: std.Io,
alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles,
actuals: ?projection_chart.ActualsOverlay,
retirement_boundary_year: ?u16,
th: theme.Theme,
path: []const u8,
) !void {
var rendered = projection_chart.renderToSurface(
@ -92,9 +100,10 @@ pub fn exportProjectionChart(
bands,
default_width,
default_height,
theme.default_theme,
th,
actuals,
true,
retirement_boundary_year,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
@ -112,6 +121,7 @@ pub fn exportTimelineChart(
alloc: std.mem.Allocator,
points: []const line_chart.LinePoint,
baseline: line_chart.Baseline,
th: theme.Theme,
path: []const u8,
) !void {
var rendered = line_chart.renderToSurface(
@ -120,7 +130,7 @@ pub fn exportTimelineChart(
points,
default_width,
default_height,
theme.default_theme,
th,
.{ .baseline = baseline, .axis_labels = true },
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
@ -131,6 +141,89 @@ pub fn exportTimelineChart(
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the convergence forecast chart (years-until-retirement vs.
/// observation date) as a PNG. Wraps
/// `forecast_chart.renderConvergenceToSurface` + `writeToPNGFile`.
pub fn exportConvergenceChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
th: theme.Theme,
path: []const u8,
) !void {
var rendered = forecast_chart.renderConvergenceToSurface(
io,
alloc,
points,
default_width,
default_height,
th,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the return back-test forecast chart (expected vs. realized
/// forward CAGR by anchor) as a PNG. Wraps
/// `forecast_chart.renderBacktestToSurface` + `writeToPNGFile`.
pub fn exportBacktestChart(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const forecast.BacktestAnchor,
th: theme.Theme,
path: []const u8,
) !void {
var rendered = forecast_chart.renderBacktestToSurface(
io,
alloc,
anchors,
default_width,
default_height,
th,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the `--vs` projection comparison overlay - two percentile-
/// band envelopes ("then" and "now") on one chart. Wraps
/// `compare_chart.renderToSurface` + `writeToPNGFile`.
pub fn exportCompareChart(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
th: theme.Theme,
path: []const u8,
) !void {
var rendered = compare_chart.renderToSurface(
io,
alloc,
then_bands,
now_bands,
default_width,
default_height,
th,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
// Tests
test "exportSymbolChart writes a non-empty PNG file" {
@ -161,7 +254,7 @@ test "exportSymbolChart writes a non-empty PNG file" {
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
defer alloc.free(path);
try exportSymbolChart(io, alloc, &candles, 60, path);
try exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path);
// Verify the file exists, starts with the PNG magic, and is
// big enough to plausibly contain a chart (not just headers).
@ -204,7 +297,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" {
try std.testing.expectError(
error.InsufficientData,
exportSymbolChart(io, alloc, &candles, 60, path),
exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path),
);
}
@ -235,7 +328,7 @@ test "exportProjectionChart writes a non-empty PNG file" {
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
defer alloc.free(path);
try exportProjectionChart(io, alloc, &bands, null, path);
try exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
defer file.close(io);
@ -271,7 +364,7 @@ test "exportProjectionChart returns InsufficientData with single band" {
try std.testing.expectError(
error.InsufficientData,
exportProjectionChart(io, alloc, &bands, null, path),
exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path),
);
}
@ -296,7 +389,7 @@ test "exportTimelineChart writes a non-empty PNG file" {
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
defer alloc.free(path);
try exportTimelineChart(io, alloc, &points, .fit, path);
try exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
defer file.close(io);
@ -326,6 +419,172 @@ test "exportTimelineChart returns InsufficientData with a single point" {
try std.testing.expectError(
error.InsufficientData,
exportTimelineChart(io, alloc, &points, .fit, path),
exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path),
);
}
test "exportConvergenceChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const points = [_]forecast.ConvergencePoint{
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2032, 1, 1), .years_until_retirement = 12.0, .reached = false },
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2031, 1, 1), .years_until_retirement = 9.0, .reached = false },
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 5.0, .reached = false },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" });
defer alloc.free(path);
try exportConvergenceChart(io, alloc, &points, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportConvergenceChart returns InsufficientData with a single point" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const points = [_]forecast.ConvergencePoint{
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportConvergenceChart(io, alloc, &points, theme.default_theme, path),
);
}
test "exportBacktestChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const anchors = [_]forecast.BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2016, 1, 1), .expected = 0.07, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
.{ .anchor_date = Date.fromYmd(2019, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null },
.{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.06, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" });
defer alloc.free(path);
try exportBacktestChart(io, alloc, &anchors, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportBacktestChart returns InsufficientData with a single anchor" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const anchors = [_]forecast.BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportBacktestChart(io, alloc, &anchors, theme.default_theme, path),
);
}
test "exportCompareChart writes a non-empty PNG file" {
const alloc = std.testing.allocator;
const io = std.testing.io;
var then_bands: [11]projections.YearPercentiles = undefined;
var now_bands: [11]projections.YearPercentiles = undefined;
for (0..11) |i| {
const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i)));
then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 };
now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 };
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" });
defer alloc.free(path);
try exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "test_export_compare.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportCompareChart returns InsufficientData with a single-year side" {
const alloc = std.testing.allocator;
const io = std.testing.io;
const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }};
var now_bands: [3]projections.YearPercentiles = undefined;
for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path),
);
}

View file

@ -63,6 +63,13 @@ pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
}
/// Unit for `drawYTicks` y-axis labels. `dollars` and `percent`
/// delegate to the canonical formatters (`fmtDollar`,
/// `format.fmtPct`); `years` is a compact whole-year tick ("10y")
/// for the convergence chart - the codebase has no shared compact-
/// years helper (the verbose "N Year" lives in the projections view).
pub const TickUnit = enum { dollars, percent, years };
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
/// the right margin a small pad to the right of `plot_right` (the plot's
@ -79,6 +86,27 @@ pub fn drawYDollarTicks(
value_min: f64,
value_max: f64,
n: usize,
) void {
drawYTicks(sfc, scale, color, plot_right, top, bottom, value_min, value_max, n, .dollars);
}
/// Like `drawYDollarTicks` but renders the tick values in the given
/// `unit`, so non-dollar axes (percent returns, years-until-
/// retirement) reuse the same tick spacing and placement.
/// `drawYDollarTicks` is the dollar-specialized wrapper. Percent
/// ticks go through `format.fmtPct` (the canonical percent
/// formatter) rather than a local reimplementation.
pub fn drawYTicks(
sfc: *Surface,
scale: i32,
color: [3]u8,
plot_right: f64,
top: f64,
bottom: f64,
value_min: f64,
value_max: f64,
n: usize,
unit: TickUnit,
) void {
const range = value_max - value_min;
const span = bottom - top;
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
const val = value_max - frac * range;
const y = top + frac * span;
var buf: [24]u8 = undefined;
const label = fmtDollar(&buf, val);
const label = switch (unit) {
.dollars => fmtDollar(&buf, val),
.percent => fmt.fmtPct(&buf, val, .{ .decimals = 0 }),
.years => std.fmt.bufPrint(&buf, "{d}y", .{@as(i64, @intFromFloat(@round(val)))}) catch "?y",
};
const ly = @as(i32, @intFromFloat(y)) - half_h;
text.drawText(sfc, lx, ly, scale, color, label);
}
@ -136,6 +168,23 @@ test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negativ
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
}
test "drawYTicks renders percent and years units" {
const alloc = testing.allocator;
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
// Percent ticks (decimal rates, via format.fmtPct) stamp glyphs.
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
defer sfc.deinit(alloc);
drawYTicks(&sfc, 2, color, 180, 10, 190, -0.05, 0.15, 5, .percent);
try testing.expect(draw.countColor(&sfc, color) > 0);
// Years ticks stamp glyphs too.
var sfc2 = try Surface.init(.image_surface_rgb, alloc, 300, 200);
defer sfc2.deinit(alloc);
drawYTicks(&sfc2, 2, color, 180, 10, 190, 0, 12, 5, .years);
try testing.expect(draw.countColor(&sfc2, color) > 0);
}
test "drawYDollarTicks stamps labels in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);

513
src/charts/braille.zig Normal file
View file

@ -0,0 +1,513 @@
//! Braille sparkline chart engine.
//!
//! Renders compact price/value sparklines using Unicode braille
//! characters (U+2800..U+28FF) for a 2-wide x 4-tall dot matrix per
//! terminal cell. `computeBrailleChart` produces a renderer-agnostic
//! `BrailleChart` (pattern grid + per-column colors); `writeBrailleAnsi`
//! renders it to a writer with ANSI color for the CLI, while the TUI
//! consumes the `BrailleChart` directly to emit vaxis cells.
//!
//! Extracted from `format.zig`; CLI and TUI both reach for it via
//! `@import("charts/braille.zig")`.
const std = @import("std");
const Date = @import("../Date.zig");
const Money = @import("../Money.zig");
const Candle = @import("../models/candle.zig").Candle;
/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
return .{
@intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
};
}
// Braille chart
/// Braille dot patterns for the 2x4 matrix within each character cell.
/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08
/// [1][4] dot1=0x02, dot4=0x10
/// [2][5] dot2=0x04, dot5=0x20
/// [6][7] dot6=0x40, dot7=0x80
pub const braille_dots = [4][2]u8{
.{ 0x01, 0x08 }, // row 0 (top)
.{ 0x02, 0x10 }, // row 1
.{ 0x04, 0x20 }, // row 2
.{ 0x40, 0x80 }, // row 3 (bottom)
};
/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
pub const braille_utf8 = blk: {
var table: [256][3]u8 = undefined;
for (0..256) |i| {
const cp: u21 = 0x2800 + @as(u21, @intCast(i));
table[i] = .{
@as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
@as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
@as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
};
}
break :blk table;
};
/// Return a static-lifetime grapheme slice for a braille pattern byte.
pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
patterns: []u8,
/// RGB color per data column
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
/// silently drop the label when portfolios cross into ten figures.
/// Renderers that need to budget cells for the label should use
/// `money_label_max_bytes` rather than guessing.
max_label: [money_label_max_bytes]u8,
max_label_len: usize,
min_label: [money_label_max_bytes]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
/// Date of last candle in the chart data
end_date: Date,
pub fn maxLabel(self: *const BrailleChart) []const u8 {
return self.max_label[0..self.max_label_len];
}
pub fn minLabel(self: *const BrailleChart) []const u8 {
return self.min_label[0..self.min_label_len];
}
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
return self.patterns[row * self.n_cols + col];
}
/// Format a date for the chart's x-axis at a granularity
/// appropriate for that individual date's recency. Two tiers,
/// per-date (driven by how far back the date is from the
/// chart's `end_date` reference, not by the chart's overall
/// span):
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
///
/// On a 12-year chart, this typically yields a long-format
/// start label (`Jul 2014`) paired with a short-format end
/// label (`08 May`) - the start is far enough back that
/// year context is what matters; the end is recent enough
/// that day-of-month resolution is useful.
///
/// The day-first ordering for the short form is intentional:
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
/// character of each label cleanly disambiguates the format
/// at a glance - digit-first is a recent date, letter-first is
/// a distant date. Saves the eye from re-parsing every label.
///
/// `buf` must be at least 8 bytes; the returned slice borrows
/// from it.
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
const age_days = self.end_date.days - date.days;
const mon = Date.monthShort(date.month());
if (age_days <= 720) {
// "DD MMM" - day-first so the leading character is a
// digit (visually distinct from the letter-first
// long form below).
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
}
// "MMM YYYY" - for dates more than ~2 years before
// `end_date`. Day-of-month resolution stops being useful
// at this scale; full 4-digit year keeps the label
// unambiguous regardless of how far back the chart goes.
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
}
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
alloc.free(self.patterns);
alloc.free(self.col_colors);
}
};
/// Compute braille sparkline chart data from candle close prices.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
alloc: std.mem.Allocator,
data: []const Candle,
chart_width: usize,
chart_height: usize,
positive_color: [3]u8,
negative_color: [3]u8,
) !BrailleChart {
if (data.len < 2) return error.InsufficientData;
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max chart-close prices (split-adjusted when available).
// See `Candle.chartClose` - using raw `close` here would render
// false cliffs at split dates.
var min_price: f64 = data[0].chartClose();
var max_price: f64 = data[0].chartClose();
for (data) |d| {
const cc = d.chartClose();
if (cc < min_price) min_price = cc;
if (cc > max_price) max_price = cc;
}
if (max_price == min_price) max_price = min_price + 1.0;
const price_range = max_price - min_price;
// Price labels
// SAFETY: every field of `result` is initialized below before
// it is read or returned. Treating it as `undefined` here is
// a deliberate "stack-allocate, then write each field"
// pattern - Zig requires the variable to exist before
// bufPrint can take a slice of one of its fields.
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
result.n_cols = n_cols;
result.chart_height = chart_height;
result.start_date = data[0].date;
result.end_date = data[data.len - 1].date;
// Map each data column to a dot-row position and color
const dot_y = try alloc.alloc(usize, n_cols);
defer alloc.free(dot_y);
result.col_colors = try alloc.alloc([3]u8, n_cols);
errdefer alloc.free(result.col_colors);
for (0..n_cols) |col| {
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
const close = data[data_idx].chartClose();
const norm = (close - min_price) / price_range; // 0 = min, 1 = max
// Inverted: 0 = top dot row, dot_rows-1 = bottom
const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
// Color: gradient from negative (bottom) to positive (top)
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
}
// Build the braille pattern grid
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
@memset(result.patterns, 0);
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y down to the bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
// Interpolate between this point and the next for smooth contour
if (col + 1 < n_cols) {
const y0 = dot_y[col];
const y1 = dot_y[col + 1];
const min_y = @min(y0, y1);
const max_y = @max(y0, y1);
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
}
}
return result;
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
out: *std.Io.Writer,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
skip_date_axis: bool,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
var last_b: u8 = 0;
var color_active = false;
for (0..chart.chart_height) |row| {
try out.writeAll(" "); // 2 leading spaces
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
if (use_color and pat != 0) {
const c = chart.col_colors[col];
// Only emit color escape if color changed
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
last_r = c[0];
last_g = c[1];
last_b = c[2];
color_active = true;
}
} else if (color_active and pat == 0) {
try out.writeAll("\x1b[0m");
color_active = false;
}
try out.writeAll(brailleGlyph(pat));
}
if (color_active) {
try out.writeAll("\x1b[0m");
color_active = false;
}
// Price label on first/last row
if (row == 0) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.maxLabel()});
if (use_color) try out.writeAll("\x1b[0m");
} else if (row == chart.chart_height - 1) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.minLabel()});
if (use_color) try out.writeAll("\x1b[0m");
}
try out.writeAll("\n");
}
// Date axis below chart
if (!skip_date_axis) {
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
}
// Tests
test "lerpColor" {
// t=0 returns first color
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
try std.testing.expectEqual(@as(u8, 0), c0[0]);
try std.testing.expectEqual(@as(u8, 0), c0[1]);
// t=1 returns second color
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
try std.testing.expectEqual(@as(u8, 255), c1[0]);
// t=0.5 returns midpoint
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}
test "brailleGlyph" {
// Pattern 0 = U+2800 (blank braille)
const blank = brailleGlyph(0);
try std.testing.expectEqual(@as(usize, 3), blank.len);
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
// Pattern 0xFF = U+28FF (full braille)
const full = brailleGlyph(0xFF);
try std.testing.expectEqual(@as(usize, 3), full.len);
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "computeBrailleChart" {
const alloc = std.testing.allocator;
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
var candles: [20]Candle = undefined;
for (0..20) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
// Max/min labels should contain price info
try std.testing.expect(chart.maxLabel().len > 0);
try std.testing.expect(chart.minLabel().len > 0);
}
test "computeBrailleChart insufficient data" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
try std.testing.expectError(error.InsufficientData, result);
}
test "computeBrailleChart preserves full label for prices over $1M" {
// Regression test for a bug where the max/min label buffers were
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
// with 13+ chars (anything $1,000,000+). Result was a silently
// empty label string in the BrailleChart, then the TUI renderer
// truncated even further to 10 cells, dropping the label entirely
// for portfolios over $1M. See `money_label_max_bytes`.
const alloc = std.testing.allocator;
var candles: [20]Candle = undefined;
for (0..20) |i| {
// Arbitrary placeholder range starting at $1,234,567.89 with
// a $500,000 step. The point of this test is the rendered
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
// overflowed the old 16-byte label buffer or the renderer's
// 10-cell budget. The exact numbers are irrelevant.
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Both labels must contain the dollar sign and a comma (the
// thousands separator) - that confirms `Money.from` produced
// a multi-million-dollar string and didn't fall through to the
// empty-string fallback when bufPrint hit NoSpaceLeft.
const max_lbl = chart.maxLabel();
const min_lbl = chart.minLabel();
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
// Both labels should match the `$X,XXX,XXX.XX` shape (at
// least 13 chars). Any silent-truncation bug would leave them
// empty or much shorter.
try std.testing.expect(max_lbl.len >= 13);
try std.testing.expect(min_lbl.len >= 13);
}
test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
// close drops 300 -> 100, but adj_close is constant at 100. The
// chart should see a flat line, not a cliff.
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Min and max labels should reflect the adjusted price (~$100),
// not the raw close range (300 -> 100). The exact values vary
// because computeBrailleChart bumps max by $1 internally when
// min == max, but neither label should mention $300.
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
}
test "fmtAxisDate: span <=720d produces DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2026, 1, 1);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
try std.testing.expectEqualStrings("27 Apr", lbl);
}
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
// 700 days from 2024-01-01 - still inside the threshold.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 1, 1);
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
// 12-year chart: start is way more than 720 days from end,
// so the start gets MMM YYYY. End is `end_date` itself
// (age 0), so it gets DD MMM.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2014, 7, 3);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
var buf2: [8]u8 = undefined;
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
try std.testing.expectEqualStrings("11 May", end_lbl);
}
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2025, 1, 1);
// 720 days later = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 12, 31);
// 721 days after 2024-12-31 = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Format the start (which is 721 days before end_date).
const lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Dec 2024", lbl);
}

View file

@ -0,0 +1,328 @@
//! Projection comparison chart: overlays two percentile-band
//! envelopes - a "then" projection (computed as of a past snapshot)
//! and a "now" projection - so the viewer can see how the forecast
//! envelope shifted between the two dates.
//!
//! Both projections are aligned at year 0 (each one's own start), so
//! the x-axis is "years from the projection's start" and the overlay
//! answers "how did my projected envelope move between then and now?".
//!
//! Each side draws a light p10-p90 fill plus a solid median line in
//! its own hue (then = `theme.info` / cyan, now = `theme.accent` /
//! purple), keyed by a small top-left "then"/"now" color legend.
//!
//! Visual layers (bottom to top):
//! - Background
//! - Horizontal grid lines
//! - "then" envelope fill, then "now" envelope fill
//! - "then" median, then "now" median (both on top of both fills)
//! - Zero line (if visible)
//! - Panel border
//! - then/now color legend (top-left)
//! - Axis labels (export only)
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const text = @import("text.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
/// Comparison chart render result (RGB for kitty graphics).
pub const CompareChartResult = struct {
rgb_data: []const u8,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` when done. The
/// surface seam shared between RGB extraction (kitty) and PNG export.
/// Mirrors `projection_chart.RenderedProjection`.
pub const RenderedCompare = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedCompare, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
pub fn extractRgb(self: *const RenderedCompare, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Render the "then" vs "now" comparison overlay into a `Surface`.
/// Both band slices are aligned at year 0; the x-axis spans the
/// longer of the two horizons. With `axis_labels`, reserves margins
/// and stamps y-axis dollar ticks + x-axis year endpoints (export
/// path); the kitty wrapper passes `false`.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
axis_labels: bool,
) !RenderedCompare {
if (then_bands.len < 2 or now_bands.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
const bg = th.bg;
const fwidth: f64 = @floatFromInt(width_px);
const fheight: f64 = @floatFromInt(height_px);
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area. With axis labels, reserve a right margin for the
// y-axis dollar ticks and a bottom margin for the year endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// Value range across BOTH envelopes (p10 floor, p90 ceiling).
var value_min: f64 = then_bands[0].p10;
var value_max: f64 = then_bands[0].p90;
for (then_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
for (now_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
const pad = (value_max - value_min) * 0.05;
value_min -= pad;
value_max += pad;
if (value_min < 0) value_min = 0;
// X step: align both at year 0; the longer horizon spans the full
// width. `bands[i].year == i`, so index doubles as the year offset.
const n = @max(then_bands.len, now_bands.len);
const x_step = chart_w / @as(f64, @floatFromInt(n - 1));
// Grid lines.
try draw.drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, draw.blendColor(th.text_muted, 40, bg));
// Envelopes: draw BOTH light fills first, then BOTH medians on
// top. Rendering uses the `.src` operator (replace, not blend), so
// drawing a fill after a median would occlude that median - hence
// the two-pass order. "now" fill goes on top of "then" fill.
try drawEnvelopeFill(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info, bg);
try drawEnvelopeFill(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent, bg);
try drawEnvelopeMedian(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info);
try drawEnvelopeMedian(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent);
// Zero line (if visible).
if (value_min <= 0 and value_max > 0) {
const zero_y = draw.mapY(0, value_min, value_max, chart_top, chart_bottom);
try draw.drawHLine(&ctx, chart_left, chart_right, zero_y, draw.blendColor(th.negative, 120, bg), 1.0);
}
// Panel border.
try draw.drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, draw.blendColor(th.border, 80, bg), 1.0);
// Color legend (top-left): keys the two envelope hues. Two
// unlabeled colored medians would otherwise be ambiguous.
{
const lgh: i32 = @intFromFloat(axis.charHeight(label_scale));
const lx: i32 = @as(i32, @intFromFloat(chart_left)) + 4 * label_scale;
const ly: i32 = @as(i32, @intFromFloat(chart_top)) + 2 * label_scale;
text.drawText(&sfc, lx, ly, label_scale, th.info, "then");
text.drawText(&sfc, lx, ly + lgh + 2 * label_scale, label_scale, th.accent, "now");
}
// Axis labels (export only): y dollar ticks + x year endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5, .dollars);
var fbuf: [8]u8 = undefined;
var lbuf: [8]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{0}) catch "0";
const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{n - 1}) catch "";
const yr_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = value_min,
.value_max = value_max,
};
}
/// Thin RGB wrapper over `renderToSurface` for the inline kitty path:
/// renders without axis labels, extracts RGB, frees the surface.
pub fn renderCompareChart(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !CompareChartResult {
var rendered = try renderToSurface(io, alloc, then_bands, now_bands, width_px, height_px, th, false);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
/// Draw one envelope's light p10-p90 fill in `hue`. Indices map to x
/// via `chart_left + i * x_step`.
fn drawEnvelopeFill(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
bg: [3]u8,
) !void {
ctx.setSourceToPixel(draw.blendColor(hue, 22, bg));
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.mapY(bp.p90, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
var j: usize = bands.len;
while (j > 0) {
j -= 1;
const x = chart_left + @as(f64, @floatFromInt(j)) * x_step;
const y = draw.mapY(bands[j].p10, value_min, value_max, chart_top, chart_bottom);
try ctx.lineTo(x, y);
}
try ctx.closePath();
try ctx.fill();
}
/// Draw one envelope's solid p50 median line in `hue`.
fn drawEnvelopeMedian(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
) !void {
ctx.setSourceToPixel(draw.opaqueColor(hue));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.mapY(bp.p50, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
try ctx.stroke();
}
// Tests
fn syntheticBands(buf: []projections.YearPercentiles, base: f64, growth: f64) []projections.YearPercentiles {
for (buf, 0..) |*b, i| {
const v = base * (1.0 + growth * @as(f64, @floatFromInt(i)));
b.* = .{
.year = @intCast(i),
.p10 = v * 0.6,
.p25 = v * 0.8,
.p50 = v,
.p75 = v * 1.2,
.p90 = v * 1.5,
};
}
return buf;
}
test "renderCompareChart produces valid RGB output for two envelopes" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_200_000, 0.06);
const th = theme.default_theme;
const result = try renderCompareChart(std.testing.io, alloc, then_bands, now_bands, 240, 120, th);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 240), result.width);
try std.testing.expectEqual(@as(u16, 120), result.height);
try std.testing.expectEqual(@as(usize, 240 * 120 * 3), result.rgb_data.len);
try std.testing.expect(result.value_max > result.value_min);
}
test "renderToSurface draws both envelope hues" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.03);
const now_bands = syntheticBands(&now_buf, 1_500_000, 0.07);
const th = theme.default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false);
defer rendered.deinit(alloc);
// Both median hues should have painted opaque pixels.
try std.testing.expect(draw.countColor(&rendered.surface, th.info) > 0);
try std.testing.expect(draw.countColor(&rendered.surface, th.accent) > 0);
}
test "renderToSurface insufficient data on a single-year band" {
const alloc = std.testing.allocator;
var then_buf: [1]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_000_000, 0.05);
const th = theme.default_theme;
try std.testing.expectError(
error.InsufficientData,
renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false),
);
}

View file

@ -31,6 +31,7 @@ const theme = @import("../tui/theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
@ -50,6 +51,80 @@ pub const ChartResult = struct {
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` when done. The
/// shared surface seam for both forecast charts: the `--export-chart`
/// path keeps the surface to hand to the PNG encoder, while the kitty
/// wrappers (`renderConvergenceChart` / `renderBacktestChart`) extract
/// RGB and free it. Mirrors `projection_chart.RenderedProjection`.
pub const RenderedForecast = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedForecast, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets; caller owns it. The
/// surface is left intact.
pub fn extractRgb(self: *const RenderedForecast, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// RGB wrapper over `renderConvergenceToSurface` for the inline kitty
/// path (CLI + TUI). Renders WITH axis labels baked in: unlike the
/// projection chart - whose callers stamp their own labels into the
/// cell grid - nothing draws the forecast chart's labels separately,
/// so the years y-ticks + observation-date x-endpoints go into the
/// surface here (matching the PNG export).
pub fn renderConvergenceChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
var rendered = try renderConvergenceToSurface(io, alloc, points, width_px, height_px, th, true);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
/// RGB wrapper over `renderBacktestToSurface` for the inline kitty
/// path (CLI + TUI). Renders WITH axis labels baked in: unlike the
/// projection chart - whose callers stamp their own labels into the
/// cell grid - nothing draws the forecast chart's labels separately,
/// so the percent y-ticks + date x-endpoints go into the surface here
/// (matching the PNG export) to show the divergence magnitude.
pub fn renderBacktestChart(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const BacktestAnchor,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
var rendered = try renderBacktestToSurface(io, alloc, anchors, width_px, height_px, th, true);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
// View 1: Convergence chart
/// Render the convergence chart. X-axis spans
@ -67,20 +142,21 @@ pub const ChartResult = struct {
/// - Solid line through the convergence points
/// - Distinct markers on `reached` rows (small filled dots,
/// theme accent color)
pub fn renderConvergenceChart(
pub fn renderConvergenceToSurface(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
axis_labels: bool,
) !RenderedForecast {
if (points.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
defer sfc.deinit(alloc);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
@ -95,11 +171,19 @@ pub fn renderConvergenceChart(
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Chart area. With axis labels, reserve a right margin for the
// y-axis years ticks and a bottom margin for the date endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// X-range: observation_date span
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
@ -186,8 +270,20 @@ pub fn renderConvergenceChart(
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
// Axis labels (export only): right-side y-axis years ticks and
// x-axis observation-date endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .years);
var fbuf: [10]u8 = undefined;
var lbuf: [10]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].observation_date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].observation_date}) catch "";
const date_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
@ -212,20 +308,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
/// - `realized_5y` (solid, theme positive - green)
///
/// Plus a y=0 reference line.
pub fn renderBacktestChart(
pub fn renderBacktestToSurface(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const BacktestAnchor,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
axis_labels: bool,
) !RenderedForecast {
if (anchors.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
defer sfc.deinit(alloc);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
@ -240,11 +337,19 @@ pub fn renderBacktestChart(
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Chart area. With axis labels, reserve a right margin for the
// y-axis percent ticks and a bottom margin for the date endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// X-range
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
@ -303,8 +408,20 @@ pub fn renderBacktestChart(
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
// Axis labels (export only): right-side y-axis percent ticks and
// x-axis anchor-date endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .percent);
var fbuf: [10]u8 = undefined;
var lbuf: [10]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{anchors[0].anchor_date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{anchors[anchors.len - 1].anchor_date}) catch "";
const date_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
@ -492,7 +609,6 @@ const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb;
// Tests

View file

@ -12,6 +12,7 @@
//! - Median (p50) line (solid)
//! - Zero line (if visible)
//! - Actuals overlay line (when present)
//! - Retirement boundary vertical line (when an accumulation phase exists)
//! - Panel border
const std = @import("std");
@ -90,6 +91,12 @@ pub const RenderedProjection = struct {
/// graphics path (extracts RGB, frees surface).
/// - `--export-chart` (CLI) wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
///
/// `retirement_boundary_year`, when non-null and within the band
/// range, draws a vertical divider at that year-offset marking where
/// the accumulation phase ends and distribution begins. Pass
/// `ResolvedRetirement.boundaryYear()`; out-of-range values (0 or
/// past the last band) are ignored.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
@ -99,6 +106,7 @@ pub fn renderToSurface(
th: theme.Theme,
actuals: ?ActualsOverlay,
axis_labels: bool,
retirement_boundary_year: ?u16,
) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData;
@ -303,6 +311,24 @@ pub fn renderToSurface(
}
}
// Retirement boundary vertical line
//
// Drawn on top of the bands (not behind, like the quiet "today"
// line) because the accumulation/distribution boundary is a
// structural feature of the projection, not just a time cursor.
// `bands[i].year == i`, so the boundary year-offset is also its
// band index; map it to x exactly as the band points are placed.
// Skipped when there's no accumulation phase (null / 0) or the
// boundary falls outside the (possibly zoom-truncated) window.
if (retirement_boundary_year) |boundary| {
if (boundary > 0 and @as(usize, boundary) <= bands.len - 1) {
const horizon_years: f64 = @floatFromInt(bands.len - 1);
const boundary_x = chart_left + (@as(f64, @floatFromInt(boundary)) / horizon_years) * chart_w;
const boundary_color = blendColor(th.warning, 200, bg);
try drawVLine(&ctx, boundary_x, chart_top, chart_bottom, boundary_color, 1.5);
}
}
// Panel border
{
const border_color = blendColor(th.border, 80, bg);
@ -343,8 +369,9 @@ pub fn renderProjectionChart(
height_px: u32,
th: theme.Theme,
actuals: ?ActualsOverlay,
retirement_boundary_year: ?u16,
) !ProjectionChartResult {
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false);
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false, retirement_boundary_year);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
@ -381,7 +408,7 @@ test "renderProjectionChart produces valid output" {
};
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width);
@ -397,7 +424,7 @@ test "renderProjectionChart insufficient data" {
};
const th = @import("../tui/theme.zig").default_theme;
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
try std.testing.expectError(error.InsufficientData, result);
}
@ -416,7 +443,7 @@ test "renderProjectionChart with overlay produces valid output" {
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width);
@ -439,7 +466,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
// Without expansion, value_max would be ~25M (band p90 + 5%).
@ -456,7 +483,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0);
}
@ -474,7 +501,7 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false, null);
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width);
@ -494,7 +521,7 @@ test "renderToSurface fills background with theme bg" {
var th = @import("../tui/theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false, null);
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
@ -515,9 +542,9 @@ test "renderToSurface is deterministic across calls with same input" {
};
const th = @import("../tui/theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
@ -544,7 +571,7 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false, null);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
@ -569,7 +596,7 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0`
@ -578,3 +605,43 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 0);
}
test "renderToSurface draws the retirement-boundary divider only when in range" {
const alloc = std.testing.allocator;
// 11 bands (year 0..10); a boundary at year 5 lands mid-chart.
var bands: [11]projections.YearPercentiles = undefined;
for (0..11) |i| {
const base: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
bands[i] = .{
.year = @intCast(i),
.p10 = base * 0.7,
.p25 = base * 0.85,
.p50 = base,
.p75 = base * 1.15,
.p90 = base * 1.3,
};
}
const th = @import("../tui/theme.zig").default_theme;
const line_px = blendColor(th.warning, 200, th.bg);
const line_rgb = [3]u8{ line_px.rgb.r, line_px.rgb.g, line_px.rgb.b };
// Baseline: no boundary requested.
var none = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, null);
defer none.deinit(alloc);
const base_count = draw.countColor(&none.surface, line_rgb);
// A boundary at year 5 adds divider pixels in the warning color.
var set = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 5);
defer set.deinit(alloc);
try std.testing.expect(draw.countColor(&set.surface, line_rgb) > base_count);
// Out-of-range offsets draw nothing: 0 (left edge / no phase) and a
// value past the last band index both match the no-boundary baseline.
var zero = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 0);
defer zero.deinit(alloc);
try std.testing.expectEqual(base_count, draw.countColor(&zero.surface, line_rgb));
var past = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 99);
defer past.deinit(alloc);
try std.testing.expectEqual(base_count, draw.countColor(&past.surface, line_rgb));
}

View file

@ -10,9 +10,12 @@
//! Solid 1-bit pixels avoid it entirely.
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
//!
//! The glyph set is intentionally minimal - just what axis labels need:
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
//! The glyph set is intentionally minimal - just what axis labels and
//! chart legends need: digits, `$`, `.`, `,`, `-`, `%`, the `T`/`B`/`M`
//! magnitude suffixes emitted by `format.fmtLargeNum`, and the
//! lowercase letters `t`/`h`/`e`/`n`/`o`/`w` (comparison-chart
//! "then"/"now" legend) plus `y` (convergence "years" axis ticks).
//! Space and unknown chars render blank.
//!
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
@ -55,10 +58,23 @@ const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 };
const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 };
const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 };
const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 };
const glyph_percent: Glyph = .{ 0x19, 0x1A, 0x02, 0x04, 0x08, 0x13, 0x03 };
const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
// Lowercase letters: the comparison chart's "then"/"now" legend
// (t, h, e, n, o, w) plus `y` for the convergence "years" axis ticks.
// 5x7, low 5 bits per row. Named `lc_*` to avoid colliding with the
// `glyph_w`/`glyph_h` cell dims.
const lc_t: Glyph = .{ 0x08, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x0C };
const lc_h: Glyph = .{ 0x10, 0x10, 0x10, 0x1E, 0x12, 0x12, 0x12 };
const lc_e: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E };
const lc_n: Glyph = .{ 0x00, 0x00, 0x1E, 0x12, 0x12, 0x12, 0x12 };
const lc_o: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E };
const lc_w: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A };
const lc_y: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x0E, 0x04, 0x08 };
/// Look up the bitmap for a character. Unknown characters (including
/// space) render blank.
fn glyphFor(ch: u8) Glyph {
@ -68,9 +84,17 @@ fn glyphFor(ch: u8) Glyph {
'.' => glyph_period,
',' => glyph_comma,
'-' => glyph_minus,
'%' => glyph_percent,
'T' => glyph_T,
'B' => glyph_B,
'M' => glyph_M,
't' => lc_t,
'h' => lc_h,
'e' => lc_e,
'n' => lc_n,
'o' => lc_o,
'w' => lc_w,
'y' => lc_y,
else => blank,
};
}
@ -209,3 +233,22 @@ test "drawText renders the comma glyph (so thousands separators show)" {
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
}
test "drawText renders the lowercase glyphs (then/now legend + years axis)" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
defer sfc.deinit(alloc);
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
// "thenowy" exercises all seven lowercase glyphs; none may be blank.
drawText(&sfc, 1, 1, 1, white, "thenowy");
try testing.expect(draw.countColor(&sfc, white) > 30);
}
test "drawText renders the percent glyph (for return-rate axis labels)" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16);
defer sfc.deinit(alloc);
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
drawText(&sfc, 1, 1, 1, white, "%");
try testing.expect(draw.countColor(&sfc, white) > 0);
}

View file

@ -7,15 +7,38 @@ const git = @import("../git.zig");
const framework = @import("framework.zig");
const stderr = @import("../stderr.zig");
pub const fmt = @import("../format.zig");
const theme = @import("../tui/theme.zig");
// Default CLI colors (match TUI default Monokai theme)
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info)
// Active CLI text palette
// RGB foreground colors for ALL CLI (non-TUI) text output, emitted as
// truecolor ANSI by setFg/printFg. Defaults match the built-in Monokai
// theme; `applyTheme` overwrites them once at startup from the resolved
// `--theme <PATH>` (or the default) so the entire CLI - gain/loss,
// headers, muted labels, warnings, accents - honors the user's theme,
// not just the charts. zfin is a single-invocation process and these
// are set once, before any command renders, so process-wide state is
// safe here and avoids threading a palette through every call site.
pub var CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
pub var CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
pub var CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
pub var CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
pub var CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
pub var CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
pub var CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info)
/// Repaint the CLI text palette from a resolved theme. Call once at
/// startup (after `--theme` resolution) so every `CLR_*` reference picks
/// up the user's colors. The field-to-CLR mapping mirrors the comments
/// on the defaults above; keep them in sync.
pub fn applyTheme(th: theme.Theme) void {
CLR_POSITIVE = th.positive;
CLR_NEGATIVE = th.negative;
CLR_MUTED = th.text_muted;
CLR_HEADER = th.accent;
CLR_ACCENT = th.bar_fill;
CLR_WARNING = th.warning;
CLR_INFO = th.info;
}
// ANSI color helpers

View file

@ -45,6 +45,7 @@ const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig");
const chart = @import("../charts/chart.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
// Group taxonomy
@ -259,6 +260,11 @@ pub const RunCtx = struct {
/// once at invocation entry. Commands consult this together with
/// `globals.chart_config` to choose kitty-graphics vs braille output.
graphics_caps: term_query.Caps = .{},
/// Theme for all CLI charts - inline terminal (kitty) charts and
/// `--export-chart` PNGs - resolved once from `--theme <PATH>` (or
/// `~/.config/zfin/theme.srf`, else the built-in default). The same
/// resolved theme also drives the CLI text palette and the TUI.
chart_theme: theme.Theme = theme.default_theme,
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.

View file

@ -45,6 +45,7 @@ const view = @import("../views/history.zig");
const chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.zig");
const chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -137,6 +138,11 @@ pub const PortfolioOpts = struct {
/// minimum if the series itself dips negative). Only consulted when
/// `--export-chart` is given.
baseline: line_chart.Baseline = .fit,
/// Theme for the chart - both the inline kitty image and the
/// `--export-chart` PNG. Comes from the global `--theme <PATH>`
/// flag; injected by `run` from `RunCtx.chart_theme`. Defaults to
/// the built-in theme.
chart_theme: theme.Theme = theme.default_theme,
};
/// Parse the arg list for portfolio-mode flags. Pure function - no IO.
@ -223,9 +229,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
switch (parsed) {
.symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts),
.portfolio => |opts| {
var o = opts;
o.chart_theme = ctx.chart_theme;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
try runPortfolio(ctx.io, ctx.allocator, pf.path, o, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
},
}
}
@ -327,7 +335,7 @@ fn runPortfolio(
// as a PNG (honoring --since / --until / --metric / --baseline) and
// exit without printing the normal timeline output.
if (opts.export_chart) |path| {
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) {
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, opts.chart_theme, path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n");
return;
@ -359,7 +367,7 @@ fn runPortfolio(
// Resolve how to draw the inline chart: kitty graphics when the
// terminal supports it (or it's forced via `--chart <WxH>`), else
// braille. `--chart braille` always forces braille.
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline };
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline, .theme = opts.chart_theme };
const chart_render: ChartRender = switch (chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
@ -548,13 +556,14 @@ fn exportMetricChart(
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
baseline: line_chart.Baseline,
th: theme.Theme,
path: []const u8,
) !void {
const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series);
const lps = try metricLinePoints(allocator, series);
defer allocator.free(lps);
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path);
try chart_export.exportTimelineChart(io, allocator, lps, baseline, th, path);
}
/// How `renderPortfolio` draws the timeline chart.
@ -570,6 +579,7 @@ const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
baseline: line_chart.Baseline,
theme: theme.Theme,
};
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics
@ -613,7 +623,7 @@ fn emitTimelineKitty(
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, theme.default_theme, .{ .baseline = k.baseline, .axis_labels = true });
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, k.theme, .{ .baseline = k.baseline, .axis_labels = true });
defer rendered.deinit(allocator);
const rgb = try rendered.extractRgb(allocator);
defer allocator.free(rgb);
@ -655,9 +665,9 @@ fn renderBraille(
}
const candles = candles_list.items;
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
var braille_chart = braille.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer braille_chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
try braille.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
}
fn renderTable(
@ -1166,7 +1176,7 @@ test "exportMetricChart writes a PNG for a multi-point timeline" {
const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" });
defer alloc.free(path);
try exportMetricChart(io, alloc, &pts, .liquid, .fit, path);
try exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, path);
var file = try tmp.dir.openFile(io, "history_timeline.png", .{});
defer file.close(io);
@ -1187,7 +1197,7 @@ test "exportMetricChart returns InsufficientData with fewer than 2 points" {
const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
};
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png"));
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, "unused.png"));
}
// rebuildRollup

View file

@ -23,6 +23,10 @@ const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const compare_chart = @import("../charts/compare_chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -39,11 +43,12 @@ pub const ParsedArgs = union(enum) {
/// `--vs <DATE>`: side-by-side compare of two projections.
compare: CompareArgs,
/// `--convergence`: plot the spreadsheet's predicted retirement
/// date over time. No knobs.
convergence,
/// date over time. `export_chart` renders a PNG instead of the
/// text table.
convergence: struct { export_chart: ?[]const u8 = null },
/// `--return-backtest [--real]`: plot expected_return vs realized
/// forward-CAGR.
return_backtest: struct { real: bool },
/// forward-CAGR. `export_chart` renders a PNG instead of text.
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
};
pub const BandsArgs = struct {
@ -64,6 +69,9 @@ pub const CompareArgs = struct {
/// "Now" side. Null = today (live); non-null = the `--as-of`
/// date the user paired with `--vs`.
as_of: ?Date = null,
/// When set, render the side-by-side comparison overlay as a PNG
/// to this path and exit. No text output.
export_chart: ?[]const u8 = null,
};
pub const meta: framework.Meta = .{
@ -104,11 +112,12 @@ pub const meta: framework.Meta = .{
\\ --return-backtest (see above)
\\ --real With --return-backtest, render in
\\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the percentile-band chart
\\ (with optional overlay if
\\ --overlay-actuals is set) as a PNG
\\ to PATH (1920x1080) and exit. Only
\\ valid in the default bands mode.
\\ --export-chart <PATH> Render the current mode's chart as a
\\ PNG to PATH (1920x1080) and exit.
\\ Works in all modes: the default bands
\\ view (with the overlay if
\\ --overlay-actuals is set), --convergence,
\\ --return-backtest, and --vs.
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
@ -202,22 +211,23 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive;
}
// Chart export only meaningful in default bands mode. The
// forecast-evaluation views (convergence, return-backtest)
// render via `forecast_chart.zig` which doesn't have a PNG
// export path yet; --vs is text-only with no chart at all.
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
// The actuals overlay plots the realized trajectory from `--as-of`
// through today; without an as-of anchor there's nothing to plot.
// Matches the TUI, which refuses the overlay without an as-of date.
// (Under `--vs` the overlay is ignored rather than required, so
// that combination is left alone.)
if (overlay_actuals and as_of == null and vs_date == null) {
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = {} };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
if (vs_date) |d| {
return ParsedArgs{ .compare = .{
.events_enabled = events_enabled,
.vs_date = d,
.as_of = as_of,
.export_chart = export_chart,
} };
}
return ParsedArgs{ .bands = .{
@ -239,9 +249,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer pf.deinit(allocator);
const file_path = pf.path;
// Inline kitty charts when the terminal supports it (or `--chart
// kitty` forces it). No braille fallback for the projection-family
// charts - non-kitty terminals get table-only output. Shared by the
// bands, convergence, and return-backtest modes.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
switch (parsed) {
.convergence => try runConvergence(io, allocator, file_path, color, out),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load today's live composition only when it's
@ -271,6 +291,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.today = today,
.live = if (live) |*l| l else null,
},
kitty_caps,
args.export_chart,
);
},
.bands => |args| {
@ -283,14 +305,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator);
// Inline kitty band chart when supported (or forced). There's
// no braille fallback for projections - non-kitty terminals
// keep the table-only output.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
try runBands(
io,
allocator,
@ -304,6 +318,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.overlay_actuals = args.overlay_actuals,
.export_chart = args.export_chart,
.live = if (live) |*l| l else null,
.chart_theme = ctx.chart_theme,
},
color,
out,
@ -451,6 +466,10 @@ pub const BandsOptions = struct {
/// Snapshot-only as-of paths ignore this field. See
/// `LiveData` for the rationale.
live: ?*const LiveData = null,
/// Theme for the `--export-chart` PNG (resolved from `--theme`).
/// Defaults to the built-in theme; the inline kitty chart always
/// uses the default.
chart_theme: theme.Theme = theme.default_theme,
};
/// Build a `ProjectionContext` for an already-resolved as-of date,
@ -554,6 +573,50 @@ pub fn anyImportedOnly(
return now_res.source == .imported;
}
/// The chart-ready overlay input + band slice for the bands-mode
/// chart. Shared by the inline-kitty (`emitBandsKitty`) and PNG-export
/// paths so both translate the actuals overlay and frame it (zoom to
/// the overlay window) identically.
const OverlayChart = struct {
overlay: ?projection_chart.ActualsOverlay,
bands: []const projections.YearPercentiles,
};
/// Translate the context's actuals overlay into the chart module's
/// shape and pick the band slice to render: zoomed to the overlay
/// window (`view.overlayZoomBands`) when an overlay is present, else
/// the full `bands_ec`. Overlay points are allocated from the arena
/// `va`, so no explicit free is needed.
fn prepOverlayChart(
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
bands_ec: []const projections.YearPercentiles,
) OverlayChart {
const overlay: ?projection_chart.ActualsOverlay = blk: {
const ov = ctx.overlay_actuals orelse break :blk null;
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
break :blk .{ .points = buf, .today_years = ov.today_years };
};
return .{
.overlay = overlay,
.bands = view.overlayZoomBands(bands_ec, if (overlay) |ov| ov.today_years else null),
};
}
/// Pixel + cell dimensions for an inline projection-family chart at
/// the standard column width, derived from the terminal's cell size.
/// Shared by the bands, convergence, and return-backtest inline-kitty
/// paths so they all render at the same on-screen footprint.
const ProjChartDims = struct { width: u32, height: u32, cols: u16, rows: u16 };
fn projectionChartDims(caps: term_query.Caps) ProjChartDims {
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
return .{ .width = dims.width, .height = dims.height, .cols = cols, .rows = rows };
}
/// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands
@ -564,30 +627,22 @@ fn emitBandsKitty(
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
caps: term_query.Caps,
th: theme.Theme,
out: *std.Io.Writer,
) !void {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return error.InsufficientData;
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
// Translate the view-layer overlay (if any) into the chart module's
// ActualsPoint shape - same conversion as the PNG export path. The
// arena owns the buffer; it lives as long as `overlay_input`.
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null);
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null);
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years };
};
// Translate the overlay and frame the band slice (zoomed to the
// overlay window) the same way the PNG-export path does.
const oc = prepOverlayChart(va, ctx, bands_ec);
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true);
const d = projectionChartDims(caps);
var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, th, oc.overlay, true, ctx.retirement.boundaryYear());
defer rendered.deinit(va);
const rgb = try rendered.extractRgb(va);
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows);
}
pub fn runBands(
@ -712,25 +767,11 @@ pub fn runBands(
return;
};
// Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
}
overlay_buf = buf;
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{
.points = buf,
.today_years = ov.today_years,
};
};
// Translate the overlay and frame the band slice (zoomed to the
// overlay window) the same way the inline-kitty path does.
const oc = prepOverlayChart(va, &ctx, bands_ec);
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), opts.chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return;
@ -764,7 +805,7 @@ pub fn runBands(
// keep the table-only view below.
if (kitty_caps) |kc| {
try out.print("\n", .{});
emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) {
emitBandsKitty(io, va, &ctx, kc, opts.chart_theme, out) catch |err| switch (err) {
error.InsufficientData => {}, // no bands yet; fall through to the table
else => return err,
};
@ -895,9 +936,9 @@ pub fn runBands(
};
}
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
var br = braille.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (br) |*chart| {
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
try braille.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
// Year axis instead of date axis
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Now", .{});
@ -1026,6 +1067,15 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
};
}
/// The longest-horizon percentile band envelope for a context, or
/// null when no horizon/bands are available. Used to retain the
/// `--vs` comparison overlay's two envelopes.
fn longestBands(ctx: view.ProjectionContext) ?[]const projections.YearPercentiles {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return null;
return ctx.data.bands[horizons.len - 1];
}
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`.
///
@ -1085,6 +1135,8 @@ pub fn runCompare(
ctx: *framework.RunCtx,
file_path: []const u8,
opts: KeyComparisonOptions,
kitty_caps: ?term_query.Caps,
export_chart: ?[]const u8,
) !void {
const io = ctx.io;
const allocator = ctx.allocator;
@ -1102,6 +1154,26 @@ pub fn runCompare(
};
defer result.cleanup();
// --export-chart: render the comparison overlay to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
if (result.then_bands == null or result.now_bands == null) {
cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n");
return;
}
chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, ctx.chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
try out.print("\n", .{});
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
@ -1142,6 +1214,21 @@ pub fn runCompare(
}
try out.print("\n", .{});
// Inline comparison overlay above the table when supported. No
// braille fallback - non-kitty terminals get the table only.
if (kitty_caps) |kc| {
if (result.then_bands != null and result.now_bands != null) {
const d = projectionChartDims(kc);
if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, ctx.chart_theme)) |cres| {
try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows);
try out.print("\n", .{});
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
}
try renderKeyComparisonRows(out, color, result.then, result.now, result.events_enabled);
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
@ -1176,8 +1263,11 @@ pub fn runConvergence(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
@ -1193,6 +1283,37 @@ pub fn runConvergence(
defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points);
// --export-chart: render the convergence chart to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
chart_export.exportConvergenceChart(io, allocator, points, chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Inline kitty chart above the table when supported. No braille
// fallback - non-kitty terminals get the table only. Too few points
// skips the chart and still renders the table below.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, chart_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
const lines = try view.convergenceLines(va, points);
try renderForecastLines(out, color, lines);
}
@ -1214,8 +1335,11 @@ pub fn runReturnBacktest(
allocator: std.mem.Allocator,
file_path: []const u8,
real_mode: bool,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
@ -1240,6 +1364,35 @@ pub fn runReturnBacktest(
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
const anchors = try forecast.pivotByAnchor(va, rows);
// --export-chart: render the back-test chart to a PNG and exit.
if (export_chart) |export_path| {
chart_export.exportBacktestChart(io, allocator, anchors, chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Inline kitty chart above the table when supported (see
// runConvergence). Too few anchors skips the chart; table still renders.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, chart_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
const lines = try view.backtestLines(va, anchors, real_mode);
try renderForecastLines(out, color, lines);
}
@ -1272,6 +1425,11 @@ fn renderForecastLines(
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
/// Longest-horizon percentile bands for each side, retained for
/// the `--vs` comparison overlay chart. Arena-lived (the caller's
/// `va`); null when a side produced no bands. Both aligned at year 0.
then_bands: ?[]const projections.YearPercentiles = null,
now_bands: ?[]const projections.YearPercentiles = null,
/// Resolution of the "then" snapshot. Always present.
resolution: AsOfResolution,
/// Resolution of the "now" snapshot. Null when now is live.
@ -1407,6 +1565,8 @@ pub fn computeKeyComparison(
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution,
.now_resolution = now_resolution,
.events_enabled = opts.events_enabled,
@ -1440,6 +1600,8 @@ pub fn computeKeyComparison(
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution,
.now_resolution = null,
.events_enabled = opts.events_enabled,
@ -1840,6 +2002,30 @@ test "parseArgs: --overlay-actuals carries into bands" {
}
}
test "parseArgs: --overlay-actuals without --as-of is rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--overlay-actuals"};
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "projectionChartDims: standard column width, sane pixel/row footprint" {
const caps = term_query.Caps{ .kitty = true, .cell_w = 10, .cell_h = 20 };
const d = projectionChartDims(caps);
try testing.expectEqual(term_graphics.projection_cols, d.cols);
try testing.expect(d.rows > 0);
try testing.expect(d.width > 0 and d.height > 0);
}
test "parseArgs: --vs with --export-chart carries into compare" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--vs", "2024-01-01", "--export-chart", "out.png" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.compare => |c| try testing.expect(c.export_chart != null),
else => try testing.expect(false),
}
}
const snapshot_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig");

View file

@ -6,6 +6,7 @@ const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -144,7 +145,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// PNG export short-circuits all text rendering.
if (parsed.export_chart) |path| {
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) {
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, ctx.chart_theme, path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
return;
@ -159,6 +160,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fetch real-time quote via DataService
var quote: ?QuoteData = null;
// Live security name (Yahoo `longName`) captured out of the
// transient Quote into a function-scope buffer. Shares the price's
// source, so the displayed name and price always describe the same
// security - even after a ticker is recycled.
var live_name_buf: [256]u8 = undefined;
var live_name: ?[]const u8 = null;
if (svc.getQuote(parsed.symbol, opts)) |q| {
quote = .{
.price = q.close,
@ -169,39 +176,45 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.prev_close = q.previous_close,
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
};
if (q.name().len > 0) live_name = clampName(&live_name_buf, q.name());
} else |_| {}
// Resolve a human-readable security name the same way the TUI
// 'K' overlay and quote tab do: the curated `metadata.srf`
// `name::` field first, then the ETF profile's fund name. The
// name is copied into `name_buf` so its lifetime is independent
// of the (transient) classification map / ETF fetch result.
// 'K' overlay and quote tab do, via the shared policy: the curated
// `metadata.srf` `name::` field first, then the live quote name,
// then the ETF profile's fund name. The result is copied into
// `name_buf` so its lifetime is independent of the (transient)
// classification map / ETF fetch result.
var name_buf: [256]u8 = undefined;
var name: ?[]const u8 = null;
{
var cm_opt = loadClassificationMap(ctx);
defer if (cm_opt) |*cm| cm.deinit();
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
// metadata > live quote name. Both are already in hand, so no
// EDGAR round-trip happens unless these come up empty.
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name })) |nm| {
name = clampName(&name_buf, nm);
} else {
// Fallback: the ETF profile's fund name, gated on isEtf()
// so it matches the TUI (which only retains fund-shaped
// profiles in symbol_data). Plain stocks not in metadata
// and without a live name render symbol-only, exactly like
// the 'K' overlay. Only fetched when the cheap sources
// yielded nothing, so already-named holdings don't pay for
// an EDGAR round-trip.
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
defer etf_result.deinit();
if (etf_result.data.isEtf()) {
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name, .etf_profile = etf_result.data.name })) |nm| {
name = clampName(&name_buf, nm);
}
}
} else |_| {}
}
}
if (name == null) {
// Fallback: the ETF profile's fund name, gated on isEtf() so
// it matches the TUI (which only retains fund-shaped
// profiles in symbol_data). Plain stocks not in metadata.srf
// therefore render symbol-only, exactly like the 'K' overlay.
// Only fetched when metadata yielded nothing, so already-named
// holdings don't pay for an EDGAR round-trip.
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
defer etf_result.deinit();
if (etf_result.data.isEtf()) {
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
}
} else |_| {}
}
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps };
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps, .theme = ctx.chart_theme };
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
@ -269,6 +282,7 @@ const ChartRender = union(enum) {
const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
theme: theme.Theme,
};
/// Braille price chart of the most recent `display_count` candles (the
@ -276,9 +290,9 @@ const KittyChart = struct {
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
const n = @min(candles.len, display_count);
const data = candles[candles.len - n ..];
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
var ch = braille.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
try braille.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
}
/// Render the price+Bollinger+volume+RSI chart for the most recent
@ -296,7 +310,7 @@ fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []
const cols = term_graphics.quote_cols;
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true);
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, k.theme, &cached, true);
defer rendered.deinit(allocator);
const rgb = try rendered.extractRgb(allocator);
defer allocator.free(rgb);
@ -340,6 +354,9 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
try out.print(" High: ${d:.2}\n", .{high_val});
try out.print(" Low: ${d:.2}\n", .{low_val});
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
if (prev_close > 0) {
try out.print(" Prev Close: ${d:.2}\n", .{prev_close});
}
if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined;

View file

@ -1,7 +1,8 @@
//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtIntCommas, etc.), financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
//! Number formatting (fmtIntCommas, etc.) and financial helpers
//! (capitalGainsIndicator, filterNearMoney). The braille sparkline
//! chart lives in `charts/braille.zig`.
const std = @import("std");
const Date = @import("Date.zig");
@ -888,329 +889,6 @@ pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
}
}
/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
return .{
@intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
};
}
// Braille chart
/// Braille dot patterns for the 2x4 matrix within each character cell.
/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08
/// [1][4] dot1=0x02, dot4=0x10
/// [2][5] dot2=0x04, dot5=0x20
/// [6][7] dot6=0x40, dot7=0x80
pub const braille_dots = [4][2]u8{
.{ 0x01, 0x08 }, // row 0 (top)
.{ 0x02, 0x10 }, // row 1
.{ 0x04, 0x20 }, // row 2
.{ 0x40, 0x80 }, // row 3 (bottom)
};
/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
pub const braille_utf8 = blk: {
var table: [256][3]u8 = undefined;
for (0..256) |i| {
const cp: u21 = 0x2800 + @as(u21, @intCast(i));
table[i] = .{
@as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
@as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
@as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
};
}
break :blk table;
};
/// Return a static-lifetime grapheme slice for a braille pattern byte.
pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
patterns: []u8,
/// RGB color per data column
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
/// silently drop the label when portfolios cross into ten figures.
/// Renderers that need to budget cells for the label should use
/// `money_label_max_bytes` rather than guessing.
max_label: [money_label_max_bytes]u8,
max_label_len: usize,
min_label: [money_label_max_bytes]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
/// Date of last candle in the chart data
end_date: Date,
pub fn maxLabel(self: *const BrailleChart) []const u8 {
return self.max_label[0..self.max_label_len];
}
pub fn minLabel(self: *const BrailleChart) []const u8 {
return self.min_label[0..self.min_label_len];
}
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
return self.patterns[row * self.n_cols + col];
}
/// Format a date as "MMM DD" for the braille chart x-axis.
/// The year context is already visible in the surrounding CLI/TUI interface.
/// Returns the number of bytes written.
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
const mon = Date.monthShort(date.month());
const d = date.day();
buf[0] = mon[0];
buf[1] = mon[1];
buf[2] = mon[2];
buf[3] = ' ';
if (d >= 10) {
buf[4] = '0' + d / 10;
} else {
buf[4] = '0';
}
buf[5] = '0' + d % 10;
return buf[0..6];
}
/// Format a date for the chart's x-axis at a granularity
/// appropriate for that individual date's recency. Two tiers,
/// per-date (driven by how far back the date is from the
/// chart's `end_date` reference, not by the chart's overall
/// span):
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
///
/// On a 12-year chart, this typically yields a long-format
/// start label (`Jul 2014`) paired with a short-format end
/// label (`08 May`) - the start is far enough back that
/// year context is what matters; the end is recent enough
/// that day-of-month resolution is useful.
///
/// The day-first ordering for the short form is intentional:
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
/// character of each label cleanly disambiguates the format
/// at a glance - digit-first is a recent date, letter-first is
/// a distant date. Saves the eye from re-parsing every label.
///
/// `buf` must be at least 8 bytes; the returned slice borrows
/// from it.
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
const age_days = self.end_date.days - date.days;
const mon = Date.monthShort(date.month());
if (age_days <= 720) {
// "DD MMM" - day-first so the leading character is a
// digit (visually distinct from the letter-first
// long form below).
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
}
// "MMM YYYY" - for dates more than ~2 years before
// `end_date`. Day-of-month resolution stops being useful
// at this scale; full 4-digit year keeps the label
// unambiguous regardless of how far back the chart goes.
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
}
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
alloc.free(self.patterns);
alloc.free(self.col_colors);
}
};
/// Compute braille sparkline chart data from candle close prices.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
alloc: std.mem.Allocator,
data: []const Candle,
chart_width: usize,
chart_height: usize,
positive_color: [3]u8,
negative_color: [3]u8,
) !BrailleChart {
if (data.len < 2) return error.InsufficientData;
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max chart-close prices (split-adjusted when available).
// See `Candle.chartClose` - using raw `close` here would render
// false cliffs at split dates.
var min_price: f64 = data[0].chartClose();
var max_price: f64 = data[0].chartClose();
for (data) |d| {
const cc = d.chartClose();
if (cc < min_price) min_price = cc;
if (cc > max_price) max_price = cc;
}
if (max_price == min_price) max_price = min_price + 1.0;
const price_range = max_price - min_price;
// Price labels
// SAFETY: every field of `result` is initialized below before
// it is read or returned. Treating it as `undefined` here is
// a deliberate "stack-allocate, then write each field"
// pattern - Zig requires the variable to exist before
// bufPrint can take a slice of one of its fields.
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
result.n_cols = n_cols;
result.chart_height = chart_height;
result.start_date = data[0].date;
result.end_date = data[data.len - 1].date;
// Map each data column to a dot-row position and color
const dot_y = try alloc.alloc(usize, n_cols);
defer alloc.free(dot_y);
result.col_colors = try alloc.alloc([3]u8, n_cols);
errdefer alloc.free(result.col_colors);
for (0..n_cols) |col| {
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
const close = data[data_idx].chartClose();
const norm = (close - min_price) / price_range; // 0 = min, 1 = max
// Inverted: 0 = top dot row, dot_rows-1 = bottom
const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
// Color: gradient from negative (bottom) to positive (top)
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
}
// Build the braille pattern grid
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
@memset(result.patterns, 0);
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y down to the bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
// Interpolate between this point and the next for smooth contour
if (col + 1 < n_cols) {
const y0 = dot_y[col];
const y1 = dot_y[col + 1];
const min_y = @min(y0, y1);
const max_y = @max(y0, y1);
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
}
}
return result;
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
out: *std.Io.Writer,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
skip_date_axis: bool,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
var last_b: u8 = 0;
var color_active = false;
for (0..chart.chart_height) |row| {
try out.writeAll(" "); // 2 leading spaces
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
if (use_color and pat != 0) {
const c = chart.col_colors[col];
// Only emit color escape if color changed
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
last_r = c[0];
last_g = c[1];
last_b = c[2];
color_active = true;
}
} else if (color_active and pat == 0) {
try out.writeAll("\x1b[0m");
color_active = false;
}
try out.writeAll(brailleGlyph(pat));
}
if (color_active) {
try out.writeAll("\x1b[0m");
color_active = false;
}
// Price label on first/last row
if (row == 0) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.maxLabel()});
if (use_color) try out.writeAll("\x1b[0m");
} else if (row == chart.chart_height - 1) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.minLabel()});
if (use_color) try out.writeAll("\x1b[0m");
}
try out.writeAll("\n");
}
// Date axis below chart
if (!skip_date_axis) {
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
}
// ANSI color helpers (for CLI)
/// Determine whether to use ANSI color output.
@ -1531,151 +1209,6 @@ test "aggregateDripLots empty" {
try std.testing.expect(agg.lt.isEmpty());
}
test "lerpColor" {
// t=0 returns first color
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
try std.testing.expectEqual(@as(u8, 0), c0[0]);
try std.testing.expectEqual(@as(u8, 0), c0[1]);
// t=1 returns second color
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
try std.testing.expectEqual(@as(u8, 255), c1[0]);
// t=0.5 returns midpoint
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}
test "brailleGlyph" {
// Pattern 0 = U+2800 (blank braille)
const blank = brailleGlyph(0);
try std.testing.expectEqual(@as(usize, 3), blank.len);
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
// Pattern 0xFF = U+28FF (full braille)
const full = brailleGlyph(0xFF);
try std.testing.expectEqual(@as(usize, 3), full.len);
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "fmtShortDate" {
var buf: [7]u8 = undefined;
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
try std.testing.expectEqualStrings("Jan 15", jan15);
const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf);
try std.testing.expectEqualStrings("Dec 01", dec01);
const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf);
try std.testing.expectEqualStrings("Jun 09", jun09);
}
test "computeBrailleChart" {
const alloc = std.testing.allocator;
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
var candles: [20]Candle = undefined;
for (0..20) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
// Max/min labels should contain price info
try std.testing.expect(chart.maxLabel().len > 0);
try std.testing.expect(chart.minLabel().len > 0);
}
test "computeBrailleChart insufficient data" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
try std.testing.expectError(error.InsufficientData, result);
}
test "computeBrailleChart preserves full label for prices over $1M" {
// Regression test for a bug where the max/min label buffers were
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
// with 13+ chars (anything $1,000,000+). Result was a silently
// empty label string in the BrailleChart, then the TUI renderer
// truncated even further to 10 cells, dropping the label entirely
// for portfolios over $1M. See `money_label_max_bytes`.
const alloc = std.testing.allocator;
var candles: [20]Candle = undefined;
for (0..20) |i| {
// Arbitrary placeholder range starting at $1,234,567.89 with
// a $500,000 step. The point of this test is the rendered
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
// overflowed the old 16-byte label buffer or the renderer's
// 10-cell budget. The exact numbers are irrelevant.
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Both labels must contain the dollar sign and a comma (the
// thousands separator) - that confirms `Money.from` produced
// a multi-million-dollar string and didn't fall through to the
// empty-string fallback when bufPrint hit NoSpaceLeft.
const max_lbl = chart.maxLabel();
const min_lbl = chart.minLabel();
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
// Both labels should match the `$X,XXX,XXX.XX` shape (at
// least 13 chars). Any silent-truncation bug would leave them
// empty or much shorter.
try std.testing.expect(max_lbl.len >= 13);
try std.testing.expect(min_lbl.len >= 13);
}
test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
// close drops 300 -> 100, but adj_close is constant at 100. The
// chart should see a flat line, not a cliff.
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Min and max labels should reflect the adjusted price (~$100),
// not the raw close range (300 -> 100). The exact values vary
// because computeBrailleChart bumps max by $1 internally when
// min == max, but neither label should mention $300.
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
}
test "fmtContractLine" {
var buf: [128]u8 = undefined;
const contract = OptionContract{
@ -1868,62 +1401,6 @@ test "fmtTimeAgo: days" {
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
}
test "fmtAxisDate: span <=720d produces DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2026, 1, 1);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
try std.testing.expectEqualStrings("27 Apr", lbl);
}
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
// 700 days from 2024-01-01 - still inside the threshold.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 1, 1);
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
// 12-year chart: start is way more than 720 days from end,
// so the start gets MMM YYYY. End is `end_date` itself
// (age 0), so it gets DD MMM.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2014, 7, 3);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
var buf2: [8]u8 = undefined;
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
try std.testing.expectEqualStrings("11 May", end_lbl);
}
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2025, 1, 1);
// 720 days later = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 12, 31);
// 721 days after 2024-12-31 = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Format the start (which is 721 days before end_date).
const lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Dec 2024", lbl);
}
test "displayCols: ASCII bytes count as 1 col each" {
try std.testing.expectEqual(@as(usize, 0), displayCols(""));
try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));

View file

@ -4,6 +4,7 @@ const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig");
const chart = @import("charts/chart.zig");
const theme = @import("tui/theme.zig");
const term_query = @import("term_query.zig");
/// Comptime registry of CLI commands. Field name is the user-facing
@ -134,6 +135,11 @@ const interactive_help =
\\ (e.g. 80x24); `auto` picks Kitty graphics
\\ if the terminal supports it, otherwise
\\ braille
\\ --theme <PATH> Theme file (a `theme.srf`) that skins the
\\ whole app: CLI text, charts, and the TUI.
\\ Falls back to ~/.config/zfin/theme.srf,
\\ then the built-in theme. Generate one with
\\ `zfin interactive --default-theme`.
\\ --default-keys Print default keybindings as a `keys.srf`
\\ template and exit (no TUI launched).
\\ Pipe to `~/.config/zfin/keys.srf` to
@ -166,6 +172,10 @@ const Globals = struct {
refresh_policy: cmd_framework.RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH).
chart_config: chart.ChartConfig = .{},
/// Theme file from `--theme <PATH>` (a `theme.srf`) that skins the
/// whole app - CLI text palette, charts, and the TUI. Null falls
/// back to ~/.config/zfin/theme.srf, then the built-in default.
theme_path: ?[]const u8 = null,
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
@ -299,6 +309,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
i += 2;
continue;
}
if (std.mem.eql(u8, a, "--theme")) {
if (i + 1 >= args.len) return error.MissingValue;
g.theme_path = args[i + 1];
i += 2;
continue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
@ -309,6 +325,30 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
return g;
}
/// Resolve the app-wide theme once. Precedence: an explicit
/// `--theme <PATH>` (warns + falls back to default if it can't load),
/// then `$HOME/.config/zfin/theme.srf` when present, then the built-in
/// default. The single resolved value skins everything identically -
/// CLI text palette, CLI charts, and the TUI.
fn resolveTheme(
io: std.Io,
allocator: std.mem.Allocator,
environ_map: *const std.process.Environ.Map,
theme_path: ?[]const u8,
) theme.Theme {
if (theme_path) |p| {
return theme.loadFromFile(io, allocator, p) orelse blk: {
cli.stderrPrint(io, "Note: could not load --theme file; using the default theme.\n");
break :blk theme.default_theme;
};
}
const home = environ_map.get("HOME") orelse return theme.default_theme;
const cfg_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
return theme.default_theme;
defer allocator.free(cfg_path);
return theme.loadFromFile(io, allocator, cfg_path) orelse theme.default_theme;
}
pub fn main(init: std.process.Init) !u8 {
return runCli(init) catch |err| switch (err) {
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
@ -430,6 +470,13 @@ fn runCli(init: std.process.Init) !u8 {
const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);
// Resolve the theme once for the entire invocation. This one value
// skins the CLI text palette (via cli.applyTheme), CLI charts (via
// RunCtx.chart_theme), and the TUI (passed into tui.run) - so
// `--theme <PATH>` (or ~/.config/zfin/theme.srf) repaints everything.
const resolved_theme = resolveTheme(io, gpa_alloc, init.environ_map, globals.theme_path);
cli.applyTheme(resolved_theme);
const command = args[globals.cursor];
const cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]);
@ -454,7 +501,7 @@ fn runCli(init: std.process.Init) !u8 {
// loader resolve + union-merge the same way the CLI does.
// This is the load-bearing fix for "CLI and TUI report
// different totals" - there's exactly one code path now.
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, resolved_theme, cmd_args, today) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
// panic / stack trace.
@ -522,6 +569,7 @@ fn runCli(init: std.process.Init) !u8 {
.color = color,
.out = out,
.graphics_caps = term_query.detect(io, init.environ_map),
.chart_theme = resolved_theme,
};
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
try cmd_framework.normalizeFirstArg(allocator, cmd_args)

View file

@ -146,41 +146,66 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
return try allocator.dupe(u8, "Unclassified");
}
/// Resolve a human-readable security name for `symbol`, applying
/// the project-wide name-source policy:
/// 1. The curated `name::` field from `metadata.srf` (via the
/// classification map) - the same source the TUI 'K' overlay
/// uses. Wins whenever present.
/// 2. `fallback_name` (typically the ETF profile's fund name)
/// when metadata has no name for the symbol.
/// The candidate name sources for a security, other than the curated
/// `metadata.srf` name (which `resolveSecurityName` looks up from the
/// classification map and ranks above all of these). Callers fill in
/// whatever they have on hand and leave the rest null; the precedence
/// *between* these sources is decided by `resolveSecurityName`, not by
/// the caller - that's the whole point of routing every surface
/// through one function.
pub const NameSources = struct {
/// The live quote provider's name (Yahoo `longName`), read from the
/// same response as the price. Ranked above the ETF profile because
/// it reflects what currently trades under the ticker and so
/// self-heals when a ticker is recycled (e.g. SPCX: SpaceX vs. the
/// defunct "SPAC and New Issue ETF").
live_quote: ?[]const u8 = null,
/// The ETF/fund profile name (EDGAR series name or Wikidata name).
etf_profile: ?[]const u8 = null,
};
/// Resolve a human-readable security name for `symbol`. This is the
/// single home for the name-precedence *policy*; every surface (the
/// CLI `quote` command, the TUI quote tab, the 'K' overlay) calls it
/// so they cannot drift. The policy, highest priority first:
///
/// Returns a slice borrowed from `cm` or `fallback_name` (the
/// caller must keep those alive for as long as the result is used),
/// or null when neither source yields a name.
/// 1. The curated `name::` from `metadata.srf` (via `cm`) - the
/// user's explicit override.
/// 2. `sources.live_quote` - the live quote provider name.
/// 3. `sources.etf_profile` - the ETF/fund profile name.
///
/// This is the single source of truth for the security name shown
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
/// normalizes symbols to upper-case before calling, matching the
/// upper-case symbols `metadata.srf` carries.
/// Empty names are skipped at every level. Returns a slice borrowed
/// from `cm` or one of `sources` (keep them alive while the result is
/// used), or null when no source yields a name. Symbol comparison is
/// exact (`std.mem.eql`); every caller upper-cases symbols first,
/// matching the upper-case symbols `metadata.srf` carries.
pub fn resolveSecurityName(
symbol: []const u8,
cm: ?*const ClassificationMap,
fallback_name: ?[]const u8,
sources: NameSources,
) ?[]const u8 {
// 1. Curated metadata name wins. First matching entry that carries
// a name wins; blended-fund symbols repeat across rows with the
// same name, so this is well-defined.
if (cm) |m| {
for (m.entries) |*e| {
if (std.mem.eql(u8, e.symbol, symbol)) {
// First matching entry that actually carries a name
// wins. Blended-fund symbols repeat across rows with
// the same name, so this is well-defined.
if (e.name) |n| {
if (n.len > 0) return n;
}
}
}
}
return fallback_name;
// 2. then the live quote name, 3. then the ETF profile name.
if (nonEmptyName(sources.live_quote)) |n| return n;
if (nonEmptyName(sources.etf_profile)) |n| return n;
return null;
}
/// An optional name, treating empty as absent.
fn nonEmptyName(s: ?[]const u8) ?[]const u8 {
const v = s orelse return null;
return if (v.len > 0) v else null;
}
test "parse classification file" {
@ -253,14 +278,14 @@ test "resolveSecurityName: metadata name wins" {
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
// Metadata hit; fallback ignored.
// Metadata hit; lower-priority sources ignored.
try std.testing.expectEqualStrings(
"Amazon",
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?,
);
try std.testing.expectEqualStrings(
"Vanguard Total Stock Market ETF",
resolveSecurityName("VTI", &cm, null).?,
resolveSecurityName("VTI", &cm, .{}).?,
);
}
@ -271,10 +296,10 @@ test "resolveSecurityName: falls back when entry has no name" {
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"iShares Semiconductor ETF",
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?,
);
// No fallback either -> null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
// No other source either -> null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null);
}
test "resolveSecurityName: falls back when symbol absent from map" {
@ -284,16 +309,16 @@ test "resolveSecurityName: falls back when symbol absent from map" {
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"SPDR S&P 500 ETF Trust",
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
resolveSecurityName("SPY", &cm, .{ .etf_profile = "SPDR S&P 500 ETF Trust" }).?,
);
}
test "resolveSecurityName: null map uses fallback, null everything is null" {
test "resolveSecurityName: null map uses a source, null everything is null" {
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", null, "Apple Inc.").?,
resolveSecurityName("AAPL", null, .{ .etf_profile = "Apple Inc." }).?,
);
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
try std.testing.expect(resolveSecurityName("AAPL", null, .{}) == null);
}
test "resolveSecurityName: empty metadata name treated as absent" {
@ -303,10 +328,44 @@ test "resolveSecurityName: empty metadata name treated as absent" {
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
resolveSecurityName("AAPL", &cm, .{ .etf_profile = "Apple Inc." }).?,
);
}
test "resolveSecurityName: precedence is metadata > live quote > etf profile" {
// No metadata: the live quote name beats the ETF profile name. This
// is the SPCX case - the live Yahoo name wins over the stale
// recycled-ticker ETF name. The ordering lives in the function, not
// in the caller, so this test pins the policy itself.
try std.testing.expectEqualStrings(
"Space Exploration Technologies Corp.",
resolveSecurityName("SPCX", null, .{
.live_quote = "Space Exploration Technologies Corp.",
.etf_profile = "The SPAC and New Issue ETF",
}).?,
);
// An empty live quote name is skipped, so the ETF profile shows.
try std.testing.expectEqualStrings(
"The SPAC and New Issue ETF",
resolveSecurityName("SPCX", null, .{
.live_quote = "",
.etf_profile = "The SPAC and New Issue ETF",
}).?,
);
// Metadata still outranks both lower-priority sources.
var entries = [_]ClassificationEntry{.{ .symbol = "SPCX", .name = "My Override" }};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"My Override",
resolveSecurityName("SPCX", &cm, .{
.live_quote = "Space Exploration Technologies Corp.",
.etf_profile = "The SPAC and New Issue ETF",
}).?,
);
// No sources at all -> null.
try std.testing.expect(resolveSecurityName("SPCX", null, .{}) == null);
}
test "deriveBucket: returns user-curated bucket when set" {
const e: ClassificationEntry = .{
.symbol = "SPY",

View file

@ -1,7 +1,16 @@
/// Real-time (or near-real-time) quote snapshot for a symbol.
pub const Quote = struct {
symbol: []const u8,
name: []const u8,
/// Display-name storage. Inline (not a slice) so the name travels
/// with the value: the TUI stores a `Quote` by value in tab state,
/// and provider parsers copy the name out of transient JSON before
/// that JSON is freed. Read via `name()`, write via `setName()`;
/// never touch these two fields directly. The buffer is zero-filled
/// (not `undefined`) so a Quote constructed without a name - the
/// providers only call `setName` when one is present - still yields
/// an empty `name()` and can never expose uninitialized bytes.
name_buf: [256]u8 = @splat(0),
name_len: usize = 0,
exchange: []const u8,
datetime: []const u8,
close: f64,
@ -15,4 +24,18 @@ pub const Quote = struct {
average_volume: u64,
fifty_two_week_low: f64,
fifty_two_week_high: f64,
/// The display name, or "" when unset. Borrowed from the Quote;
/// valid for as long as the Quote value is alive.
pub fn name(self: *const Quote) []const u8 {
return self.name_buf[0..self.name_len];
}
/// Copy `s` into the inline name buffer, truncating to capacity.
/// Callers should pass an already-trimmed string.
pub fn setName(self: *Quote, s: []const u8) void {
const n = @min(s.len, self.name_buf.len);
@memcpy(self.name_buf[0..n], s[0..n]);
self.name_len = n;
}
};

View file

@ -1,4 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.http);
@ -406,10 +407,17 @@ pub const Client = struct {
// those to debug so the warn-level log stream
// stays focused on cases the operator can act on
// (auth, rate, server outages).
if (response.status == .not_found) {
log.debug("http rejection body status=404 body={s}", .{response.body});
} else {
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
//
// Skipped entirely under `zig build test`: the
// error-classification tests intentionally drive
// non-2xx statuses through here, and their warn
// output would otherwise pollute the test stream.
if (!builtin.is_test) {
if (response.status == .not_found) {
log.debug("http rejection body status=404 body={s}", .{response.body});
} else {
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
}
}
response.allocator.free(response.body);
if (response.etag) |e| response.allocator.free(e);

View file

@ -155,6 +155,8 @@ const RateLimiter = @import("../net/RateLimiter.zig");
const fmt = @import("../format.zig");
const xml = @import("xml.zig");
const log = std.log.scoped(.edgar);
const tickers_funds_url = "https://www.sec.gov/files/company_tickers_mf.json";
const tickers_companies_url = "https://www.sec.gov/files/company_tickers.json";
const search_url_prefix = "https://efts.sec.gov/LATEST/search-index?";
@ -336,63 +338,94 @@ pub fn fetchEtfMetrics(
symbol: []const u8,
top_n_holdings: usize,
) !EtfMetricsResult {
// MF/ETF map first - authoritative for symbols filed under a
// series. Series-keyed full-text search; CIK fallback would
// yield arbitrary other series under the same trust.
if (mf_ticker_map.get(symbol)) |entry| {
const filing_url = (try self.findLatestNportP(allocator, entry.series_id.?)) orelse {
return .not_a_fund;
};
defer allocator.free(filing_url);
const m = try self.fetchAndParseNportP(
io,
allocator,
entry.toGeneric(),
filing_url,
symbol,
top_n_holdings,
);
return .{ .full = m };
}
const mf_entry = mf_ticker_map.get(symbol);
const co_entry = stock_ticker_map.get(symbol);
// Stock map: probe the submissions feed (one extra HTTP per
// unique CIK) to classify the entity. Branches:
// - fund_shaped + has NPORT-P -> full holdings (SPY)
// - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer)
// - trust_shaped -> profile-only (GLD commodity)
// - operating -> not-a-fund (AAPL, MSFT)
if (stock_ticker_map.get(symbol)) |entry| {
var sub = try self.fetchSubmissionsFeed(allocator, entry.cik);
defer sub.deinit(allocator);
const class = classifyByEntityType(&sub);
switch (class) {
.operating => return .not_a_fund,
.fund_shaped => {
if (sub.latest_nport_p_url) |url| {
const m = try self.fetchAndParseNportP(
io,
allocator,
entry.toGeneric(),
url,
symbol,
top_n_holdings,
);
return .{ .full = m };
}
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
return .{ .profile_only = profile };
},
.trust_shaped => {
// Skip the NPORT-P probe - by definition these
// don't file one. Saves an HTTP roundtrip.
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
return .{ .profile_only = profile };
},
// Ticker-recycling guard: when a symbol is in BOTH ticker maps,
// classify the company side up front. SEC's
// `company_tickers_mf.json` is slow to drop a ticker after it's
// reassigned, so a stale fund-series mapping can collide with an
// operating company that now owns the ticker (e.g. SPCX: SpaceX's
// 2026 IPO took over the defunct Tuttle "SPAC and New Issue ETF"
// ticker). The probe is best-effort - on failure we leave
// `co_class` null and `resolveMapPrecedence` trusts the MF map, so
// a flaky company-feed fetch can't regress a both-map symbol.
var co_sub: ?SubmissionsSummary = null;
defer if (co_sub) |*s| s.deinit(allocator);
var co_class: ?EntityClass = null;
if (mf_entry != null and co_entry != null) {
if (self.fetchSubmissionsFeed(allocator, co_entry.?.cik)) |sub| {
co_sub = sub;
co_class = classifyByEntityType(&co_sub.?);
} else |err| {
log.warn("{s}: recycled-ticker company probe failed, trusting MF map: {s}", .{ symbol, @errorName(err) });
}
}
return .not_in_edgar;
switch (resolveMapPrecedence(mf_entry != null, co_entry != null, co_class)) {
// A ticker now owned by an operating company: do not report the
// stale fund series as a fund.
.recycled_not_a_fund => return .not_a_fund,
// MF/ETF map - authoritative for symbols filed under a series.
// Series-keyed full-text search; a CIK fallback would yield
// arbitrary other series under the same trust.
.mf_series => {
const entry = mf_entry.?;
const filing_url = (try self.findLatestNportP(allocator, entry.series_id.?)) orelse {
return .not_a_fund;
};
defer allocator.free(filing_url);
const m = try self.fetchAndParseNportP(
io,
allocator,
entry.toGeneric(),
filing_url,
symbol,
top_n_holdings,
);
return .{ .full = m };
},
// Stock map only: probe the submissions feed (one extra HTTP
// per unique CIK) to classify the entity. Branches:
// - fund_shaped + has NPORT-P -> full holdings (SPY)
// - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer)
// - trust_shaped -> profile-only (GLD commodity)
// - operating -> not-a-fund (AAPL, MSFT)
.probe_company => {
const entry = co_entry.?;
var sub = try self.fetchSubmissionsFeed(allocator, entry.cik);
defer sub.deinit(allocator);
switch (classifyByEntityType(&sub)) {
.operating => return .not_a_fund,
.fund_shaped => {
if (sub.latest_nport_p_url) |url| {
const m = try self.fetchAndParseNportP(
io,
allocator,
entry.toGeneric(),
url,
symbol,
top_n_holdings,
);
return .{ .full = m };
}
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
return .{ .profile_only = profile };
},
.trust_shaped => {
// Skip the NPORT-P probe - by definition these
// don't file one. Saves an HTTP roundtrip.
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
return .{ .profile_only = profile };
},
}
},
.not_in_edgar => return .not_in_edgar,
}
}
/// Download and parse a NPORT-P primary_doc.xml at `filing_url`.
@ -1221,6 +1254,73 @@ fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const
);
}
/// Coarse classification of an EDGAR entity from its submissions feed,
/// produced by `classifyByEntityType`.
const EntityClass = enum { fund_shaped, trust_shaped, operating };
/// Which path `fetchEtfMetrics` should take for a symbol, given its
/// ticker-map membership.
const MapResolution = enum {
/// Use the MF/ETF series-keyed path (symbol filed under a series).
mf_series,
/// Probe the company submissions feed to classify (symbol present
/// only in the company map).
probe_company,
/// A ticker present in both maps that now belongs to an operating
/// company (recycled ticker): treat as not-a-fund.
recycled_not_a_fund,
/// Symbol is in neither ticker map.
not_in_edgar,
};
/// Pure precedence decision for `fetchEtfMetrics`. `in_mf` / `in_co`
/// are whether the symbol appears in the MF/ETF and company ticker
/// maps; `co_class` is the company side's classification, which is
/// only consulted (and only needs to be computed) when the symbol is
/// in BOTH maps.
///
/// The load-bearing case is a both-maps collision. SEC's
/// `company_tickers_mf.json` lags ticker reassignments, so a stale
/// fund-series mapping can coexist with an operating company that now
/// owns the ticker (e.g. SPCX: SpaceX's 2026 IPO over the defunct
/// Tuttle "SPAC and New Issue ETF"). When the company side is an
/// operating company the ticker belongs to it now, so the stale series
/// must not be reported as a fund. A fund/trust-shaped company side
/// keeps the more precise series path. A null `co_class` (probe failed
/// or not performed) falls back to the MF path - the pre-guard
/// behavior.
fn resolveMapPrecedence(in_mf: bool, in_co: bool, co_class: ?EntityClass) MapResolution {
if (in_mf and in_co) {
if (co_class) |c| return switch (c) {
.operating => .recycled_not_a_fund,
.fund_shaped, .trust_shaped => .mf_series,
};
return .mf_series;
}
if (in_mf) return .mf_series;
if (in_co) return .probe_company;
return .not_in_edgar;
}
test "resolveMapPrecedence: ticker-map membership decides the path" {
const T = std.testing;
// Single-map cases ignore co_class.
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, false, null));
try T.expectEqual(MapResolution.probe_company, resolveMapPrecedence(false, true, null));
try T.expectEqual(MapResolution.not_in_edgar, resolveMapPrecedence(false, false, null));
// Both maps: an operating company side means the ticker was
// recycled (SPCX/SpaceX) -> not a fund.
try T.expectEqual(MapResolution.recycled_not_a_fund, resolveMapPrecedence(true, true, .operating));
// Both maps but the company side is itself a fund/trust: the
// series-keyed MF path remains the more precise source.
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, .fund_shaped));
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, .trust_shaped));
// Both maps but the probe failed (null): trust the MF map, matching
// the pre-guard behavior.
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, null));
}
/// Classify a CIK based on its submissions-feed metadata. Decides
/// whether the symbol is a registered fund (probe NPORT-P), a
/// trust/ETN-style instrument (profile-only), or a plain operating
@ -1258,11 +1358,7 @@ fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const
/// that distribute rental income, not registered investment
/// companies. They get bucketed under `operating` - Wikidata is
/// the right source for them.
fn classifyByEntityType(sub: *const SubmissionsSummary) enum {
fund_shaped,
trust_shaped,
operating,
} {
fn classifyByEntityType(sub: *const SubmissionsSummary) EntityClass {
// Rule 1: NPORT-P presence is the strongest fund signal.
if (sub.latest_nport_p_url != null) return .fund_shaped;
@ -1364,6 +1460,16 @@ test "classifyByEntityType buckets real-world entities" {
s.sic_description = try T.allocator.dupe(u8, "Real Estate Investment Trusts");
try T.expectEqual(.operating, classifyByEntityType(&s));
}
// SPCX/SpaceX after its 2026 IPO: operating company, no NPORT-P,
// aerospace SIC. Must be `operating` so the ticker-recycling guard
// can override the stale "SPAC and New Issue ETF" fund-map entry.
{
var s: SubmissionsSummary = .{};
defer s.deinit(T.allocator);
s.entity_type = try T.allocator.dupe(u8, "operating");
s.sic_description = try T.allocator.dupe(u8, "Guided Missiles & Space Vehicles & Parts");
try T.expectEqual(.operating, classifyByEntityType(&s));
}
}
/// Result kind for `fetchEtfMetrics`. The caller - see `main.zig` -

View file

@ -184,9 +184,8 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
const ftw = root.get("fifty_two_week");
return .{
var quote: Quote = .{
.symbol = symbol,
.name = symbol,
.exchange = "",
.datetime = "",
.close = parseJsonFloat(root.get("close")),
@ -207,6 +206,14 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
else => 0,
} else 0,
};
// TwelveData's /quote payload carries the security name in `name`.
// Copy it before the parsed JSON is freed on return so the name
// rides with the quote (same contract as the Yahoo path).
if (json_utils.jsonStr(root.get("name"))) |raw| {
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
if (trimmed.len > 0) quote.setName(trimmed);
}
return quote;
}
// -- Tests --
@ -348,6 +355,24 @@ test "parseQuoteResponse basic" {
try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume);
try std.testing.expectApproxEqAbs(@as(f64, 140.0), quote.fifty_two_week_low, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 200.0), quote.fifty_two_week_high, 0.01);
try std.testing.expectEqualStrings("Apple Inc", quote.name());
}
test "parseQuoteResponse: missing name field leaves the name empty" {
// `name` is optional in TwelveData's payload. When absent, setName
// is never called, so name() must return "" - never the buffer's
// uninitialized tail.
const body =
\\{
\\ "symbol": "FOO",
\\ "close": "10.00",
\\ "previous_close": "9.50"
\\}
;
const allocator = std.testing.allocator;
const quote = try parseQuoteResponse(allocator, body, "FOO");
try std.testing.expectEqualStrings("", quote.name());
try std.testing.expectApproxEqAbs(@as(f64, 10.00), quote.close, 0.01);
}
test "parseQuoteResponse error response" {

View file

@ -13,6 +13,8 @@ const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
const Quote = @import("../models/quote.zig").Quote;
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
const optFloat = @import("json_utils.zig").optFloat;
const jsonStr = @import("json_utils.zig").jsonStr;
const base_url = "https://query1.finance.yahoo.com/v8/finance/chart";
@ -222,20 +224,39 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con
};
const price = parseJsonFloat(m.get("regularMarketPrice"));
const prev_close = parseJsonFloat(m.get("chartPreviousClose"));
// The day's open/high/low/volume AND the previous close come from
// the indicators.quote arrays, not the meta block:
// - meta has no reliable regularMarketOpen, and its fiftyTwoWeek*
// fields are the 52-week range, not the day's.
// - meta.chartPreviousClose is the close before the *entire*
// requested range (range=5d here), so it is ~a week stale and
// turns a flat day into a huge bogus 1-day change. The correct
// previous close is the prior trading day's close from the
// close array.
// Falls back to `price` (open/high/low), 0 (volume), and the meta
// chartPreviousClose (previous close) when the arrays are absent or
// hold a single bar (where chartPreviousClose is itself correct).
const snap = parseDaySnapshot(result);
const open = if (snap) |s| (s.open orelse price) else price;
const high = if (snap) |s| (s.high orelse price) else price;
const low = if (snap) |s| (s.low orelse price) else price;
const volume: u64 = if (snap) |s| (s.volume orelse 0) else 0;
const prev_close = (if (snap) |s| s.prev_close else null) orelse parseJsonFloat(m.get("chartPreviousClose"));
const change = price - prev_close;
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
return .{
var quote: Quote = .{
.symbol = symbol,
.name = symbol,
.exchange = "",
.datetime = "",
.close = price,
.open = price, // meta doesn't have open
.high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
.low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
.volume = 0,
.open = open,
.high = high,
.low = low,
.volume = volume,
.previous_close = prev_close,
.change = change,
.percent_change = pct,
@ -243,6 +264,16 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con
.fifty_two_week_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
.fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
};
// Yahoo's chart `meta` carries the security's display name in
// `longName` (preferred) / `shortName`. Copy it into the quote's
// inline buffer before the parsed JSON is freed on return. This is
// the same live source as the price, so the name and price always
// describe the same security even after a ticker is recycled.
if (jsonStr(m.get("longName")) orelse jsonStr(m.get("shortName"))) |raw| {
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
if (trimmed.len > 0) quote.setName(trimmed);
}
return quote;
}
fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
@ -253,6 +284,79 @@ fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
};
}
/// Optional float at array index `i`: null when out of bounds or JSON null.
fn optFloatAt(arr: []const std.json.Value, i: usize) ?f64 {
if (i >= arr.len) return null;
return optFloat(arr[i]);
}
/// The current trading day's OHLCV plus the prior day's close, pulled
/// from a chart result's `indicators.quote` arrays.
const DaySnapshot = struct {
open: ?f64,
high: ?f64,
low: ?f64,
volume: ?u64,
/// Close of the most recent trading day *before* the current (last)
/// bar in the window - the correct base for a 1-day change. null
/// when the window holds no prior day (single-bar window), in which
/// case the caller falls back to meta.chartPreviousClose.
prev_close: ?f64,
};
/// Build a DaySnapshot from a chart result. The current bar is the last
/// array entry (the in-progress day while the market is open - its
/// close is null then, but open/high/low/volume are live - or the last
/// completed day). The previous close is the most recent non-null close
/// strictly before that last entry. Returns null when the
/// indicators.quote arrays are absent or empty.
fn parseDaySnapshot(result: std.json.ObjectMap) ?DaySnapshot {
const indicators = result.get("indicators") orelse return null;
const indicators_obj = switch (indicators) {
.object => |o| o,
else => return null,
};
const quote_arr = indicators_obj.get("quote") orelse return null;
const quotes = switch (quote_arr) {
.array => |a| a.items,
else => return null,
};
if (quotes.len == 0) return null;
const q = switch (quotes[0]) {
.object => |o| o,
else => return null,
};
const opens = getFloatArray(q.get("open")) orelse return null;
const highs = getFloatArray(q.get("high")) orelse return null;
const lows = getFloatArray(q.get("low")) orelse return null;
const closes = getFloatArray(q.get("close")) orelse return null;
const volumes = getFloatArray(q.get("volume")) orelse return null;
if (closes.len == 0) return null;
const last = closes.len - 1;
// Previous close: scan backward from the entry just before the
// current (last) one for the first non-null close.
var prev_close: ?f64 = null;
var i = last;
while (i > 0) {
i -= 1;
if (optFloatAt(closes, i)) |c| {
prev_close = c;
break;
}
}
return .{
.open = optFloatAt(opens, last),
.high = optFloatAt(highs, last),
.low = optFloatAt(lows, last),
.volume = if (optFloatAt(volumes, last)) |v| @as(u64, @intFromFloat(@max(0, v))) else null,
.prev_close = prev_close,
};
}
// -- Tests --
test "parseChartCandles basic" {
@ -352,7 +456,7 @@ test "parseChartQuote basic" {
\\ "fiftyTwoWeekLow": 22.21
\\ },
\\ "timestamp": [1704067800],
\\ "indicators": {"quote": [{"open": [27.78], "high": [27.78], "low": [27.78], "close": [27.78], "volume": [0]}]}
\\ "indicators": {"quote": [{"open": [27.50], "high": [27.90], "low": [27.40], "close": [27.78], "volume": [123456]}]}
\\ }],
\\ "error": null
\\ }
@ -363,11 +467,232 @@ test "parseChartQuote basic" {
const quote = try parseChartQuote(allocator, body, "VTTHX");
try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01);
// Single-bar window: no prior day in the array, so the previous
// close falls back to meta chartPreviousClose (which IS correct when
// the window is a single day).
try std.testing.expectApproxEqAbs(@as(f64, 28.06), quote.previous_close, 0.01);
// Day OHLCV comes from indicators.quote - NOT the meta 52-week range
// and NOT regularMarketPrice. Regression: open used to be the current
// price, and high/low used to be the 52-week high/low.
try std.testing.expectApproxEqAbs(@as(f64, 27.50), quote.open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 27.90), quote.high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 27.40), quote.low, 0.01);
try std.testing.expectEqual(@as(u64, 123456), quote.volume);
// The 52-week range stays in its own dedicated fields.
try std.testing.expectApproxEqAbs(@as(f64, 28.59), quote.fifty_two_week_high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01);
// change = 27.78 - 28.06 = -0.28
try std.testing.expectApproxEqAbs(@as(f64, -0.28), quote.change, 0.01);
// percent_change = (-0.28 / 28.06) * 100 -0.998
// percent_change = (-0.28 / 28.06) * 100 ~ -0.998
try std.testing.expectApproxEqAbs(@as(f64, -0.998), quote.percent_change, 0.01);
}
test "parseChartQuote: in-progress day uses live bar and prior day's close" {
// Mirrors the SPCX bug: the market is open, so the last array entry
// is today (open/high/low/volume are live, close still null), while
// meta.chartPreviousClose is the close from *before* the whole 5-day
// range - badly stale. The 1-day change must use yesterday's close
// from the array, not the stale meta value (which produced a bogus
// -17% day).
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "SPCX",
\\ "regularMarketPrice": 153.23,
\\ "chartPreviousClose": 185.00,
\\ "fiftyTwoWeekHigh": 225.64,
\\ "fiftyTwoWeekLow": 147.11
\\ },
\\ "timestamp": [1782135000, 1782221400, 1782307800, 1782394200, 1782480600],
\\ "indicators": {"quote": [{
\\ "open": [176.04, 151.06, 154.20, 156.63, 150.62],
\\ "high": [176.75, 165.50, 159.86, 160.65, 158.40],
\\ "low": [154.00, 147.11, 150.72, 150.00, 148.51],
\\ "close": [154.60, 156.11, 154.54, 153.00, null],
\\ "volume": [169183800, 155848100, 76101500, 62212400, 126431973]
\\ }]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "SPCX");
// Today's (in-progress) bar is the last array entry, even though its
// close is null.
try std.testing.expectApproxEqAbs(@as(f64, 150.62), quote.open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 158.40), quote.high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 148.51), quote.low, 0.01);
try std.testing.expectEqual(@as(u64, 126431973), quote.volume);
// Previous close = yesterday's array close (153.00), NOT the stale
// meta chartPreviousClose (185.00).
try std.testing.expectApproxEqAbs(@as(f64, 153.00), quote.previous_close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 153.23), quote.close, 0.01);
// change = 153.23 - 153.00 = +0.23 (~+0.15%), not -17%.
try std.testing.expectApproxEqAbs(@as(f64, 0.23), quote.change, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.150), quote.percent_change, 0.01);
}
test "parseChartQuote: market-closed day uses last close and prior day's close" {
// After the close, the last array entry is a completed day (close
// non-null) and matches regularMarketPrice. Previous close is the
// day immediately before it, not two days back and not the stale
// range close.
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "AAPL",
\\ "regularMarketPrice": 105.00,
\\ "chartPreviousClose": 90.00,
\\ "fiftyTwoWeekHigh": 130.00,
\\ "fiftyTwoWeekLow": 80.00
\\ },
\\ "timestamp": [1704067800, 1704154200, 1704240600],
\\ "indicators": {"quote": [{
\\ "open": [99.00, 101.00, 104.00],
\\ "high": [100.00, 103.00, 106.00],
\\ "low": [98.00, 100.50, 103.50],
\\ "close": [100.00, 102.00, 105.00],
\\ "volume": [1000000, 1100000, 1200000]
\\ }]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "AAPL");
// Current (last) completed bar.
try std.testing.expectApproxEqAbs(@as(f64, 104.00), quote.open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 106.00), quote.high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 103.50), quote.low, 0.01);
try std.testing.expectEqual(@as(u64, 1200000), quote.volume);
// Previous close = the day before the last (102.00), not 100.00 and
// not the stale meta 90.00.
try std.testing.expectApproxEqAbs(@as(f64, 102.00), quote.previous_close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 105.00), quote.close, 0.01);
// change = 105.00 - 102.00 = +3.00
try std.testing.expectApproxEqAbs(@as(f64, 3.00), quote.change, 0.01);
}
test "parseChartQuote falls back to price when indicators are absent" {
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "VFIAX",
\\ "regularMarketPrice": 500.00,
\\ "chartPreviousClose": 495.00,
\\ "fiftyTwoWeekHigh": 520.00,
\\ "fiftyTwoWeekLow": 400.00
\\ },
\\ "timestamp": []
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "VFIAX");
// No indicators.quote arrays -> open/high/low fall back to price,
// volume to 0. They must NOT pick up the 52-week range.
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.low, 0.01);
try std.testing.expectEqual(@as(u64, 0), quote.volume);
try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01);
}
test "parseChartQuote: prefers meta.longName for the display name" {
// The chart meta carries the real security name. longName is
// preferred over shortName (which Yahoo often truncates / pads).
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "SPCX",
\\ "longName": "Space Exploration Technologies Corp.",
\\ "shortName": "Space Exploration Technologies ",
\\ "regularMarketPrice": 153.23,
\\ "chartPreviousClose": 153.00
\\ },
\\ "timestamp": [1782480600],
\\ "indicators": {"quote": [{
\\ "open": [150.62], "high": [158.40], "low": [148.51],
\\ "close": [153.23], "volume": [126431973]
\\ }]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "SPCX");
try std.testing.expectEqualStrings("Space Exploration Technologies Corp.", quote.name());
}
test "parseChartQuote: falls back to trimmed shortName when longName absent" {
// No longName -> use shortName, trimming Yahoo's trailing padding.
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "FOO",
\\ "shortName": "Foo Industries ",
\\ "regularMarketPrice": 10.00,
\\ "chartPreviousClose": 9.50
\\ },
\\ "timestamp": [1782480600],
\\ "indicators": {"quote": [{
\\ "open": [9.6], "high": [10.2], "low": [9.4],
\\ "close": [10.0], "volume": [1000]
\\ }]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "FOO");
try std.testing.expectEqualStrings("Foo Industries", quote.name());
}
test "parseChartQuote: no name fields leaves the name empty" {
// When the meta carries neither longName nor shortName, the name
// stays empty (callers fall back to symbol-only / other sources).
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "BAR",
\\ "regularMarketPrice": 42.00,
\\ "chartPreviousClose": 41.00
\\ },
\\ "timestamp": [1782480600],
\\ "indicators": {"quote": [{
\\ "open": [41.5], "high": [42.5], "low": [41.0],
\\ "close": [42.0], "volume": [500]
\\ }]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "BAR");
try std.testing.expectEqualStrings("", quote.name());
}

View file

@ -1,7 +1,6 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const Money = @import("Money.zig");
const cli = @import("commands/common.zig");
const stderr = @import("stderr.zig");
@ -10,6 +9,7 @@ const tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("charts/chart.zig");
const braille = @import("charts/braille.zig");
const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig");
@ -1348,40 +1348,16 @@ pub const App = struct {
// state, and re-fetch via `loadData` (or whatever the
// tab's loader is named). The framework dispatcher routes
// to the active tab's reload; tabs that share data
// (quote/performance) delegate via their reload bodies.
// (quote/performance) delegate via their reload bodies. The
// Quote tab's reload also force-refreshes its live quote (the
// "refreshed Xs ago" ticker), so there is no tab-specific quote
// fetch here - the dispatch stays uniform across tabs.
//
// Reload is contractually self-completing: when it
// returns, the tab's state is fully refreshed. There's no
// need for a follow-up `loadTabData` - `activate` would
// see `state.loaded = true` and no-op.
self.dispatchTry("reload", .{});
// Live-quote re-fetch: the quote tab's freshness display
// ("refreshed Xs ago") is driven by `states.quote.timestamp`,
// which is independent of the candles cache. The user's
// mental model for `r` includes "and the price ticker is
// current as of NOW," so we hit the live-quote endpoint
// here. Only on tabs that show a live quote.
//
// This is the one place where `r` reaches past the cache;
// tab-switches that activate quote/performance don't trigger
// it (they should keep using whatever cached price the user
// last fetched). When live-streaming quotes ship someday,
// this block goes away.
switch (self.active_tab) {
.quote, .performance => {
if (self.symbol.len > 0) {
if (self.svc.getQuote(self.symbol, .{})) |q| {
self.states.quote.live = q;
// wall-clock required: records the exact moment
// this quote was served so the "refreshed Xs ago"
// display is honest about freshness.
self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
} else |_| {}
}
},
else => {},
}
}
/// Activate the current tab - load any data it needs, set
@ -1641,12 +1617,21 @@ pub const App = struct {
return self.appPredicate(t, "isDisabled");
}
/// Whether the App's active symbol is the user-selected row in the
/// active tab - drives the `*` marker on the tab bar. Dispatches to
/// the active tab's optional `isSymbolSelected` hook (tabs without
/// it default to "not selected"); no App-level reach into any
/// specific tab's state.
fn isSymbolSelected(self: *App) bool {
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
if (self.active_tab != .portfolio) return false;
if (self.states.portfolio.rows.items.len == 0) return false;
if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false;
return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol);
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, "isSymbolSelected")) return false;
const state_ptr = &@field(self.states, field.name);
return Module.tab.isSymbolSelected(state_ptr, self);
}
}
return false;
}
fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
@ -1908,7 +1893,7 @@ pub const App = struct {
(if (self.symbol_data.etf_profile) |p| p.name else null)
else
null;
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback);
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, .{ .etf_profile = etf_fallback });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
.style = th.headerStyle(),
@ -2010,22 +1995,19 @@ pub const App = struct {
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// Default status bar: getStatus() + optional account-filter
// suffix on the portfolio tab.
// Default status bar: the App's status message, optionally
// annotated by the active tab's `statusSuffix` hook (e.g.
// portfolio's account filter). No App-level reach into any
// specific tab's state.
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus(ctx.arena);
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus(ctx.arena);
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
const msg = self.getStatus(ctx.arena);
const line = if (self.activeTabStatusSuffix(ctx.arena)) |suffix|
std.fmt.allocPrint(ctx.arena, "{s} {s}", .{ msg, suffix }) catch msg
else
msg;
for (0..@min(line.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(line[i]) }, .style = status_style };
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
@ -2047,6 +2029,36 @@ pub const App = struct {
return null;
}
/// Call the active tab's `statusSuffix` hook (when declared) to get
/// an annotation appended to the default status message (e.g.
/// portfolio's account filter). Comptime-walks `tab_modules`;
/// returns null when the active tab declares no suffix.
fn activeTabStatusSuffix(self: *App, arena: std.mem.Allocator) ?[]const u8 {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, "statusSuffix")) return null;
const state_ptr = &@field(self.states, field.name);
return Module.tab.statusSuffix(state_ptr, self, arena);
}
}
return null;
}
/// Release every tab's transmitted Kitty graphics via the optional
/// `releaseGraphics` hook. Called once at App teardown (from the
/// run-scope defer) while `app.vx_app` is still valid - tabs without
/// graphics simply omit the hook.
fn releaseAllGraphics(self: *App) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (@hasDecl(Module.tab, "releaseGraphics")) {
const state_ptr = &@field(self.states, field.name);
Module.tab.releaseGraphics(state_ptr, self);
}
}
}
// Help
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -2131,14 +2143,14 @@ pub const App = struct {
pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void {
// Local shadows the `chart` module import; use a shorter name for
// the local BrailleChart handle.
var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
var br = braille.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
// No deinit needed: arena handles cleanup
// Cell budget per row: 2 leading spaces + n_cols chart cells +
// 1 separator space + up to `money_label_max_bytes` for the
// price label. Sizing this too small silently truncates labels
// for portfolios over $1M; see `fmt.money_label_max_bytes`.
const label_cells: usize = 1 + fmt.money_label_max_bytes;
// for portfolios over $1M; see `braille.money_label_max_bytes`.
const label_cells: usize = 1 + braille.money_label_max_bytes;
const row_cells: usize = 2 + br.n_cols + label_cells;
const bg = th.bg;
@ -2158,7 +2170,7 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
// Chart columns
for (0..br.n_cols) |col| {
const pattern = br.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pattern);
graphemes[gpos] = braille.brailleGlyph(pattern);
if (pattern != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
} else {
@ -2439,6 +2451,7 @@ pub fn run(
config: zfin.Config,
portfolio_patterns: []const []const u8,
global_watchlist_path: ?[]const u8,
app_theme: theme.Theme,
args: []const []const u8,
today: zfin.Date,
) !void {
@ -2504,15 +2517,6 @@ pub fn run(
try stderr_writer.interface.flush();
}
const loaded_theme = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk theme.default_theme;
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
break :blk theme.default_theme;
defer allocator.free(theme_path);
break :blk theme.loadFromFile(io, allocator, theme_path) orelse theme.default_theme;
};
var svc = try allocator.create(zfin.DataService);
defer allocator.destroy(svc);
svc.* = zfin.DataService.init(io, allocator, config);
@ -2534,7 +2538,7 @@ pub fn run(
.config = config,
.svc = svc,
.keymap = keymap,
.theme = loaded_theme,
.theme = app_theme,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
@ -2654,15 +2658,12 @@ pub fn run(
app_inst.vx_app = &vx_app;
defer app_inst.vx_app = null;
defer {
// Free any chart image before vaxis is torn down
if (app_inst.states.quote.chart.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.quote.chart.image_id = null;
}
if (app_inst.states.projections.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.projections.image_id = null;
}
// Free any per-tab Kitty chart images before vaxis is torn
// down. Each tab holding image IDs releases them via its
// optional `releaseGraphics` hook. This defer runs before
// the `vx_app = null` / `vx_app.deinit` defers above (LIFO),
// so `app.vx_app` is still valid inside the hooks.
app_inst.releaseAllGraphics();
}
try vx_app.run(app_inst.widget(), .{});
}
@ -3079,7 +3080,7 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $
// and `n_cols` chart cells). For 13+ char money strings like
// `$X,XXX,XXX.XX` (14 chars including the leading separator
// space), the label got silently truncated to the 10 cell
// budget. Buffer is now sized via `fmt.money_label_max_bytes`.
// budget. Buffer is now sized via `braille.money_label_max_bytes`.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

View file

@ -561,6 +561,30 @@ pub const tab = struct {
};
}
/// Whether the App's active symbol is the row under the cursor -
/// drives the `*` marker the tab bar shows next to the symbol.
/// Active-tab hook; the App consults it generically rather than
/// reaching into portfolio state itself.
pub fn isSymbolSelected(state: *State, app: *App) bool {
if (state.cursor >= state.rows.items.len) return false;
return std.mem.eql(u8, state.rows.items[state.cursor].symbol, app.symbol);
}
/// Status-bar suffix: when an account filter is active, annotate
/// the default status line with it (the App appends this to
/// `getStatus()`). Null when no filter is set.
pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 {
_ = app;
return formatAccountSuffix(arena, state.account_filter);
}
/// Format the account-filter status suffix (or null when unset).
/// Split from `statusSuffix` so it's unit-testable without an App.
fn formatAccountSuffix(arena: std.mem.Allocator, account_filter: ?[]const u8) ?[]const u8 {
const af = account_filter orelse return null;
return std.fmt.allocPrint(arena, "[Account: {s}]", .{af}) catch null;
}
/// Mouse handling. In account-picker mode, drives the modal
/// (wheel scroll, click-to-select). Otherwise: clicks on the
/// column-header row sort by that column; clicks on a data
@ -2489,6 +2513,18 @@ test "matchesAccountFilter: with filter, null account fails" {
try testing.expect(!matchesAccountFilter(&state, null));
}
test "statusSuffix: formats the active account filter, null when unset" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const s = tab.formatAccountSuffix(arena, "Sample IRA");
try testing.expect(s != null);
try testing.expectEqualStrings("[Account: Sample IRA]", s.?);
try testing.expect(tab.formatAccountSuffix(arena, null) == null);
}
test "ensureCursorVisible: cursor above viewport scrolls up" {
var state: State = .{ .cursor = 5, .header_lines = 2 };
var scroll: usize = 20;

View file

@ -28,6 +28,7 @@ const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const braille = @import("../charts/braille.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
@ -255,6 +256,17 @@ pub const tab = struct {
state.* = .{};
}
/// Release the transmitted Kitty projection-chart image before
/// vaxis is torn down. Called across all tabs at App teardown while
/// `app.vx_app` is still valid (distinct from `deinit`, which runs
/// after vaxis is gone).
pub fn releaseGraphics(state: *State, app: *App) void {
if (state.image_id) |id| {
if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
state.image_id = null;
}
}
pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return;
// Projections reads `app.portfolio.summary` and `.file`,
@ -851,16 +863,10 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
const ov = ctx_data.overlay_actuals orelse break :blk null;
break :blk ov.today_years;
};
const bands = if (state.zoom_overlay) bz: {
const ty = overlay_today_years orelse break :bz full_bands;
const window_years_f = ty * 2.0;
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands;
const window_years: usize = @intFromFloat(@ceil(window_years_f));
const want = window_years + 1; // inclusive of year 0 and year `window_years`
if (want >= full_bands.len) break :bz full_bands;
if (want < 2) break :bz full_bands;
break :bz full_bands[0..want];
} else full_bands;
const bands = if (state.zoom_overlay)
view.overlayZoomBands(full_bands, overlay_today_years)
else
full_bands;
// Build text header (benchmark comparison + allocation note)
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
@ -948,6 +954,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
capped_h,
th,
overlay_input,
pctx.retirement.boundaryYear(),
) catch {
state.chart_dirty = false;
return;
@ -1081,9 +1088,11 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
};
}
}
// "{horizon}yr" at right edge of chart area
// "{years}yr" at right edge of chart area - the last
// rendered band's year, so a zoomed overlay window
// labels its true span (not the full horizon).
var yr_buf: [8]u8 = undefined;
const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr";
const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{bands[bands.len - 1].year}) catch "??yr";
const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
for (yr_label, 0..) |ch, ci| {
const idx = axis_base + yr_start + ci;
@ -1970,7 +1979,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Compute braille chart with wider dimensions
const chart_width: usize = 80;
const chart_height: usize = 12;
var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
var br = braille.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
if (br) |*br_chart| {
const bg = th.bg;
@ -1983,8 +1992,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Sized via a named constant so the projection
// chart doesn't silently truncate labels for
// portfolios that grow past $1M; see
// `fmt.money_label_max_bytes`.
const proj_label_cells: usize = 1 + fmt.money_label_max_bytes;
// `braille.money_label_max_bytes`.
const proj_label_cells: usize = 1 + braille.money_label_max_bytes;
const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;
for (0..br_chart.chart_height) |row| {
@ -2003,7 +2012,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Chart columns
for (0..br_chart.n_cols) |col| {
const pat = br_chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pat);
graphemes[gpos] = braille.brailleGlyph(pat);
if (pat != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v };
} else {

View file

@ -7,11 +7,18 @@ const theme = @import("theme.zig");
const chart = @import("../charts/chart.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const market = @import("../market.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
/// Re-fetch the Quote tab's live quote on re-activation once the held
/// quote is older than this many seconds - but only while the market is
/// open (outside regular hours the price is frozen). See
/// `shouldFetchLiveQuote`.
const quote_live_stale_s: i64 = 60;
/// Per-symbol chart state for the quote tab. Tracks the active
/// timeframe, transmitted Kitty image (when supported), cached
/// indicator overlays (SMA/Bollinger/etc), and last-rendered
@ -84,8 +91,10 @@ pub const Action = enum {
// Tab-private state
pub const State = struct {
/// Stored real-time quote (only fetched on manual refresh; not
/// auto-refetched on every redraw).
/// Stored real-time quote. Fetched on tab activation (staleness-
/// gated; see `refreshLiveQuote`) and force-refreshed on r/F5. Null
/// until the first successful fetch, in which case the headline
/// price falls back to the last candle close.
live: ?zfin.Quote = null,
/// Unix-epoch seconds for the live-quote fetch - drives the
/// "data Xs ago" header readout.
@ -114,6 +123,38 @@ pub const meta: framework.TabMeta(Action) = .{
},
};
/// Whether the Quote tab should (re)fetch the live quote on activation.
/// Pure so the policy is unit-testable without a DataService.
///
/// - No quote held yet (cold open / new symbol): always fetch.
/// - Market open: refetch once the held quote is older than the
/// staleness window, so re-entering the tab stays current like the
/// CLI `quote` command; rapid toggling within the window reuses it.
/// - Otherwise (pre/after-hours, weekend, holiday): the last price is
/// frozen, so the single cold-open fetch suffices - don't refetch.
fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_age_s: i64) bool {
if (!has_quote) return true;
return session == .open and quote_age_s > quote_live_stale_s;
}
/// Fetch the live quote into `state` when warranted. `force` (r/F5)
/// always fetches; otherwise `shouldFetchLiveQuote` gates it. This is
/// the same `DataService.getQuote` the CLI `quote` command uses. On
/// failure the prior value is left untouched (the tab falls back to the
/// last candle close) and the error is debug-logged - a missing live
/// quote must never break tab activation.
fn refreshLiveQuote(state: *State, app: *App, force: bool) void {
if (app.symbol.len == 0) return;
// wall-clock required: drives the staleness gate and the
// "refreshed Xs ago" header timestamp.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
if (!force and !shouldFetchLiveQuote(market.marketSession(now_s), state.live != null, now_s - state.timestamp)) return;
if (app.svc.getQuote(app.symbol, .{})) |q| {
state.live = q;
state.timestamp = now_s;
} else |err| std.log.scoped(.quote_tab).debug("{s}: live-quote fetch failed: {t}", .{ app.symbol, err });
}
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
@ -128,35 +169,46 @@ pub const tab = struct {
state.* = .{};
}
/// Quote loads its own data on activation (the live-quote
/// fetch path lives in tui.zig after the tab switches because
/// it depends on App.svc); no-op here. Chart redraws are
/// triggered by the dirty flag on `state.chart`.
/// Quote and performance share `app.symbol_data` (candles +
/// dividends). Performance owns the loader; quote piggybacks
/// by delegating its activate to performance's. This keeps
/// `loadTabData`'s dispatch uniform - every tab activates its
/// own state - while preserving the historical "switching to
/// quote populates shared candle data" behavior.
/// Release the transmitted Kitty chart image before vaxis is torn
/// down. The App calls this across all tabs at teardown while
/// `app.vx_app` is still valid - distinct from `deinit`, which runs
/// after vaxis is already gone.
pub fn releaseGraphics(state: *State, app: *App) void {
if (state.chart.image_id) |id| {
if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
state.chart.image_id = null;
}
}
/// On activation the Quote tab loads its shared candle data (by
/// delegating to performance, which owns it) and fetches the live
/// quote that drives the headline price/change - the same
/// `DataService.getQuote` the CLI `quote` command uses. EOD candles
/// can't represent the *current* price intraday (today's bar isn't
/// available until after the close), so without the live fetch the
/// tab would show yesterday's close during market hours. The fetch
/// is staleness-gated (see `refreshLiveQuote` / `shouldFetchLiveQuote`)
/// so re-entering the tab refetches when stale while rapid toggling
/// reuses the held quote. This also covers `zfin i <symbol>` startup,
/// which routes through `App.loadTabData` -> activate.
pub fn activate(state: *State, app: *App) !void {
_ = state;
const perf_module = @import("performance_tab.zig");
try perf_module.tab.activate(&app.states.performance, app);
refreshLiveQuote(state, app, false);
}
pub const deactivate = framework.noopDeactivate(State);
/// Refresh: delegate to performance.reload, which owns the
/// shared candle/dividend data and svc invalidation. Quote's
/// chart-state (dirty + freeCache) is also reset by
/// performance.reload - see the comment there for why.
/// Quote-only state (live quote + timestamp) is reset here
/// because performance doesn't know about it.
/// Refresh (r/F5): reset the quote-only state, delegate to
/// performance.reload (which owns the shared candle/dividend data,
/// svc invalidation, and resetting quote's chart cache), then force
/// a fresh live quote so the headline price is current as of NOW.
pub fn reload(state: *State, app: *App) !void {
state.live = null;
state.timestamp = 0;
const perf_module = @import("performance_tab.zig");
try perf_module.tab.reload(&app.states.performance, app);
refreshLiveQuote(state, app, true);
}
pub const tick = framework.noopTick(State);
@ -623,17 +675,18 @@ pub fn formatQuoteHeader(
};
}
/// Resolve the active symbol's display name using the shared
/// policy (metadata.srf `name::`, then the ETF profile's fund
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
/// the three surfaces always agree on the name they show. The ETF
/// profile is loaded lazily on the performance tab, so the fallback
/// only kicks in once that data is present; the metadata name is
/// always available when enriched.
/// Resolve the active symbol's display name using the shared policy
/// (`resolveSecurityName`): the curated `metadata.srf` `name::` field
/// first, then the live quote name (Yahoo `longName`, the same source
/// as the price), then the ETF profile's fund name. Mirrors the CLI
/// `quote` command so the two quote surfaces always agree. The 'K'
/// overlay uses the same helper but, lacking a per-symbol live quote,
/// resolves metadata -> ETF name only.
fn quoteTabName(app: *App) ?[]const u8 {
const cm = app.portfolio.classificationMap();
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null;
const etf: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
return zfin.classification.resolveSecurityName(app.symbol, cm, .{ .live_quote = live, .etf_profile = etf });
}
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -668,6 +721,9 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
if (quote_data) |q| {
// No candle data but have a quote - show it
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
if (q.previous_close > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{q.previous_close}), .style = th.mutedStyle() });
}
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
@ -763,6 +819,9 @@ fn buildDetailColumns(
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
if (prev_close > 0) {
try col1.add(arena, try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{prev_close}), th.mutedStyle());
}
if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined;
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
@ -955,3 +1014,25 @@ test "formatQuoteHeader: empty name is omitted" {
const text = try formatQuoteHeader(arena, "AAPL", "", .none);
try testing.expectEqualStrings(" AAPL", text);
}
test "shouldFetchLiveQuote: with no held quote, always fetches" {
try testing.expect(shouldFetchLiveQuote(.open, false, 0));
try testing.expect(shouldFetchLiveQuote(.closed, false, 0));
try testing.expect(shouldFetchLiveQuote(.premarket, false, 99_999));
try testing.expect(shouldFetchLiveQuote(.afterhours, false, 99_999));
}
test "shouldFetchLiveQuote: market open refetches only once past the staleness window" {
try testing.expect(!shouldFetchLiveQuote(.open, true, 0));
// Boundary: equal-to-window is not yet "older than", so no refetch.
try testing.expect(!shouldFetchLiveQuote(.open, true, quote_live_stale_s));
try testing.expect(shouldFetchLiveQuote(.open, true, quote_live_stale_s + 1));
}
test "shouldFetchLiveQuote: outside regular hours a held quote is never refetched" {
// Price is frozen pre/after-hours and on weekends/holidays, so even a
// very stale held quote should not trigger a refetch.
try testing.expect(!shouldFetchLiveQuote(.premarket, true, 100_000));
try testing.expect(!shouldFetchLiveQuote(.afterhours, true, 100_000));
try testing.expect(!shouldFetchLiveQuote(.closed, true, 100_000));
}

View file

@ -54,6 +54,22 @@
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
//! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... }
//!
//! /// Optional: a short suffix appended to the App's default
//! /// status message (e.g. portfolio's "[Account: <filter>]").
//! /// Allocate the returned slice in `arena`. Active tab only.
//! pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... }
//!
//! /// Optional: is the App's active symbol the user-selected
//! /// row in this tab? Drives the tab-bar `*` marker. Active
//! /// tab only; tabs without it default to "not selected".
//! pub fn isSymbolSelected(state: *State, app: *App) bool { ... }
//!
//! /// Optional: release any transmitted Kitty graphics (chart
//! /// images) before vaxis is torn down. Called across ALL tabs
//! /// at App teardown while `app.vx_app` is still valid - distinct
//! /// from `deinit`, which runs after vaxis is already gone.
//! pub fn releaseGraphics(state: *State, app: *App) void { ... }
//!
//! /// Optional: does this tab currently have async work in
//! /// flight that needs poll-driven redraws? While the ACTIVE
//! /// tab answers true, the App keeps a one-shot vxfw Tick
@ -498,6 +514,36 @@ pub fn validateTabModule(comptime Module: type) void {
"pub fn wantsPollTick(state: *State, app: *App) bool { ... }",
);
}
if (@hasDecl(tab_decl, "isSymbolSelected")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"isSymbolSelected",
fn (*State, *App) bool,
"pub fn isSymbolSelected(state: *State, app: *App) bool { ... }",
);
}
if (@hasDecl(tab_decl, "statusSuffix")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"statusSuffix",
fn (*State, *App, std.mem.Allocator) ?[]const u8,
"pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... }",
);
}
if (@hasDecl(tab_decl, "releaseGraphics")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"releaseGraphics",
fn (*State, *App) void,
"pub fn releaseGraphics(state: *State, app: *App) void { ... }",
);
}
// Draw hooks (mutually exclusive, exactly one required)
//

View file

@ -279,6 +279,28 @@ pub fn buildOverlayActuals(
};
}
/// Clamp projection `full_bands` to the overlay-relevant window so a
/// short realized history isn't squashed into the start of a long
/// horizon. Returns the leading slice covering roughly
/// `[year 0, 2*today_years]` (both endpoints inclusive). Falls back to
/// `full_bands` unchanged when there's no overlay (`today_years` null),
/// the window is degenerate, or it would span the whole horizon.
/// Shared by the TUI zoom toggle and the CLI export/inline paths so an
/// overlay is framed identically in both.
pub fn overlayZoomBands(
full_bands: []const projections.YearPercentiles,
today_years: ?f64,
) []const projections.YearPercentiles {
const ty = today_years orelse return full_bands;
const window_years_f = ty * 2.0;
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) return full_bands;
const window_years: usize = @intFromFloat(@ceil(window_years_f));
const want = window_years + 1; // inclusive of year 0 and year `window_years`
if (want >= full_bands.len) return full_bands;
if (want < 2) return full_bands;
return full_bands[0..want];
}
/// Which retirement-planning inputs the user has configured.
///
/// The simulation always runs the same two-phase model
@ -2132,6 +2154,26 @@ test "buildOverlayActuals: empty input produces empty section" {
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01);
}
test "overlayZoomBands: clamps to ~2x today_years, falls back when appropriate" {
// 51 bands: year 0..50.
var full: [51]projections.YearPercentiles = undefined;
for (0..51) |i| full[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
// No overlay -> full slice unchanged.
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, null).len);
// today_years = 2.5 -> window = ceil(5.0) = 5 -> 6 bands (years 0..5).
const z = overlayZoomBands(&full, 2.5);
try std.testing.expectEqual(@as(usize, 6), z.len);
try std.testing.expectEqual(@as(u16, 5), z[z.len - 1].year);
// Degenerate today_years (<= 0) -> full slice.
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 0).len);
// Window wider than the horizon -> full slice (no OOB).
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 100.0).len);
}
test "buildOverlayActuals: single point at as_of has years=0" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),