Compare commits
21 commits
0cd01dd452
...
d59555bf92
| Author | SHA1 | Date | |
|---|---|---|---|
| d59555bf92 | |||
| ef7f8c2bd1 | |||
| 55cacbe763 | |||
| ec96b60eb4 | |||
| 3fda9eabcf | |||
| 76d00b8dfe | |||
| 243f3c40bf | |||
| f107002b93 | |||
| a70e61d873 | |||
| 42a16cbbd3 | |||
| 98f0f96bf5 | |||
| 6827b70f85 | |||
| acf5f723f8 | |||
| 8ca673c8e3 | |||
| 3abce454dc | |||
| 2bd49af8f3 | |||
| bfabd66866 | |||
| fa4ec246c2 | |||
| aa8dafa3ab | |||
| b7bb2c85d7 | |||
| 46375fed5a |
40 changed files with 3249 additions and 972 deletions
|
|
@ -46,7 +46,7 @@ repos:
|
|||
- id: test
|
||||
name: Run zig build test
|
||||
entry: zig
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=78"]
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=79"]
|
||||
language: system
|
||||
types: [file]
|
||||
pass_filenames: false
|
||||
|
|
|
|||
157
README.md
157
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# zfin
|
||||
|
||||
A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains, earnings history, and more -- all from the terminal.
|
||||
A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains and earnings history, and renders price and projection charts (inline Kitty graphics or PNG export) -- all from the terminal.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
@ -102,60 +102,124 @@ caching/TTL model, and the complete environment-variable list, see
|
|||
|
||||
```
|
||||
src/
|
||||
root.zig Library root, exports all public types
|
||||
format.zig Shared formatters, braille engine, ANSI helpers
|
||||
config.zig Configuration from env vars / .env files
|
||||
service.zig DataService: cache-check -> fetch -> cache -> return
|
||||
main.zig CLI dispatch + TUI entry point
|
||||
root.zig Library root, exports all public types
|
||||
Config.zig Configuration from env vars / .env files
|
||||
service.zig DataService: cache-check -> fetch -> cache -> return
|
||||
Date.zig Date type (i32 days since epoch); arithmetic, formatting
|
||||
Money.zig Money formatting ($1,234.56; whole/trim/signed variants)
|
||||
format.zig Shared formatters, braille engine, ANSI helpers
|
||||
padded.zig Generic width/alignment wrapper for any formattable type
|
||||
PortfolioData.zig Per-portfolio parse, price fetch, summary, background workers
|
||||
portfolio_loader.zig Loads + union-merges portfolio_*.srf for CLI and TUI
|
||||
compare.zig Composes two points in time (snapshot vs live) for `compare`
|
||||
history.zig Reads history/<date>-portfolio.srf snapshots; aggregation
|
||||
market.zig Market calendar; close-anchored cache freshness
|
||||
git.zig Thin git subprocess wrappers (diff/walk tracked portfolios)
|
||||
atomic.zig Crash-safe atomic file writes (tmp + fsync + rename)
|
||||
stderr.zig Best-effort (non-throwing) stderr hint/progress writers
|
||||
term_graphics.zig Kitty graphics emission for plain (non-TUI) CLI charts
|
||||
term_query.zig Terminal capability probing for inline CLI graphics
|
||||
chart_export.zig Chart-to-PNG export (`--export-chart <path>`)
|
||||
comptime_validator.zig Shared comptime contract-validation helpers
|
||||
models/
|
||||
candle.zig OHLCV price bars
|
||||
date.zig Date type with arithmetic, snapping, formatting
|
||||
dividend.zig Dividend records with type classification
|
||||
split.zig Stock splits
|
||||
option.zig Option contracts and chains
|
||||
earnings.zig Earnings events with surprise calculation
|
||||
etf_profile.zig ETF profiles with holdings and sectors
|
||||
portfolio.zig Lots, positions, and portfolio aggregation
|
||||
classification.zig Classification metadata parser
|
||||
quote.zig Real-time quote data
|
||||
candle.zig OHLCV price bars
|
||||
quote.zig Real-time quote data
|
||||
dividend.zig Dividend records with type classification
|
||||
split.zig Stock splits
|
||||
option.zig Option contracts and chains
|
||||
earnings.zig Earnings events with surprise calculation
|
||||
etf_profile.zig ETF profiles with holdings and sectors
|
||||
portfolio.zig Lots, positions, and portfolio aggregation
|
||||
classification.zig Classification metadata parser
|
||||
snapshot.zig Snapshot record types (history/*-portfolio.srf wire format)
|
||||
transaction_log.zig transaction_log.srf wire format (transfer:: records)
|
||||
shiller_year.zig One year of Shiller returns (S&P / bond / CPI)
|
||||
providers/
|
||||
tiingo.zig Tiingo: daily candles (primary), supplementary div/split merge
|
||||
twelvedata.zig TwelveData: quote fallback
|
||||
polygon.zig Polygon: dividends, splits (primary, with forward-looking entries)
|
||||
fmp.zig FMP: earnings (actuals + estimates)
|
||||
cboe.zig CBOE: options chains (no API key)
|
||||
Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), mutual-fund ticker map, XBRL company facts
|
||||
Wikidata.zig Wikidata SPARQL: classification metadata for `enrich`
|
||||
yahoo.zig Yahoo Finance: quotes (primary), candles (Tiingo fallback)
|
||||
openfigi.zig OpenFIGI: CUSIP to ticker lookup
|
||||
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code")
|
||||
tiingo.zig Tiingo: daily candles (primary)
|
||||
yahoo.zig Yahoo Finance: quotes (primary), candle fallback
|
||||
twelvedata.zig TwelveData: quote fallback
|
||||
polygon.zig Polygon: dividends, splits (with forward-looking entries)
|
||||
fmp.zig FMP: earnings (actuals + estimates)
|
||||
cboe.zig CBOE: options chains (no API key)
|
||||
Edgar.zig SEC EDGAR: ETF profiles (NPORT-P), ticker map, XBRL facts
|
||||
Wikidata.zig Wikidata SPARQL: classification metadata for `enrich`
|
||||
openfigi.zig OpenFIGI: CUSIP to ticker lookup
|
||||
json_utils.zig Shared JSON parsing helpers
|
||||
xml.zig Vendored XML parser used by Edgar.zig (see "Vendored code")
|
||||
analytics/
|
||||
indicators.zig SMA, Bollinger Bands, RSI
|
||||
performance.zig Trailing returns (as-of-date + month-end)
|
||||
risk.zig Volatility, Sharpe, drawdown
|
||||
valuation.zig Portfolio summary, allocations, covered call adjustments
|
||||
analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax)
|
||||
indicators.zig SMA, Bollinger Bands, RSI
|
||||
performance.zig Trailing returns (as-of-date + month-end)
|
||||
risk.zig Per-symbol volatility, Sharpe, drawdown
|
||||
portfolio_risk.zig Correlation-aware portfolio-level risk
|
||||
valuation.zig Portfolio summary, allocations, covered-call adjustments
|
||||
analysis.zig Breakdowns by class/sector/geo/account/tax
|
||||
benchmark.zig Per-position benchmark returns
|
||||
exposure.zig Look-through exposure (direct + ETF holdings)
|
||||
milestones.zig Retirement-attainment grid
|
||||
projections.zig Monte Carlo + percentile bands
|
||||
forecast_evaluation.zig Forecast-vs-actual (convergence + return back-test)
|
||||
observations.zig Portfolio sanity checks for the review surface
|
||||
timeline.zig History-tab tier rollup
|
||||
data/
|
||||
ie_data.csv Raw Shiller S&P 500 + CPI dataset (CSV)
|
||||
shiller.zig Shiller series, comptime-generated from ie_data.csv
|
||||
imported_values.zig Back-history portfolio values (imported from spreadsheet)
|
||||
staleness.zig Account-cadence staleness checks
|
||||
Journal.zig acknowledgments.srf journal (acknowledged findings)
|
||||
brokerage/
|
||||
types.zig Normalized BrokeragePosition record + dollar parser
|
||||
fidelity.zig Fidelity "Download Positions" CSV parser
|
||||
schwab.zig Schwab export parser
|
||||
wells_fargo.zig Wells Fargo export parser
|
||||
views/
|
||||
portfolio_sections.zig Portfolio view model (renderer-agnostic StyleIntent)
|
||||
compare.zig Compare view model
|
||||
history.zig History view model
|
||||
projections.zig Projections view model
|
||||
review.zig Per-holding performance + risk dashboard view model
|
||||
observations_view.zig Findings list (engine output + acknowledgments)
|
||||
cache/
|
||||
store.zig SRF file cache with TTL freshness checks
|
||||
net/
|
||||
http.zig HTTP client with retries and server error retry
|
||||
rate_limiter.zig Token-bucket rate limiter
|
||||
store.zig SRF file cache with TTL freshness checks
|
||||
net/
|
||||
http.zig HTTP client with retries and server-error retry
|
||||
RateLimiter.zig Token-bucket rate limiter
|
||||
commands/
|
||||
common.zig Shared CLI helpers (progress, formatting)
|
||||
perf.zig Trailing returns command
|
||||
quote.zig Quote command
|
||||
... (14 command files)
|
||||
tui.zig Interactive TUI application
|
||||
common.zig Shared CLI helpers (progress, formatting, color)
|
||||
framework.zig CLI command framework + portfolio pattern resolution
|
||||
TimeRange.zig Shared --range / time-window parsing
|
||||
audit/ Brokerage reconciliation helpers for `audit`
|
||||
(one .zig per CLI command: perf, quote, portfolio, analysis, history, ...)
|
||||
charts/
|
||||
chart.zig Price + Bollinger + volume + RSI renderer (z2d)
|
||||
projection_chart.zig Percentile-band + median projection renderer
|
||||
forecast_chart.zig Convergence / back-test line renderer
|
||||
line_chart.zig Single-series value-over-time line renderer
|
||||
axis.zig Shared axis-label helpers
|
||||
draw.zig Shared z2d drawing primitives
|
||||
text.zig 5x7 bitmap font for axis labels
|
||||
tui.zig Interactive TUI App orchestrator
|
||||
tui/
|
||||
chart.zig z2d pixel chart renderer (Kitty graphics)
|
||||
keybinds.zig Configurable keybinding system
|
||||
theme.zig Configurable color theme
|
||||
tab_framework.zig Tab contract + comptime validator
|
||||
keybinds.zig Configurable keybinding system
|
||||
theme.zig Configurable color theme
|
||||
input_buffer.zig Modal text-input state machine
|
||||
(nine *_tab.zig tabs: portfolio, analysis, review, projections,
|
||||
history, quote, performance, earnings, options)
|
||||
version.zig Build version string
|
||||
```
|
||||
|
||||
Data files (user-managed, in project root):
|
||||
Data files (user-managed, auto-detected in cwd or `$ZFIN_HOME`):
|
||||
```
|
||||
portfolio.srf Portfolio lots
|
||||
metadata.srf Classification metadata for analysis
|
||||
accounts.srf Account to tax type mapping for analysis
|
||||
portfolio.srf Portfolio lots (positions and cost basis)
|
||||
accounts.srf Account -> tax-type mapping for analysis
|
||||
metadata.srf Per-symbol classification (class/sector/geo) for analysis
|
||||
watchlist.srf Symbols to track in the TUI watchlist
|
||||
transaction_log.srf Declared transfers so contributions math skips internal moves
|
||||
projections.srf Retirement / Monte Carlo projection assumptions
|
||||
theme.srf TUI color theme overrides
|
||||
keys.srf TUI keybinding overrides
|
||||
acknowledgments.srf Acknowledged review-tab findings (app-maintained)
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
|
@ -165,6 +229,7 @@ accounts.srf Account to tax type mapping for analysis
|
|||
| [SRF](https://git.lerch.org/lobo/srf) | Git | Cache file format and portfolio/watchlist parsing |
|
||||
| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering |
|
||||
| [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
|
||||
| [zeit](https://github.com/rockorager/zeit) | Git (v0.9.0) | Calendar arithmetic + timezone conversion (ET) |
|
||||
|
||||
## Building
|
||||
|
||||
|
|
|
|||
33
TODO.md
33
TODO.md
|
|
@ -7,13 +7,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
|
||||
## Projections: future enhancements
|
||||
|
||||
- **Chart vertical line at retirement boundary - priority LOW.**
|
||||
The accumulation-phase spec called this "mandatory" but it was
|
||||
explicitly deferred during implementation. The chart currently
|
||||
shows the full `accumulation_years + horizon` span without a
|
||||
visual marker for where accumulation ends and distribution
|
||||
begins. Easier to add to the kitty-graphics chart than the braille
|
||||
one.
|
||||
- **Goal-seek over distribution horizon for W1 - priority LOW.**
|
||||
Today the W1 ("set spending, find date") workflow reports the
|
||||
earliest retirement at each user-configured `(horizon, confidence)`
|
||||
|
|
@ -80,14 +73,9 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
## `--export-chart` follow-ups - priority LOW
|
||||
|
||||
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
||||
(default bands mode only), and `history`. Several adjacent surfaces
|
||||
still don't have PNG export and were deferred:
|
||||
(bands, `--convergence`, and `--return-backtest` modes), and
|
||||
`history`. Two adjacent surfaces still don't have PNG export:
|
||||
|
||||
- **`projections --convergence` / `--return-backtest`.** Both
|
||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
Not refactored to expose a `renderToSurface` seam yet -
|
||||
parser rejects `--export-chart` in those modes today. Low
|
||||
effort to add (mirror the `tui/chart.zig` pattern).
|
||||
- **`projections --vs <DATE>`.** No chart at all in this mode
|
||||
(text-only delta table); `--export-chart` rejected at parse
|
||||
time. Could grow a side-by-side bands comparison chart, but
|
||||
|
|
@ -97,24 +85,21 @@ still don't have PNG export and were deferred:
|
|||
time would let users render with their configured theme or a
|
||||
presentation-friendly one. Out of scope for V1; gate when
|
||||
someone asks for it.
|
||||
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
|
||||
exports PNG natively today; would need an external dependency
|
||||
or a pixel-buffer-to-format conversion.
|
||||
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
|
||||
|
||||
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
||||
`src/format.zig` is still a ~1600-line grab-bag, but the money- and
|
||||
date-shaped helpers that used to live there have been moved out:
|
||||
money formatting now lives in `src/Money.zig` (with `{f}` /
|
||||
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
|
||||
and date formatting lives in `src/Date.zig` (with `{f}` /
|
||||
`padRight(N)` / `padLeft(N)`). What's left in `format.zig` is the
|
||||
genuinely-format-domain stuff: braille charts, return formatters,
|
||||
allocation notes, signed-percent rendering.
|
||||
date formatting lives in `src/Date.zig` (with `{f}` /
|
||||
`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
|
||||
lives in `src/charts/braille.zig`. What's left in `format.zig` is
|
||||
the genuinely-format-domain stuff: return formatters, allocation
|
||||
notes, signed-percent rendering.
|
||||
|
||||
If the file ever grows enough to be annoying again, consider
|
||||
renaming to `src/render.zig` to better describe what's left, or
|
||||
splitting the braille chart out (it's ~600 lines on its own).
|
||||
renaming to `src/render.zig` to better describe what's left.
|
||||
Not blocking - file it as cleanup if and when it bites.
|
||||
|
||||
## Investigate: detailed 401(k) contributions data source
|
||||
|
|
|
|||
30
examples/themes/catppuccin-frappe.srf
Normal file
30
examples/themes/catppuccin-frappe.srf
Normal 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
|
||||
30
examples/themes/catppuccin-macchiato.srf
Normal file
30
examples/themes/catppuccin-macchiato.srf
Normal 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
|
||||
30
examples/themes/catppuccin-mocha.srf
Normal file
30
examples/themes/catppuccin-mocha.srf
Normal 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
|
||||
30
examples/themes/dracula.srf
Normal file
30
examples/themes/dracula.srf
Normal 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
|
||||
30
examples/themes/gruvbox-dark.srf
Normal file
30
examples/themes/gruvbox-dark.srf
Normal 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
|
||||
30
examples/themes/molokai.srf
Normal file
30
examples/themes/molokai.srf
Normal 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
|
||||
30
examples/themes/monokai.srf
Normal file
30
examples/themes/monokai.srf
Normal 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
30
examples/themes/nord.srf
Normal 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
|
||||
30
examples/themes/solarized-dark.srf
Normal file
30
examples/themes/solarized-dark.srf
Normal 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
|
||||
30
examples/themes/tokyo-night.srf
Normal file
30
examples/themes/tokyo-night.srf
Normal 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
|
||||
|
|
@ -142,6 +142,18 @@ pub const ResolvedRetirement = struct {
|
|||
/// The line renders "not feasible" instead of a date.
|
||||
promoted_infeasible,
|
||||
},
|
||||
|
||||
/// The accumulation/distribution boundary as a year-offset for the
|
||||
/// projection chart's x-axis. `bands[i].year == i` in production,
|
||||
/// so this offset doubles as the band index. Returns `null` when
|
||||
/// there's no accumulation phase to mark (`accumulation_years == 0`:
|
||||
/// already retired or distribution-only), in which case the chart
|
||||
/// draws no divider. Otherwise the chart draws a vertical line at
|
||||
/// this offset separating the saving phase (left) from the
|
||||
/// withdrawal phase (right).
|
||||
pub fn boundaryYear(self: ResolvedRetirement) ?u16 {
|
||||
return if (self.accumulation_years == 0) null else self.accumulation_years;
|
||||
}
|
||||
};
|
||||
|
||||
/// User-configurable projection parameters, loaded from projections.srf.
|
||||
|
|
@ -2594,6 +2606,18 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
|
|||
try std.testing.expect(r1.date.?.eql(r2.date.?));
|
||||
}
|
||||
|
||||
test "ResolvedRetirement.boundaryYear: zero accumulation -> null, positive -> offset" {
|
||||
// No accumulation phase (already retired / distribution-only):
|
||||
// no divider to draw.
|
||||
const none_r: ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none };
|
||||
try std.testing.expectEqual(@as(?u16, null), none_r.boundaryYear());
|
||||
|
||||
// An accumulation phase: the boundary offset equals
|
||||
// accumulation_years (which doubles as the band index).
|
||||
const acc_r: ResolvedRetirement = .{ .accumulation_years = 12, .date = Date.fromYmd(2038, 1, 1), .source = .at_age };
|
||||
try std.testing.expectEqual(@as(?u16, 12), acc_r.boundaryYear());
|
||||
}
|
||||
|
||||
// ── Two-phase simulation regression tests ──────────────────────
|
||||
|
||||
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@
|
|||
//! The TUI's adaptive chart sizing isn't used here because the
|
||||
//! export target is a file, not a cell grid.
|
||||
//!
|
||||
//! The module deliberately reuses the TUI's `default_theme` for
|
||||
//! consistent visual identity across the live TUI surface and
|
||||
//! exported images. A `--theme` override at export time is out of
|
||||
//! scope for V1 (see TODO follow-up).
|
||||
//! Each `export*` takes the `theme.Theme` to render with - the CLI
|
||||
//! resolves it from `--theme <PATH>` (a `theme.srf`), defaulting to
|
||||
//! the built-in theme so exports keep the TUI's visual identity unless
|
||||
//! the user opts into a custom or presentation-friendly palette.
|
||||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
|
|
@ -29,6 +29,9 @@ const chart = @import("charts/chart.zig");
|
|||
const projection_chart = @import("charts/projection_chart.zig");
|
||||
const line_chart = @import("charts/line_chart.zig");
|
||||
const projections = @import("analytics/projections.zig");
|
||||
const forecast = @import("analytics/forecast_evaluation.zig");
|
||||
const forecast_chart = @import("charts/forecast_chart.zig");
|
||||
const compare_chart = @import("charts/compare_chart.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
|
||||
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
||||
|
|
@ -47,6 +50,7 @@ pub fn exportSymbolChart(
|
|||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
display_count: usize,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
|
||||
|
|
@ -64,7 +68,7 @@ pub fn exportSymbolChart(
|
|||
null,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
th,
|
||||
&cached,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
|
|
@ -78,12 +82,16 @@ pub fn exportSymbolChart(
|
|||
|
||||
/// Export a projection percentile-band chart with optional actuals
|
||||
/// overlay. Wraps `projection_chart.renderToSurface` +
|
||||
/// `writeToPNGFile`.
|
||||
/// `writeToPNGFile`. `retirement_boundary_year` (pass
|
||||
/// `ResolvedRetirement.boundaryYear()`) draws the
|
||||
/// accumulation/distribution divider when set.
|
||||
pub fn exportProjectionChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
bands: []const projections.YearPercentiles,
|
||||
actuals: ?projection_chart.ActualsOverlay,
|
||||
retirement_boundary_year: ?u16,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = projection_chart.renderToSurface(
|
||||
|
|
@ -92,9 +100,10 @@ pub fn exportProjectionChart(
|
|||
bands,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
th,
|
||||
actuals,
|
||||
true,
|
||||
retirement_boundary_year,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
|
|
@ -112,6 +121,7 @@ pub fn exportTimelineChart(
|
|||
alloc: std.mem.Allocator,
|
||||
points: []const line_chart.LinePoint,
|
||||
baseline: line_chart.Baseline,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = line_chart.renderToSurface(
|
||||
|
|
@ -120,7 +130,7 @@ pub fn exportTimelineChart(
|
|||
points,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
th,
|
||||
.{ .baseline = baseline, .axis_labels = true },
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
|
|
@ -131,6 +141,89 @@ pub fn exportTimelineChart(
|
|||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the convergence forecast chart (years-until-retirement vs.
|
||||
/// observation date) as a PNG. Wraps
|
||||
/// `forecast_chart.renderConvergenceToSurface` + `writeToPNGFile`.
|
||||
pub fn exportConvergenceChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = forecast_chart.renderConvergenceToSurface(
|
||||
io,
|
||||
alloc,
|
||||
points,
|
||||
default_width,
|
||||
default_height,
|
||||
th,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the return back-test forecast chart (expected vs. realized
|
||||
/// forward CAGR by anchor) as a PNG. Wraps
|
||||
/// `forecast_chart.renderBacktestToSurface` + `writeToPNGFile`.
|
||||
pub fn exportBacktestChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const forecast.BacktestAnchor,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = forecast_chart.renderBacktestToSurface(
|
||||
io,
|
||||
alloc,
|
||||
anchors,
|
||||
default_width,
|
||||
default_height,
|
||||
th,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the `--vs` projection comparison overlay - two percentile-
|
||||
/// band envelopes ("then" and "now") on one chart. Wraps
|
||||
/// `compare_chart.renderToSurface` + `writeToPNGFile`.
|
||||
pub fn exportCompareChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
then_bands: []const projections.YearPercentiles,
|
||||
now_bands: []const projections.YearPercentiles,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = compare_chart.renderToSurface(
|
||||
io,
|
||||
alloc,
|
||||
then_bands,
|
||||
now_bands,
|
||||
default_width,
|
||||
default_height,
|
||||
th,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
test "exportSymbolChart writes a non-empty PNG file" {
|
||||
|
|
@ -161,7 +254,7 @@ test "exportSymbolChart writes a non-empty PNG file" {
|
|||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportSymbolChart(io, alloc, &candles, 60, path);
|
||||
try exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path);
|
||||
|
||||
// Verify the file exists, starts with the PNG magic, and is
|
||||
// big enough to plausibly contain a chart (not just headers).
|
||||
|
|
@ -204,7 +297,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" {
|
|||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportSymbolChart(io, alloc, &candles, 60, path),
|
||||
exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +328,7 @@ test "exportProjectionChart writes a non-empty PNG file" {
|
|||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportProjectionChart(io, alloc, &bands, null, path);
|
||||
try exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
|
||||
defer file.close(io);
|
||||
|
|
@ -271,7 +364,7 @@ test "exportProjectionChart returns InsufficientData with single band" {
|
|||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportProjectionChart(io, alloc, &bands, null, path),
|
||||
exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +389,7 @@ test "exportTimelineChart writes a non-empty PNG file" {
|
|||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportTimelineChart(io, alloc, &points, .fit, path);
|
||||
try exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
|
||||
defer file.close(io);
|
||||
|
|
@ -326,6 +419,172 @@ test "exportTimelineChart returns InsufficientData with a single point" {
|
|||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportTimelineChart(io, alloc, &points, .fit, path),
|
||||
exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportConvergenceChart writes a non-empty PNG file" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const points = [_]forecast.ConvergencePoint{
|
||||
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2032, 1, 1), .years_until_retirement = 12.0, .reached = false },
|
||||
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2031, 1, 1), .years_until_retirement = 9.0, .reached = false },
|
||||
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 5.0, .reached = false },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportConvergenceChart(io, alloc, &points, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportConvergenceChart returns InsufficientData with a single point" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const points = [_]forecast.ConvergencePoint{
|
||||
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportConvergenceChart(io, alloc, &points, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportBacktestChart writes a non-empty PNG file" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const anchors = [_]forecast.BacktestAnchor{
|
||||
.{ .anchor_date = Date.fromYmd(2016, 1, 1), .expected = 0.07, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
|
||||
.{ .anchor_date = Date.fromYmd(2019, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null },
|
||||
.{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.06, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportBacktestChart(io, alloc, &anchors, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportBacktestChart returns InsufficientData with a single anchor" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const anchors = [_]forecast.BacktestAnchor{
|
||||
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportBacktestChart(io, alloc, &anchors, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportCompareChart writes a non-empty PNG file" {
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var then_bands: [11]projections.YearPercentiles = undefined;
|
||||
var now_bands: [11]projections.YearPercentiles = undefined;
|
||||
for (0..11) |i| {
|
||||
const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
|
||||
const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i)));
|
||||
then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 };
|
||||
now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 };
|
||||
}
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_compare.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportCompareChart returns InsufficientData with a single-year side" {
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }};
|
||||
var now_bands: [3]projections.YearPercentiles = undefined;
|
||||
for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@ pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
|
|||
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
|
||||
}
|
||||
|
||||
/// Unit for `drawYTicks` y-axis labels. `dollars` and `percent`
|
||||
/// delegate to the canonical formatters (`fmtDollar`,
|
||||
/// `format.fmtPct`); `years` is a compact whole-year tick ("10y")
|
||||
/// for the convergence chart - the codebase has no shared compact-
|
||||
/// years helper (the verbose "N Year" lives in the projections view).
|
||||
pub const TickUnit = enum { dollars, percent, years };
|
||||
|
||||
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
|
||||
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
|
||||
/// the right margin a small pad to the right of `plot_right` (the plot's
|
||||
|
|
@ -79,6 +86,27 @@ pub fn drawYDollarTicks(
|
|||
value_min: f64,
|
||||
value_max: f64,
|
||||
n: usize,
|
||||
) void {
|
||||
drawYTicks(sfc, scale, color, plot_right, top, bottom, value_min, value_max, n, .dollars);
|
||||
}
|
||||
|
||||
/// Like `drawYDollarTicks` but renders the tick values in the given
|
||||
/// `unit`, so non-dollar axes (percent returns, years-until-
|
||||
/// retirement) reuse the same tick spacing and placement.
|
||||
/// `drawYDollarTicks` is the dollar-specialized wrapper. Percent
|
||||
/// ticks go through `format.fmtPct` (the canonical percent
|
||||
/// formatter) rather than a local reimplementation.
|
||||
pub fn drawYTicks(
|
||||
sfc: *Surface,
|
||||
scale: i32,
|
||||
color: [3]u8,
|
||||
plot_right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
n: usize,
|
||||
unit: TickUnit,
|
||||
) void {
|
||||
const range = value_max - value_min;
|
||||
const span = bottom - top;
|
||||
|
|
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
|
|||
const val = value_max - frac * range;
|
||||
const y = top + frac * span;
|
||||
var buf: [24]u8 = undefined;
|
||||
const label = fmtDollar(&buf, val);
|
||||
const label = switch (unit) {
|
||||
.dollars => fmtDollar(&buf, val),
|
||||
.percent => fmt.fmtPct(&buf, val, .{ .decimals = 0 }),
|
||||
.years => std.fmt.bufPrint(&buf, "{d}y", .{@as(i64, @intFromFloat(@round(val)))}) catch "?y",
|
||||
};
|
||||
const ly = @as(i32, @intFromFloat(y)) - half_h;
|
||||
text.drawText(sfc, lx, ly, scale, color, label);
|
||||
}
|
||||
|
|
@ -136,6 +168,23 @@ test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negativ
|
|||
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
|
||||
}
|
||||
|
||||
test "drawYTicks renders percent and years units" {
|
||||
const alloc = testing.allocator;
|
||||
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
|
||||
|
||||
// Percent ticks (decimal rates, via format.fmtPct) stamp glyphs.
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
defer sfc.deinit(alloc);
|
||||
drawYTicks(&sfc, 2, color, 180, 10, 190, -0.05, 0.15, 5, .percent);
|
||||
try testing.expect(draw.countColor(&sfc, color) > 0);
|
||||
|
||||
// Years ticks stamp glyphs too.
|
||||
var sfc2 = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
defer sfc2.deinit(alloc);
|
||||
drawYTicks(&sfc2, 2, color, 180, 10, 190, 0, 12, 5, .years);
|
||||
try testing.expect(draw.countColor(&sfc2, color) > 0);
|
||||
}
|
||||
|
||||
test "drawYDollarTicks stamps labels in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
|
|
|
|||
513
src/charts/braille.zig
Normal file
513
src/charts/braille.zig
Normal 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);
|
||||
}
|
||||
328
src/charts/compare_chart.zig
Normal file
328
src/charts/compare_chart.zig
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ const theme = @import("../tui/theme.zig");
|
|||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const axis = @import("axis.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
|
|
@ -50,6 +51,80 @@ pub const ChartResult = struct {
|
|||
value_max: f64,
|
||||
};
|
||||
|
||||
/// Owned by the caller - call `result.deinit(alloc)` when done. The
|
||||
/// shared surface seam for both forecast charts: the `--export-chart`
|
||||
/// path keeps the surface to hand to the PNG encoder, while the kitty
|
||||
/// wrappers (`renderConvergenceChart` / `renderBacktestChart`) extract
|
||||
/// RGB and free it. Mirrors `projection_chart.RenderedProjection`.
|
||||
pub const RenderedForecast = struct {
|
||||
surface: Surface,
|
||||
width: u16,
|
||||
height: u16,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
|
||||
pub fn deinit(self: *RenderedForecast, alloc: std.mem.Allocator) void {
|
||||
self.surface.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Extract a flat []u8 of R,G,B triplets; caller owns it. The
|
||||
/// surface is left intact.
|
||||
pub fn extractRgb(self: *const RenderedForecast, alloc: std.mem.Allocator) ![]u8 {
|
||||
return draw.extractRgb(alloc, &self.surface);
|
||||
}
|
||||
};
|
||||
|
||||
/// RGB wrapper over `renderConvergenceToSurface` for the inline kitty
|
||||
/// path (CLI + TUI). Renders WITH axis labels baked in: unlike the
|
||||
/// projection chart - whose callers stamp their own labels into the
|
||||
/// cell grid - nothing draws the forecast chart's labels separately,
|
||||
/// so the years y-ticks + observation-date x-endpoints go into the
|
||||
/// surface here (matching the PNG export).
|
||||
pub fn renderConvergenceChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
var rendered = try renderConvergenceToSurface(io, alloc, points, width_px, height_px, th, true);
|
||||
defer rendered.deinit(alloc);
|
||||
return .{
|
||||
.rgb_data = try rendered.extractRgb(alloc),
|
||||
.width = rendered.width,
|
||||
.height = rendered.height,
|
||||
.value_min = rendered.value_min,
|
||||
.value_max = rendered.value_max,
|
||||
};
|
||||
}
|
||||
|
||||
/// RGB wrapper over `renderBacktestToSurface` for the inline kitty
|
||||
/// path (CLI + TUI). Renders WITH axis labels baked in: unlike the
|
||||
/// projection chart - whose callers stamp their own labels into the
|
||||
/// cell grid - nothing draws the forecast chart's labels separately,
|
||||
/// so the percent y-ticks + date x-endpoints go into the surface here
|
||||
/// (matching the PNG export) to show the divergence magnitude.
|
||||
pub fn renderBacktestChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const BacktestAnchor,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
var rendered = try renderBacktestToSurface(io, alloc, anchors, width_px, height_px, th, true);
|
||||
defer rendered.deinit(alloc);
|
||||
return .{
|
||||
.rgb_data = try rendered.extractRgb(alloc),
|
||||
.width = rendered.width,
|
||||
.height = rendered.height,
|
||||
.value_min = rendered.value_min,
|
||||
.value_max = rendered.value_max,
|
||||
};
|
||||
}
|
||||
|
||||
// ── View 1: Convergence chart ────────────────────────────────
|
||||
|
||||
/// Render the convergence chart. X-axis spans
|
||||
|
|
@ -67,20 +142,21 @@ pub const ChartResult = struct {
|
|||
/// - Solid line through the convergence points
|
||||
/// - Distinct markers on `reached` rows (small filled dots,
|
||||
/// theme accent color)
|
||||
pub fn renderConvergenceChart(
|
||||
pub fn renderConvergenceToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
axis_labels: bool,
|
||||
) !RenderedForecast {
|
||||
if (points.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
defer ctx.deinit();
|
||||
|
|
@ -95,11 +171,19 @@ pub fn renderConvergenceChart(
|
|||
// Background
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Chart area. With axis labels, reserve a right margin for the
|
||||
// y-axis years ticks and a bottom margin for the date endpoints.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const chart_bottom = fheight - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// X-range: observation_date span
|
||||
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
|
||||
|
|
@ -186,8 +270,20 @@ pub fn renderConvergenceChart(
|
|||
// Border
|
||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
||||
|
||||
// Axis labels (export only): right-side y-axis years ticks and
|
||||
// x-axis observation-date endpoints.
|
||||
if (axis_labels) {
|
||||
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .years);
|
||||
var fbuf: [10]u8 = undefined;
|
||||
var lbuf: [10]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].observation_date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].observation_date}) catch "";
|
||||
const date_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.rgb_data = try extractRgb(alloc, &sfc),
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.value_min = y_min,
|
||||
|
|
@ -212,20 +308,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
|
|||
/// - `realized_5y` (solid, theme positive - green)
|
||||
///
|
||||
/// Plus a y=0 reference line.
|
||||
pub fn renderBacktestChart(
|
||||
pub fn renderBacktestToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const BacktestAnchor,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
axis_labels: bool,
|
||||
) !RenderedForecast {
|
||||
if (anchors.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
defer ctx.deinit();
|
||||
|
|
@ -240,11 +337,19 @@ pub fn renderBacktestChart(
|
|||
// Background
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Chart area. With axis labels, reserve a right margin for the
|
||||
// y-axis percent ticks and a bottom margin for the date endpoints.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const chart_bottom = fheight - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// X-range
|
||||
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
|
||||
|
|
@ -303,8 +408,20 @@ pub fn renderBacktestChart(
|
|||
// Border
|
||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
||||
|
||||
// Axis labels (export only): right-side y-axis percent ticks and
|
||||
// x-axis anchor-date endpoints.
|
||||
if (axis_labels) {
|
||||
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .percent);
|
||||
var fbuf: [10]u8 = undefined;
|
||||
var lbuf: [10]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{anchors[0].anchor_date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{anchors[anchors.len - 1].anchor_date}) catch "";
|
||||
const date_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.rgb_data = try extractRgb(alloc, &sfc),
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.value_min = y_min,
|
||||
|
|
@ -492,7 +609,6 @@ const opaqueColor = draw.opaqueColor;
|
|||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawRect = draw.drawRect;
|
||||
const extractRgb = draw.extractRgb;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
//! - Median (p50) line (solid)
|
||||
//! - Zero line (if visible)
|
||||
//! - Actuals overlay line (when present)
|
||||
//! - Retirement boundary vertical line (when an accumulation phase exists)
|
||||
//! - Panel border
|
||||
|
||||
const std = @import("std");
|
||||
|
|
@ -90,6 +91,12 @@ pub const RenderedProjection = struct {
|
|||
/// graphics path (extracts RGB, frees surface).
|
||||
/// - `--export-chart` (CLI) wraps this for PNG export via
|
||||
/// `z2d.png_exporter.writeToPNGFile`.
|
||||
///
|
||||
/// `retirement_boundary_year`, when non-null and within the band
|
||||
/// range, draws a vertical divider at that year-offset marking where
|
||||
/// the accumulation phase ends and distribution begins. Pass
|
||||
/// `ResolvedRetirement.boundaryYear()`; out-of-range values (0 or
|
||||
/// past the last band) are ignored.
|
||||
pub fn renderToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
|
|
@ -99,6 +106,7 @@ pub fn renderToSurface(
|
|||
th: theme.Theme,
|
||||
actuals: ?ActualsOverlay,
|
||||
axis_labels: bool,
|
||||
retirement_boundary_year: ?u16,
|
||||
) !RenderedProjection {
|
||||
if (bands.len < 2) return error.InsufficientData;
|
||||
|
||||
|
|
@ -303,6 +311,24 @@ pub fn renderToSurface(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Retirement boundary vertical line ─────────────────────────
|
||||
//
|
||||
// Drawn on top of the bands (not behind, like the quiet "today"
|
||||
// line) because the accumulation/distribution boundary is a
|
||||
// structural feature of the projection, not just a time cursor.
|
||||
// `bands[i].year == i`, so the boundary year-offset is also its
|
||||
// band index; map it to x exactly as the band points are placed.
|
||||
// Skipped when there's no accumulation phase (null / 0) or the
|
||||
// boundary falls outside the (possibly zoom-truncated) window.
|
||||
if (retirement_boundary_year) |boundary| {
|
||||
if (boundary > 0 and @as(usize, boundary) <= bands.len - 1) {
|
||||
const horizon_years: f64 = @floatFromInt(bands.len - 1);
|
||||
const boundary_x = chart_left + (@as(f64, @floatFromInt(boundary)) / horizon_years) * chart_w;
|
||||
const boundary_color = blendColor(th.warning, 200, bg);
|
||||
try drawVLine(&ctx, boundary_x, chart_top, chart_bottom, boundary_color, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel border ─────────────────────────────────────────────
|
||||
{
|
||||
const border_color = blendColor(th.border, 80, bg);
|
||||
|
|
@ -343,8 +369,9 @@ pub fn renderProjectionChart(
|
|||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
actuals: ?ActualsOverlay,
|
||||
retirement_boundary_year: ?u16,
|
||||
) !ProjectionChartResult {
|
||||
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false);
|
||||
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false, retirement_boundary_year);
|
||||
defer rendered.deinit(alloc);
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
return .{
|
||||
|
|
@ -381,7 +408,7 @@ test "renderProjectionChart produces valid output" {
|
|||
};
|
||||
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 200), result.width);
|
||||
|
|
@ -397,7 +424,7 @@ test "renderProjectionChart insufficient data" {
|
|||
};
|
||||
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
|
||||
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
|
||||
try std.testing.expectError(error.InsufficientData, result);
|
||||
}
|
||||
|
||||
|
|
@ -416,7 +443,7 @@ test "renderProjectionChart with overlay produces valid output" {
|
|||
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
|
||||
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 200), result.width);
|
||||
|
|
@ -439,7 +466,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
|
|||
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
|
||||
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
// Without expansion, value_max would be ~25M (band p90 + 5%).
|
||||
|
|
@ -456,7 +483,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
|
|||
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
|
||||
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
|
||||
defer alloc.free(result.rgb_data);
|
||||
try std.testing.expect(result.rgb_data.len > 0);
|
||||
}
|
||||
|
|
@ -474,7 +501,7 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
|
|||
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
|
||||
};
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false);
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false, null);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 150), rendered.width);
|
||||
|
|
@ -494,7 +521,7 @@ test "renderToSurface fills background with theme bg" {
|
|||
var th = @import("../tui/theme.zig").default_theme;
|
||||
th.bg = .{ 0xab, 0xcd, 0xef };
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false);
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false, null);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
const buf = switch (rendered.surface) {
|
||||
|
|
@ -515,9 +542,9 @@ test "renderToSurface is deterministic across calls with same input" {
|
|||
};
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
|
||||
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
|
||||
defer a.deinit(alloc);
|
||||
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
|
||||
defer b.deinit(alloc);
|
||||
|
||||
const buf_a = switch (a.surface) {
|
||||
|
|
@ -544,7 +571,7 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
|
|||
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
|
||||
};
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false);
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false, null);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
|
|
@ -569,7 +596,7 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
|
|||
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
|
||||
};
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
// After 5% padding and the `if (value_min < 0) value_min = 0`
|
||||
|
|
@ -578,3 +605,43 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
|
|||
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
|
||||
try std.testing.expect(rendered.value_max > 0);
|
||||
}
|
||||
|
||||
test "renderToSurface draws the retirement-boundary divider only when in range" {
|
||||
const alloc = std.testing.allocator;
|
||||
// 11 bands (year 0..10); a boundary at year 5 lands mid-chart.
|
||||
var bands: [11]projections.YearPercentiles = undefined;
|
||||
for (0..11) |i| {
|
||||
const base: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
|
||||
bands[i] = .{
|
||||
.year = @intCast(i),
|
||||
.p10 = base * 0.7,
|
||||
.p25 = base * 0.85,
|
||||
.p50 = base,
|
||||
.p75 = base * 1.15,
|
||||
.p90 = base * 1.3,
|
||||
};
|
||||
}
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const line_px = blendColor(th.warning, 200, th.bg);
|
||||
const line_rgb = [3]u8{ line_px.rgb.r, line_px.rgb.g, line_px.rgb.b };
|
||||
|
||||
// Baseline: no boundary requested.
|
||||
var none = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, null);
|
||||
defer none.deinit(alloc);
|
||||
const base_count = draw.countColor(&none.surface, line_rgb);
|
||||
|
||||
// A boundary at year 5 adds divider pixels in the warning color.
|
||||
var set = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 5);
|
||||
defer set.deinit(alloc);
|
||||
try std.testing.expect(draw.countColor(&set.surface, line_rgb) > base_count);
|
||||
|
||||
// Out-of-range offsets draw nothing: 0 (left edge / no phase) and a
|
||||
// value past the last band index both match the no-boundary baseline.
|
||||
var zero = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 0);
|
||||
defer zero.deinit(alloc);
|
||||
try std.testing.expectEqual(base_count, draw.countColor(&zero.surface, line_rgb));
|
||||
|
||||
var past = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 99);
|
||||
defer past.deinit(alloc);
|
||||
try std.testing.expectEqual(base_count, draw.countColor(&past.surface, line_rgb));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@
|
|||
//! Solid 1-bit pixels avoid it entirely.
|
||||
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
|
||||
//!
|
||||
//! The glyph set is intentionally minimal - just what axis labels need:
|
||||
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
|
||||
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
|
||||
//! The glyph set is intentionally minimal - just what axis labels and
|
||||
//! chart legends need: digits, `$`, `.`, `,`, `-`, `%`, the `T`/`B`/`M`
|
||||
//! magnitude suffixes emitted by `format.fmtLargeNum`, and the
|
||||
//! lowercase letters `t`/`h`/`e`/`n`/`o`/`w` (comparison-chart
|
||||
//! "then"/"now" legend) plus `y` (convergence "years" axis ticks).
|
||||
//! Space and unknown chars render blank.
|
||||
//!
|
||||
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
|
||||
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
|
||||
|
|
@ -55,10 +58,23 @@ const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 };
|
|||
const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 };
|
||||
const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 };
|
||||
const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 };
|
||||
const glyph_percent: Glyph = .{ 0x19, 0x1A, 0x02, 0x04, 0x08, 0x13, 0x03 };
|
||||
const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
|
||||
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
|
||||
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
|
||||
|
||||
// Lowercase letters: the comparison chart's "then"/"now" legend
|
||||
// (t, h, e, n, o, w) plus `y` for the convergence "years" axis ticks.
|
||||
// 5x7, low 5 bits per row. Named `lc_*` to avoid colliding with the
|
||||
// `glyph_w`/`glyph_h` cell dims.
|
||||
const lc_t: Glyph = .{ 0x08, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x0C };
|
||||
const lc_h: Glyph = .{ 0x10, 0x10, 0x10, 0x1E, 0x12, 0x12, 0x12 };
|
||||
const lc_e: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E };
|
||||
const lc_n: Glyph = .{ 0x00, 0x00, 0x1E, 0x12, 0x12, 0x12, 0x12 };
|
||||
const lc_o: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E };
|
||||
const lc_w: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A };
|
||||
const lc_y: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x0E, 0x04, 0x08 };
|
||||
|
||||
/// Look up the bitmap for a character. Unknown characters (including
|
||||
/// space) render blank.
|
||||
fn glyphFor(ch: u8) Glyph {
|
||||
|
|
@ -68,9 +84,17 @@ fn glyphFor(ch: u8) Glyph {
|
|||
'.' => glyph_period,
|
||||
',' => glyph_comma,
|
||||
'-' => glyph_minus,
|
||||
'%' => glyph_percent,
|
||||
'T' => glyph_T,
|
||||
'B' => glyph_B,
|
||||
'M' => glyph_M,
|
||||
't' => lc_t,
|
||||
'h' => lc_h,
|
||||
'e' => lc_e,
|
||||
'n' => lc_n,
|
||||
'o' => lc_o,
|
||||
'w' => lc_w,
|
||||
'y' => lc_y,
|
||||
else => blank,
|
||||
};
|
||||
}
|
||||
|
|
@ -209,3 +233,22 @@ test "drawText renders the comma glyph (so thousands separators show)" {
|
|||
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
|
||||
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
|
||||
}
|
||||
|
||||
test "drawText renders the lowercase glyphs (then/now legend + years axis)" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
// "thenowy" exercises all seven lowercase glyphs; none may be blank.
|
||||
drawText(&sfc, 1, 1, 1, white, "thenowy");
|
||||
try testing.expect(draw.countColor(&sfc, white) > 30);
|
||||
}
|
||||
|
||||
test "drawText renders the percent glyph (for return-rate axis labels)" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
drawText(&sfc, 1, 1, 1, white, "%");
|
||||
try testing.expect(draw.countColor(&sfc, white) > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,38 @@ const git = @import("../git.zig");
|
|||
const framework = @import("framework.zig");
|
||||
const stderr = @import("../stderr.zig");
|
||||
pub const fmt = @import("../format.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
||||
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
||||
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
|
||||
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
|
||||
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
|
||||
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
|
||||
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
|
||||
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
|
||||
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info)
|
||||
// ── Active CLI text palette ──────────────────────────────────
|
||||
// RGB foreground colors for ALL CLI (non-TUI) text output, emitted as
|
||||
// truecolor ANSI by setFg/printFg. Defaults match the built-in Monokai
|
||||
// theme; `applyTheme` overwrites them once at startup from the resolved
|
||||
// `--theme <PATH>` (or the default) so the entire CLI - gain/loss,
|
||||
// headers, muted labels, warnings, accents - honors the user's theme,
|
||||
// not just the charts. zfin is a single-invocation process and these
|
||||
// are set once, before any command renders, so process-wide state is
|
||||
// safe here and avoids threading a palette through every call site.
|
||||
pub var CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
|
||||
pub var CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
|
||||
pub var CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
|
||||
pub var CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
|
||||
pub var CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
|
||||
pub var CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
|
||||
pub var CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info)
|
||||
|
||||
/// Repaint the CLI text palette from a resolved theme. Call once at
|
||||
/// startup (after `--theme` resolution) so every `CLR_*` reference picks
|
||||
/// up the user's colors. The field-to-CLR mapping mirrors the comments
|
||||
/// on the defaults above; keep them in sync.
|
||||
pub fn applyTheme(th: theme.Theme) void {
|
||||
CLR_POSITIVE = th.positive;
|
||||
CLR_NEGATIVE = th.negative;
|
||||
CLR_MUTED = th.text_muted;
|
||||
CLR_HEADER = th.accent;
|
||||
CLR_ACCENT = th.bar_fill;
|
||||
CLR_WARNING = th.warning;
|
||||
CLR_INFO = th.info;
|
||||
}
|
||||
|
||||
// ── ANSI color helpers ───────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const zfin = @import("../root.zig");
|
|||
const validator = @import("../comptime_validator.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
||||
// ── Group taxonomy ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -259,6 +260,11 @@ pub const RunCtx = struct {
|
|||
/// once at invocation entry. Commands consult this together with
|
||||
/// `globals.chart_config` to choose kitty-graphics vs braille output.
|
||||
graphics_caps: term_query.Caps = .{},
|
||||
/// Theme for all CLI charts - inline terminal (kitty) charts and
|
||||
/// `--export-chart` PNGs - resolved once from `--theme <PATH>` (or
|
||||
/// `~/.config/zfin/theme.srf`, else the built-in default). The same
|
||||
/// resolved theme also drives the CLI text palette and the TUI.
|
||||
chart_theme: theme.Theme = theme.default_theme,
|
||||
|
||||
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
|
||||
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const view = @import("../views/history.zig");
|
|||
const chart_export = @import("../chart_export.zig");
|
||||
const line_chart = @import("../charts/line_chart.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const braille = @import("../charts/braille.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
|
@ -137,6 +138,11 @@ pub const PortfolioOpts = struct {
|
|||
/// minimum if the series itself dips negative). Only consulted when
|
||||
/// `--export-chart` is given.
|
||||
baseline: line_chart.Baseline = .fit,
|
||||
/// Theme for the chart - both the inline kitty image and the
|
||||
/// `--export-chart` PNG. Comes from the global `--theme <PATH>`
|
||||
/// flag; injected by `run` from `RunCtx.chart_theme`. Defaults to
|
||||
/// the built-in theme.
|
||||
chart_theme: theme.Theme = theme.default_theme,
|
||||
};
|
||||
|
||||
/// Parse the arg list for portfolio-mode flags. Pure function - no IO.
|
||||
|
|
@ -223,9 +229,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
switch (parsed) {
|
||||
.symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts),
|
||||
.portfolio => |opts| {
|
||||
var o = opts;
|
||||
o.chart_theme = ctx.chart_theme;
|
||||
const pf = ctx.resolvePortfolioPath();
|
||||
defer pf.deinit(ctx.allocator);
|
||||
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
|
||||
try runPortfolio(ctx.io, ctx.allocator, pf.path, o, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -327,7 +335,7 @@ fn runPortfolio(
|
|||
// as a PNG (honoring --since / --until / --metric / --baseline) and
|
||||
// exit without printing the normal timeline output.
|
||||
if (opts.export_chart) |path| {
|
||||
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) {
|
||||
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, opts.chart_theme, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n");
|
||||
return;
|
||||
|
|
@ -359,7 +367,7 @@ fn runPortfolio(
|
|||
// Resolve how to draw the inline chart: kitty graphics when the
|
||||
// terminal supports it (or it's forced via `--chart <WxH>`), else
|
||||
// braille. `--chart braille` always forces braille.
|
||||
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline };
|
||||
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline, .theme = opts.chart_theme };
|
||||
const chart_render: ChartRender = switch (chart_config.mode) {
|
||||
.braille => .braille,
|
||||
.kitty => .{ .kitty = k },
|
||||
|
|
@ -548,13 +556,14 @@ fn exportMetricChart(
|
|||
points: []const timeline.TimelinePoint,
|
||||
metric: timeline.Metric,
|
||||
baseline: line_chart.Baseline,
|
||||
th: theme.Theme,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
const series = try timeline.extractChartSeries(allocator, points, metric);
|
||||
defer allocator.free(series);
|
||||
const lps = try metricLinePoints(allocator, series);
|
||||
defer allocator.free(lps);
|
||||
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path);
|
||||
try chart_export.exportTimelineChart(io, allocator, lps, baseline, th, path);
|
||||
}
|
||||
|
||||
/// How `renderPortfolio` draws the timeline chart.
|
||||
|
|
@ -570,6 +579,7 @@ const KittyChart = struct {
|
|||
io: std.Io,
|
||||
caps: term_query.Caps,
|
||||
baseline: line_chart.Baseline,
|
||||
theme: theme.Theme,
|
||||
};
|
||||
|
||||
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics
|
||||
|
|
@ -613,7 +623,7 @@ fn emitTimelineKitty(
|
|||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
|
||||
|
||||
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, theme.default_theme, .{ .baseline = k.baseline, .axis_labels = true });
|
||||
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, k.theme, .{ .baseline = k.baseline, .axis_labels = true });
|
||||
defer rendered.deinit(allocator);
|
||||
const rgb = try rendered.extractRgb(allocator);
|
||||
defer allocator.free(rgb);
|
||||
|
|
@ -655,9 +665,9 @@ fn renderBraille(
|
|||
}
|
||||
const candles = candles_list.items;
|
||||
|
||||
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
var braille_chart = braille.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer braille_chart.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
|
||||
try braille.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
|
||||
}
|
||||
|
||||
fn renderTable(
|
||||
|
|
@ -1166,7 +1176,7 @@ test "exportMetricChart writes a PNG for a multi-point timeline" {
|
|||
const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportMetricChart(io, alloc, &pts, .liquid, .fit, path);
|
||||
try exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "history_timeline.png", .{});
|
||||
defer file.close(io);
|
||||
|
|
@ -1187,7 +1197,7 @@ test "exportMetricChart returns InsufficientData with fewer than 2 points" {
|
|||
const pts = [_]timeline.TimelinePoint{
|
||||
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
|
||||
};
|
||||
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png"));
|
||||
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, "unused.png"));
|
||||
}
|
||||
|
||||
// ── rebuildRollup ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ const milestones = @import("../analytics/milestones.zig");
|
|||
const shiller = @import("../data/shiller.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const projection_chart = @import("../charts/projection_chart.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const forecast_chart = @import("../charts/forecast_chart.zig");
|
||||
const compare_chart = @import("../charts/compare_chart.zig");
|
||||
const braille = @import("../charts/braille.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
|
@ -39,11 +43,12 @@ pub const ParsedArgs = union(enum) {
|
|||
/// `--vs <DATE>`: side-by-side compare of two projections.
|
||||
compare: CompareArgs,
|
||||
/// `--convergence`: plot the spreadsheet's predicted retirement
|
||||
/// date over time. No knobs.
|
||||
convergence,
|
||||
/// date over time. `export_chart` renders a PNG instead of the
|
||||
/// text table.
|
||||
convergence: struct { export_chart: ?[]const u8 = null },
|
||||
/// `--return-backtest [--real]`: plot expected_return vs realized
|
||||
/// forward-CAGR.
|
||||
return_backtest: struct { real: bool },
|
||||
/// forward-CAGR. `export_chart` renders a PNG instead of text.
|
||||
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
|
||||
};
|
||||
|
||||
pub const BandsArgs = struct {
|
||||
|
|
@ -64,6 +69,9 @@ pub const CompareArgs = struct {
|
|||
/// "Now" side. Null = today (live); non-null = the `--as-of`
|
||||
/// date the user paired with `--vs`.
|
||||
as_of: ?Date = null,
|
||||
/// When set, render the side-by-side comparison overlay as a PNG
|
||||
/// to this path and exit. No text output.
|
||||
export_chart: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
|
|
@ -104,11 +112,12 @@ pub const meta: framework.Meta = .{
|
|||
\\ --return-backtest (see above)
|
||||
\\ --real With --return-backtest, render in
|
||||
\\ CPI-adjusted dollars.
|
||||
\\ --export-chart <PATH> Render the percentile-band chart
|
||||
\\ (with optional overlay if
|
||||
\\ --overlay-actuals is set) as a PNG
|
||||
\\ to PATH (1920x1080) and exit. Only
|
||||
\\ valid in the default bands mode.
|
||||
\\ --export-chart <PATH> Render the current mode's chart as a
|
||||
\\ PNG to PATH (1920x1080) and exit.
|
||||
\\ Works in all modes: the default bands
|
||||
\\ view (with the overlay if
|
||||
\\ --overlay-actuals is set), --convergence,
|
||||
\\ --return-backtest, and --vs.
|
||||
\\
|
||||
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
|
||||
\\
|
||||
|
|
@ -202,22 +211,23 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
// Chart export only meaningful in default bands mode. The
|
||||
// forecast-evaluation views (convergence, return-backtest)
|
||||
// render via `forecast_chart.zig` which doesn't have a PNG
|
||||
// export path yet; --vs is text-only with no chart at all.
|
||||
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
|
||||
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
|
||||
// The actuals overlay plots the realized trajectory from `--as-of`
|
||||
// through today; without an as-of anchor there's nothing to plot.
|
||||
// Matches the TUI, which refuses the overlay without an as-of date.
|
||||
// (Under `--vs` the overlay is ignored rather than required, so
|
||||
// that combination is left alone.)
|
||||
if (overlay_actuals and as_of == null and vs_date == null) {
|
||||
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
|
||||
if (convergence) return ParsedArgs{ .convergence = {} };
|
||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
|
||||
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
|
||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
|
||||
if (vs_date) |d| {
|
||||
return ParsedArgs{ .compare = .{
|
||||
.events_enabled = events_enabled,
|
||||
.vs_date = d,
|
||||
.as_of = as_of,
|
||||
.export_chart = export_chart,
|
||||
} };
|
||||
}
|
||||
return ParsedArgs{ .bands = .{
|
||||
|
|
@ -239,9 +249,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer pf.deinit(allocator);
|
||||
const file_path = pf.path;
|
||||
|
||||
// Inline kitty charts when the terminal supports it (or `--chart
|
||||
// kitty` forces it). No braille fallback for the projection-family
|
||||
// charts - non-kitty terminals get table-only output. Shared by the
|
||||
// bands, convergence, and return-backtest modes.
|
||||
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
|
||||
.braille => null,
|
||||
.kitty => ctx.graphics_caps,
|
||||
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
|
||||
};
|
||||
|
||||
switch (parsed) {
|
||||
.convergence => try runConvergence(io, allocator, file_path, color, out),
|
||||
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
|
||||
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
|
||||
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
|
||||
.compare => |args| {
|
||||
_ = ctx.svc orelse return error.MissingDataService;
|
||||
// Pre-load today's live composition only when it's
|
||||
|
|
@ -271,6 +291,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.today = today,
|
||||
.live = if (live) |*l| l else null,
|
||||
},
|
||||
kitty_caps,
|
||||
args.export_chart,
|
||||
);
|
||||
},
|
||||
.bands => |args| {
|
||||
|
|
@ -283,14 +305,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// total. Snapshot-only as-of paths ignore it.
|
||||
var live = try loadLiveData(ctx, today, color);
|
||||
defer if (live) |*l| l.deinit(allocator);
|
||||
// Inline kitty band chart when supported (or forced). There's
|
||||
// no braille fallback for projections - non-kitty terminals
|
||||
// keep the table-only output.
|
||||
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
|
||||
.braille => null,
|
||||
.kitty => ctx.graphics_caps,
|
||||
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
|
||||
};
|
||||
try runBands(
|
||||
io,
|
||||
allocator,
|
||||
|
|
@ -304,6 +318,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.overlay_actuals = args.overlay_actuals,
|
||||
.export_chart = args.export_chart,
|
||||
.live = if (live) |*l| l else null,
|
||||
.chart_theme = ctx.chart_theme,
|
||||
},
|
||||
color,
|
||||
out,
|
||||
|
|
@ -451,6 +466,10 @@ pub const BandsOptions = struct {
|
|||
/// Snapshot-only as-of paths ignore this field. See
|
||||
/// `LiveData` for the rationale.
|
||||
live: ?*const LiveData = null,
|
||||
/// Theme for the `--export-chart` PNG (resolved from `--theme`).
|
||||
/// Defaults to the built-in theme; the inline kitty chart always
|
||||
/// uses the default.
|
||||
chart_theme: theme.Theme = theme.default_theme,
|
||||
};
|
||||
|
||||
/// Build a `ProjectionContext` for an already-resolved as-of date,
|
||||
|
|
@ -554,6 +573,50 @@ pub fn anyImportedOnly(
|
|||
return now_res.source == .imported;
|
||||
}
|
||||
|
||||
/// The chart-ready overlay input + band slice for the bands-mode
|
||||
/// chart. Shared by the inline-kitty (`emitBandsKitty`) and PNG-export
|
||||
/// paths so both translate the actuals overlay and frame it (zoom to
|
||||
/// the overlay window) identically.
|
||||
const OverlayChart = struct {
|
||||
overlay: ?projection_chart.ActualsOverlay,
|
||||
bands: []const projections.YearPercentiles,
|
||||
};
|
||||
|
||||
/// Translate the context's actuals overlay into the chart module's
|
||||
/// shape and pick the band slice to render: zoomed to the overlay
|
||||
/// window (`view.overlayZoomBands`) when an overlay is present, else
|
||||
/// the full `bands_ec`. Overlay points are allocated from the arena
|
||||
/// `va`, so no explicit free is needed.
|
||||
fn prepOverlayChart(
|
||||
va: std.mem.Allocator,
|
||||
ctx: *const view.ProjectionContext,
|
||||
bands_ec: []const projections.YearPercentiles,
|
||||
) OverlayChart {
|
||||
const overlay: ?projection_chart.ActualsOverlay = blk: {
|
||||
const ov = ctx.overlay_actuals orelse break :blk null;
|
||||
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
|
||||
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
|
||||
break :blk .{ .points = buf, .today_years = ov.today_years };
|
||||
};
|
||||
return .{
|
||||
.overlay = overlay,
|
||||
.bands = view.overlayZoomBands(bands_ec, if (overlay) |ov| ov.today_years else null),
|
||||
};
|
||||
}
|
||||
|
||||
/// Pixel + cell dimensions for an inline projection-family chart at
|
||||
/// the standard column width, derived from the terminal's cell size.
|
||||
/// Shared by the bands, convergence, and return-backtest inline-kitty
|
||||
/// paths so they all render at the same on-screen footprint.
|
||||
const ProjChartDims = struct { width: u32, height: u32, cols: u16, rows: u16 };
|
||||
|
||||
fn projectionChartDims(caps: term_query.Caps) ProjChartDims {
|
||||
const cols = term_graphics.projection_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
|
||||
return .{ .width = dims.width, .height = dims.height, .cols = cols, .rows = rows };
|
||||
}
|
||||
|
||||
/// Render the percentile-band chart (longest horizon, with the actuals
|
||||
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
|
||||
/// wide and emit it inline. Returns `error.InsufficientData` when bands
|
||||
|
|
@ -564,30 +627,22 @@ fn emitBandsKitty(
|
|||
va: std.mem.Allocator,
|
||||
ctx: *const view.ProjectionContext,
|
||||
caps: term_query.Caps,
|
||||
th: theme.Theme,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const horizons = ctx.config.getHorizons();
|
||||
if (horizons.len == 0) return error.InsufficientData;
|
||||
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
|
||||
|
||||
// Translate the view-layer overlay (if any) into the chart module's
|
||||
// ActualsPoint shape - same conversion as the PNG export path. The
|
||||
// arena owns the buffer; it lives as long as `overlay_input`.
|
||||
const overlay_input = blk: {
|
||||
const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null);
|
||||
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null);
|
||||
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
|
||||
break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years };
|
||||
};
|
||||
// Translate the overlay and frame the band slice (zoomed to the
|
||||
// overlay window) the same way the PNG-export path does.
|
||||
const oc = prepOverlayChart(va, ctx, bands_ec);
|
||||
|
||||
const cols = term_graphics.projection_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
|
||||
|
||||
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true);
|
||||
const d = projectionChartDims(caps);
|
||||
var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, th, oc.overlay, true, ctx.retirement.boundaryYear());
|
||||
defer rendered.deinit(va);
|
||||
const rgb = try rendered.extractRgb(va);
|
||||
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
|
||||
try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows);
|
||||
}
|
||||
|
||||
pub fn runBands(
|
||||
|
|
@ -712,25 +767,11 @@ pub fn runBands(
|
|||
return;
|
||||
};
|
||||
|
||||
// Translate the view-layer overlay points (if any) into the
|
||||
// chart-module's ActualsPoint shape. Same conversion the TUI
|
||||
// does in `projections_tab.drawWithKittyChart`.
|
||||
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null;
|
||||
defer if (overlay_buf) |ob| va.free(ob);
|
||||
const overlay_input = blk: {
|
||||
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
|
||||
const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
|
||||
for (ov.points, 0..) |p, i| {
|
||||
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
|
||||
}
|
||||
overlay_buf = buf;
|
||||
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{
|
||||
.points = buf,
|
||||
.today_years = ov.today_years,
|
||||
};
|
||||
};
|
||||
// Translate the overlay and frame the band slice (zoomed to the
|
||||
// overlay window) the same way the inline-kitty path does.
|
||||
const oc = prepOverlayChart(va, &ctx, bands_ec);
|
||||
|
||||
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
|
||||
chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), opts.chart_theme, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
|
||||
return;
|
||||
|
|
@ -764,7 +805,7 @@ pub fn runBands(
|
|||
// keep the table-only view below.
|
||||
if (kitty_caps) |kc| {
|
||||
try out.print("\n", .{});
|
||||
emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) {
|
||||
emitBandsKitty(io, va, &ctx, kc, opts.chart_theme, out) catch |err| switch (err) {
|
||||
error.InsufficientData => {}, // no bands yet; fall through to the table
|
||||
else => return err,
|
||||
};
|
||||
|
|
@ -895,9 +936,9 @@ pub fn runBands(
|
|||
};
|
||||
}
|
||||
|
||||
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
|
||||
var br = braille.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
|
||||
if (br) |*chart| {
|
||||
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
|
||||
try braille.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
|
||||
// Year axis instead of date axis
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Now", .{});
|
||||
|
|
@ -1026,6 +1067,15 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
|
|||
};
|
||||
}
|
||||
|
||||
/// The longest-horizon percentile band envelope for a context, or
|
||||
/// null when no horizon/bands are available. Used to retain the
|
||||
/// `--vs` comparison overlay's two envelopes.
|
||||
fn longestBands(ctx: view.ProjectionContext) ?[]const projections.YearPercentiles {
|
||||
const horizons = ctx.config.getHorizons();
|
||||
if (horizons.len == 0) return null;
|
||||
return ctx.data.bands[horizons.len - 1];
|
||||
}
|
||||
|
||||
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
|
||||
/// "then" or snapshot "now" side at `requested_date`.
|
||||
///
|
||||
|
|
@ -1085,6 +1135,8 @@ pub fn runCompare(
|
|||
ctx: *framework.RunCtx,
|
||||
file_path: []const u8,
|
||||
opts: KeyComparisonOptions,
|
||||
kitty_caps: ?term_query.Caps,
|
||||
export_chart: ?[]const u8,
|
||||
) !void {
|
||||
const io = ctx.io;
|
||||
const allocator = ctx.allocator;
|
||||
|
|
@ -1102,6 +1154,26 @@ pub fn runCompare(
|
|||
};
|
||||
defer result.cleanup();
|
||||
|
||||
// --export-chart: render the comparison overlay to a PNG and exit
|
||||
// before any text output.
|
||||
if (export_chart) |export_path| {
|
||||
if (result.then_bands == null or result.now_bands == null) {
|
||||
cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n");
|
||||
return;
|
||||
}
|
||||
chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, ctx.chart_theme, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try out.print("\n", .{});
|
||||
var then_buf: [10]u8 = undefined;
|
||||
var now_buf: [10]u8 = undefined;
|
||||
|
|
@ -1142,6 +1214,21 @@ pub fn runCompare(
|
|||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
// Inline comparison overlay above the table when supported. No
|
||||
// braille fallback - non-kitty terminals get the table only.
|
||||
if (kitty_caps) |kc| {
|
||||
if (result.then_bands != null and result.now_bands != null) {
|
||||
const d = projectionChartDims(kc);
|
||||
if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, ctx.chart_theme)) |cres| {
|
||||
try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows);
|
||||
try out.print("\n", .{});
|
||||
} else |err| switch (err) {
|
||||
error.InsufficientData => {},
|
||||
else => return err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try renderKeyComparisonRows(out, color, result.then, result.now, result.events_enabled);
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
|
||||
|
|
@ -1176,8 +1263,11 @@ pub fn runConvergence(
|
|||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
export_chart: ?[]const u8,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
kitty_caps: ?term_query.Caps,
|
||||
chart_theme: theme.Theme,
|
||||
) !void {
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
|
|
@ -1193,6 +1283,37 @@ pub fn runConvergence(
|
|||
defer iv.deinit();
|
||||
|
||||
const points = try forecast.convergencePoints(va, iv.points);
|
||||
|
||||
// --export-chart: render the convergence chart to a PNG and exit
|
||||
// before any text output.
|
||||
if (export_chart) |export_path| {
|
||||
chart_export.exportConvergenceChart(io, allocator, points, chart_theme, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline kitty chart above the table when supported. No braille
|
||||
// fallback - non-kitty terminals get the table only. Too few points
|
||||
// skips the chart and still renders the table below.
|
||||
if (kitty_caps) |kc| {
|
||||
const d = projectionChartDims(kc);
|
||||
if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, chart_theme)) |result| {
|
||||
try out.print("\n", .{});
|
||||
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
|
||||
} else |err| switch (err) {
|
||||
error.InsufficientData => {},
|
||||
else => return err,
|
||||
}
|
||||
}
|
||||
|
||||
const lines = try view.convergenceLines(va, points);
|
||||
try renderForecastLines(out, color, lines);
|
||||
}
|
||||
|
|
@ -1214,8 +1335,11 @@ pub fn runReturnBacktest(
|
|||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
real_mode: bool,
|
||||
export_chart: ?[]const u8,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
kitty_caps: ?term_query.Caps,
|
||||
chart_theme: theme.Theme,
|
||||
) !void {
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
|
|
@ -1240,6 +1364,35 @@ pub fn runReturnBacktest(
|
|||
|
||||
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
|
||||
const anchors = try forecast.pivotByAnchor(va, rows);
|
||||
|
||||
// --export-chart: render the back-test chart to a PNG and exit.
|
||||
if (export_chart) |export_path| {
|
||||
chart_export.exportBacktestChart(io, allocator, anchors, chart_theme, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline kitty chart above the table when supported (see
|
||||
// runConvergence). Too few anchors skips the chart; table still renders.
|
||||
if (kitty_caps) |kc| {
|
||||
const d = projectionChartDims(kc);
|
||||
if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, chart_theme)) |result| {
|
||||
try out.print("\n", .{});
|
||||
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
|
||||
} else |err| switch (err) {
|
||||
error.InsufficientData => {},
|
||||
else => return err,
|
||||
}
|
||||
}
|
||||
|
||||
const lines = try view.backtestLines(va, anchors, real_mode);
|
||||
try renderForecastLines(out, color, lines);
|
||||
}
|
||||
|
|
@ -1272,6 +1425,11 @@ fn renderForecastLines(
|
|||
pub const KeyComparisonResult = struct {
|
||||
then: KeyMetrics,
|
||||
now: KeyMetrics,
|
||||
/// Longest-horizon percentile bands for each side, retained for
|
||||
/// the `--vs` comparison overlay chart. Arena-lived (the caller's
|
||||
/// `va`); null when a side produced no bands. Both aligned at year 0.
|
||||
then_bands: ?[]const projections.YearPercentiles = null,
|
||||
now_bands: ?[]const projections.YearPercentiles = null,
|
||||
/// Resolution of the "then" snapshot. Always present.
|
||||
resolution: AsOfResolution,
|
||||
/// Resolution of the "now" snapshot. Null when now is live.
|
||||
|
|
@ -1407,6 +1565,8 @@ pub fn computeKeyComparison(
|
|||
return .{
|
||||
.then = extractKeyMetrics(then_ctx),
|
||||
.now = extractKeyMetrics(now_ctx),
|
||||
.then_bands = longestBands(then_ctx),
|
||||
.now_bands = longestBands(now_ctx),
|
||||
.resolution = then_resolution,
|
||||
.now_resolution = now_resolution,
|
||||
.events_enabled = opts.events_enabled,
|
||||
|
|
@ -1440,6 +1600,8 @@ pub fn computeKeyComparison(
|
|||
return .{
|
||||
.then = extractKeyMetrics(then_ctx),
|
||||
.now = extractKeyMetrics(now_ctx),
|
||||
.then_bands = longestBands(then_ctx),
|
||||
.now_bands = longestBands(now_ctx),
|
||||
.resolution = then_resolution,
|
||||
.now_resolution = null,
|
||||
.events_enabled = opts.events_enabled,
|
||||
|
|
@ -1840,6 +2002,30 @@ test "parseArgs: --overlay-actuals carries into bands" {
|
|||
}
|
||||
}
|
||||
|
||||
test "parseArgs: --overlay-actuals without --as-of is rejected" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{"--overlay-actuals"};
|
||||
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
|
||||
}
|
||||
|
||||
test "projectionChartDims: standard column width, sane pixel/row footprint" {
|
||||
const caps = term_query.Caps{ .kitty = true, .cell_w = 10, .cell_h = 20 };
|
||||
const d = projectionChartDims(caps);
|
||||
try testing.expectEqual(term_graphics.projection_cols, d.cols);
|
||||
try testing.expect(d.rows > 0);
|
||||
try testing.expect(d.width > 0 and d.height > 0);
|
||||
}
|
||||
|
||||
test "parseArgs: --vs with --export-chart carries into compare" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{ "--vs", "2024-01-01", "--export-chart", "out.png" };
|
||||
const parsed = try parseArgsForTest(today, &args);
|
||||
switch (parsed) {
|
||||
.compare => |c| try testing.expect(c.export_chart != null),
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot_model = @import("../models/snapshot.zig");
|
||||
const snapshot = @import("snapshot.zig");
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const fmt = cli.fmt;
|
|||
const Money = @import("../Money.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const tui_chart = @import("../charts/chart.zig");
|
||||
const braille = @import("../charts/braille.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
|
@ -144,7 +145,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// PNG export short-circuits all text rendering.
|
||||
if (parsed.export_chart) |path| {
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) {
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, ctx.chart_theme, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||
return;
|
||||
|
|
@ -159,6 +160,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Fetch real-time quote via DataService
|
||||
var quote: ?QuoteData = null;
|
||||
// Live security name (Yahoo `longName`) captured out of the
|
||||
// transient Quote into a function-scope buffer. Shares the price's
|
||||
// source, so the displayed name and price always describe the same
|
||||
// security - even after a ticker is recycled.
|
||||
var live_name_buf: [256]u8 = undefined;
|
||||
var live_name: ?[]const u8 = null;
|
||||
if (svc.getQuote(parsed.symbol, opts)) |q| {
|
||||
quote = .{
|
||||
.price = q.close,
|
||||
|
|
@ -169,39 +176,45 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.prev_close = q.previous_close,
|
||||
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
|
||||
};
|
||||
if (q.name().len > 0) live_name = clampName(&live_name_buf, q.name());
|
||||
} else |_| {}
|
||||
|
||||
// Resolve a human-readable security name the same way the TUI
|
||||
// 'K' overlay and quote tab do: the curated `metadata.srf`
|
||||
// `name::` field first, then the ETF profile's fund name. The
|
||||
// name is copied into `name_buf` so its lifetime is independent
|
||||
// of the (transient) classification map / ETF fetch result.
|
||||
// 'K' overlay and quote tab do, via the shared policy: the curated
|
||||
// `metadata.srf` `name::` field first, then the live quote name,
|
||||
// then the ETF profile's fund name. The result is copied into
|
||||
// `name_buf` so its lifetime is independent of the (transient)
|
||||
// classification map / ETF fetch result.
|
||||
var name_buf: [256]u8 = undefined;
|
||||
var name: ?[]const u8 = null;
|
||||
{
|
||||
var cm_opt = loadClassificationMap(ctx);
|
||||
defer if (cm_opt) |*cm| cm.deinit();
|
||||
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
|
||||
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
|
||||
// metadata > live quote name. Both are already in hand, so no
|
||||
// EDGAR round-trip happens unless these come up empty.
|
||||
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name })) |nm| {
|
||||
name = clampName(&name_buf, nm);
|
||||
} else {
|
||||
// Fallback: the ETF profile's fund name, gated on isEtf()
|
||||
// so it matches the TUI (which only retains fund-shaped
|
||||
// profiles in symbol_data). Plain stocks not in metadata
|
||||
// and without a live name render symbol-only, exactly like
|
||||
// the 'K' overlay. Only fetched when the cheap sources
|
||||
// yielded nothing, so already-named holdings don't pay for
|
||||
// an EDGAR round-trip.
|
||||
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
|
||||
defer etf_result.deinit();
|
||||
if (etf_result.data.isEtf()) {
|
||||
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name, .etf_profile = etf_result.data.name })) |nm| {
|
||||
name = clampName(&name_buf, nm);
|
||||
}
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
if (name == null) {
|
||||
// Fallback: the ETF profile's fund name, gated on isEtf() so
|
||||
// it matches the TUI (which only retains fund-shaped
|
||||
// profiles in symbol_data). Plain stocks not in metadata.srf
|
||||
// therefore render symbol-only, exactly like the 'K' overlay.
|
||||
// Only fetched when metadata yielded nothing, so already-named
|
||||
// holdings don't pay for an EDGAR round-trip.
|
||||
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
|
||||
defer etf_result.deinit();
|
||||
if (etf_result.data.isEtf()) {
|
||||
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps };
|
||||
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps, .theme = ctx.chart_theme };
|
||||
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
|
||||
.braille => .braille,
|
||||
.kitty => .{ .kitty = k },
|
||||
|
|
@ -269,6 +282,7 @@ const ChartRender = union(enum) {
|
|||
const KittyChart = struct {
|
||||
io: std.Io,
|
||||
caps: term_query.Caps,
|
||||
theme: theme.Theme,
|
||||
};
|
||||
|
||||
/// Braille price chart of the most recent `display_count` candles (the
|
||||
|
|
@ -276,9 +290,9 @@ const KittyChart = struct {
|
|||
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
|
||||
const n = @min(candles.len, display_count);
|
||||
const data = candles[candles.len - n ..];
|
||||
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
var ch = braille.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer ch.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
||||
try braille.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
||||
}
|
||||
|
||||
/// Render the price+Bollinger+volume+RSI chart for the most recent
|
||||
|
|
@ -296,7 +310,7 @@ fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []
|
|||
const cols = term_graphics.quote_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
|
||||
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true);
|
||||
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, k.theme, &cached, true);
|
||||
defer rendered.deinit(allocator);
|
||||
const rgb = try rendered.extractRgb(allocator);
|
||||
defer allocator.free(rgb);
|
||||
|
|
@ -340,6 +354,9 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
|||
try out.print(" High: ${d:.2}\n", .{high_val});
|
||||
try out.print(" Low: ${d:.2}\n", .{low_val});
|
||||
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
|
||||
if (prev_close > 0) {
|
||||
try out.print(" Prev Close: ${d:.2}\n", .{prev_close});
|
||||
}
|
||||
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
|
|
|
|||
529
src/format.zig
529
src/format.zig
|
|
@ -1,7 +1,8 @@
|
|||
//! Shared formatting utilities used by both CLI and TUI.
|
||||
//!
|
||||
//! Number formatting (fmtIntCommas, etc.), financial helpers
|
||||
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
|
||||
//! Number formatting (fmtIntCommas, etc.) and financial helpers
|
||||
//! (capitalGainsIndicator, filterNearMoney). The braille sparkline
|
||||
//! chart lives in `charts/braille.zig`.
|
||||
|
||||
const std = @import("std");
|
||||
const Date = @import("Date.zig");
|
||||
|
|
@ -888,329 +889,6 @@ pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Interpolate color between two RGB values. t in [0.0, 1.0].
|
||||
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
|
||||
return .{
|
||||
@intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
|
||||
@intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
|
||||
@intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Braille chart ────────────────────────────────────────────
|
||||
|
||||
/// Braille dot patterns for the 2x4 matrix within each character cell.
|
||||
/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08
|
||||
/// [1][4] dot1=0x02, dot4=0x10
|
||||
/// [2][5] dot2=0x04, dot5=0x20
|
||||
/// [6][7] dot6=0x40, dot7=0x80
|
||||
pub const braille_dots = [4][2]u8{
|
||||
.{ 0x01, 0x08 }, // row 0 (top)
|
||||
.{ 0x02, 0x10 }, // row 1
|
||||
.{ 0x04, 0x20 }, // row 2
|
||||
.{ 0x40, 0x80 }, // row 3 (bottom)
|
||||
};
|
||||
|
||||
/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
|
||||
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
|
||||
pub const braille_utf8 = blk: {
|
||||
var table: [256][3]u8 = undefined;
|
||||
for (0..256) |i| {
|
||||
const cp: u21 = 0x2800 + @as(u21, @intCast(i));
|
||||
table[i] = .{
|
||||
@as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
|
||||
@as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
|
||||
@as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
|
||||
};
|
||||
}
|
||||
break :blk table;
|
||||
};
|
||||
|
||||
/// Return a static-lifetime grapheme slice for a braille pattern byte.
|
||||
pub fn brailleGlyph(pattern: u8) []const u8 {
|
||||
return &braille_utf8[pattern];
|
||||
}
|
||||
|
||||
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
|
||||
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
|
||||
/// up to a trillion-plus) with slack. Renderers that pre-allocate
|
||||
/// buffer cells for these labels should use this constant rather
|
||||
/// than hard-coding a smaller width and silently truncating
|
||||
/// portfolios over $1M.
|
||||
pub const money_label_max_bytes: usize = 24;
|
||||
|
||||
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
|
||||
pub const BrailleChart = struct {
|
||||
/// Braille pattern bytes: patterns[row * n_cols + col]
|
||||
patterns: []u8,
|
||||
/// RGB color per data column
|
||||
col_colors: [][3]u8,
|
||||
n_cols: usize,
|
||||
chart_height: usize,
|
||||
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
|
||||
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
|
||||
/// silently drop the label when portfolios cross into ten figures.
|
||||
/// Renderers that need to budget cells for the label should use
|
||||
/// `money_label_max_bytes` rather than guessing.
|
||||
max_label: [money_label_max_bytes]u8,
|
||||
max_label_len: usize,
|
||||
min_label: [money_label_max_bytes]u8,
|
||||
min_label_len: usize,
|
||||
/// Date of first candle in the chart data
|
||||
start_date: Date,
|
||||
/// Date of last candle in the chart data
|
||||
end_date: Date,
|
||||
|
||||
pub fn maxLabel(self: *const BrailleChart) []const u8 {
|
||||
return self.max_label[0..self.max_label_len];
|
||||
}
|
||||
|
||||
pub fn minLabel(self: *const BrailleChart) []const u8 {
|
||||
return self.min_label[0..self.min_label_len];
|
||||
}
|
||||
|
||||
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
|
||||
return self.patterns[row * self.n_cols + col];
|
||||
}
|
||||
|
||||
/// Format a date as "MMM DD" for the braille chart x-axis.
|
||||
/// The year context is already visible in the surrounding CLI/TUI interface.
|
||||
/// Returns the number of bytes written.
|
||||
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
|
||||
const mon = Date.monthShort(date.month());
|
||||
const d = date.day();
|
||||
buf[0] = mon[0];
|
||||
buf[1] = mon[1];
|
||||
buf[2] = mon[2];
|
||||
buf[3] = ' ';
|
||||
if (d >= 10) {
|
||||
buf[4] = '0' + d / 10;
|
||||
} else {
|
||||
buf[4] = '0';
|
||||
}
|
||||
buf[5] = '0' + d % 10;
|
||||
return buf[0..6];
|
||||
}
|
||||
|
||||
/// Format a date for the chart's x-axis at a granularity
|
||||
/// appropriate for that individual date's recency. Two tiers,
|
||||
/// per-date (driven by how far back the date is from the
|
||||
/// chart's `end_date` reference, not by the chart's overall
|
||||
/// span):
|
||||
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
|
||||
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
|
||||
///
|
||||
/// On a 12-year chart, this typically yields a long-format
|
||||
/// start label (`Jul 2014`) paired with a short-format end
|
||||
/// label (`08 May`) - the start is far enough back that
|
||||
/// year context is what matters; the end is recent enough
|
||||
/// that day-of-month resolution is useful.
|
||||
///
|
||||
/// The day-first ordering for the short form is intentional:
|
||||
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
|
||||
/// character of each label cleanly disambiguates the format
|
||||
/// at a glance - digit-first is a recent date, letter-first is
|
||||
/// a distant date. Saves the eye from re-parsing every label.
|
||||
///
|
||||
/// `buf` must be at least 8 bytes; the returned slice borrows
|
||||
/// from it.
|
||||
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
|
||||
const age_days = self.end_date.days - date.days;
|
||||
const mon = Date.monthShort(date.month());
|
||||
|
||||
if (age_days <= 720) {
|
||||
// "DD MMM" - day-first so the leading character is a
|
||||
// digit (visually distinct from the letter-first
|
||||
// long form below).
|
||||
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
|
||||
}
|
||||
// "MMM YYYY" - for dates more than ~2 years before
|
||||
// `end_date`. Day-of-month resolution stops being useful
|
||||
// at this scale; full 4-digit year keeps the label
|
||||
// unambiguous regardless of how far back the chart goes.
|
||||
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
|
||||
}
|
||||
|
||||
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
|
||||
alloc.free(self.patterns);
|
||||
alloc.free(self.col_colors);
|
||||
}
|
||||
};
|
||||
|
||||
/// Compute braille sparkline chart data from candle close prices.
|
||||
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
|
||||
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
|
||||
///
|
||||
/// Returns a BrailleChart with the pattern grid and per-column colors.
|
||||
/// Caller must call deinit() when done (unless using an arena allocator).
|
||||
pub fn computeBrailleChart(
|
||||
alloc: std.mem.Allocator,
|
||||
data: []const Candle,
|
||||
chart_width: usize,
|
||||
chart_height: usize,
|
||||
positive_color: [3]u8,
|
||||
negative_color: [3]u8,
|
||||
) !BrailleChart {
|
||||
if (data.len < 2) return error.InsufficientData;
|
||||
|
||||
const dot_rows: usize = chart_height * 4; // vertical dot resolution
|
||||
|
||||
// Find min/max chart-close prices (split-adjusted when available).
|
||||
// See `Candle.chartClose` - using raw `close` here would render
|
||||
// false cliffs at split dates.
|
||||
var min_price: f64 = data[0].chartClose();
|
||||
var max_price: f64 = data[0].chartClose();
|
||||
for (data) |d| {
|
||||
const cc = d.chartClose();
|
||||
if (cc < min_price) min_price = cc;
|
||||
if (cc > max_price) max_price = cc;
|
||||
}
|
||||
if (max_price == min_price) max_price = min_price + 1.0;
|
||||
const price_range = max_price - min_price;
|
||||
|
||||
// Price labels
|
||||
// SAFETY: every field of `result` is initialized below before
|
||||
// it is read or returned. Treating it as `undefined` here is
|
||||
// a deliberate "stack-allocate, then write each field"
|
||||
// pattern - Zig requires the variable to exist before
|
||||
// bufPrint can take a slice of one of its fields.
|
||||
var result: BrailleChart = undefined;
|
||||
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
|
||||
result.max_label_len = max_str.len;
|
||||
const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
|
||||
result.min_label_len = min_str.len;
|
||||
|
||||
const n_cols = @min(data.len, chart_width);
|
||||
result.n_cols = n_cols;
|
||||
result.chart_height = chart_height;
|
||||
result.start_date = data[0].date;
|
||||
result.end_date = data[data.len - 1].date;
|
||||
|
||||
// Map each data column to a dot-row position and color
|
||||
const dot_y = try alloc.alloc(usize, n_cols);
|
||||
defer alloc.free(dot_y);
|
||||
|
||||
result.col_colors = try alloc.alloc([3]u8, n_cols);
|
||||
errdefer alloc.free(result.col_colors);
|
||||
|
||||
for (0..n_cols) |col| {
|
||||
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
|
||||
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
|
||||
const close = data[data_idx].chartClose();
|
||||
const norm = (close - min_price) / price_range; // 0 = min, 1 = max
|
||||
// Inverted: 0 = top dot row, dot_rows-1 = bottom
|
||||
const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
|
||||
dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
|
||||
// Color: gradient from negative (bottom) to positive (top)
|
||||
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
|
||||
}
|
||||
|
||||
// Build the braille pattern grid
|
||||
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
|
||||
@memset(result.patterns, 0);
|
||||
|
||||
for (0..n_cols) |col| {
|
||||
const target_y = dot_y[col];
|
||||
// Fill from target_y down to the bottom
|
||||
for (target_y..dot_rows) |dy| {
|
||||
const term_row = dy / 4;
|
||||
const sub_row = dy % 4;
|
||||
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
|
||||
}
|
||||
|
||||
// Interpolate between this point and the next for smooth contour
|
||||
if (col + 1 < n_cols) {
|
||||
const y0 = dot_y[col];
|
||||
const y1 = dot_y[col + 1];
|
||||
const min_y = @min(y0, y1);
|
||||
const max_y = @max(y0, y1);
|
||||
for (min_y..max_y + 1) |dy| {
|
||||
const term_row = dy / 4;
|
||||
const sub_row = dy % 4;
|
||||
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Write a braille chart to a writer with ANSI color escapes.
|
||||
/// Used by the CLI for terminal output. Set `skip_date_axis` to
|
||||
/// provide a custom x-axis (e.g. year labels instead of dates).
|
||||
pub fn writeBrailleAnsi(
|
||||
out: *std.Io.Writer,
|
||||
chart: *const BrailleChart,
|
||||
use_color: bool,
|
||||
muted_color: [3]u8,
|
||||
skip_date_axis: bool,
|
||||
) !void {
|
||||
var last_r: u8 = 0;
|
||||
var last_g: u8 = 0;
|
||||
var last_b: u8 = 0;
|
||||
var color_active = false;
|
||||
|
||||
for (0..chart.chart_height) |row| {
|
||||
try out.writeAll(" "); // 2 leading spaces
|
||||
|
||||
for (0..chart.n_cols) |col| {
|
||||
const pat = chart.pattern(row, col);
|
||||
if (use_color and pat != 0) {
|
||||
const c = chart.col_colors[col];
|
||||
// Only emit color escape if color changed
|
||||
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
|
||||
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
|
||||
last_r = c[0];
|
||||
last_g = c[1];
|
||||
last_b = c[2];
|
||||
color_active = true;
|
||||
}
|
||||
} else if (color_active and pat == 0) {
|
||||
try out.writeAll("\x1b[0m");
|
||||
color_active = false;
|
||||
}
|
||||
try out.writeAll(brailleGlyph(pat));
|
||||
}
|
||||
|
||||
if (color_active) {
|
||||
try out.writeAll("\x1b[0m");
|
||||
color_active = false;
|
||||
}
|
||||
|
||||
// Price label on first/last row
|
||||
if (row == 0) {
|
||||
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
|
||||
try out.print(" {s}", .{chart.maxLabel()});
|
||||
if (use_color) try out.writeAll("\x1b[0m");
|
||||
} else if (row == chart.chart_height - 1) {
|
||||
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
|
||||
try out.print(" {s}", .{chart.minLabel()});
|
||||
if (use_color) try out.writeAll("\x1b[0m");
|
||||
}
|
||||
try out.writeAll("\n");
|
||||
}
|
||||
|
||||
// Date axis below chart
|
||||
if (!skip_date_axis) {
|
||||
var start_buf: [8]u8 = undefined;
|
||||
var end_buf: [8]u8 = undefined;
|
||||
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
|
||||
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
|
||||
|
||||
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
|
||||
try out.writeAll(" "); // match leading indent
|
||||
try out.writeAll(start_label);
|
||||
const total_width = chart.n_cols;
|
||||
if (total_width > start_label.len + end_label.len) {
|
||||
const gap = total_width - start_label.len - end_label.len;
|
||||
for (0..gap) |_| try out.writeAll(" ");
|
||||
}
|
||||
try out.writeAll(end_label);
|
||||
if (use_color) try out.writeAll("\x1b[0m");
|
||||
try out.writeAll("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANSI color helpers (for CLI) ─────────────────────────────
|
||||
|
||||
/// Determine whether to use ANSI color output.
|
||||
|
|
@ -1531,151 +1209,6 @@ test "aggregateDripLots empty" {
|
|||
try std.testing.expect(agg.lt.isEmpty());
|
||||
}
|
||||
|
||||
test "lerpColor" {
|
||||
// t=0 returns first color
|
||||
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
|
||||
try std.testing.expectEqual(@as(u8, 0), c0[0]);
|
||||
try std.testing.expectEqual(@as(u8, 0), c0[1]);
|
||||
// t=1 returns second color
|
||||
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
|
||||
try std.testing.expectEqual(@as(u8, 255), c1[0]);
|
||||
// t=0.5 returns midpoint
|
||||
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
|
||||
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
|
||||
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
|
||||
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
|
||||
}
|
||||
|
||||
test "brailleGlyph" {
|
||||
// Pattern 0 = U+2800 (blank braille)
|
||||
const blank = brailleGlyph(0);
|
||||
try std.testing.expectEqual(@as(usize, 3), blank.len);
|
||||
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
|
||||
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
|
||||
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
|
||||
// Pattern 0xFF = U+28FF (full braille)
|
||||
const full = brailleGlyph(0xFF);
|
||||
try std.testing.expectEqual(@as(usize, 3), full.len);
|
||||
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
|
||||
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
|
||||
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
|
||||
}
|
||||
|
||||
test "fmtShortDate" {
|
||||
var buf: [7]u8 = undefined;
|
||||
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
|
||||
try std.testing.expectEqualStrings("Jan 15", jan15);
|
||||
const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf);
|
||||
try std.testing.expectEqualStrings("Dec 01", dec01);
|
||||
const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf);
|
||||
try std.testing.expectEqualStrings("Jun 09", jun09);
|
||||
}
|
||||
|
||||
test "computeBrailleChart" {
|
||||
const alloc = std.testing.allocator;
|
||||
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
|
||||
var candles: [20]Candle = undefined;
|
||||
for (0..20) |i| {
|
||||
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
|
||||
candles[i] = .{
|
||||
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
|
||||
.open = price,
|
||||
.high = price,
|
||||
.low = price,
|
||||
.close = price,
|
||||
.adj_close = price,
|
||||
.volume = 1000,
|
||||
};
|
||||
}
|
||||
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
|
||||
defer chart.deinit(alloc);
|
||||
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
|
||||
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
|
||||
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
|
||||
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
|
||||
// Max/min labels should contain price info
|
||||
try std.testing.expect(chart.maxLabel().len > 0);
|
||||
try std.testing.expect(chart.minLabel().len > 0);
|
||||
}
|
||||
|
||||
test "computeBrailleChart insufficient data" {
|
||||
const alloc = std.testing.allocator;
|
||||
const candles = [_]Candle{
|
||||
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
|
||||
};
|
||||
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
|
||||
try std.testing.expectError(error.InsufficientData, result);
|
||||
}
|
||||
|
||||
test "computeBrailleChart preserves full label for prices over $1M" {
|
||||
// Regression test for a bug where the max/min label buffers were
|
||||
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
|
||||
// with 13+ chars (anything $1,000,000+). Result was a silently
|
||||
// empty label string in the BrailleChart, then the TUI renderer
|
||||
// truncated even further to 10 cells, dropping the label entirely
|
||||
// for portfolios over $1M. See `money_label_max_bytes`.
|
||||
const alloc = std.testing.allocator;
|
||||
var candles: [20]Candle = undefined;
|
||||
for (0..20) |i| {
|
||||
// Arbitrary placeholder range starting at $1,234,567.89 with
|
||||
// a $500,000 step. The point of this test is the rendered
|
||||
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
|
||||
// overflowed the old 16-byte label buffer or the renderer's
|
||||
// 10-cell budget. The exact numbers are irrelevant.
|
||||
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
|
||||
candles[i] = .{
|
||||
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
|
||||
.open = price,
|
||||
.high = price,
|
||||
.low = price,
|
||||
.close = price,
|
||||
.adj_close = price,
|
||||
.volume = 1000,
|
||||
};
|
||||
}
|
||||
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
|
||||
defer chart.deinit(alloc);
|
||||
|
||||
// Both labels must contain the dollar sign and a comma (the
|
||||
// thousands separator) - that confirms `Money.from` produced
|
||||
// a multi-million-dollar string and didn't fall through to the
|
||||
// empty-string fallback when bufPrint hit NoSpaceLeft.
|
||||
const max_lbl = chart.maxLabel();
|
||||
const min_lbl = chart.minLabel();
|
||||
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
|
||||
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
|
||||
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
|
||||
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
|
||||
// Both labels should match the `$X,XXX,XXX.XX` shape (at
|
||||
// least 13 chars). Any silent-truncation bug would leave them
|
||||
// empty or much shorter.
|
||||
try std.testing.expect(max_lbl.len >= 13);
|
||||
try std.testing.expect(min_lbl.len >= 13);
|
||||
}
|
||||
|
||||
test "computeBrailleChart uses adj_close to avoid split cliff" {
|
||||
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
|
||||
// because the chart consumed raw `close` instead of `adj_close`.
|
||||
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
|
||||
// close drops 300 -> 100, but adj_close is constant at 100. The
|
||||
// chart should see a flat line, not a cliff.
|
||||
const alloc = std.testing.allocator;
|
||||
const candles = [_]Candle{
|
||||
.{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
|
||||
.{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
|
||||
.{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
|
||||
.{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
|
||||
};
|
||||
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
|
||||
defer chart.deinit(alloc);
|
||||
// Min and max labels should reflect the adjusted price (~$100),
|
||||
// not the raw close range (300 -> 100). The exact values vary
|
||||
// because computeBrailleChart bumps max by $1 internally when
|
||||
// min == max, but neither label should mention $300.
|
||||
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
|
||||
}
|
||||
|
||||
test "fmtContractLine" {
|
||||
var buf: [128]u8 = undefined;
|
||||
const contract = OptionContract{
|
||||
|
|
@ -1868,62 +1401,6 @@ test "fmtTimeAgo: days" {
|
|||
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
|
||||
}
|
||||
|
||||
test "fmtAxisDate: span <=720d produces DD MMM" {
|
||||
var br: BrailleChart = undefined;
|
||||
br.start_date = Date.fromYmd(2026, 1, 1);
|
||||
br.end_date = Date.fromYmd(2026, 5, 11);
|
||||
var buf: [8]u8 = undefined;
|
||||
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
|
||||
try std.testing.expectEqualStrings("27 Apr", lbl);
|
||||
}
|
||||
|
||||
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
|
||||
// 700 days from 2024-01-01 - still inside the threshold.
|
||||
var br: BrailleChart = undefined;
|
||||
br.start_date = Date.fromYmd(2024, 1, 1);
|
||||
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
|
||||
var buf: [8]u8 = undefined;
|
||||
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
|
||||
try std.testing.expectEqualStrings("01 Jan", lbl);
|
||||
}
|
||||
|
||||
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
|
||||
// 12-year chart: start is way more than 720 days from end,
|
||||
// so the start gets MMM YYYY. End is `end_date` itself
|
||||
// (age 0), so it gets DD MMM.
|
||||
var br: BrailleChart = undefined;
|
||||
br.start_date = Date.fromYmd(2014, 7, 3);
|
||||
br.end_date = Date.fromYmd(2026, 5, 11);
|
||||
var buf: [8]u8 = undefined;
|
||||
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
|
||||
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
|
||||
var buf2: [8]u8 = undefined;
|
||||
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
|
||||
try std.testing.expectEqualStrings("11 May", end_lbl);
|
||||
}
|
||||
|
||||
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
|
||||
var br: BrailleChart = undefined;
|
||||
br.start_date = Date.fromYmd(2025, 1, 1);
|
||||
// 720 days later = 2026-12-22.
|
||||
br.end_date = Date.fromYmd(2026, 12, 22);
|
||||
var buf: [8]u8 = undefined;
|
||||
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
|
||||
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
|
||||
try std.testing.expectEqualStrings("01 Jan", lbl);
|
||||
}
|
||||
|
||||
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
|
||||
var br: BrailleChart = undefined;
|
||||
br.start_date = Date.fromYmd(2024, 12, 31);
|
||||
// 721 days after 2024-12-31 = 2026-12-22.
|
||||
br.end_date = Date.fromYmd(2026, 12, 22);
|
||||
var buf: [8]u8 = undefined;
|
||||
// Format the start (which is 721 days before end_date).
|
||||
const lbl = br.fmtAxisDate(br.start_date, &buf);
|
||||
try std.testing.expectEqualStrings("Dec 2024", lbl);
|
||||
}
|
||||
|
||||
test "displayCols: ASCII bytes count as 1 col each" {
|
||||
try std.testing.expectEqual(@as(usize, 0), displayCols(""));
|
||||
try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));
|
||||
|
|
|
|||
50
src/main.zig
50
src/main.zig
|
|
@ -4,6 +4,7 @@ const tui = @import("tui.zig");
|
|||
const cli = @import("commands/common.zig");
|
||||
const cmd_framework = @import("commands/framework.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
const term_query = @import("term_query.zig");
|
||||
|
||||
/// Comptime registry of CLI commands. Field name is the user-facing
|
||||
|
|
@ -134,6 +135,11 @@ const interactive_help =
|
|||
\\ (e.g. 80x24); `auto` picks Kitty graphics
|
||||
\\ if the terminal supports it, otherwise
|
||||
\\ braille
|
||||
\\ --theme <PATH> Theme file (a `theme.srf`) that skins the
|
||||
\\ whole app: CLI text, charts, and the TUI.
|
||||
\\ Falls back to ~/.config/zfin/theme.srf,
|
||||
\\ then the built-in theme. Generate one with
|
||||
\\ `zfin interactive --default-theme`.
|
||||
\\ --default-keys Print default keybindings as a `keys.srf`
|
||||
\\ template and exit (no TUI launched).
|
||||
\\ Pipe to `~/.config/zfin/keys.srf` to
|
||||
|
|
@ -166,6 +172,10 @@ const Globals = struct {
|
|||
refresh_policy: cmd_framework.RefreshPolicy = .auto,
|
||||
/// Chart graphics mode from `--chart` (auto / braille / WxH).
|
||||
chart_config: chart.ChartConfig = .{},
|
||||
/// Theme file from `--theme <PATH>` (a `theme.srf`) that skins the
|
||||
/// whole app - CLI text palette, charts, and the TUI. Null falls
|
||||
/// back to ~/.config/zfin/theme.srf, then the built-in default.
|
||||
theme_path: ?[]const u8 = null,
|
||||
/// Index into args of the first post-global token (the subcommand).
|
||||
cursor: usize,
|
||||
};
|
||||
|
|
@ -299,6 +309,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, a, "--theme")) {
|
||||
if (i + 1 >= args.len) return error.MissingValue;
|
||||
g.theme_path = args[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Help flags are subcommand-like tokens, stop scanning.
|
||||
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
|
||||
|
||||
|
|
@ -309,6 +325,30 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
return g;
|
||||
}
|
||||
|
||||
/// Resolve the app-wide theme once. Precedence: an explicit
|
||||
/// `--theme <PATH>` (warns + falls back to default if it can't load),
|
||||
/// then `$HOME/.config/zfin/theme.srf` when present, then the built-in
|
||||
/// default. The single resolved value skins everything identically -
|
||||
/// CLI text palette, CLI charts, and the TUI.
|
||||
fn resolveTheme(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
environ_map: *const std.process.Environ.Map,
|
||||
theme_path: ?[]const u8,
|
||||
) theme.Theme {
|
||||
if (theme_path) |p| {
|
||||
return theme.loadFromFile(io, allocator, p) orelse blk: {
|
||||
cli.stderrPrint(io, "Note: could not load --theme file; using the default theme.\n");
|
||||
break :blk theme.default_theme;
|
||||
};
|
||||
}
|
||||
const home = environ_map.get("HOME") orelse return theme.default_theme;
|
||||
const cfg_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
|
||||
return theme.default_theme;
|
||||
defer allocator.free(cfg_path);
|
||||
return theme.loadFromFile(io, allocator, cfg_path) orelse theme.default_theme;
|
||||
}
|
||||
|
||||
pub fn main(init: std.process.Init) !u8 {
|
||||
return runCli(init) catch |err| switch (err) {
|
||||
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
|
||||
|
|
@ -430,6 +470,13 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
|
||||
const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);
|
||||
|
||||
// Resolve the theme once for the entire invocation. This one value
|
||||
// skins the CLI text palette (via cli.applyTheme), CLI charts (via
|
||||
// RunCtx.chart_theme), and the TUI (passed into tui.run) - so
|
||||
// `--theme <PATH>` (or ~/.config/zfin/theme.srf) repaints everything.
|
||||
const resolved_theme = resolveTheme(io, gpa_alloc, init.environ_map, globals.theme_path);
|
||||
cli.applyTheme(resolved_theme);
|
||||
|
||||
const command = args[globals.cursor];
|
||||
const cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]);
|
||||
|
||||
|
|
@ -454,7 +501,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
// loader resolve + union-merge the same way the CLI does.
|
||||
// This is the load-bearing fix for "CLI and TUI report
|
||||
// different totals" - there's exactly one code path now.
|
||||
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
|
||||
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, resolved_theme, cmd_args, today) catch |err| switch (err) {
|
||||
// tui.run already printed an actionable stderr message
|
||||
// for invalid CLI args; surface as exit 1 without a
|
||||
// panic / stack trace.
|
||||
|
|
@ -522,6 +569,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
.color = color,
|
||||
.out = out,
|
||||
.graphics_caps = term_query.detect(io, init.environ_map),
|
||||
.chart_theme = resolved_theme,
|
||||
};
|
||||
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
|
||||
try cmd_framework.normalizeFirstArg(allocator, cmd_args)
|
||||
|
|
|
|||
|
|
@ -146,41 +146,66 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
|
|||
return try allocator.dupe(u8, "Unclassified");
|
||||
}
|
||||
|
||||
/// Resolve a human-readable security name for `symbol`, applying
|
||||
/// the project-wide name-source policy:
|
||||
/// 1. The curated `name::` field from `metadata.srf` (via the
|
||||
/// classification map) - the same source the TUI 'K' overlay
|
||||
/// uses. Wins whenever present.
|
||||
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
||||
/// when metadata has no name for the symbol.
|
||||
/// The candidate name sources for a security, other than the curated
|
||||
/// `metadata.srf` name (which `resolveSecurityName` looks up from the
|
||||
/// classification map and ranks above all of these). Callers fill in
|
||||
/// whatever they have on hand and leave the rest null; the precedence
|
||||
/// *between* these sources is decided by `resolveSecurityName`, not by
|
||||
/// the caller - that's the whole point of routing every surface
|
||||
/// through one function.
|
||||
pub const NameSources = struct {
|
||||
/// The live quote provider's name (Yahoo `longName`), read from the
|
||||
/// same response as the price. Ranked above the ETF profile because
|
||||
/// it reflects what currently trades under the ticker and so
|
||||
/// self-heals when a ticker is recycled (e.g. SPCX: SpaceX vs. the
|
||||
/// defunct "SPAC and New Issue ETF").
|
||||
live_quote: ?[]const u8 = null,
|
||||
/// The ETF/fund profile name (EDGAR series name or Wikidata name).
|
||||
etf_profile: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Resolve a human-readable security name for `symbol`. This is the
|
||||
/// single home for the name-precedence *policy*; every surface (the
|
||||
/// CLI `quote` command, the TUI quote tab, the 'K' overlay) calls it
|
||||
/// so they cannot drift. The policy, highest priority first:
|
||||
///
|
||||
/// Returns a slice borrowed from `cm` or `fallback_name` (the
|
||||
/// caller must keep those alive for as long as the result is used),
|
||||
/// or null when neither source yields a name.
|
||||
/// 1. The curated `name::` from `metadata.srf` (via `cm`) - the
|
||||
/// user's explicit override.
|
||||
/// 2. `sources.live_quote` - the live quote provider name.
|
||||
/// 3. `sources.etf_profile` - the ETF/fund profile name.
|
||||
///
|
||||
/// This is the single source of truth for the security name shown
|
||||
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
|
||||
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
|
||||
/// normalizes symbols to upper-case before calling, matching the
|
||||
/// upper-case symbols `metadata.srf` carries.
|
||||
/// Empty names are skipped at every level. Returns a slice borrowed
|
||||
/// from `cm` or one of `sources` (keep them alive while the result is
|
||||
/// used), or null when no source yields a name. Symbol comparison is
|
||||
/// exact (`std.mem.eql`); every caller upper-cases symbols first,
|
||||
/// matching the upper-case symbols `metadata.srf` carries.
|
||||
pub fn resolveSecurityName(
|
||||
symbol: []const u8,
|
||||
cm: ?*const ClassificationMap,
|
||||
fallback_name: ?[]const u8,
|
||||
sources: NameSources,
|
||||
) ?[]const u8 {
|
||||
// 1. Curated metadata name wins. First matching entry that carries
|
||||
// a name wins; blended-fund symbols repeat across rows with the
|
||||
// same name, so this is well-defined.
|
||||
if (cm) |m| {
|
||||
for (m.entries) |*e| {
|
||||
if (std.mem.eql(u8, e.symbol, symbol)) {
|
||||
// First matching entry that actually carries a name
|
||||
// wins. Blended-fund symbols repeat across rows with
|
||||
// the same name, so this is well-defined.
|
||||
if (e.name) |n| {
|
||||
if (n.len > 0) return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback_name;
|
||||
// 2. then the live quote name, 3. then the ETF profile name.
|
||||
if (nonEmptyName(sources.live_quote)) |n| return n;
|
||||
if (nonEmptyName(sources.etf_profile)) |n| return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// An optional name, treating empty as absent.
|
||||
fn nonEmptyName(s: ?[]const u8) ?[]const u8 {
|
||||
const v = s orelse return null;
|
||||
return if (v.len > 0) v else null;
|
||||
}
|
||||
|
||||
test "parse classification file" {
|
||||
|
|
@ -253,14 +278,14 @@ test "resolveSecurityName: metadata name wins" {
|
|||
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
// Metadata hit; fallback ignored.
|
||||
// Metadata hit; lower-priority sources ignored.
|
||||
try std.testing.expectEqualStrings(
|
||||
"Amazon",
|
||||
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
|
||||
resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?,
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"Vanguard Total Stock Market ETF",
|
||||
resolveSecurityName("VTI", &cm, null).?,
|
||||
resolveSecurityName("VTI", &cm, .{}).?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -271,10 +296,10 @@ test "resolveSecurityName: falls back when entry has no name" {
|
|||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"iShares Semiconductor ETF",
|
||||
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
||||
resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?,
|
||||
);
|
||||
// No fallback either -> null.
|
||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
||||
// No other source either -> null.
|
||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: falls back when symbol absent from map" {
|
||||
|
|
@ -284,16 +309,16 @@ test "resolveSecurityName: falls back when symbol absent from map" {
|
|||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"SPDR S&P 500 ETF Trust",
|
||||
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
|
||||
resolveSecurityName("SPY", &cm, .{ .etf_profile = "SPDR S&P 500 ETF Trust" }).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: null map uses fallback, null everything is null" {
|
||||
test "resolveSecurityName: null map uses a source, null everything is null" {
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", null, "Apple Inc.").?,
|
||||
resolveSecurityName("AAPL", null, .{ .etf_profile = "Apple Inc." }).?,
|
||||
);
|
||||
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
|
||||
try std.testing.expect(resolveSecurityName("AAPL", null, .{}) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: empty metadata name treated as absent" {
|
||||
|
|
@ -303,10 +328,44 @@ test "resolveSecurityName: empty metadata name treated as absent" {
|
|||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
|
||||
resolveSecurityName("AAPL", &cm, .{ .etf_profile = "Apple Inc." }).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: precedence is metadata > live quote > etf profile" {
|
||||
// No metadata: the live quote name beats the ETF profile name. This
|
||||
// is the SPCX case - the live Yahoo name wins over the stale
|
||||
// recycled-ticker ETF name. The ordering lives in the function, not
|
||||
// in the caller, so this test pins the policy itself.
|
||||
try std.testing.expectEqualStrings(
|
||||
"Space Exploration Technologies Corp.",
|
||||
resolveSecurityName("SPCX", null, .{
|
||||
.live_quote = "Space Exploration Technologies Corp.",
|
||||
.etf_profile = "The SPAC and New Issue ETF",
|
||||
}).?,
|
||||
);
|
||||
// An empty live quote name is skipped, so the ETF profile shows.
|
||||
try std.testing.expectEqualStrings(
|
||||
"The SPAC and New Issue ETF",
|
||||
resolveSecurityName("SPCX", null, .{
|
||||
.live_quote = "",
|
||||
.etf_profile = "The SPAC and New Issue ETF",
|
||||
}).?,
|
||||
);
|
||||
// Metadata still outranks both lower-priority sources.
|
||||
var entries = [_]ClassificationEntry{.{ .symbol = "SPCX", .name = "My Override" }};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"My Override",
|
||||
resolveSecurityName("SPCX", &cm, .{
|
||||
.live_quote = "Space Exploration Technologies Corp.",
|
||||
.etf_profile = "The SPAC and New Issue ETF",
|
||||
}).?,
|
||||
);
|
||||
// No sources at all -> null.
|
||||
try std.testing.expect(resolveSecurityName("SPCX", null, .{}) == null);
|
||||
}
|
||||
|
||||
test "deriveBucket: returns user-curated bucket when set" {
|
||||
const e: ClassificationEntry = .{
|
||||
.symbol = "SPY",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
/// Real-time (or near-real-time) quote snapshot for a symbol.
|
||||
pub const Quote = struct {
|
||||
symbol: []const u8,
|
||||
name: []const u8,
|
||||
/// Display-name storage. Inline (not a slice) so the name travels
|
||||
/// with the value: the TUI stores a `Quote` by value in tab state,
|
||||
/// and provider parsers copy the name out of transient JSON before
|
||||
/// that JSON is freed. Read via `name()`, write via `setName()`;
|
||||
/// never touch these two fields directly. The buffer is zero-filled
|
||||
/// (not `undefined`) so a Quote constructed without a name - the
|
||||
/// providers only call `setName` when one is present - still yields
|
||||
/// an empty `name()` and can never expose uninitialized bytes.
|
||||
name_buf: [256]u8 = @splat(0),
|
||||
name_len: usize = 0,
|
||||
exchange: []const u8,
|
||||
datetime: []const u8,
|
||||
close: f64,
|
||||
|
|
@ -15,4 +24,18 @@ pub const Quote = struct {
|
|||
average_volume: u64,
|
||||
fifty_two_week_low: f64,
|
||||
fifty_two_week_high: f64,
|
||||
|
||||
/// The display name, or "" when unset. Borrowed from the Quote;
|
||||
/// valid for as long as the Quote value is alive.
|
||||
pub fn name(self: *const Quote) []const u8 {
|
||||
return self.name_buf[0..self.name_len];
|
||||
}
|
||||
|
||||
/// Copy `s` into the inline name buffer, truncating to capacity.
|
||||
/// Callers should pass an already-trimmed string.
|
||||
pub fn setName(self: *Quote, s: []const u8) void {
|
||||
const n = @min(s.len, self.name_buf.len);
|
||||
@memcpy(self.name_buf[0..n], s[0..n]);
|
||||
self.name_len = n;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = std.log.scoped(.http);
|
||||
|
||||
|
|
@ -406,10 +407,17 @@ pub const Client = struct {
|
|||
// those to debug so the warn-level log stream
|
||||
// stays focused on cases the operator can act on
|
||||
// (auth, rate, server outages).
|
||||
if (response.status == .not_found) {
|
||||
log.debug("http rejection body status=404 body={s}", .{response.body});
|
||||
} else {
|
||||
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
|
||||
//
|
||||
// Skipped entirely under `zig build test`: the
|
||||
// error-classification tests intentionally drive
|
||||
// non-2xx statuses through here, and their warn
|
||||
// output would otherwise pollute the test stream.
|
||||
if (!builtin.is_test) {
|
||||
if (response.status == .not_found) {
|
||||
log.debug("http rejection body status=404 body={s}", .{response.body});
|
||||
} else {
|
||||
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
|
||||
}
|
||||
}
|
||||
response.allocator.free(response.body);
|
||||
if (response.etag) |e| response.allocator.free(e);
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ const RateLimiter = @import("../net/RateLimiter.zig");
|
|||
const fmt = @import("../format.zig");
|
||||
const xml = @import("xml.zig");
|
||||
|
||||
const log = std.log.scoped(.edgar);
|
||||
|
||||
const tickers_funds_url = "https://www.sec.gov/files/company_tickers_mf.json";
|
||||
const tickers_companies_url = "https://www.sec.gov/files/company_tickers.json";
|
||||
const search_url_prefix = "https://efts.sec.gov/LATEST/search-index?";
|
||||
|
|
@ -336,63 +338,94 @@ pub fn fetchEtfMetrics(
|
|||
symbol: []const u8,
|
||||
top_n_holdings: usize,
|
||||
) !EtfMetricsResult {
|
||||
// MF/ETF map first - authoritative for symbols filed under a
|
||||
// series. Series-keyed full-text search; CIK fallback would
|
||||
// yield arbitrary other series under the same trust.
|
||||
if (mf_ticker_map.get(symbol)) |entry| {
|
||||
const filing_url = (try self.findLatestNportP(allocator, entry.series_id.?)) orelse {
|
||||
return .not_a_fund;
|
||||
};
|
||||
defer allocator.free(filing_url);
|
||||
const m = try self.fetchAndParseNportP(
|
||||
io,
|
||||
allocator,
|
||||
entry.toGeneric(),
|
||||
filing_url,
|
||||
symbol,
|
||||
top_n_holdings,
|
||||
);
|
||||
return .{ .full = m };
|
||||
}
|
||||
const mf_entry = mf_ticker_map.get(symbol);
|
||||
const co_entry = stock_ticker_map.get(symbol);
|
||||
|
||||
// Stock map: probe the submissions feed (one extra HTTP per
|
||||
// unique CIK) to classify the entity. Branches:
|
||||
// - fund_shaped + has NPORT-P -> full holdings (SPY)
|
||||
// - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer)
|
||||
// - trust_shaped -> profile-only (GLD commodity)
|
||||
// - operating -> not-a-fund (AAPL, MSFT)
|
||||
if (stock_ticker_map.get(symbol)) |entry| {
|
||||
var sub = try self.fetchSubmissionsFeed(allocator, entry.cik);
|
||||
defer sub.deinit(allocator);
|
||||
|
||||
const class = classifyByEntityType(&sub);
|
||||
switch (class) {
|
||||
.operating => return .not_a_fund,
|
||||
.fund_shaped => {
|
||||
if (sub.latest_nport_p_url) |url| {
|
||||
const m = try self.fetchAndParseNportP(
|
||||
io,
|
||||
allocator,
|
||||
entry.toGeneric(),
|
||||
url,
|
||||
symbol,
|
||||
top_n_holdings,
|
||||
);
|
||||
return .{ .full = m };
|
||||
}
|
||||
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
|
||||
return .{ .profile_only = profile };
|
||||
},
|
||||
.trust_shaped => {
|
||||
// Skip the NPORT-P probe - by definition these
|
||||
// don't file one. Saves an HTTP roundtrip.
|
||||
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
|
||||
return .{ .profile_only = profile };
|
||||
},
|
||||
// Ticker-recycling guard: when a symbol is in BOTH ticker maps,
|
||||
// classify the company side up front. SEC's
|
||||
// `company_tickers_mf.json` is slow to drop a ticker after it's
|
||||
// reassigned, so a stale fund-series mapping can collide with an
|
||||
// operating company that now owns the ticker (e.g. SPCX: SpaceX's
|
||||
// 2026 IPO took over the defunct Tuttle "SPAC and New Issue ETF"
|
||||
// ticker). The probe is best-effort - on failure we leave
|
||||
// `co_class` null and `resolveMapPrecedence` trusts the MF map, so
|
||||
// a flaky company-feed fetch can't regress a both-map symbol.
|
||||
var co_sub: ?SubmissionsSummary = null;
|
||||
defer if (co_sub) |*s| s.deinit(allocator);
|
||||
var co_class: ?EntityClass = null;
|
||||
if (mf_entry != null and co_entry != null) {
|
||||
if (self.fetchSubmissionsFeed(allocator, co_entry.?.cik)) |sub| {
|
||||
co_sub = sub;
|
||||
co_class = classifyByEntityType(&co_sub.?);
|
||||
} else |err| {
|
||||
log.warn("{s}: recycled-ticker company probe failed, trusting MF map: {s}", .{ symbol, @errorName(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return .not_in_edgar;
|
||||
switch (resolveMapPrecedence(mf_entry != null, co_entry != null, co_class)) {
|
||||
// A ticker now owned by an operating company: do not report the
|
||||
// stale fund series as a fund.
|
||||
.recycled_not_a_fund => return .not_a_fund,
|
||||
|
||||
// MF/ETF map - authoritative for symbols filed under a series.
|
||||
// Series-keyed full-text search; a CIK fallback would yield
|
||||
// arbitrary other series under the same trust.
|
||||
.mf_series => {
|
||||
const entry = mf_entry.?;
|
||||
const filing_url = (try self.findLatestNportP(allocator, entry.series_id.?)) orelse {
|
||||
return .not_a_fund;
|
||||
};
|
||||
defer allocator.free(filing_url);
|
||||
const m = try self.fetchAndParseNportP(
|
||||
io,
|
||||
allocator,
|
||||
entry.toGeneric(),
|
||||
filing_url,
|
||||
symbol,
|
||||
top_n_holdings,
|
||||
);
|
||||
return .{ .full = m };
|
||||
},
|
||||
|
||||
// Stock map only: probe the submissions feed (one extra HTTP
|
||||
// per unique CIK) to classify the entity. Branches:
|
||||
// - fund_shaped + has NPORT-P -> full holdings (SPY)
|
||||
// - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer)
|
||||
// - trust_shaped -> profile-only (GLD commodity)
|
||||
// - operating -> not-a-fund (AAPL, MSFT)
|
||||
.probe_company => {
|
||||
const entry = co_entry.?;
|
||||
var sub = try self.fetchSubmissionsFeed(allocator, entry.cik);
|
||||
defer sub.deinit(allocator);
|
||||
|
||||
switch (classifyByEntityType(&sub)) {
|
||||
.operating => return .not_a_fund,
|
||||
.fund_shaped => {
|
||||
if (sub.latest_nport_p_url) |url| {
|
||||
const m = try self.fetchAndParseNportP(
|
||||
io,
|
||||
allocator,
|
||||
entry.toGeneric(),
|
||||
url,
|
||||
symbol,
|
||||
top_n_holdings,
|
||||
);
|
||||
return .{ .full = m };
|
||||
}
|
||||
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
|
||||
return .{ .profile_only = profile };
|
||||
},
|
||||
.trust_shaped => {
|
||||
// Skip the NPORT-P probe - by definition these
|
||||
// don't file one. Saves an HTTP roundtrip.
|
||||
const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol);
|
||||
return .{ .profile_only = profile };
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
.not_in_edgar => return .not_in_edgar,
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and parse a NPORT-P primary_doc.xml at `filing_url`.
|
||||
|
|
@ -1221,6 +1254,73 @@ fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const
|
|||
);
|
||||
}
|
||||
|
||||
/// Coarse classification of an EDGAR entity from its submissions feed,
|
||||
/// produced by `classifyByEntityType`.
|
||||
const EntityClass = enum { fund_shaped, trust_shaped, operating };
|
||||
|
||||
/// Which path `fetchEtfMetrics` should take for a symbol, given its
|
||||
/// ticker-map membership.
|
||||
const MapResolution = enum {
|
||||
/// Use the MF/ETF series-keyed path (symbol filed under a series).
|
||||
mf_series,
|
||||
/// Probe the company submissions feed to classify (symbol present
|
||||
/// only in the company map).
|
||||
probe_company,
|
||||
/// A ticker present in both maps that now belongs to an operating
|
||||
/// company (recycled ticker): treat as not-a-fund.
|
||||
recycled_not_a_fund,
|
||||
/// Symbol is in neither ticker map.
|
||||
not_in_edgar,
|
||||
};
|
||||
|
||||
/// Pure precedence decision for `fetchEtfMetrics`. `in_mf` / `in_co`
|
||||
/// are whether the symbol appears in the MF/ETF and company ticker
|
||||
/// maps; `co_class` is the company side's classification, which is
|
||||
/// only consulted (and only needs to be computed) when the symbol is
|
||||
/// in BOTH maps.
|
||||
///
|
||||
/// The load-bearing case is a both-maps collision. SEC's
|
||||
/// `company_tickers_mf.json` lags ticker reassignments, so a stale
|
||||
/// fund-series mapping can coexist with an operating company that now
|
||||
/// owns the ticker (e.g. SPCX: SpaceX's 2026 IPO over the defunct
|
||||
/// Tuttle "SPAC and New Issue ETF"). When the company side is an
|
||||
/// operating company the ticker belongs to it now, so the stale series
|
||||
/// must not be reported as a fund. A fund/trust-shaped company side
|
||||
/// keeps the more precise series path. A null `co_class` (probe failed
|
||||
/// or not performed) falls back to the MF path - the pre-guard
|
||||
/// behavior.
|
||||
fn resolveMapPrecedence(in_mf: bool, in_co: bool, co_class: ?EntityClass) MapResolution {
|
||||
if (in_mf and in_co) {
|
||||
if (co_class) |c| return switch (c) {
|
||||
.operating => .recycled_not_a_fund,
|
||||
.fund_shaped, .trust_shaped => .mf_series,
|
||||
};
|
||||
return .mf_series;
|
||||
}
|
||||
if (in_mf) return .mf_series;
|
||||
if (in_co) return .probe_company;
|
||||
return .not_in_edgar;
|
||||
}
|
||||
|
||||
test "resolveMapPrecedence: ticker-map membership decides the path" {
|
||||
const T = std.testing;
|
||||
// Single-map cases ignore co_class.
|
||||
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, false, null));
|
||||
try T.expectEqual(MapResolution.probe_company, resolveMapPrecedence(false, true, null));
|
||||
try T.expectEqual(MapResolution.not_in_edgar, resolveMapPrecedence(false, false, null));
|
||||
|
||||
// Both maps: an operating company side means the ticker was
|
||||
// recycled (SPCX/SpaceX) -> not a fund.
|
||||
try T.expectEqual(MapResolution.recycled_not_a_fund, resolveMapPrecedence(true, true, .operating));
|
||||
// Both maps but the company side is itself a fund/trust: the
|
||||
// series-keyed MF path remains the more precise source.
|
||||
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, .fund_shaped));
|
||||
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, .trust_shaped));
|
||||
// Both maps but the probe failed (null): trust the MF map, matching
|
||||
// the pre-guard behavior.
|
||||
try T.expectEqual(MapResolution.mf_series, resolveMapPrecedence(true, true, null));
|
||||
}
|
||||
|
||||
/// Classify a CIK based on its submissions-feed metadata. Decides
|
||||
/// whether the symbol is a registered fund (probe NPORT-P), a
|
||||
/// trust/ETN-style instrument (profile-only), or a plain operating
|
||||
|
|
@ -1258,11 +1358,7 @@ fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const
|
|||
/// that distribute rental income, not registered investment
|
||||
/// companies. They get bucketed under `operating` - Wikidata is
|
||||
/// the right source for them.
|
||||
fn classifyByEntityType(sub: *const SubmissionsSummary) enum {
|
||||
fund_shaped,
|
||||
trust_shaped,
|
||||
operating,
|
||||
} {
|
||||
fn classifyByEntityType(sub: *const SubmissionsSummary) EntityClass {
|
||||
// Rule 1: NPORT-P presence is the strongest fund signal.
|
||||
if (sub.latest_nport_p_url != null) return .fund_shaped;
|
||||
|
||||
|
|
@ -1364,6 +1460,16 @@ test "classifyByEntityType buckets real-world entities" {
|
|||
s.sic_description = try T.allocator.dupe(u8, "Real Estate Investment Trusts");
|
||||
try T.expectEqual(.operating, classifyByEntityType(&s));
|
||||
}
|
||||
// SPCX/SpaceX after its 2026 IPO: operating company, no NPORT-P,
|
||||
// aerospace SIC. Must be `operating` so the ticker-recycling guard
|
||||
// can override the stale "SPAC and New Issue ETF" fund-map entry.
|
||||
{
|
||||
var s: SubmissionsSummary = .{};
|
||||
defer s.deinit(T.allocator);
|
||||
s.entity_type = try T.allocator.dupe(u8, "operating");
|
||||
s.sic_description = try T.allocator.dupe(u8, "Guided Missiles & Space Vehicles & Parts");
|
||||
try T.expectEqual(.operating, classifyByEntityType(&s));
|
||||
}
|
||||
}
|
||||
|
||||
/// Result kind for `fetchEtfMetrics`. The caller - see `main.zig` -
|
||||
|
|
|
|||
|
|
@ -184,9 +184,8 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
|
|||
|
||||
const ftw = root.get("fifty_two_week");
|
||||
|
||||
return .{
|
||||
var quote: Quote = .{
|
||||
.symbol = symbol,
|
||||
.name = symbol,
|
||||
.exchange = "",
|
||||
.datetime = "",
|
||||
.close = parseJsonFloat(root.get("close")),
|
||||
|
|
@ -207,6 +206,14 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
|
|||
else => 0,
|
||||
} else 0,
|
||||
};
|
||||
// TwelveData's /quote payload carries the security name in `name`.
|
||||
// Copy it before the parsed JSON is freed on return so the name
|
||||
// rides with the quote (same contract as the Yahoo path).
|
||||
if (json_utils.jsonStr(root.get("name"))) |raw| {
|
||||
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
|
||||
if (trimmed.len > 0) quote.setName(trimmed);
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
|
@ -348,6 +355,24 @@ test "parseQuoteResponse basic" {
|
|||
try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 140.0), quote.fifty_two_week_low, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 200.0), quote.fifty_two_week_high, 0.01);
|
||||
try std.testing.expectEqualStrings("Apple Inc", quote.name());
|
||||
}
|
||||
|
||||
test "parseQuoteResponse: missing name field leaves the name empty" {
|
||||
// `name` is optional in TwelveData's payload. When absent, setName
|
||||
// is never called, so name() must return "" - never the buffer's
|
||||
// uninitialized tail.
|
||||
const body =
|
||||
\\{
|
||||
\\ "symbol": "FOO",
|
||||
\\ "close": "10.00",
|
||||
\\ "previous_close": "9.50"
|
||||
\\}
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseQuoteResponse(allocator, body, "FOO");
|
||||
try std.testing.expectEqualStrings("", quote.name());
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.00), quote.close, 0.01);
|
||||
}
|
||||
|
||||
test "parseQuoteResponse error response" {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const Date = @import("../Date.zig");
|
|||
const Candle = @import("../models/candle.zig").Candle;
|
||||
const Quote = @import("../models/quote.zig").Quote;
|
||||
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
|
||||
const optFloat = @import("json_utils.zig").optFloat;
|
||||
const jsonStr = @import("json_utils.zig").jsonStr;
|
||||
|
||||
const base_url = "https://query1.finance.yahoo.com/v8/finance/chart";
|
||||
|
||||
|
|
@ -222,20 +224,39 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con
|
|||
};
|
||||
|
||||
const price = parseJsonFloat(m.get("regularMarketPrice"));
|
||||
const prev_close = parseJsonFloat(m.get("chartPreviousClose"));
|
||||
|
||||
// The day's open/high/low/volume AND the previous close come from
|
||||
// the indicators.quote arrays, not the meta block:
|
||||
// - meta has no reliable regularMarketOpen, and its fiftyTwoWeek*
|
||||
// fields are the 52-week range, not the day's.
|
||||
// - meta.chartPreviousClose is the close before the *entire*
|
||||
// requested range (range=5d here), so it is ~a week stale and
|
||||
// turns a flat day into a huge bogus 1-day change. The correct
|
||||
// previous close is the prior trading day's close from the
|
||||
// close array.
|
||||
// Falls back to `price` (open/high/low), 0 (volume), and the meta
|
||||
// chartPreviousClose (previous close) when the arrays are absent or
|
||||
// hold a single bar (where chartPreviousClose is itself correct).
|
||||
const snap = parseDaySnapshot(result);
|
||||
|
||||
const open = if (snap) |s| (s.open orelse price) else price;
|
||||
const high = if (snap) |s| (s.high orelse price) else price;
|
||||
const low = if (snap) |s| (s.low orelse price) else price;
|
||||
const volume: u64 = if (snap) |s| (s.volume orelse 0) else 0;
|
||||
const prev_close = (if (snap) |s| s.prev_close else null) orelse parseJsonFloat(m.get("chartPreviousClose"));
|
||||
|
||||
const change = price - prev_close;
|
||||
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
|
||||
|
||||
return .{
|
||||
var quote: Quote = .{
|
||||
.symbol = symbol,
|
||||
.name = symbol,
|
||||
.exchange = "",
|
||||
.datetime = "",
|
||||
.close = price,
|
||||
.open = price, // meta doesn't have open
|
||||
.high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
|
||||
.low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
|
||||
.volume = 0,
|
||||
.open = open,
|
||||
.high = high,
|
||||
.low = low,
|
||||
.volume = volume,
|
||||
.previous_close = prev_close,
|
||||
.change = change,
|
||||
.percent_change = pct,
|
||||
|
|
@ -243,6 +264,16 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con
|
|||
.fifty_two_week_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
|
||||
.fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
|
||||
};
|
||||
// Yahoo's chart `meta` carries the security's display name in
|
||||
// `longName` (preferred) / `shortName`. Copy it into the quote's
|
||||
// inline buffer before the parsed JSON is freed on return. This is
|
||||
// the same live source as the price, so the name and price always
|
||||
// describe the same security even after a ticker is recycled.
|
||||
if (jsonStr(m.get("longName")) orelse jsonStr(m.get("shortName"))) |raw| {
|
||||
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
|
||||
if (trimmed.len > 0) quote.setName(trimmed);
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
|
||||
|
|
@ -253,6 +284,79 @@ fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
|
|||
};
|
||||
}
|
||||
|
||||
/// Optional float at array index `i`: null when out of bounds or JSON null.
|
||||
fn optFloatAt(arr: []const std.json.Value, i: usize) ?f64 {
|
||||
if (i >= arr.len) return null;
|
||||
return optFloat(arr[i]);
|
||||
}
|
||||
|
||||
/// The current trading day's OHLCV plus the prior day's close, pulled
|
||||
/// from a chart result's `indicators.quote` arrays.
|
||||
const DaySnapshot = struct {
|
||||
open: ?f64,
|
||||
high: ?f64,
|
||||
low: ?f64,
|
||||
volume: ?u64,
|
||||
/// Close of the most recent trading day *before* the current (last)
|
||||
/// bar in the window - the correct base for a 1-day change. null
|
||||
/// when the window holds no prior day (single-bar window), in which
|
||||
/// case the caller falls back to meta.chartPreviousClose.
|
||||
prev_close: ?f64,
|
||||
};
|
||||
|
||||
/// Build a DaySnapshot from a chart result. The current bar is the last
|
||||
/// array entry (the in-progress day while the market is open - its
|
||||
/// close is null then, but open/high/low/volume are live - or the last
|
||||
/// completed day). The previous close is the most recent non-null close
|
||||
/// strictly before that last entry. Returns null when the
|
||||
/// indicators.quote arrays are absent or empty.
|
||||
fn parseDaySnapshot(result: std.json.ObjectMap) ?DaySnapshot {
|
||||
const indicators = result.get("indicators") orelse return null;
|
||||
const indicators_obj = switch (indicators) {
|
||||
.object => |o| o,
|
||||
else => return null,
|
||||
};
|
||||
const quote_arr = indicators_obj.get("quote") orelse return null;
|
||||
const quotes = switch (quote_arr) {
|
||||
.array => |a| a.items,
|
||||
else => return null,
|
||||
};
|
||||
if (quotes.len == 0) return null;
|
||||
const q = switch (quotes[0]) {
|
||||
.object => |o| o,
|
||||
else => return null,
|
||||
};
|
||||
|
||||
const opens = getFloatArray(q.get("open")) orelse return null;
|
||||
const highs = getFloatArray(q.get("high")) orelse return null;
|
||||
const lows = getFloatArray(q.get("low")) orelse return null;
|
||||
const closes = getFloatArray(q.get("close")) orelse return null;
|
||||
const volumes = getFloatArray(q.get("volume")) orelse return null;
|
||||
|
||||
if (closes.len == 0) return null;
|
||||
const last = closes.len - 1;
|
||||
|
||||
// Previous close: scan backward from the entry just before the
|
||||
// current (last) one for the first non-null close.
|
||||
var prev_close: ?f64 = null;
|
||||
var i = last;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
if (optFloatAt(closes, i)) |c| {
|
||||
prev_close = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.open = optFloatAt(opens, last),
|
||||
.high = optFloatAt(highs, last),
|
||||
.low = optFloatAt(lows, last),
|
||||
.volume = if (optFloatAt(volumes, last)) |v| @as(u64, @intFromFloat(@max(0, v))) else null,
|
||||
.prev_close = prev_close,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
test "parseChartCandles basic" {
|
||||
|
|
@ -352,7 +456,7 @@ test "parseChartQuote basic" {
|
|||
\\ "fiftyTwoWeekLow": 22.21
|
||||
\\ },
|
||||
\\ "timestamp": [1704067800],
|
||||
\\ "indicators": {"quote": [{"open": [27.78], "high": [27.78], "low": [27.78], "close": [27.78], "volume": [0]}]}
|
||||
\\ "indicators": {"quote": [{"open": [27.50], "high": [27.90], "low": [27.40], "close": [27.78], "volume": [123456]}]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
|
|
@ -363,11 +467,232 @@ test "parseChartQuote basic" {
|
|||
const quote = try parseChartQuote(allocator, body, "VTTHX");
|
||||
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01);
|
||||
// Single-bar window: no prior day in the array, so the previous
|
||||
// close falls back to meta chartPreviousClose (which IS correct when
|
||||
// the window is a single day).
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 28.06), quote.previous_close, 0.01);
|
||||
// Day OHLCV comes from indicators.quote - NOT the meta 52-week range
|
||||
// and NOT regularMarketPrice. Regression: open used to be the current
|
||||
// price, and high/low used to be the 52-week high/low.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 27.50), quote.open, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 27.90), quote.high, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 27.40), quote.low, 0.01);
|
||||
try std.testing.expectEqual(@as(u64, 123456), quote.volume);
|
||||
// The 52-week range stays in its own dedicated fields.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 28.59), quote.fifty_two_week_high, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01);
|
||||
// change = 27.78 - 28.06 = -0.28
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -0.28), quote.change, 0.01);
|
||||
// percent_change = (-0.28 / 28.06) * 100 ≈ -0.998
|
||||
// percent_change = (-0.28 / 28.06) * 100 ~ -0.998
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -0.998), quote.percent_change, 0.01);
|
||||
}
|
||||
|
||||
test "parseChartQuote: in-progress day uses live bar and prior day's close" {
|
||||
// Mirrors the SPCX bug: the market is open, so the last array entry
|
||||
// is today (open/high/low/volume are live, close still null), while
|
||||
// meta.chartPreviousClose is the close from *before* the whole 5-day
|
||||
// range - badly stale. The 1-day change must use yesterday's close
|
||||
// from the array, not the stale meta value (which produced a bogus
|
||||
// -17% day).
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "SPCX",
|
||||
\\ "regularMarketPrice": 153.23,
|
||||
\\ "chartPreviousClose": 185.00,
|
||||
\\ "fiftyTwoWeekHigh": 225.64,
|
||||
\\ "fiftyTwoWeekLow": 147.11
|
||||
\\ },
|
||||
\\ "timestamp": [1782135000, 1782221400, 1782307800, 1782394200, 1782480600],
|
||||
\\ "indicators": {"quote": [{
|
||||
\\ "open": [176.04, 151.06, 154.20, 156.63, 150.62],
|
||||
\\ "high": [176.75, 165.50, 159.86, 160.65, 158.40],
|
||||
\\ "low": [154.00, 147.11, 150.72, 150.00, 148.51],
|
||||
\\ "close": [154.60, 156.11, 154.54, 153.00, null],
|
||||
\\ "volume": [169183800, 155848100, 76101500, 62212400, 126431973]
|
||||
\\ }]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "SPCX");
|
||||
|
||||
// Today's (in-progress) bar is the last array entry, even though its
|
||||
// close is null.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 150.62), quote.open, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 158.40), quote.high, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 148.51), quote.low, 0.01);
|
||||
try std.testing.expectEqual(@as(u64, 126431973), quote.volume);
|
||||
// Previous close = yesterday's array close (153.00), NOT the stale
|
||||
// meta chartPreviousClose (185.00).
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 153.00), quote.previous_close, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 153.23), quote.close, 0.01);
|
||||
// change = 153.23 - 153.00 = +0.23 (~+0.15%), not -17%.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.23), quote.change, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.150), quote.percent_change, 0.01);
|
||||
}
|
||||
|
||||
test "parseChartQuote: market-closed day uses last close and prior day's close" {
|
||||
// After the close, the last array entry is a completed day (close
|
||||
// non-null) and matches regularMarketPrice. Previous close is the
|
||||
// day immediately before it, not two days back and not the stale
|
||||
// range close.
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "AAPL",
|
||||
\\ "regularMarketPrice": 105.00,
|
||||
\\ "chartPreviousClose": 90.00,
|
||||
\\ "fiftyTwoWeekHigh": 130.00,
|
||||
\\ "fiftyTwoWeekLow": 80.00
|
||||
\\ },
|
||||
\\ "timestamp": [1704067800, 1704154200, 1704240600],
|
||||
\\ "indicators": {"quote": [{
|
||||
\\ "open": [99.00, 101.00, 104.00],
|
||||
\\ "high": [100.00, 103.00, 106.00],
|
||||
\\ "low": [98.00, 100.50, 103.50],
|
||||
\\ "close": [100.00, 102.00, 105.00],
|
||||
\\ "volume": [1000000, 1100000, 1200000]
|
||||
\\ }]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "AAPL");
|
||||
|
||||
// Current (last) completed bar.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 104.00), quote.open, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 106.00), quote.high, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 103.50), quote.low, 0.01);
|
||||
try std.testing.expectEqual(@as(u64, 1200000), quote.volume);
|
||||
// Previous close = the day before the last (102.00), not 100.00 and
|
||||
// not the stale meta 90.00.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 102.00), quote.previous_close, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 105.00), quote.close, 0.01);
|
||||
// change = 105.00 - 102.00 = +3.00
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 3.00), quote.change, 0.01);
|
||||
}
|
||||
|
||||
test "parseChartQuote falls back to price when indicators are absent" {
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "VFIAX",
|
||||
\\ "regularMarketPrice": 500.00,
|
||||
\\ "chartPreviousClose": 495.00,
|
||||
\\ "fiftyTwoWeekHigh": 520.00,
|
||||
\\ "fiftyTwoWeekLow": 400.00
|
||||
\\ },
|
||||
\\ "timestamp": []
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "VFIAX");
|
||||
|
||||
// No indicators.quote arrays -> open/high/low fall back to price,
|
||||
// volume to 0. They must NOT pick up the 52-week range.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.open, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.high, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.low, 0.01);
|
||||
try std.testing.expectEqual(@as(u64, 0), quote.volume);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01);
|
||||
}
|
||||
|
||||
test "parseChartQuote: prefers meta.longName for the display name" {
|
||||
// The chart meta carries the real security name. longName is
|
||||
// preferred over shortName (which Yahoo often truncates / pads).
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "SPCX",
|
||||
\\ "longName": "Space Exploration Technologies Corp.",
|
||||
\\ "shortName": "Space Exploration Technologies ",
|
||||
\\ "regularMarketPrice": 153.23,
|
||||
\\ "chartPreviousClose": 153.00
|
||||
\\ },
|
||||
\\ "timestamp": [1782480600],
|
||||
\\ "indicators": {"quote": [{
|
||||
\\ "open": [150.62], "high": [158.40], "low": [148.51],
|
||||
\\ "close": [153.23], "volume": [126431973]
|
||||
\\ }]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "SPCX");
|
||||
try std.testing.expectEqualStrings("Space Exploration Technologies Corp.", quote.name());
|
||||
}
|
||||
|
||||
test "parseChartQuote: falls back to trimmed shortName when longName absent" {
|
||||
// No longName -> use shortName, trimming Yahoo's trailing padding.
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "FOO",
|
||||
\\ "shortName": "Foo Industries ",
|
||||
\\ "regularMarketPrice": 10.00,
|
||||
\\ "chartPreviousClose": 9.50
|
||||
\\ },
|
||||
\\ "timestamp": [1782480600],
|
||||
\\ "indicators": {"quote": [{
|
||||
\\ "open": [9.6], "high": [10.2], "low": [9.4],
|
||||
\\ "close": [10.0], "volume": [1000]
|
||||
\\ }]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "FOO");
|
||||
try std.testing.expectEqualStrings("Foo Industries", quote.name());
|
||||
}
|
||||
|
||||
test "parseChartQuote: no name fields leaves the name empty" {
|
||||
// When the meta carries neither longName nor shortName, the name
|
||||
// stays empty (callers fall back to symbol-only / other sources).
|
||||
const body =
|
||||
\\{
|
||||
\\ "chart": {
|
||||
\\ "result": [{
|
||||
\\ "meta": {
|
||||
\\ "symbol": "BAR",
|
||||
\\ "regularMarketPrice": 42.00,
|
||||
\\ "chartPreviousClose": 41.00
|
||||
\\ },
|
||||
\\ "timestamp": [1782480600],
|
||||
\\ "indicators": {"quote": [{
|
||||
\\ "open": [41.5], "high": [42.5], "low": [41.0],
|
||||
\\ "close": [42.0], "volume": [500]
|
||||
\\ }]}
|
||||
\\ }],
|
||||
\\ "error": null
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
const quote = try parseChartQuote(allocator, body, "BAR");
|
||||
try std.testing.expectEqualStrings("", quote.name());
|
||||
}
|
||||
|
|
|
|||
147
src/tui.zig
147
src/tui.zig
|
|
@ -1,7 +1,6 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("root.zig");
|
||||
const fmt = @import("format.zig");
|
||||
const Money = @import("Money.zig");
|
||||
const cli = @import("commands/common.zig");
|
||||
const stderr = @import("stderr.zig");
|
||||
|
|
@ -10,6 +9,7 @@ const tab_framework = @import("tui/tab_framework.zig");
|
|||
const framework = @import("commands/framework.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const braille = @import("charts/braille.zig");
|
||||
const input_buffer = @import("tui/input_buffer.zig");
|
||||
pub const PortfolioData = @import("PortfolioData.zig");
|
||||
|
||||
|
|
@ -1348,40 +1348,16 @@ pub const App = struct {
|
|||
// state, and re-fetch via `loadData` (or whatever the
|
||||
// tab's loader is named). The framework dispatcher routes
|
||||
// to the active tab's reload; tabs that share data
|
||||
// (quote/performance) delegate via their reload bodies.
|
||||
// (quote/performance) delegate via their reload bodies. The
|
||||
// Quote tab's reload also force-refreshes its live quote (the
|
||||
// "refreshed Xs ago" ticker), so there is no tab-specific quote
|
||||
// fetch here - the dispatch stays uniform across tabs.
|
||||
//
|
||||
// Reload is contractually self-completing: when it
|
||||
// returns, the tab's state is fully refreshed. There's no
|
||||
// need for a follow-up `loadTabData` - `activate` would
|
||||
// see `state.loaded = true` and no-op.
|
||||
self.dispatchTry("reload", .{});
|
||||
|
||||
// Live-quote re-fetch: the quote tab's freshness display
|
||||
// ("refreshed Xs ago") is driven by `states.quote.timestamp`,
|
||||
// which is independent of the candles cache. The user's
|
||||
// mental model for `r` includes "and the price ticker is
|
||||
// current as of NOW," so we hit the live-quote endpoint
|
||||
// here. Only on tabs that show a live quote.
|
||||
//
|
||||
// This is the one place where `r` reaches past the cache;
|
||||
// tab-switches that activate quote/performance don't trigger
|
||||
// it (they should keep using whatever cached price the user
|
||||
// last fetched). When live-streaming quotes ship someday,
|
||||
// this block goes away.
|
||||
switch (self.active_tab) {
|
||||
.quote, .performance => {
|
||||
if (self.symbol.len > 0) {
|
||||
if (self.svc.getQuote(self.symbol, .{})) |q| {
|
||||
self.states.quote.live = q;
|
||||
// wall-clock required: records the exact moment
|
||||
// this quote was served so the "refreshed Xs ago"
|
||||
// display is honest about freshness.
|
||||
self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
|
||||
} else |_| {}
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate the current tab - load any data it needs, set
|
||||
|
|
@ -1641,12 +1617,21 @@ pub const App = struct {
|
|||
return self.appPredicate(t, "isDisabled");
|
||||
}
|
||||
|
||||
/// Whether the App's active symbol is the user-selected row in the
|
||||
/// active tab - drives the `*` marker on the tab bar. Dispatches to
|
||||
/// the active tab's optional `isSymbolSelected` hook (tabs without
|
||||
/// it default to "not selected"); no App-level reach into any
|
||||
/// specific tab's state.
|
||||
fn isSymbolSelected(self: *App) bool {
|
||||
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
||||
if (self.active_tab != .portfolio) return false;
|
||||
if (self.states.portfolio.rows.items.len == 0) return false;
|
||||
if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false;
|
||||
return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol);
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
|
||||
const Module = @field(tab_modules, field.name);
|
||||
if (!@hasDecl(Module.tab, "isSymbolSelected")) return false;
|
||||
const state_ptr = &@field(self.states, field.name);
|
||||
return Module.tab.isSymbolSelected(state_ptr, self);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
|
||||
|
|
@ -1908,7 +1893,7 @@ pub const App = struct {
|
|||
(if (self.symbol_data.etf_profile) |p| p.name else null)
|
||||
else
|
||||
null;
|
||||
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback);
|
||||
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, .{ .etf_profile = etf_fallback });
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
|
||||
.style = th.headerStyle(),
|
||||
|
|
@ -2010,22 +1995,19 @@ pub const App = struct {
|
|||
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
||||
}
|
||||
|
||||
// Default status bar: getStatus() + optional account-filter
|
||||
// suffix on the portfolio tab.
|
||||
// Default status bar: the App's status message, optionally
|
||||
// annotated by the active tab's `statusSuffix` hook (e.g.
|
||||
// portfolio's account filter). No App-level reach into any
|
||||
// specific tab's state.
|
||||
const status_style = t.statusStyle();
|
||||
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
|
||||
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
|
||||
const af = self.states.portfolio.account_filter.?;
|
||||
const msg = self.getStatus(ctx.arena);
|
||||
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
|
||||
for (0..@min(filter_text.len, width)) |i| {
|
||||
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
|
||||
}
|
||||
} else {
|
||||
const msg = self.getStatus(ctx.arena);
|
||||
for (0..@min(msg.len, width)) |i| {
|
||||
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
|
||||
}
|
||||
const msg = self.getStatus(ctx.arena);
|
||||
const line = if (self.activeTabStatusSuffix(ctx.arena)) |suffix|
|
||||
std.fmt.allocPrint(ctx.arena, "{s} {s}", .{ msg, suffix }) catch msg
|
||||
else
|
||||
msg;
|
||||
for (0..@min(line.len, width)) |i| {
|
||||
buf[i] = .{ .char = .{ .grapheme = glyph(line[i]) }, .style = status_style };
|
||||
}
|
||||
|
||||
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
||||
|
|
@ -2047,6 +2029,36 @@ pub const App = struct {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Call the active tab's `statusSuffix` hook (when declared) to get
|
||||
/// an annotation appended to the default status message (e.g.
|
||||
/// portfolio's account filter). Comptime-walks `tab_modules`;
|
||||
/// returns null when the active tab declares no suffix.
|
||||
fn activeTabStatusSuffix(self: *App, arena: std.mem.Allocator) ?[]const u8 {
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
|
||||
const Module = @field(tab_modules, field.name);
|
||||
if (!@hasDecl(Module.tab, "statusSuffix")) return null;
|
||||
const state_ptr = &@field(self.states, field.name);
|
||||
return Module.tab.statusSuffix(state_ptr, self, arena);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Release every tab's transmitted Kitty graphics via the optional
|
||||
/// `releaseGraphics` hook. Called once at App teardown (from the
|
||||
/// run-scope defer) while `app.vx_app` is still valid - tabs without
|
||||
/// graphics simply omit the hook.
|
||||
fn releaseAllGraphics(self: *App) void {
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
const Module = @field(tab_modules, field.name);
|
||||
if (@hasDecl(Module.tab, "releaseGraphics")) {
|
||||
const state_ptr = &@field(self.states, field.name);
|
||||
Module.tab.releaseGraphics(state_ptr, self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Help ─────────────────────────────────────────────────────
|
||||
|
||||
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
|
|
@ -2131,14 +2143,14 @@ pub const App = struct {
|
|||
pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void {
|
||||
// Local shadows the `chart` module import; use a shorter name for
|
||||
// the local BrailleChart handle.
|
||||
var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
|
||||
var br = braille.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
|
||||
// No deinit needed: arena handles cleanup
|
||||
|
||||
// Cell budget per row: 2 leading spaces + n_cols chart cells +
|
||||
// 1 separator space + up to `money_label_max_bytes` for the
|
||||
// price label. Sizing this too small silently truncates labels
|
||||
// for portfolios over $1M; see `fmt.money_label_max_bytes`.
|
||||
const label_cells: usize = 1 + fmt.money_label_max_bytes;
|
||||
// for portfolios over $1M; see `braille.money_label_max_bytes`.
|
||||
const label_cells: usize = 1 + braille.money_label_max_bytes;
|
||||
const row_cells: usize = 2 + br.n_cols + label_cells;
|
||||
|
||||
const bg = th.bg;
|
||||
|
|
@ -2158,7 +2170,7 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
|
|||
// Chart columns
|
||||
for (0..br.n_cols) |col| {
|
||||
const pattern = br.pattern(row, col);
|
||||
graphemes[gpos] = fmt.brailleGlyph(pattern);
|
||||
graphemes[gpos] = braille.brailleGlyph(pattern);
|
||||
if (pattern != 0) {
|
||||
styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
|
||||
} else {
|
||||
|
|
@ -2439,6 +2451,7 @@ pub fn run(
|
|||
config: zfin.Config,
|
||||
portfolio_patterns: []const []const u8,
|
||||
global_watchlist_path: ?[]const u8,
|
||||
app_theme: theme.Theme,
|
||||
args: []const []const u8,
|
||||
today: zfin.Date,
|
||||
) !void {
|
||||
|
|
@ -2504,15 +2517,6 @@ pub fn run(
|
|||
try stderr_writer.interface.flush();
|
||||
}
|
||||
|
||||
const loaded_theme = blk: {
|
||||
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
|
||||
const home = home_opt orelse break :blk theme.default_theme;
|
||||
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
|
||||
break :blk theme.default_theme;
|
||||
defer allocator.free(theme_path);
|
||||
break :blk theme.loadFromFile(io, allocator, theme_path) orelse theme.default_theme;
|
||||
};
|
||||
|
||||
var svc = try allocator.create(zfin.DataService);
|
||||
defer allocator.destroy(svc);
|
||||
svc.* = zfin.DataService.init(io, allocator, config);
|
||||
|
|
@ -2534,7 +2538,7 @@ pub fn run(
|
|||
.config = config,
|
||||
.svc = svc,
|
||||
.keymap = keymap,
|
||||
.theme = loaded_theme,
|
||||
.theme = app_theme,
|
||||
.symbol = symbol,
|
||||
.has_explicit_symbol = has_explicit_symbol,
|
||||
.chart_config = chart_config,
|
||||
|
|
@ -2654,15 +2658,12 @@ pub fn run(
|
|||
app_inst.vx_app = &vx_app;
|
||||
defer app_inst.vx_app = null;
|
||||
defer {
|
||||
// Free any chart image before vaxis is torn down
|
||||
if (app_inst.states.quote.chart.image_id) |id| {
|
||||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||
app_inst.states.quote.chart.image_id = null;
|
||||
}
|
||||
if (app_inst.states.projections.image_id) |id| {
|
||||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||
app_inst.states.projections.image_id = null;
|
||||
}
|
||||
// Free any per-tab Kitty chart images before vaxis is torn
|
||||
// down. Each tab holding image IDs releases them via its
|
||||
// optional `releaseGraphics` hook. This defer runs before
|
||||
// the `vx_app = null` / `vx_app.deinit` defers above (LIFO),
|
||||
// so `app.vx_app` is still valid inside the hooks.
|
||||
app_inst.releaseAllGraphics();
|
||||
}
|
||||
try vx_app.run(app_inst.widget(), .{});
|
||||
}
|
||||
|
|
@ -3079,7 +3080,7 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $
|
|||
// and `n_cols` chart cells). For 13+ char money strings like
|
||||
// `$X,XXX,XXX.XX` (14 chars including the leading separator
|
||||
// space), the label got silently truncated to the 10 cell
|
||||
// budget. Buffer is now sized via `fmt.money_label_max_bytes`.
|
||||
// budget. Buffer is now sized via `braille.money_label_max_bytes`.
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
|
|
|||
|
|
@ -561,6 +561,30 @@ pub const tab = struct {
|
|||
};
|
||||
}
|
||||
|
||||
/// Whether the App's active symbol is the row under the cursor -
|
||||
/// drives the `*` marker the tab bar shows next to the symbol.
|
||||
/// Active-tab hook; the App consults it generically rather than
|
||||
/// reaching into portfolio state itself.
|
||||
pub fn isSymbolSelected(state: *State, app: *App) bool {
|
||||
if (state.cursor >= state.rows.items.len) return false;
|
||||
return std.mem.eql(u8, state.rows.items[state.cursor].symbol, app.symbol);
|
||||
}
|
||||
|
||||
/// Status-bar suffix: when an account filter is active, annotate
|
||||
/// the default status line with it (the App appends this to
|
||||
/// `getStatus()`). Null when no filter is set.
|
||||
pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 {
|
||||
_ = app;
|
||||
return formatAccountSuffix(arena, state.account_filter);
|
||||
}
|
||||
|
||||
/// Format the account-filter status suffix (or null when unset).
|
||||
/// Split from `statusSuffix` so it's unit-testable without an App.
|
||||
fn formatAccountSuffix(arena: std.mem.Allocator, account_filter: ?[]const u8) ?[]const u8 {
|
||||
const af = account_filter orelse return null;
|
||||
return std.fmt.allocPrint(arena, "[Account: {s}]", .{af}) catch null;
|
||||
}
|
||||
|
||||
/// Mouse handling. In account-picker mode, drives the modal
|
||||
/// (wheel scroll, click-to-select). Otherwise: clicks on the
|
||||
/// column-header row sort by that column; clicks on a data
|
||||
|
|
@ -2489,6 +2513,18 @@ test "matchesAccountFilter: with filter, null account fails" {
|
|||
try testing.expect(!matchesAccountFilter(&state, null));
|
||||
}
|
||||
|
||||
test "statusSuffix: formats the active account filter, null when unset" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const s = tab.formatAccountSuffix(arena, "Sample IRA");
|
||||
try testing.expect(s != null);
|
||||
try testing.expectEqualStrings("[Account: Sample IRA]", s.?);
|
||||
|
||||
try testing.expect(tab.formatAccountSuffix(arena, null) == null);
|
||||
}
|
||||
|
||||
test "ensureCursorVisible: cursor above viewport scrolls up" {
|
||||
var state: State = .{ .cursor = 5, .header_lines = 2 };
|
||||
var scroll: usize = 20;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const theme = @import("theme.zig");
|
|||
const tui = @import("../tui.zig");
|
||||
const projection_chart = @import("../charts/projection_chart.zig");
|
||||
const forecast_chart = @import("../charts/forecast_chart.zig");
|
||||
const braille = @import("../charts/braille.zig");
|
||||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const imported = @import("../data/imported_values.zig");
|
||||
const milestones = @import("../analytics/milestones.zig");
|
||||
|
|
@ -255,6 +256,17 @@ pub const tab = struct {
|
|||
state.* = .{};
|
||||
}
|
||||
|
||||
/// Release the transmitted Kitty projection-chart image before
|
||||
/// vaxis is torn down. Called across all tabs at App teardown while
|
||||
/// `app.vx_app` is still valid (distinct from `deinit`, which runs
|
||||
/// after vaxis is gone).
|
||||
pub fn releaseGraphics(state: *State, app: *App) void {
|
||||
if (state.image_id) |id| {
|
||||
if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
|
||||
state.image_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(state: *State, app: *App) !void {
|
||||
if (state.loaded) return;
|
||||
// Projections reads `app.portfolio.summary` and `.file`,
|
||||
|
|
@ -851,16 +863,10 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
|
|||
const ov = ctx_data.overlay_actuals orelse break :blk null;
|
||||
break :blk ov.today_years;
|
||||
};
|
||||
const bands = if (state.zoom_overlay) bz: {
|
||||
const ty = overlay_today_years orelse break :bz full_bands;
|
||||
const window_years_f = ty * 2.0;
|
||||
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands;
|
||||
const window_years: usize = @intFromFloat(@ceil(window_years_f));
|
||||
const want = window_years + 1; // inclusive of year 0 and year `window_years`
|
||||
if (want >= full_bands.len) break :bz full_bands;
|
||||
if (want < 2) break :bz full_bands;
|
||||
break :bz full_bands[0..want];
|
||||
} else full_bands;
|
||||
const bands = if (state.zoom_overlay)
|
||||
view.overlayZoomBands(full_bands, overlay_today_years)
|
||||
else
|
||||
full_bands;
|
||||
|
||||
// Build text header (benchmark comparison + allocation note)
|
||||
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
|
|
@ -948,6 +954,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
|
|||
capped_h,
|
||||
th,
|
||||
overlay_input,
|
||||
pctx.retirement.boundaryYear(),
|
||||
) catch {
|
||||
state.chart_dirty = false;
|
||||
return;
|
||||
|
|
@ -1081,9 +1088,11 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
|
|||
};
|
||||
}
|
||||
}
|
||||
// "{horizon}yr" at right edge of chart area
|
||||
// "{years}yr" at right edge of chart area - the last
|
||||
// rendered band's year, so a zoomed overlay window
|
||||
// labels its true span (not the full horizon).
|
||||
var yr_buf: [8]u8 = undefined;
|
||||
const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr";
|
||||
const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{bands[bands.len - 1].year}) catch "??yr";
|
||||
const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
|
||||
for (yr_label, 0..) |ch, ci| {
|
||||
const idx = axis_base + yr_start + ci;
|
||||
|
|
@ -1970,7 +1979,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
|||
// Compute braille chart with wider dimensions
|
||||
const chart_width: usize = 80;
|
||||
const chart_height: usize = 12;
|
||||
var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
|
||||
var br = braille.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
|
||||
|
||||
if (br) |*br_chart| {
|
||||
const bg = th.bg;
|
||||
|
|
@ -1983,8 +1992,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
|||
// Sized via a named constant so the projection
|
||||
// chart doesn't silently truncate labels for
|
||||
// portfolios that grow past $1M; see
|
||||
// `fmt.money_label_max_bytes`.
|
||||
const proj_label_cells: usize = 1 + fmt.money_label_max_bytes;
|
||||
// `braille.money_label_max_bytes`.
|
||||
const proj_label_cells: usize = 1 + braille.money_label_max_bytes;
|
||||
const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;
|
||||
|
||||
for (0..br_chart.chart_height) |row| {
|
||||
|
|
@ -2003,7 +2012,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
|||
// Chart columns
|
||||
for (0..br_chart.n_cols) |col| {
|
||||
const pat = br_chart.pattern(row, col);
|
||||
graphemes[gpos] = fmt.brailleGlyph(pat);
|
||||
graphemes[gpos] = braille.brailleGlyph(pat);
|
||||
if (pat != 0) {
|
||||
styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v };
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ const theme = @import("theme.zig");
|
|||
const chart = @import("../charts/chart.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const framework = @import("tab_framework.zig");
|
||||
const market = @import("../market.zig");
|
||||
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
const glyph = tui.glyph;
|
||||
|
||||
/// Re-fetch the Quote tab's live quote on re-activation once the held
|
||||
/// quote is older than this many seconds - but only while the market is
|
||||
/// open (outside regular hours the price is frozen). See
|
||||
/// `shouldFetchLiveQuote`.
|
||||
const quote_live_stale_s: i64 = 60;
|
||||
|
||||
/// Per-symbol chart state for the quote tab. Tracks the active
|
||||
/// timeframe, transmitted Kitty image (when supported), cached
|
||||
/// indicator overlays (SMA/Bollinger/etc), and last-rendered
|
||||
|
|
@ -84,8 +91,10 @@ pub const Action = enum {
|
|||
// ── Tab-private state ─────────────────────────────────────────
|
||||
|
||||
pub const State = struct {
|
||||
/// Stored real-time quote (only fetched on manual refresh; not
|
||||
/// auto-refetched on every redraw).
|
||||
/// Stored real-time quote. Fetched on tab activation (staleness-
|
||||
/// gated; see `refreshLiveQuote`) and force-refreshed on r/F5. Null
|
||||
/// until the first successful fetch, in which case the headline
|
||||
/// price falls back to the last candle close.
|
||||
live: ?zfin.Quote = null,
|
||||
/// Unix-epoch seconds for the live-quote fetch - drives the
|
||||
/// "data Xs ago" header readout.
|
||||
|
|
@ -114,6 +123,38 @@ pub const meta: framework.TabMeta(Action) = .{
|
|||
},
|
||||
};
|
||||
|
||||
/// Whether the Quote tab should (re)fetch the live quote on activation.
|
||||
/// Pure so the policy is unit-testable without a DataService.
|
||||
///
|
||||
/// - No quote held yet (cold open / new symbol): always fetch.
|
||||
/// - Market open: refetch once the held quote is older than the
|
||||
/// staleness window, so re-entering the tab stays current like the
|
||||
/// CLI `quote` command; rapid toggling within the window reuses it.
|
||||
/// - Otherwise (pre/after-hours, weekend, holiday): the last price is
|
||||
/// frozen, so the single cold-open fetch suffices - don't refetch.
|
||||
fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_age_s: i64) bool {
|
||||
if (!has_quote) return true;
|
||||
return session == .open and quote_age_s > quote_live_stale_s;
|
||||
}
|
||||
|
||||
/// Fetch the live quote into `state` when warranted. `force` (r/F5)
|
||||
/// always fetches; otherwise `shouldFetchLiveQuote` gates it. This is
|
||||
/// the same `DataService.getQuote` the CLI `quote` command uses. On
|
||||
/// failure the prior value is left untouched (the tab falls back to the
|
||||
/// last candle close) and the error is debug-logged - a missing live
|
||||
/// quote must never break tab activation.
|
||||
fn refreshLiveQuote(state: *State, app: *App, force: bool) void {
|
||||
if (app.symbol.len == 0) return;
|
||||
// wall-clock required: drives the staleness gate and the
|
||||
// "refreshed Xs ago" header timestamp.
|
||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
||||
if (!force and !shouldFetchLiveQuote(market.marketSession(now_s), state.live != null, now_s - state.timestamp)) return;
|
||||
if (app.svc.getQuote(app.symbol, .{})) |q| {
|
||||
state.live = q;
|
||||
state.timestamp = now_s;
|
||||
} else |err| std.log.scoped(.quote_tab).debug("{s}: live-quote fetch failed: {t}", .{ app.symbol, err });
|
||||
}
|
||||
|
||||
pub const tab = struct {
|
||||
pub const ActionT = Action;
|
||||
pub const StateT = State;
|
||||
|
|
@ -128,35 +169,46 @@ pub const tab = struct {
|
|||
state.* = .{};
|
||||
}
|
||||
|
||||
/// Quote loads its own data on activation (the live-quote
|
||||
/// fetch path lives in tui.zig after the tab switches because
|
||||
/// it depends on App.svc); no-op here. Chart redraws are
|
||||
/// triggered by the dirty flag on `state.chart`.
|
||||
/// Quote and performance share `app.symbol_data` (candles +
|
||||
/// dividends). Performance owns the loader; quote piggybacks
|
||||
/// by delegating its activate to performance's. This keeps
|
||||
/// `loadTabData`'s dispatch uniform - every tab activates its
|
||||
/// own state - while preserving the historical "switching to
|
||||
/// quote populates shared candle data" behavior.
|
||||
/// Release the transmitted Kitty chart image before vaxis is torn
|
||||
/// down. The App calls this across all tabs at teardown while
|
||||
/// `app.vx_app` is still valid - distinct from `deinit`, which runs
|
||||
/// after vaxis is already gone.
|
||||
pub fn releaseGraphics(state: *State, app: *App) void {
|
||||
if (state.chart.image_id) |id| {
|
||||
if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
|
||||
state.chart.image_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// On activation the Quote tab loads its shared candle data (by
|
||||
/// delegating to performance, which owns it) and fetches the live
|
||||
/// quote that drives the headline price/change - the same
|
||||
/// `DataService.getQuote` the CLI `quote` command uses. EOD candles
|
||||
/// can't represent the *current* price intraday (today's bar isn't
|
||||
/// available until after the close), so without the live fetch the
|
||||
/// tab would show yesterday's close during market hours. The fetch
|
||||
/// is staleness-gated (see `refreshLiveQuote` / `shouldFetchLiveQuote`)
|
||||
/// so re-entering the tab refetches when stale while rapid toggling
|
||||
/// reuses the held quote. This also covers `zfin i <symbol>` startup,
|
||||
/// which routes through `App.loadTabData` -> activate.
|
||||
pub fn activate(state: *State, app: *App) !void {
|
||||
_ = state;
|
||||
const perf_module = @import("performance_tab.zig");
|
||||
try perf_module.tab.activate(&app.states.performance, app);
|
||||
refreshLiveQuote(state, app, false);
|
||||
}
|
||||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
|
||||
/// Refresh: delegate to performance.reload, which owns the
|
||||
/// shared candle/dividend data and svc invalidation. Quote's
|
||||
/// chart-state (dirty + freeCache) is also reset by
|
||||
/// performance.reload - see the comment there for why.
|
||||
/// Quote-only state (live quote + timestamp) is reset here
|
||||
/// because performance doesn't know about it.
|
||||
/// Refresh (r/F5): reset the quote-only state, delegate to
|
||||
/// performance.reload (which owns the shared candle/dividend data,
|
||||
/// svc invalidation, and resetting quote's chart cache), then force
|
||||
/// a fresh live quote so the headline price is current as of NOW.
|
||||
pub fn reload(state: *State, app: *App) !void {
|
||||
state.live = null;
|
||||
state.timestamp = 0;
|
||||
const perf_module = @import("performance_tab.zig");
|
||||
try perf_module.tab.reload(&app.states.performance, app);
|
||||
refreshLiveQuote(state, app, true);
|
||||
}
|
||||
|
||||
pub const tick = framework.noopTick(State);
|
||||
|
|
@ -623,17 +675,18 @@ pub fn formatQuoteHeader(
|
|||
};
|
||||
}
|
||||
|
||||
/// Resolve the active symbol's display name using the shared
|
||||
/// policy (metadata.srf `name::`, then the ETF profile's fund
|
||||
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
|
||||
/// the three surfaces always agree on the name they show. The ETF
|
||||
/// profile is loaded lazily on the performance tab, so the fallback
|
||||
/// only kicks in once that data is present; the metadata name is
|
||||
/// always available when enriched.
|
||||
/// Resolve the active symbol's display name using the shared policy
|
||||
/// (`resolveSecurityName`): the curated `metadata.srf` `name::` field
|
||||
/// first, then the live quote name (Yahoo `longName`, the same source
|
||||
/// as the price), then the ETF profile's fund name. Mirrors the CLI
|
||||
/// `quote` command so the two quote surfaces always agree. The 'K'
|
||||
/// overlay uses the same helper but, lacking a per-symbol live quote,
|
||||
/// resolves metadata -> ETF name only.
|
||||
fn quoteTabName(app: *App) ?[]const u8 {
|
||||
const cm = app.portfolio.classificationMap();
|
||||
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
|
||||
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
|
||||
const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null;
|
||||
const etf: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
|
||||
return zfin.classification.resolveSecurityName(app.symbol, cm, .{ .live_quote = live, .etf_profile = etf });
|
||||
}
|
||||
|
||||
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
|
|
@ -668,6 +721,9 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
if (quote_data) |q| {
|
||||
// No candle data but have a quote - show it
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
|
||||
if (q.previous_close > 0) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{q.previous_close}), .style = th.mutedStyle() });
|
||||
}
|
||||
{
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
|
|
@ -763,6 +819,9 @@ fn buildDetailColumns(
|
|||
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
|
||||
if (prev_close > 0) {
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{prev_close}), th.mutedStyle());
|
||||
}
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
|
|
@ -955,3 +1014,25 @@ test "formatQuoteHeader: empty name is omitted" {
|
|||
const text = try formatQuoteHeader(arena, "AAPL", "", .none);
|
||||
try testing.expectEqualStrings(" AAPL", text);
|
||||
}
|
||||
|
||||
test "shouldFetchLiveQuote: with no held quote, always fetches" {
|
||||
try testing.expect(shouldFetchLiveQuote(.open, false, 0));
|
||||
try testing.expect(shouldFetchLiveQuote(.closed, false, 0));
|
||||
try testing.expect(shouldFetchLiveQuote(.premarket, false, 99_999));
|
||||
try testing.expect(shouldFetchLiveQuote(.afterhours, false, 99_999));
|
||||
}
|
||||
|
||||
test "shouldFetchLiveQuote: market open refetches only once past the staleness window" {
|
||||
try testing.expect(!shouldFetchLiveQuote(.open, true, 0));
|
||||
// Boundary: equal-to-window is not yet "older than", so no refetch.
|
||||
try testing.expect(!shouldFetchLiveQuote(.open, true, quote_live_stale_s));
|
||||
try testing.expect(shouldFetchLiveQuote(.open, true, quote_live_stale_s + 1));
|
||||
}
|
||||
|
||||
test "shouldFetchLiveQuote: outside regular hours a held quote is never refetched" {
|
||||
// Price is frozen pre/after-hours and on weekends/holidays, so even a
|
||||
// very stale held quote should not trigger a refetch.
|
||||
try testing.expect(!shouldFetchLiveQuote(.premarket, true, 100_000));
|
||||
try testing.expect(!shouldFetchLiveQuote(.afterhours, true, 100_000));
|
||||
try testing.expect(!shouldFetchLiveQuote(.closed, true, 100_000));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,22 @@
|
|||
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
|
||||
//! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... }
|
||||
//!
|
||||
//! /// Optional: a short suffix appended to the App's default
|
||||
//! /// status message (e.g. portfolio's "[Account: <filter>]").
|
||||
//! /// Allocate the returned slice in `arena`. Active tab only.
|
||||
//! pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... }
|
||||
//!
|
||||
//! /// Optional: is the App's active symbol the user-selected
|
||||
//! /// row in this tab? Drives the tab-bar `*` marker. Active
|
||||
//! /// tab only; tabs without it default to "not selected".
|
||||
//! pub fn isSymbolSelected(state: *State, app: *App) bool { ... }
|
||||
//!
|
||||
//! /// Optional: release any transmitted Kitty graphics (chart
|
||||
//! /// images) before vaxis is torn down. Called across ALL tabs
|
||||
//! /// at App teardown while `app.vx_app` is still valid - distinct
|
||||
//! /// from `deinit`, which runs after vaxis is already gone.
|
||||
//! pub fn releaseGraphics(state: *State, app: *App) void { ... }
|
||||
//!
|
||||
//! /// Optional: does this tab currently have async work in
|
||||
//! /// flight that needs poll-driven redraws? While the ACTIVE
|
||||
//! /// tab answers true, the App keeps a one-shot vxfw Tick
|
||||
|
|
@ -498,6 +514,36 @@ pub fn validateTabModule(comptime Module: type) void {
|
|||
"pub fn wantsPollTick(state: *State, app: *App) bool { ... }",
|
||||
);
|
||||
}
|
||||
if (@hasDecl(tab_decl, "isSymbolSelected")) {
|
||||
validator.expectFn(
|
||||
"Tab module",
|
||||
mod_name,
|
||||
tab_decl,
|
||||
"isSymbolSelected",
|
||||
fn (*State, *App) bool,
|
||||
"pub fn isSymbolSelected(state: *State, app: *App) bool { ... }",
|
||||
);
|
||||
}
|
||||
if (@hasDecl(tab_decl, "statusSuffix")) {
|
||||
validator.expectFn(
|
||||
"Tab module",
|
||||
mod_name,
|
||||
tab_decl,
|
||||
"statusSuffix",
|
||||
fn (*State, *App, std.mem.Allocator) ?[]const u8,
|
||||
"pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... }",
|
||||
);
|
||||
}
|
||||
if (@hasDecl(tab_decl, "releaseGraphics")) {
|
||||
validator.expectFn(
|
||||
"Tab module",
|
||||
mod_name,
|
||||
tab_decl,
|
||||
"releaseGraphics",
|
||||
fn (*State, *App) void,
|
||||
"pub fn releaseGraphics(state: *State, app: *App) void { ... }",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Draw hooks (mutually exclusive, exactly one required) ──
|
||||
//
|
||||
|
|
|
|||
|
|
@ -279,6 +279,28 @@ pub fn buildOverlayActuals(
|
|||
};
|
||||
}
|
||||
|
||||
/// Clamp projection `full_bands` to the overlay-relevant window so a
|
||||
/// short realized history isn't squashed into the start of a long
|
||||
/// horizon. Returns the leading slice covering roughly
|
||||
/// `[year 0, 2*today_years]` (both endpoints inclusive). Falls back to
|
||||
/// `full_bands` unchanged when there's no overlay (`today_years` null),
|
||||
/// the window is degenerate, or it would span the whole horizon.
|
||||
/// Shared by the TUI zoom toggle and the CLI export/inline paths so an
|
||||
/// overlay is framed identically in both.
|
||||
pub fn overlayZoomBands(
|
||||
full_bands: []const projections.YearPercentiles,
|
||||
today_years: ?f64,
|
||||
) []const projections.YearPercentiles {
|
||||
const ty = today_years orelse return full_bands;
|
||||
const window_years_f = ty * 2.0;
|
||||
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) return full_bands;
|
||||
const window_years: usize = @intFromFloat(@ceil(window_years_f));
|
||||
const want = window_years + 1; // inclusive of year 0 and year `window_years`
|
||||
if (want >= full_bands.len) return full_bands;
|
||||
if (want < 2) return full_bands;
|
||||
return full_bands[0..want];
|
||||
}
|
||||
|
||||
/// Which retirement-planning inputs the user has configured.
|
||||
///
|
||||
/// The simulation always runs the same two-phase model
|
||||
|
|
@ -2132,6 +2154,26 @@ test "buildOverlayActuals: empty input produces empty section" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01);
|
||||
}
|
||||
|
||||
test "overlayZoomBands: clamps to ~2x today_years, falls back when appropriate" {
|
||||
// 51 bands: year 0..50.
|
||||
var full: [51]projections.YearPercentiles = undefined;
|
||||
for (0..51) |i| full[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
|
||||
|
||||
// No overlay -> full slice unchanged.
|
||||
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, null).len);
|
||||
|
||||
// today_years = 2.5 -> window = ceil(5.0) = 5 -> 6 bands (years 0..5).
|
||||
const z = overlayZoomBands(&full, 2.5);
|
||||
try std.testing.expectEqual(@as(usize, 6), z.len);
|
||||
try std.testing.expectEqual(@as(u16, 5), z[z.len - 1].year);
|
||||
|
||||
// Degenerate today_years (<= 0) -> full slice.
|
||||
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 0).len);
|
||||
|
||||
// Window wider than the horizon -> full slice (no OOB).
|
||||
try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 100.0).len);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: single point at as_of has years=0" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue