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