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 - id: test
name: Run zig build test name: Run zig build test
entry: zig entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=78"] args: ["build", "coverage", "-Dcoverage-threshold=79"]
language: system language: system
types: [file] types: [file]
pass_filenames: false pass_filenames: false

157
README.md
View file

@ -1,6 +1,6 @@
# zfin # 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 ## Quick start
@ -102,60 +102,124 @@ caching/TTL model, and the complete environment-variable list, see
``` ```
src/ src/
root.zig Library root, exports all public types main.zig CLI dispatch + TUI entry point
format.zig Shared formatters, braille engine, ANSI helpers root.zig Library root, exports all public types
config.zig Configuration from env vars / .env files Config.zig Configuration from env vars / .env files
service.zig DataService: cache-check -> fetch -> cache -> return 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/ models/
candle.zig OHLCV price bars candle.zig OHLCV price bars
date.zig Date type with arithmetic, snapping, formatting quote.zig Real-time quote data
dividend.zig Dividend records with type classification dividend.zig Dividend records with type classification
split.zig Stock splits split.zig Stock splits
option.zig Option contracts and chains option.zig Option contracts and chains
earnings.zig Earnings events with surprise calculation earnings.zig Earnings events with surprise calculation
etf_profile.zig ETF profiles with holdings and sectors etf_profile.zig ETF profiles with holdings and sectors
portfolio.zig Lots, positions, and portfolio aggregation portfolio.zig Lots, positions, and portfolio aggregation
classification.zig Classification metadata parser classification.zig Classification metadata parser
quote.zig Real-time quote data 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/ providers/
tiingo.zig Tiingo: daily candles (primary), supplementary div/split merge tiingo.zig Tiingo: daily candles (primary)
twelvedata.zig TwelveData: quote fallback yahoo.zig Yahoo Finance: quotes (primary), candle fallback
polygon.zig Polygon: dividends, splits (primary, with forward-looking entries) twelvedata.zig TwelveData: quote fallback
fmp.zig FMP: earnings (actuals + estimates) polygon.zig Polygon: dividends, splits (with forward-looking entries)
cboe.zig CBOE: options chains (no API key) fmp.zig FMP: earnings (actuals + estimates)
Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), mutual-fund ticker map, XBRL company facts cboe.zig CBOE: options chains (no API key)
Wikidata.zig Wikidata SPARQL: classification metadata for `enrich` Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), ticker map, XBRL facts
yahoo.zig Yahoo Finance: quotes (primary), candles (Tiingo fallback) Wikidata.zig Wikidata SPARQL: classification metadata for `enrich`
openfigi.zig OpenFIGI: CUSIP to ticker lookup openfigi.zig OpenFIGI: CUSIP to ticker lookup
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code") json_utils.zig Shared JSON parsing helpers
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code")
analytics/ analytics/
indicators.zig SMA, Bollinger Bands, RSI indicators.zig SMA, Bollinger Bands, RSI
performance.zig Trailing returns (as-of-date + month-end) performance.zig Trailing returns (as-of-date + month-end)
risk.zig Volatility, Sharpe, drawdown risk.zig Per-symbol volatility, Sharpe, drawdown
valuation.zig Portfolio summary, allocations, covered call adjustments portfolio_risk.zig Correlation-aware portfolio-level risk
analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax) 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/ cache/
store.zig SRF file cache with TTL freshness checks store.zig SRF file cache with TTL freshness checks
net/ net/
http.zig HTTP client with retries and server error retry http.zig HTTP client with retries and server-error retry
rate_limiter.zig Token-bucket rate limiter RateLimiter.zig Token-bucket rate limiter
commands/ commands/
common.zig Shared CLI helpers (progress, formatting) common.zig Shared CLI helpers (progress, formatting, color)
perf.zig Trailing returns command framework.zig CLI command framework + portfolio pattern resolution
quote.zig Quote command TimeRange.zig Shared --range / time-window parsing
... (14 command files) audit/ Brokerage reconciliation helpers for `audit`
tui.zig Interactive TUI application (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/ tui/
chart.zig z2d pixel chart renderer (Kitty graphics) tab_framework.zig Tab contract + comptime validator
keybinds.zig Configurable keybinding system keybinds.zig Configurable keybinding system
theme.zig Configurable color theme 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 portfolio.srf Portfolio lots (positions and cost basis)
metadata.srf Classification metadata for analysis accounts.srf Account -> tax-type mapping for analysis
accounts.srf Account to 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 ### 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 | | [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 | | [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) | | [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 ## Building

33
TODO.md
View file

@ -7,13 +7,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
## Projections: future enhancements ## 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.** - **Goal-seek over distribution horizon for W1 - priority LOW.**
Today the W1 ("set spending, find date") workflow reports the Today the W1 ("set spending, find date") workflow reports the
earliest retirement at each user-configured `(horizon, confidence)` 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 ## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote`, `projections` V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
(default bands mode only), and `history`. Several adjacent surfaces (bands, `--convergence`, and `--return-backtest` modes), and
still don't have PNG export and were deferred: `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 - **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse (text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but 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 time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when presentation-friendly one. Out of scope for V1; gate when
someone asks for it. 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 ## 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: date-shaped helpers that used to live there have been moved out:
money formatting now lives in `src/Money.zig` (with `{f}` / money formatting now lives in `src/Money.zig` (with `{f}` /
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`), `whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
and date formatting lives in `src/Date.zig` (with `{f}` / date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`). What's left in `format.zig` is the `padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
genuinely-format-domain stuff: braille charts, return formatters, lives in `src/charts/braille.zig`. What's left in `format.zig` is
allocation notes, signed-percent rendering. the genuinely-format-domain stuff: return formatters, allocation
notes, signed-percent rendering.
If the file ever grows enough to be annoying again, consider If the file ever grows enough to be annoying again, consider
renaming to `src/render.zig` to better describe what's left, or renaming to `src/render.zig` to better describe what's left.
splitting the braille chart out (it's ~600 lines on its own).
Not blocking - file it as cleanup if and when it bites. Not blocking - file it as cleanup if and when it bites.
## Investigate: detailed 401(k) contributions data source ## 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. /// The line renders "not feasible" instead of a date.
promoted_infeasible, 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. /// 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.?)); 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 ────────────────────── // ── Two-phase simulation regression tests ──────────────────────
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" { 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 //! The TUI's adaptive chart sizing isn't used here because the
//! export target is a file, not a cell grid. //! export target is a file, not a cell grid.
//! //!
//! The module deliberately reuses the TUI's `default_theme` for //! Each `export*` takes the `theme.Theme` to render with - the CLI
//! consistent visual identity across the live TUI surface and //! resolves it from `--theme <PATH>` (a `theme.srf`), defaulting to
//! exported images. A `--theme` override at export time is out of //! the built-in theme so exports keep the TUI's visual identity unless
//! scope for V1 (see TODO follow-up). //! the user opts into a custom or presentation-friendly palette.
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
@ -29,6 +29,9 @@ const chart = @import("charts/chart.zig");
const projection_chart = @import("charts/projection_chart.zig"); const projection_chart = @import("charts/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig"); const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.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"); const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `charts/chart.zig`'s /// Default PNG export resolution. Matches `charts/chart.zig`'s
@ -47,6 +50,7 @@ pub fn exportSymbolChart(
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
candles: []const zfin.Candle, candles: []const zfin.Candle,
display_count: usize, display_count: usize,
th: theme.Theme,
path: []const u8, path: []const u8,
) !void { ) !void {
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) { var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
@ -64,7 +68,7 @@ pub fn exportSymbolChart(
null, null,
default_width, default_width,
default_height, default_height,
theme.default_theme, th,
&cached, &cached,
true, true,
) catch |err| switch (err) { ) catch |err| switch (err) {
@ -78,12 +82,16 @@ pub fn exportSymbolChart(
/// Export a projection percentile-band chart with optional actuals /// Export a projection percentile-band chart with optional actuals
/// overlay. Wraps `projection_chart.renderToSurface` + /// overlay. Wraps `projection_chart.renderToSurface` +
/// `writeToPNGFile`. /// `writeToPNGFile`. `retirement_boundary_year` (pass
/// `ResolvedRetirement.boundaryYear()`) draws the
/// accumulation/distribution divider when set.
pub fn exportProjectionChart( pub fn exportProjectionChart(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles, bands: []const projections.YearPercentiles,
actuals: ?projection_chart.ActualsOverlay, actuals: ?projection_chart.ActualsOverlay,
retirement_boundary_year: ?u16,
th: theme.Theme,
path: []const u8, path: []const u8,
) !void { ) !void {
var rendered = projection_chart.renderToSurface( var rendered = projection_chart.renderToSurface(
@ -92,9 +100,10 @@ pub fn exportProjectionChart(
bands, bands,
default_width, default_width,
default_height, default_height,
theme.default_theme, th,
actuals, actuals,
true, true,
retirement_boundary_year,
) catch |err| switch (err) { ) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData, error.InsufficientData => return error.InsufficientData,
else => return err, else => return err,
@ -112,6 +121,7 @@ pub fn exportTimelineChart(
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
points: []const line_chart.LinePoint, points: []const line_chart.LinePoint,
baseline: line_chart.Baseline, baseline: line_chart.Baseline,
th: theme.Theme,
path: []const u8, path: []const u8,
) !void { ) !void {
var rendered = line_chart.renderToSurface( var rendered = line_chart.renderToSurface(
@ -120,7 +130,7 @@ pub fn exportTimelineChart(
points, points,
default_width, default_width,
default_height, default_height,
theme.default_theme, th,
.{ .baseline = baseline, .axis_labels = true }, .{ .baseline = baseline, .axis_labels = true },
) catch |err| switch (err) { ) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData, error.InsufficientData => return error.InsufficientData,
@ -131,6 +141,89 @@ pub fn exportTimelineChart(
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{}); 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 ───────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────
test "exportSymbolChart writes a non-empty PNG file" { 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" }); const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
defer alloc.free(path); 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 // Verify the file exists, starts with the PNG magic, and is
// big enough to plausibly contain a chart (not just headers). // 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( try std.testing.expectError(
error.InsufficientData, 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" }); const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
defer alloc.free(path); 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", .{}); var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
defer file.close(io); defer file.close(io);
@ -271,7 +364,7 @@ test "exportProjectionChart returns InsufficientData with single band" {
try std.testing.expectError( try std.testing.expectError(
error.InsufficientData, 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" }); const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
defer alloc.free(path); 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", .{}); var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
defer file.close(io); defer file.close(io);
@ -326,6 +419,172 @@ test "exportTimelineChart returns InsufficientData with a single point" {
try std.testing.expectError( try std.testing.expectError(
error.InsufficientData, 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 "$?"; 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 /// Draw `n + 1` left-aligned dollar labels evenly spaced from
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in /// `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 /// 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_min: f64,
value_max: f64, value_max: f64,
n: usize, 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 { ) void {
const range = value_max - value_min; const range = value_max - value_min;
const span = bottom - top; const span = bottom - top;
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
const val = value_max - frac * range; const val = value_max - frac * range;
const y = top + frac * span; const y = top + frac * span;
var buf: [24]u8 = undefined; 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; const ly = @as(i32, @intFromFloat(y)) - half_h;
text.drawText(sfc, lx, ly, scale, color, label); 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)); 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" { test "drawYDollarTicks stamps labels in the requested color" {
const alloc = testing.allocator; const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200); 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 forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
const draw = @import("draw.zig"); const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface; const Surface = z2d.Surface;
const Context = z2d.Context; const Context = z2d.Context;
@ -50,6 +51,80 @@ pub const ChartResult = struct {
value_max: f64, 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 ──────────────────────────────── // ── View 1: Convergence chart ────────────────────────────────
/// Render the convergence chart. X-axis spans /// Render the convergence chart. X-axis spans
@ -67,20 +142,21 @@ pub const ChartResult = struct {
/// - Solid line through the convergence points /// - Solid line through the convergence points
/// - Distinct markers on `reached` rows (small filled dots, /// - Distinct markers on `reached` rows (small filled dots,
/// theme accent color) /// theme accent color)
pub fn renderConvergenceChart( pub fn renderConvergenceToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint, points: []const forecast.ConvergencePoint,
width_px: u32, width_px: u32,
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
) !ChartResult { axis_labels: bool,
) !RenderedForecast {
if (points.len < 2) return error.InsufficientData; if (points.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px); const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px); const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); 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); var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit(); defer ctx.deinit();
@ -95,11 +171,19 @@ pub fn renderConvergenceChart(
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left; // Chart area. With axis labels, reserve a right margin for the
const chart_right = fwidth - margin_right; // 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_w = chart_right - chart_left;
const chart_top = margin_top; const chart_top = m_top;
const chart_bottom = fheight - margin_bottom; const chart_bottom = fheight - m_bottom;
// X-range: observation_date span // X-range: observation_date span
const x0_days: f64 = @floatFromInt(points[0].observation_date.days); const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
@ -186,8 +270,20 @@ pub fn renderConvergenceChart(
// Border // Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); 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 .{ return .{
.rgb_data = try extractRgb(alloc, &sfc), .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
.height = @intCast(height_px), .height = @intCast(height_px),
.value_min = y_min, .value_min = y_min,
@ -212,20 +308,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
/// - `realized_5y` (solid, theme positive - green) /// - `realized_5y` (solid, theme positive - green)
/// ///
/// Plus a y=0 reference line. /// Plus a y=0 reference line.
pub fn renderBacktestChart( pub fn renderBacktestToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
anchors: []const BacktestAnchor, anchors: []const BacktestAnchor,
width_px: u32, width_px: u32,
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
) !ChartResult { axis_labels: bool,
) !RenderedForecast {
if (anchors.len < 2) return error.InsufficientData; if (anchors.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px); const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px); const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); 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); var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit(); defer ctx.deinit();
@ -240,11 +337,19 @@ pub fn renderBacktestChart(
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left; // Chart area. With axis labels, reserve a right margin for the
const chart_right = fwidth - margin_right; // 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_w = chart_right - chart_left;
const chart_top = margin_top; const chart_top = m_top;
const chart_bottom = fheight - margin_bottom; const chart_bottom = fheight - m_bottom;
// X-range // X-range
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days); const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
@ -303,8 +408,20 @@ pub fn renderBacktestChart(
// Border // Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); 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 .{ return .{
.rgb_data = try extractRgb(alloc, &sfc), .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
.height = @intCast(height_px), .height = @intCast(height_px),
.value_min = y_min, .value_min = y_min,
@ -492,7 +609,6 @@ const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines; const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine; const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect; const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb;
// ── Tests ───────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────

View file

@ -12,6 +12,7 @@
//! - Median (p50) line (solid) //! - Median (p50) line (solid)
//! - Zero line (if visible) //! - Zero line (if visible)
//! - Actuals overlay line (when present) //! - Actuals overlay line (when present)
//! - Retirement boundary vertical line (when an accumulation phase exists)
//! - Panel border //! - Panel border
const std = @import("std"); const std = @import("std");
@ -90,6 +91,12 @@ pub const RenderedProjection = struct {
/// graphics path (extracts RGB, frees surface). /// graphics path (extracts RGB, frees surface).
/// - `--export-chart` (CLI) wraps this for PNG export via /// - `--export-chart` (CLI) wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`. /// `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( pub fn renderToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
@ -99,6 +106,7 @@ pub fn renderToSurface(
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay, actuals: ?ActualsOverlay,
axis_labels: bool, axis_labels: bool,
retirement_boundary_year: ?u16,
) !RenderedProjection { ) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData; 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 ───────────────────────────────────────────── // ── Panel border ─────────────────────────────────────────────
{ {
const border_color = blendColor(th.border, 80, bg); const border_color = blendColor(th.border, 80, bg);
@ -343,8 +369,9 @@ pub fn renderProjectionChart(
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay, actuals: ?ActualsOverlay,
retirement_boundary_year: ?u16,
) !ProjectionChartResult { ) !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); defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc); const raw = try rendered.extractRgb(alloc);
return .{ return .{
@ -381,7 +408,7 @@ test "renderProjectionChart produces valid output" {
}; };
const th = @import("../tui/theme.zig").default_theme; 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); defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width); 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 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); 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 overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme; 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); defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width); 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 overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme; 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); defer alloc.free(result.rgb_data);
// Without expansion, value_max would be ~25M (band p90 + 5%). // 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 overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("../tui/theme.zig").default_theme; 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); defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0); 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 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
}; };
const th = @import("../tui/theme.zig").default_theme; 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); defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width); 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; var th = @import("../tui/theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef }; 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); defer rendered.deinit(alloc);
const buf = switch (rendered.surface) { 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; 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); 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); defer b.deinit(alloc);
const buf_a = switch (a.surface) { 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 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
}; };
const th = @import("../tui/theme.zig").default_theme; 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); defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(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 }, .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
}; };
const th = @import("../tui/theme.zig").default_theme; 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); defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0` // 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.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 0); 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. //! Solid 1-bit pixels avoid it entirely.
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo. //! 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: //! The glyph set is intentionally minimal - just what axis labels and
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes //! chart legends need: digits, `$`, `.`, `,`, `-`, `%`, the `T`/`B`/`M`
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank. //! 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 //! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the //! (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_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 };
const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 }; const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 };
const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 }; 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_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E }; const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 }; 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 /// Look up the bitmap for a character. Unknown characters (including
/// space) render blank. /// space) render blank.
fn glyphFor(ch: u8) Glyph { fn glyphFor(ch: u8) Glyph {
@ -68,9 +84,17 @@ fn glyphFor(ch: u8) Glyph {
'.' => glyph_period, '.' => glyph_period,
',' => glyph_comma, ',' => glyph_comma,
'-' => glyph_minus, '-' => glyph_minus,
'%' => glyph_percent,
'T' => glyph_T, 'T' => glyph_T,
'B' => glyph_B, 'B' => glyph_B,
'M' => glyph_M, '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, 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. // The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white)); 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 framework = @import("framework.zig");
const stderr = @import("../stderr.zig"); const stderr = @import("../stderr.zig");
pub const fmt = @import("../format.zig"); pub const fmt = @import("../format.zig");
const theme = @import("../tui/theme.zig");
// ── Default CLI colors (match TUI default Monokai theme) ───── // ── Active CLI text palette ──────────────────────────────────
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive) // RGB foreground colors for ALL CLI (non-TUI) text output, emitted as
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative) // truecolor ANSI by setFg/printFg. Defaults match the built-in Monokai
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted) // theme; `applyTheme` overwrites them once at startup from the resolved
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) // `--theme <PATH>` (or the default) so the entire CLI - gain/loss,
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) // headers, muted labels, warnings, accents - honors the user's theme,
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) // not just the charts. zfin is a single-invocation process and these
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info) // 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 ─────────────────────────────────────── // ── ANSI color helpers ───────────────────────────────────────

View file

@ -45,6 +45,7 @@ const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig"); const validator = @import("../comptime_validator.zig");
const chart = @import("../charts/chart.zig"); const chart = @import("../charts/chart.zig");
const term_query = @import("../term_query.zig"); const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
// ── Group taxonomy ──────────────────────────────────────────── // ── Group taxonomy ────────────────────────────────────────────
@ -259,6 +260,11 @@ pub const RunCtx = struct {
/// once at invocation entry. Commands consult this together with /// once at invocation entry. Commands consult this together with
/// `globals.chart_config` to choose kitty-graphics vs braille output. /// `globals.chart_config` to choose kitty-graphics vs braille output.
graphics_caps: term_query.Caps = .{}, 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 /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME. /// 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 chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.zig"); const line_chart = @import("../charts/line_chart.zig");
const chart = @import("../charts/chart.zig"); const chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig"); const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig"); const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.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 /// minimum if the series itself dips negative). Only consulted when
/// `--export-chart` is given. /// `--export-chart` is given.
baseline: line_chart.Baseline = .fit, 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. /// 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) { switch (parsed) {
.symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts), .symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts),
.portfolio => |opts| { .portfolio => |opts| {
var o = opts;
o.chart_theme = ctx.chart_theme;
const pf = ctx.resolvePortfolioPath(); const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator); 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 // as a PNG (honoring --since / --until / --metric / --baseline) and
// exit without printing the normal timeline output. // exit without printing the normal timeline output.
if (opts.export_chart) |path| { 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 => { error.InsufficientData => {
cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n"); cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n");
return; return;
@ -359,7 +367,7 @@ fn runPortfolio(
// Resolve how to draw the inline chart: kitty graphics when the // Resolve how to draw the inline chart: kitty graphics when the
// terminal supports it (or it's forced via `--chart <WxH>`), else // terminal supports it (or it's forced via `--chart <WxH>`), else
// braille. `--chart braille` always forces braille. // 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) { const chart_render: ChartRender = switch (chart_config.mode) {
.braille => .braille, .braille => .braille,
.kitty => .{ .kitty = k }, .kitty => .{ .kitty = k },
@ -548,13 +556,14 @@ fn exportMetricChart(
points: []const timeline.TimelinePoint, points: []const timeline.TimelinePoint,
metric: timeline.Metric, metric: timeline.Metric,
baseline: line_chart.Baseline, baseline: line_chart.Baseline,
th: theme.Theme,
path: []const u8, path: []const u8,
) !void { ) !void {
const series = try timeline.extractChartSeries(allocator, points, metric); const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series); defer allocator.free(series);
const lps = try metricLinePoints(allocator, series); const lps = try metricLinePoints(allocator, series);
defer allocator.free(lps); 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. /// How `renderPortfolio` draws the timeline chart.
@ -570,6 +579,7 @@ const KittyChart = struct {
io: std.Io, io: std.Io,
caps: term_query.Caps, caps: term_query.Caps,
baseline: line_chart.Baseline, baseline: line_chart.Baseline,
theme: theme.Theme,
}; };
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics /// 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 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); 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); defer rendered.deinit(allocator);
const rgb = try rendered.extractRgb(allocator); const rgb = try rendered.extractRgb(allocator);
defer allocator.free(rgb); defer allocator.free(rgb);
@ -655,9 +665,9 @@ fn renderBraille(
} }
const candles = candles_list.items; 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); 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( 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" }); const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" });
defer alloc.free(path); 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", .{}); var file = try tmp.dir.openFile(io, "history_timeline.png", .{});
defer file.close(io); defer file.close(io);
@ -1187,7 +1197,7 @@ test "exportMetricChart returns InsufficientData with fewer than 2 points" {
const pts = [_]timeline.TimelinePoint{ const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000), 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 ──────────────────────────────────────────── // ── rebuildRollup ────────────────────────────────────────────

View file

@ -23,6 +23,10 @@ const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig"); const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.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_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig"); const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.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. /// `--vs <DATE>`: side-by-side compare of two projections.
compare: CompareArgs, compare: CompareArgs,
/// `--convergence`: plot the spreadsheet's predicted retirement /// `--convergence`: plot the spreadsheet's predicted retirement
/// date over time. No knobs. /// date over time. `export_chart` renders a PNG instead of the
convergence, /// text table.
convergence: struct { export_chart: ?[]const u8 = null },
/// `--return-backtest [--real]`: plot expected_return vs realized /// `--return-backtest [--real]`: plot expected_return vs realized
/// forward-CAGR. /// forward-CAGR. `export_chart` renders a PNG instead of text.
return_backtest: struct { real: bool }, return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
}; };
pub const BandsArgs = struct { pub const BandsArgs = struct {
@ -64,6 +69,9 @@ pub const CompareArgs = struct {
/// "Now" side. Null = today (live); non-null = the `--as-of` /// "Now" side. Null = today (live); non-null = the `--as-of`
/// date the user paired with `--vs`. /// date the user paired with `--vs`.
as_of: ?Date = null, 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 = .{ pub const meta: framework.Meta = .{
@ -104,11 +112,12 @@ pub const meta: framework.Meta = .{
\\ --return-backtest (see above) \\ --return-backtest (see above)
\\ --real With --return-backtest, render in \\ --real With --return-backtest, render in
\\ CPI-adjusted dollars. \\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the percentile-band chart \\ --export-chart <PATH> Render the current mode's chart as a
\\ (with optional overlay if \\ PNG to PATH (1920x1080) and exit.
\\ --overlay-actuals is set) as a PNG \\ Works in all modes: the default bands
\\ to PATH (1920x1080) and exit. Only \\ view (with the overlay if
\\ valid in the default bands mode. \\ --overlay-actuals is set), --convergence,
\\ --return-backtest, and --vs.
\\ \\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). \\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"); cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive; return error.MutuallyExclusive;
} }
// Chart export only meaningful in default bands mode. The // The actuals overlay plots the realized trajectory from `--as-of`
// forecast-evaluation views (convergence, return-backtest) // through today; without an as-of anchor there's nothing to plot.
// render via `forecast_chart.zig` which doesn't have a PNG // Matches the TUI, which refuses the overlay without an as-of date.
// export path yet; --vs is text-only with no chart at all. // (Under `--vs` the overlay is ignored rather than required, so
if (export_chart != null and (convergence or return_backtest or vs_date != null)) { // that combination is left alone.)
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n"); if (overlay_actuals and as_of == null and vs_date == null) {
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
return error.MutuallyExclusive; return error.MutuallyExclusive;
} }
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
if (convergence) return ParsedArgs{ .convergence = {} }; if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
if (vs_date) |d| { if (vs_date) |d| {
return ParsedArgs{ .compare = .{ return ParsedArgs{ .compare = .{
.events_enabled = events_enabled, .events_enabled = events_enabled,
.vs_date = d, .vs_date = d,
.as_of = as_of, .as_of = as_of,
.export_chart = export_chart,
} }; } };
} }
return ParsedArgs{ .bands = .{ return ParsedArgs{ .bands = .{
@ -239,9 +249,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer pf.deinit(allocator); defer pf.deinit(allocator);
const file_path = pf.path; 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) { switch (parsed) {
.convergence => try runConvergence(io, allocator, file_path, 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, color, out), .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
.compare => |args| { .compare => |args| {
_ = ctx.svc orelse return error.MissingDataService; _ = ctx.svc orelse return error.MissingDataService;
// Pre-load today's live composition only when it's // 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, .today = today,
.live = if (live) |*l| l else null, .live = if (live) |*l| l else null,
}, },
kitty_caps,
args.export_chart,
); );
}, },
.bands => |args| { .bands => |args| {
@ -283,14 +305,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it. // total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color); var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator); 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( try runBands(
io, io,
allocator, allocator,
@ -304,6 +318,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.overlay_actuals = args.overlay_actuals, .overlay_actuals = args.overlay_actuals,
.export_chart = args.export_chart, .export_chart = args.export_chart,
.live = if (live) |*l| l else null, .live = if (live) |*l| l else null,
.chart_theme = ctx.chart_theme,
}, },
color, color,
out, out,
@ -451,6 +466,10 @@ pub const BandsOptions = struct {
/// Snapshot-only as-of paths ignore this field. See /// Snapshot-only as-of paths ignore this field. See
/// `LiveData` for the rationale. /// `LiveData` for the rationale.
live: ?*const LiveData = null, 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, /// Build a `ProjectionContext` for an already-resolved as-of date,
@ -554,6 +573,50 @@ pub fn anyImportedOnly(
return now_res.source == .imported; 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 /// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols` /// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands /// wide and emit it inline. Returns `error.InsufficientData` when bands
@ -564,30 +627,22 @@ fn emitBandsKitty(
va: std.mem.Allocator, va: std.mem.Allocator,
ctx: *const view.ProjectionContext, ctx: *const view.ProjectionContext,
caps: term_query.Caps, caps: term_query.Caps,
th: theme.Theme,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
const horizons = ctx.config.getHorizons(); const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return error.InsufficientData; if (horizons.len == 0) return error.InsufficientData;
const bands_ec = ctx.data.bands[horizons.len - 1] orelse 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 // Translate the overlay and frame the band slice (zoomed to the
// ActualsPoint shape - same conversion as the PNG export path. The // overlay window) the same way the PNG-export path does.
// arena owns the buffer; it lives as long as `overlay_input`. const oc = prepOverlayChart(va, ctx, bands_ec);
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 };
};
const cols = term_graphics.projection_cols; const d = projectionChartDims(caps);
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h); var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, th, oc.overlay, true, ctx.retirement.boundaryYear());
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);
defer rendered.deinit(va); defer rendered.deinit(va);
const rgb = try rendered.extractRgb(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( pub fn runBands(
@ -712,25 +767,11 @@ pub fn runBands(
return; return;
}; };
// Translate the view-layer overlay points (if any) into the // Translate the overlay and frame the band slice (zoomed to the
// chart-module's ActualsPoint shape. Same conversion the TUI // overlay window) the same way the inline-kitty path does.
// does in `projections_tab.drawWithKittyChart`. const oc = prepOverlayChart(va, &ctx, bands_ec);
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,
};
};
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 => { error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return; return;
@ -764,7 +805,7 @@ pub fn runBands(
// keep the table-only view below. // keep the table-only view below.
if (kitty_caps) |kc| { if (kitty_caps) |kc| {
try out.print("\n", .{}); 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 error.InsufficientData => {}, // no bands yet; fall through to the table
else => return err, 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| { 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 // Year axis instead of date axis
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Now", .{}); 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` /// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`. /// "then" or snapshot "now" side at `requested_date`.
/// ///
@ -1085,6 +1135,8 @@ pub fn runCompare(
ctx: *framework.RunCtx, ctx: *framework.RunCtx,
file_path: []const u8, file_path: []const u8,
opts: KeyComparisonOptions, opts: KeyComparisonOptions,
kitty_caps: ?term_query.Caps,
export_chart: ?[]const u8,
) !void { ) !void {
const io = ctx.io; const io = ctx.io;
const allocator = ctx.allocator; const allocator = ctx.allocator;
@ -1102,6 +1154,26 @@ pub fn runCompare(
}; };
defer result.cleanup(); 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", .{}); try out.print("\n", .{});
var then_buf: [10]u8 = undefined; var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined;
@ -1142,6 +1214,21 @@ pub fn runCompare(
} }
try out.print("\n", .{}); 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 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", .{ 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, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
file_path: []const u8, file_path: []const u8,
export_chart: ?[]const u8,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !void { ) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator); var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit(); defer arena_state.deinit();
@ -1193,6 +1283,37 @@ pub fn runConvergence(
defer iv.deinit(); defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points); 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); const lines = try view.convergenceLines(va, points);
try renderForecastLines(out, color, lines); try renderForecastLines(out, color, lines);
} }
@ -1214,8 +1335,11 @@ pub fn runReturnBacktest(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
file_path: []const u8, file_path: []const u8,
real_mode: bool, real_mode: bool,
export_chart: ?[]const u8,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !void { ) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator); var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit(); 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 rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
const anchors = try forecast.pivotByAnchor(va, rows); 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); const lines = try view.backtestLines(va, anchors, real_mode);
try renderForecastLines(out, color, lines); try renderForecastLines(out, color, lines);
} }
@ -1272,6 +1425,11 @@ fn renderForecastLines(
pub const KeyComparisonResult = struct { pub const KeyComparisonResult = struct {
then: KeyMetrics, then: KeyMetrics,
now: 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 of the "then" snapshot. Always present.
resolution: AsOfResolution, resolution: AsOfResolution,
/// Resolution of the "now" snapshot. Null when now is live. /// Resolution of the "now" snapshot. Null when now is live.
@ -1407,6 +1565,8 @@ pub fn computeKeyComparison(
return .{ return .{
.then = extractKeyMetrics(then_ctx), .then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx), .now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution, .resolution = then_resolution,
.now_resolution = now_resolution, .now_resolution = now_resolution,
.events_enabled = opts.events_enabled, .events_enabled = opts.events_enabled,
@ -1440,6 +1600,8 @@ pub fn computeKeyComparison(
return .{ return .{
.then = extractKeyMetrics(then_ctx), .then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx), .now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution, .resolution = then_resolution,
.now_resolution = null, .now_resolution = null,
.events_enabled = opts.events_enabled, .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_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig"); const snapshot = @import("snapshot.zig");

View file

@ -6,6 +6,7 @@ const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig"); const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../charts/chart.zig"); const tui_chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig"); const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig"); const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.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. // PNG export short-circuits all text rendering.
if (parsed.export_chart) |path| { 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 => { error.InsufficientData => {
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
return; return;
@ -159,6 +160,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fetch real-time quote via DataService // Fetch real-time quote via DataService
var quote: ?QuoteData = null; 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| { if (svc.getQuote(parsed.symbol, opts)) |q| {
quote = .{ quote = .{
.price = q.close, .price = q.close,
@ -169,39 +176,45 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.prev_close = q.previous_close, .prev_close = q.previous_close,
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today, .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 |_| {} } else |_| {}
// Resolve a human-readable security name the same way the TUI // Resolve a human-readable security name the same way the TUI
// 'K' overlay and quote tab do: the curated `metadata.srf` // 'K' overlay and quote tab do, via the shared policy: the curated
// `name::` field first, then the ETF profile's fund name. The // `metadata.srf` `name::` field first, then the live quote name,
// name is copied into `name_buf` so its lifetime is independent // then the ETF profile's fund name. The result is copied into
// of the (transient) classification map / ETF fetch result. // `name_buf` so its lifetime is independent of the (transient)
// classification map / ETF fetch result.
var name_buf: [256]u8 = undefined; var name_buf: [256]u8 = undefined;
var name: ?[]const u8 = null; var name: ?[]const u8 = null;
{ {
var cm_opt = loadClassificationMap(ctx); var cm_opt = loadClassificationMap(ctx);
defer if (cm_opt) |*cm| cm.deinit(); defer if (cm_opt) |*cm| cm.deinit();
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null; 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); 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) { const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
.braille => .braille, .braille => .braille,
.kitty => .{ .kitty = k }, .kitty => .{ .kitty = k },
@ -269,6 +282,7 @@ const ChartRender = union(enum) {
const KittyChart = struct { const KittyChart = struct {
io: std.Io, io: std.Io,
caps: term_query.Caps, caps: term_query.Caps,
theme: theme.Theme,
}; };
/// Braille price chart of the most recent `display_count` candles (the /// 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 { 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 n = @min(candles.len, display_count);
const data = candles[candles.len - n ..]; 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); 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 /// 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 cols = term_graphics.quote_cols;
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h); 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); 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); defer rendered.deinit(allocator);
const rgb = try rendered.extractRgb(allocator); const rgb = try rendered.extractRgb(allocator);
defer allocator.free(rgb); 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(" High: ${d:.2}\n", .{high_val});
try out.print(" Low: ${d:.2}\n", .{low_val}); try out.print(" Low: ${d:.2}\n", .{low_val});
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_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| { if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;

View file

@ -1,7 +1,8 @@
//! Shared formatting utilities used by both CLI and TUI. //! Shared formatting utilities used by both CLI and TUI.
//! //!
//! Number formatting (fmtIntCommas, etc.), financial helpers //! Number formatting (fmtIntCommas, etc.) and financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation. //! (capitalGainsIndicator, filterNearMoney). The braille sparkline
//! chart lives in `charts/braille.zig`.
const std = @import("std"); const std = @import("std");
const Date = @import("Date.zig"); 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) ───────────────────────────── // ── ANSI color helpers (for CLI) ─────────────────────────────
/// Determine whether to use ANSI color output. /// Determine whether to use ANSI color output.
@ -1531,151 +1209,6 @@ test "aggregateDripLots empty" {
try std.testing.expect(agg.lt.isEmpty()); 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" { test "fmtContractLine" {
var buf: [128]u8 = undefined; var buf: [128]u8 = undefined;
const contract = OptionContract{ 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)); 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" { test "displayCols: ASCII bytes count as 1 col each" {
try std.testing.expectEqual(@as(usize, 0), displayCols("")); try std.testing.expectEqual(@as(usize, 0), displayCols(""));
try std.testing.expectEqual(@as(usize, 5), displayCols("hello")); 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 cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig"); const cmd_framework = @import("commands/framework.zig");
const chart = @import("charts/chart.zig"); const chart = @import("charts/chart.zig");
const theme = @import("tui/theme.zig");
const term_query = @import("term_query.zig"); const term_query = @import("term_query.zig");
/// Comptime registry of CLI commands. Field name is the user-facing /// 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 \\ (e.g. 80x24); `auto` picks Kitty graphics
\\ if the terminal supports it, otherwise \\ if the terminal supports it, otherwise
\\ braille \\ 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` \\ --default-keys Print default keybindings as a `keys.srf`
\\ template and exit (no TUI launched). \\ template and exit (no TUI launched).
\\ Pipe to `~/.config/zfin/keys.srf` to \\ Pipe to `~/.config/zfin/keys.srf` to
@ -166,6 +172,10 @@ const Globals = struct {
refresh_policy: cmd_framework.RefreshPolicy = .auto, refresh_policy: cmd_framework.RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH). /// Chart graphics mode from `--chart` (auto / braille / WxH).
chart_config: chart.ChartConfig = .{}, 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). /// Index into args of the first post-global token (the subcommand).
cursor: usize, cursor: usize,
}; };
@ -299,6 +309,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
i += 2; i += 2;
continue; 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. // Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break; 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; 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 { pub fn main(init: std.process.Init) !u8 {
return runCli(init) catch |err| switch (err) { return runCli(init) catch |err| switch (err) {
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's // 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); 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 command = args[globals.cursor];
const cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]); 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. // loader resolve + union-merge the same way the CLI does.
// This is the load-bearing fix for "CLI and TUI report // This is the load-bearing fix for "CLI and TUI report
// different totals" - there's exactly one code path now. // 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 // tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a // for invalid CLI args; surface as exit 1 without a
// panic / stack trace. // panic / stack trace.
@ -522,6 +569,7 @@ fn runCli(init: std.process.Init) !u8 {
.color = color, .color = color,
.out = out, .out = out,
.graphics_caps = term_query.detect(io, init.environ_map), .graphics_caps = term_query.detect(io, init.environ_map),
.chart_theme = resolved_theme,
}; };
const dispatched_args = if (comptime Module.meta.uppercase_first_arg) const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
try cmd_framework.normalizeFirstArg(allocator, cmd_args) 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"); return try allocator.dupe(u8, "Unclassified");
} }
/// Resolve a human-readable security name for `symbol`, applying /// The candidate name sources for a security, other than the curated
/// the project-wide name-source policy: /// `metadata.srf` name (which `resolveSecurityName` looks up from the
/// 1. The curated `name::` field from `metadata.srf` (via the /// classification map and ranks above all of these). Callers fill in
/// classification map) - the same source the TUI 'K' overlay /// whatever they have on hand and leave the rest null; the precedence
/// uses. Wins whenever present. /// *between* these sources is decided by `resolveSecurityName`, not by
/// 2. `fallback_name` (typically the ETF profile's fund name) /// the caller - that's the whole point of routing every surface
/// when metadata has no name for the symbol. /// 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 /// 1. The curated `name::` from `metadata.srf` (via `cm`) - the
/// caller must keep those alive for as long as the result is used), /// user's explicit override.
/// or null when neither source yields a name. /// 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 /// Empty names are skipped at every level. Returns a slice borrowed
/// by the 'K' overlay, the quote tab header, and the CLI `quote` /// from `cm` or one of `sources` (keep them alive while the result is
/// command. Symbol comparison is exact (`std.mem.eql`); every caller /// used), or null when no source yields a name. Symbol comparison is
/// normalizes symbols to upper-case before calling, matching the /// exact (`std.mem.eql`); every caller upper-cases symbols first,
/// upper-case symbols `metadata.srf` carries. /// matching the upper-case symbols `metadata.srf` carries.
pub fn resolveSecurityName( pub fn resolveSecurityName(
symbol: []const u8, symbol: []const u8,
cm: ?*const ClassificationMap, cm: ?*const ClassificationMap,
fallback_name: ?[]const u8, sources: NameSources,
) ?[]const u8 { ) ?[]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| { if (cm) |m| {
for (m.entries) |*e| { for (m.entries) |*e| {
if (std.mem.eql(u8, e.symbol, symbol)) { 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 (e.name) |n| {
if (n.len > 0) return 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" { test "parse classification file" {
@ -253,14 +278,14 @@ test "resolveSecurityName: metadata name wins" {
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" }, .{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
}; };
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
// Metadata hit; fallback ignored. // Metadata hit; lower-priority sources ignored.
try std.testing.expectEqualStrings( try std.testing.expectEqualStrings(
"Amazon", "Amazon",
resolveSecurityName("AMZN", &cm, "ignored fallback").?, resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?,
); );
try std.testing.expectEqualStrings( try std.testing.expectEqualStrings(
"Vanguard Total Stock Market ETF", "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 }; const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings( try std.testing.expectEqualStrings(
"iShares Semiconductor ETF", "iShares Semiconductor ETF",
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?, resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?,
); );
// No fallback either -> null. // No other source either -> null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null); try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null);
} }
test "resolveSecurityName: falls back when symbol absent from map" { 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 }; const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings( try std.testing.expectEqualStrings(
"SPDR S&P 500 ETF Trust", "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( try std.testing.expectEqualStrings(
"Apple Inc.", "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" { 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 }; const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings( try std.testing.expectEqualStrings(
"Apple Inc.", "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" { test "deriveBucket: returns user-curated bucket when set" {
const e: ClassificationEntry = .{ const e: ClassificationEntry = .{
.symbol = "SPY", .symbol = "SPY",

View file

@ -1,7 +1,16 @@
/// Real-time (or near-real-time) quote snapshot for a symbol. /// Real-time (or near-real-time) quote snapshot for a symbol.
pub const Quote = struct { pub const Quote = struct {
symbol: []const u8, 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, exchange: []const u8,
datetime: []const u8, datetime: []const u8,
close: f64, close: f64,
@ -15,4 +24,18 @@ pub const Quote = struct {
average_volume: u64, average_volume: u64,
fifty_two_week_low: f64, fifty_two_week_low: f64,
fifty_two_week_high: 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 std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.http); const log = std.log.scoped(.http);
@ -406,10 +407,17 @@ pub const Client = struct {
// those to debug so the warn-level log stream // those to debug so the warn-level log stream
// stays focused on cases the operator can act on // stays focused on cases the operator can act on
// (auth, rate, server outages). // (auth, rate, server outages).
if (response.status == .not_found) { //
log.debug("http rejection body status=404 body={s}", .{response.body}); // Skipped entirely under `zig build test`: the
} else { // error-classification tests intentionally drive
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body }); // 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); response.allocator.free(response.body);
if (response.etag) |e| response.allocator.free(e); 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 fmt = @import("../format.zig");
const xml = @import("xml.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_funds_url = "https://www.sec.gov/files/company_tickers_mf.json";
const tickers_companies_url = "https://www.sec.gov/files/company_tickers.json"; const tickers_companies_url = "https://www.sec.gov/files/company_tickers.json";
const search_url_prefix = "https://efts.sec.gov/LATEST/search-index?"; const search_url_prefix = "https://efts.sec.gov/LATEST/search-index?";
@ -336,63 +338,94 @@ pub fn fetchEtfMetrics(
symbol: []const u8, symbol: []const u8,
top_n_holdings: usize, top_n_holdings: usize,
) !EtfMetricsResult { ) !EtfMetricsResult {
// MF/ETF map first - authoritative for symbols filed under a const mf_entry = mf_ticker_map.get(symbol);
// series. Series-keyed full-text search; CIK fallback would const co_entry = stock_ticker_map.get(symbol);
// 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 };
}
// Stock map: probe the submissions feed (one extra HTTP per // Ticker-recycling guard: when a symbol is in BOTH ticker maps,
// unique CIK) to classify the entity. Branches: // classify the company side up front. SEC's
// - fund_shaped + has NPORT-P -> full holdings (SPY) // `company_tickers_mf.json` is slow to drop a ticker after it's
// - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer) // reassigned, so a stale fund-series mapping can collide with an
// - trust_shaped -> profile-only (GLD commodity) // operating company that now owns the ticker (e.g. SPCX: SpaceX's
// - operating -> not-a-fund (AAPL, MSFT) // 2026 IPO took over the defunct Tuttle "SPAC and New Issue ETF"
if (stock_ticker_map.get(symbol)) |entry| { // ticker). The probe is best-effort - on failure we leave
var sub = try self.fetchSubmissionsFeed(allocator, entry.cik); // `co_class` null and `resolveMapPrecedence` trusts the MF map, so
defer sub.deinit(allocator); // a flaky company-feed fetch can't regress a both-map symbol.
var co_sub: ?SubmissionsSummary = null;
const class = classifyByEntityType(&sub); defer if (co_sub) |*s| s.deinit(allocator);
switch (class) { var co_class: ?EntityClass = null;
.operating => return .not_a_fund, if (mf_entry != null and co_entry != null) {
.fund_shaped => { if (self.fetchSubmissionsFeed(allocator, co_entry.?.cik)) |sub| {
if (sub.latest_nport_p_url) |url| { co_sub = sub;
const m = try self.fetchAndParseNportP( co_class = classifyByEntityType(&co_sub.?);
io, } else |err| {
allocator, log.warn("{s}: recycled-ticker company probe failed, trusting MF map: {s}", .{ symbol, @errorName(err) });
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 };
},
} }
} }
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`. /// 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 /// Classify a CIK based on its submissions-feed metadata. Decides
/// whether the symbol is a registered fund (probe NPORT-P), a /// whether the symbol is a registered fund (probe NPORT-P), a
/// trust/ETN-style instrument (profile-only), or a plain operating /// 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 /// that distribute rental income, not registered investment
/// companies. They get bucketed under `operating` - Wikidata is /// companies. They get bucketed under `operating` - Wikidata is
/// the right source for them. /// the right source for them.
fn classifyByEntityType(sub: *const SubmissionsSummary) enum { fn classifyByEntityType(sub: *const SubmissionsSummary) EntityClass {
fund_shaped,
trust_shaped,
operating,
} {
// Rule 1: NPORT-P presence is the strongest fund signal. // Rule 1: NPORT-P presence is the strongest fund signal.
if (sub.latest_nport_p_url != null) return .fund_shaped; 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"); s.sic_description = try T.allocator.dupe(u8, "Real Estate Investment Trusts");
try T.expectEqual(.operating, classifyByEntityType(&s)); 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` - /// 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"); const ftw = root.get("fifty_two_week");
return .{ var quote: Quote = .{
.symbol = symbol, .symbol = symbol,
.name = symbol,
.exchange = "", .exchange = "",
.datetime = "", .datetime = "",
.close = parseJsonFloat(root.get("close")), .close = parseJsonFloat(root.get("close")),
@ -207,6 +206,14 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
else => 0, else => 0,
} 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 -- // -- Tests --
@ -348,6 +355,24 @@ test "parseQuoteResponse basic" {
try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume); 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, 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.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" { test "parseQuoteResponse error response" {

View file

@ -13,6 +13,8 @@ const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle; const Candle = @import("../models/candle.zig").Candle;
const Quote = @import("../models/quote.zig").Quote; const Quote = @import("../models/quote.zig").Quote;
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat; 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"; 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 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 change = price - prev_close;
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0; const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
return .{ var quote: Quote = .{
.symbol = symbol, .symbol = symbol,
.name = symbol,
.exchange = "", .exchange = "",
.datetime = "", .datetime = "",
.close = price, .close = price,
.open = price, // meta doesn't have open .open = open,
.high = parseJsonFloat(m.get("fiftyTwoWeekHigh")), .high = high,
.low = parseJsonFloat(m.get("fiftyTwoWeekLow")), .low = low,
.volume = 0, .volume = volume,
.previous_close = prev_close, .previous_close = prev_close,
.change = change, .change = change,
.percent_change = pct, .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_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
.fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")), .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 { 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 -- // -- Tests --
test "parseChartCandles basic" { test "parseChartCandles basic" {
@ -352,7 +456,7 @@ test "parseChartQuote basic" {
\\ "fiftyTwoWeekLow": 22.21 \\ "fiftyTwoWeekLow": 22.21
\\ }, \\ },
\\ "timestamp": [1704067800], \\ "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 \\ "error": null
\\ } \\ }
@ -363,11 +467,232 @@ test "parseChartQuote basic" {
const quote = try parseChartQuote(allocator, body, "VTTHX"); const quote = try parseChartQuote(allocator, body, "VTTHX");
try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01); 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); 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, 28.59), quote.fifty_two_week_high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01);
// change = 27.78 - 28.06 = -0.28 // change = 27.78 - 28.06 = -0.28
try std.testing.expectApproxEqAbs(@as(f64, -0.28), quote.change, 0.01); 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); 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 std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const fmt = @import("format.zig");
const Money = @import("Money.zig"); const Money = @import("Money.zig");
const cli = @import("commands/common.zig"); const cli = @import("commands/common.zig");
const stderr = @import("stderr.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 framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig"); const theme = @import("tui/theme.zig");
const chart = @import("charts/chart.zig"); const chart = @import("charts/chart.zig");
const braille = @import("charts/braille.zig");
const input_buffer = @import("tui/input_buffer.zig"); const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig"); pub const PortfolioData = @import("PortfolioData.zig");
@ -1348,40 +1348,16 @@ pub const App = struct {
// state, and re-fetch via `loadData` (or whatever the // state, and re-fetch via `loadData` (or whatever the
// tab's loader is named). The framework dispatcher routes // tab's loader is named). The framework dispatcher routes
// to the active tab's reload; tabs that share data // 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 // Reload is contractually self-completing: when it
// returns, the tab's state is fully refreshed. There's no // returns, the tab's state is fully refreshed. There's no
// need for a follow-up `loadTabData` - `activate` would // need for a follow-up `loadTabData` - `activate` would
// see `state.loaded = true` and no-op. // see `state.loaded = true` and no-op.
self.dispatchTry("reload", .{}); 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 /// Activate the current tab - load any data it needs, set
@ -1641,12 +1617,21 @@ pub const App = struct {
return self.appPredicate(t, "isDisabled"); 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 { fn isSymbolSelected(self: *App) bool {
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (self.active_tab != .portfolio) return false; if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
if (self.states.portfolio.rows.items.len == 0) return false; const Module = @field(tab_modules, field.name);
if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false; if (!@hasDecl(Module.tab, "isSymbolSelected")) return false;
return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol); 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 { 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) (if (self.symbol_data.etf_profile) |p| p.name else null)
else else
null; 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, .{ try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}), .text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
.style = th.headerStyle(), .style = th.headerStyle(),
@ -2010,22 +1995,19 @@ pub const App = struct {
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
} }
// Default status bar: getStatus() + optional account-filter // Default status bar: the App's status message, optionally
// suffix on the portfolio tab. // 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(); const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) { const msg = self.getStatus(ctx.arena);
const af = self.states.portfolio.account_filter.?; const line = if (self.activeTabStatusSuffix(ctx.arena)) |suffix|
const msg = self.getStatus(ctx.arena); std.fmt.allocPrint(ctx.arena, "{s} {s}", .{ msg, suffix }) catch msg
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg; else
for (0..@min(filter_text.len, width)) |i| { msg;
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style }; for (0..@min(line.len, width)) |i| {
} buf[i] = .{ .char = .{ .grapheme = glyph(line[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 };
}
} }
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
@ -2047,6 +2029,36 @@ pub const App = struct {
return null; 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 ───────────────────────────────────────────────────── // ── Help ─────────────────────────────────────────────────────
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { 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 { 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 // Local shadows the `chart` module import; use a shorter name for
// the local BrailleChart handle. // 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 // No deinit needed: arena handles cleanup
// Cell budget per row: 2 leading spaces + n_cols chart cells + // Cell budget per row: 2 leading spaces + n_cols chart cells +
// 1 separator space + up to `money_label_max_bytes` for the // 1 separator space + up to `money_label_max_bytes` for the
// price label. Sizing this too small silently truncates labels // price label. Sizing this too small silently truncates labels
// for portfolios over $1M; see `fmt.money_label_max_bytes`. // for portfolios over $1M; see `braille.money_label_max_bytes`.
const label_cells: usize = 1 + fmt.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 row_cells: usize = 2 + br.n_cols + label_cells;
const bg = th.bg; const bg = th.bg;
@ -2158,7 +2170,7 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
// Chart columns // Chart columns
for (0..br.n_cols) |col| { for (0..br.n_cols) |col| {
const pattern = br.pattern(row, col); const pattern = br.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pattern); graphemes[gpos] = braille.brailleGlyph(pattern);
if (pattern != 0) { if (pattern != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) }; styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
} else { } else {
@ -2439,6 +2451,7 @@ pub fn run(
config: zfin.Config, config: zfin.Config,
portfolio_patterns: []const []const u8, portfolio_patterns: []const []const u8,
global_watchlist_path: ?[]const u8, global_watchlist_path: ?[]const u8,
app_theme: theme.Theme,
args: []const []const u8, args: []const []const u8,
today: zfin.Date, today: zfin.Date,
) !void { ) !void {
@ -2504,15 +2517,6 @@ pub fn run(
try stderr_writer.interface.flush(); 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); var svc = try allocator.create(zfin.DataService);
defer allocator.destroy(svc); defer allocator.destroy(svc);
svc.* = zfin.DataService.init(io, allocator, config); svc.* = zfin.DataService.init(io, allocator, config);
@ -2534,7 +2538,7 @@ pub fn run(
.config = config, .config = config,
.svc = svc, .svc = svc,
.keymap = keymap, .keymap = keymap,
.theme = loaded_theme, .theme = app_theme,
.symbol = symbol, .symbol = symbol,
.has_explicit_symbol = has_explicit_symbol, .has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config, .chart_config = chart_config,
@ -2654,15 +2658,12 @@ pub fn run(
app_inst.vx_app = &vx_app; app_inst.vx_app = &vx_app;
defer app_inst.vx_app = null; defer app_inst.vx_app = null;
defer { defer {
// Free any chart image before vaxis is torn down // Free any per-tab Kitty chart images before vaxis is torn
if (app_inst.states.quote.chart.image_id) |id| { // down. Each tab holding image IDs releases them via its
vx_app.vx.freeImage(vx_app.tty.writer(), id); // optional `releaseGraphics` hook. This defer runs before
app_inst.states.quote.chart.image_id = null; // the `vx_app = null` / `vx_app.deinit` defers above (LIFO),
} // so `app.vx_app` is still valid inside the hooks.
if (app_inst.states.projections.image_id) |id| { app_inst.releaseAllGraphics();
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.projections.image_id = null;
}
} }
try vx_app.run(app_inst.widget(), .{}); 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 // and `n_cols` chart cells). For 13+ char money strings like
// `$X,XXX,XXX.XX` (14 chars including the leading separator // `$X,XXX,XXX.XX` (14 chars including the leading separator
// space), the label got silently truncated to the 10 cell // 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); var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit(); 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 /// Mouse handling. In account-picker mode, drives the modal
/// (wheel scroll, click-to-select). Otherwise: clicks on the /// (wheel scroll, click-to-select). Otherwise: clicks on the
/// column-header row sort by that column; clicks on a data /// 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)); 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" { test "ensureCursorVisible: cursor above viewport scrolls up" {
var state: State = .{ .cursor = 5, .header_lines = 2 }; var state: State = .{ .cursor = 5, .header_lines = 2 };
var scroll: usize = 20; var scroll: usize = 20;

View file

@ -28,6 +28,7 @@ const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const projection_chart = @import("../charts/projection_chart.zig"); const projection_chart = @import("../charts/projection_chart.zig");
const forecast_chart = @import("../charts/forecast_chart.zig"); const forecast_chart = @import("../charts/forecast_chart.zig");
const braille = @import("../charts/braille.zig");
const forecast = @import("../analytics/forecast_evaluation.zig"); const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig"); const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig"); const milestones = @import("../analytics/milestones.zig");
@ -255,6 +256,17 @@ pub const tab = struct {
state.* = .{}; 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 { pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return; if (state.loaded) return;
// Projections reads `app.portfolio.summary` and `.file`, // 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; const ov = ctx_data.overlay_actuals orelse break :blk null;
break :blk ov.today_years; break :blk ov.today_years;
}; };
const bands = if (state.zoom_overlay) bz: { const bands = if (state.zoom_overlay)
const ty = overlay_today_years orelse break :bz full_bands; view.overlayZoomBands(full_bands, overlay_today_years)
const window_years_f = ty * 2.0; else
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands; 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;
// Build text header (benchmark comparison + allocation note) // Build text header (benchmark comparison + allocation note)
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
@ -948,6 +954,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
capped_h, capped_h,
th, th,
overlay_input, overlay_input,
pctx.retirement.boundaryYear(),
) catch { ) catch {
state.chart_dirty = false; state.chart_dirty = false;
return; 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; 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; const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
for (yr_label, 0..) |ch, ci| { for (yr_label, 0..) |ch, ci| {
const idx = axis_base + yr_start + 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 // Compute braille chart with wider dimensions
const chart_width: usize = 80; const chart_width: usize = 80;
const chart_height: usize = 12; 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| { if (br) |*br_chart| {
const bg = th.bg; 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 // Sized via a named constant so the projection
// chart doesn't silently truncate labels for // chart doesn't silently truncate labels for
// portfolios that grow past $1M; see // portfolios that grow past $1M; see
// `fmt.money_label_max_bytes`. // `braille.money_label_max_bytes`.
const proj_label_cells: usize = 1 + fmt.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; const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;
for (0..br_chart.chart_height) |row| { 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 // Chart columns
for (0..br_chart.n_cols) |col| { for (0..br_chart.n_cols) |col| {
const pat = br_chart.pattern(row, col); const pat = br_chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pat); graphemes[gpos] = braille.brailleGlyph(pat);
if (pat != 0) { if (pat != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v }; styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v };
} else { } else {

View file

@ -7,11 +7,18 @@ const theme = @import("theme.zig");
const chart = @import("../charts/chart.zig"); const chart = @import("../charts/chart.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig"); const framework = @import("tab_framework.zig");
const market = @import("../market.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; const StyledLine = tui.StyledLine;
const glyph = tui.glyph; 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 /// Per-symbol chart state for the quote tab. Tracks the active
/// timeframe, transmitted Kitty image (when supported), cached /// timeframe, transmitted Kitty image (when supported), cached
/// indicator overlays (SMA/Bollinger/etc), and last-rendered /// indicator overlays (SMA/Bollinger/etc), and last-rendered
@ -84,8 +91,10 @@ pub const Action = enum {
// ── Tab-private state ───────────────────────────────────────── // ── Tab-private state ─────────────────────────────────────────
pub const State = struct { pub const State = struct {
/// Stored real-time quote (only fetched on manual refresh; not /// Stored real-time quote. Fetched on tab activation (staleness-
/// auto-refetched on every redraw). /// 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, live: ?zfin.Quote = null,
/// Unix-epoch seconds for the live-quote fetch - drives the /// Unix-epoch seconds for the live-quote fetch - drives the
/// "data Xs ago" header readout. /// "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 tab = struct {
pub const ActionT = Action; pub const ActionT = Action;
pub const StateT = State; pub const StateT = State;
@ -128,35 +169,46 @@ pub const tab = struct {
state.* = .{}; state.* = .{};
} }
/// Quote loads its own data on activation (the live-quote /// Release the transmitted Kitty chart image before vaxis is torn
/// fetch path lives in tui.zig after the tab switches because /// down. The App calls this across all tabs at teardown while
/// it depends on App.svc); no-op here. Chart redraws are /// `app.vx_app` is still valid - distinct from `deinit`, which runs
/// triggered by the dirty flag on `state.chart`. /// after vaxis is already gone.
/// Quote and performance share `app.symbol_data` (candles + pub fn releaseGraphics(state: *State, app: *App) void {
/// dividends). Performance owns the loader; quote piggybacks if (state.chart.image_id) |id| {
/// by delegating its activate to performance's. This keeps if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
/// `loadTabData`'s dispatch uniform - every tab activates its state.chart.image_id = null;
/// own state - while preserving the historical "switching to }
/// quote populates shared candle data" behavior. }
/// 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 { pub fn activate(state: *State, app: *App) !void {
_ = state;
const perf_module = @import("performance_tab.zig"); const perf_module = @import("performance_tab.zig");
try perf_module.tab.activate(&app.states.performance, app); try perf_module.tab.activate(&app.states.performance, app);
refreshLiveQuote(state, app, false);
} }
pub const deactivate = framework.noopDeactivate(State); pub const deactivate = framework.noopDeactivate(State);
/// Refresh: delegate to performance.reload, which owns the /// Refresh (r/F5): reset the quote-only state, delegate to
/// shared candle/dividend data and svc invalidation. Quote's /// performance.reload (which owns the shared candle/dividend data,
/// chart-state (dirty + freeCache) is also reset by /// svc invalidation, and resetting quote's chart cache), then force
/// performance.reload - see the comment there for why. /// a fresh live quote so the headline price is current as of NOW.
/// Quote-only state (live quote + timestamp) is reset here
/// because performance doesn't know about it.
pub fn reload(state: *State, app: *App) !void { pub fn reload(state: *State, app: *App) !void {
state.live = null; state.live = null;
state.timestamp = 0; state.timestamp = 0;
const perf_module = @import("performance_tab.zig"); const perf_module = @import("performance_tab.zig");
try perf_module.tab.reload(&app.states.performance, app); try perf_module.tab.reload(&app.states.performance, app);
refreshLiveQuote(state, app, true);
} }
pub const tick = framework.noopTick(State); pub const tick = framework.noopTick(State);
@ -623,17 +675,18 @@ pub fn formatQuoteHeader(
}; };
} }
/// Resolve the active symbol's display name using the shared /// Resolve the active symbol's display name using the shared policy
/// policy (metadata.srf `name::`, then the ETF profile's fund /// (`resolveSecurityName`): the curated `metadata.srf` `name::` field
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so /// first, then the live quote name (Yahoo `longName`, the same source
/// the three surfaces always agree on the name they show. The ETF /// as the price), then the ETF profile's fund name. Mirrors the CLI
/// profile is loaded lazily on the performance tab, so the fallback /// `quote` command so the two quote surfaces always agree. The 'K'
/// only kicks in once that data is present; the metadata name is /// overlay uses the same helper but, lacking a per-symbol live quote,
/// always available when enriched. /// resolves metadata -> ETF name only.
fn quoteTabName(app: *App) ?[]const u8 { fn quoteTabName(app: *App) ?[]const u8 {
const cm = app.portfolio.classificationMap(); const cm = app.portfolio.classificationMap();
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null; const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null;
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback); 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 { 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| { if (quote_data) |q| {
// No candle data but have a quote - show it // 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() }); 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; var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); 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, " 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, " 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()); 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| { if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle(); 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); const text = try formatQuoteHeader(arena, "AAPL", "", .none);
try testing.expectEqualStrings(" AAPL", text); 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 handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
//! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... } //! 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 //! /// Optional: does this tab currently have async work in
//! /// flight that needs poll-driven redraws? While the ACTIVE //! /// flight that needs poll-driven redraws? While the ACTIVE
//! /// tab answers true, the App keeps a one-shot vxfw Tick //! /// 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 { ... }", "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) ── // ── 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. /// Which retirement-planning inputs the user has configured.
/// ///
/// The simulation always runs the same two-phase model /// 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); 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" { test "buildOverlayActuals: single point at as_of has years=0" {
const points = [_]timeline.TimelinePoint{ const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000), makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),