Compare commits

...

19 commits

Author SHA1 Message Date
44f940c882
pass fetch options through TUI
All checks were successful
Generic zig build / build (push) Successful in 5m18s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 20s
2026-06-30 11:43:40 -07:00
3a1500bfbb
additional spacing based on crypto example 2026-06-30 11:42:49 -07:00
baf5e2a86f
update todo and docs 2026-06-30 08:32:56 -07:00
bb04d7babd
Tiingo as live provider message parsing - working end to end 2026-06-30 07:38:32 -07:00
fd5af31114
add test for arena dupe 2026-06-29 21:26:15 -07:00
0f09c6d31a
implement Tiingo IEX 2026-06-29 21:17:35 -07:00
a73966ad39
support Tiingo power tier rate limit 2026-06-29 20:53:42 -07:00
051ab35975
support live streaming on portfolio tab 2026-06-29 20:21:21 -07:00
eadba4696c
more graceful shutdown handling 2026-06-29 20:20:17 -07:00
279659188f
handle candle-less holding from overlay 2026-06-29 20:18:19 -07:00
123bd3eb46
computation of the overall portfolio summary in live mode 2026-06-29 19:35:41 -07:00
b2128bfcdd
live quote support (yahoo only) quote tab in tui 2026-06-29 19:29:24 -07:00
1bca0d596d
add price live streaming support (quote only/Yahoo only) 2026-06-29 15:57:33 -07:00
a978b2352e
add server api key support 2026-06-29 15:34:48 -07:00
027f3b67ab
add docs relating to comprehension of dividend-focused holdings vs other 2026-06-29 08:33:40 -07:00
0202652d09
remove last projections item from todo - would require years of git history 2026-06-28 17:07:58 -07:00
4ed15c7cf4
projection modeling enhancements based on youngest in a couple + documentation 2026-06-28 14:29:30 -07:00
e22e987121
update docs for new audit behavior 2026-06-28 13:27:56 -07:00
00482da00f
make value discrepancies warnings if we can detect it is likely a problem 2026-06-28 13:21:56 -07:00
47 changed files with 4498 additions and 592 deletions

View file

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

View file

@ -399,7 +399,7 @@ zig build test # run all tests (single binary, discovers all tests
zig build run -- <args> # build and run CLI
zig build docs # generate library documentation
zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section.
zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor)
zig build coverage -Dcoverage-threshold=80 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor)
```
**Tooling** (managed via `.mise.toml`):

202
TODO.md
View file

@ -5,68 +5,6 @@ ordered roughly by priority within each section. Priority labels
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
ranking; unlabeled items are "someday, if the mood strikes."
## Projections: future enhancements
- **Goal-seek over distribution horizon for W1 - priority LOW.**
Today the W1 ("set spending, find date") workflow reports the
earliest retirement at each user-configured `(horizon, confidence)`
cell. The philosophically correct version asks "when have I
accumulated enough wealth that the projection shows a 95%
probability of success withdrawing X per year from retirement
until age-of-death?" - i.e. goal-seek across both `accumulation_years`
AND `distribution_years` simultaneously, anchored to a configured
age-of-death. NP-shaped search; not worth optimizing until
someone wants it.
- **Per-person retirement_age - priority LOW.**
V1 of the accumulation-phase spec chose Option A: a single
household retirement boundary derived from the oldest configured
birthdate. Households where one earner retires significantly
earlier than the other would benefit from per-person
`retirement_age` fields on each `type::birthdate` record, with
contributions stopped per-person.
- **Historical projection overlay follow-ups.** The base
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
Open enhancements:
- Historical `metadata.srf` / `projections.srf` for back-dated
runs. Today the overlay re-runs against current classifications
and assumptions; for historically faithful what-the-model-said-then
output we'd check out the git-tracked versions of those files
at the as-of commit and load those instead. Edge case until
classifications materially drift.
- Contribution-attribution overlay. Today's actuals line includes
contributions implicitly; the bands assume modeled contributions
that may or may not match reality. A "decompose actuals into
market return vs contributions" annotation would clarify how
much of the trajectory was the model being right vs new money
arriving on schedule.
- **Better composition basis for imported-only as-of.** Today
the imported-only path uses today's allocations scaled by
`imported_liquid / today_total_liquid`. That's the simplest
thing that could work, but it's "today's mix back-dated" -
it ignores everything we know about the historical context.
Specifically: `imported_values.srf` already carries an
`expected_return` field per row that the user captured at
that date in their source spreadsheet. We could:
- Use the imported `expected_return` as a sanity check
against the simulation's per-position weighted return
(warn or clamp if they diverge wildly - the spreadsheet's
number reflects what the user actually saw at the time).
- Use the imported `expected_return` to bias the
stock/bond split inference: a higher expected return
implies a higher historical equity weighting than today's
mix probably reflects.
- Reach further: derive a synthetic stock/bond split from
the imported `expected_return` directly, treating it as
a weighted average of SPY and AGG returns at that date
and solving for the weights. That gives a per-imported-
row composition that's locally faithful instead of
one-mix-fits-all.
None of these are urgent - the current "today's mix scaled"
approximation is documented as such and the bands still
render meaningfully - but each would tighten the historical
faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited.
## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one
@ -117,145 +55,7 @@ populate. This could be solved on the server by spawning a thread to fetch the
data, then returning 202 Accepted, which could then be polled client side. Maybe
this is a better long term approach?
## Support Tiingo paid plan - priority LOW
zfin hardwires Tiingo to free-tier assumptions: the provider is
constructed with `RateLimiter.perHour(io, 50)` in `Tiingo.init`
(`providers/tiingo.zig`), and the only Tiingo surface is end-of-day
candles plus the corporate actions that ride along in the same
response. A user who pays for a Tiingo plan ($30/mo Power tier and
up) gets nothing for it today - the same 50/hour throttle, the same
EOD-only data. "Support the paid plan" is the umbrella for unlocking
what that subscription actually buys: higher rate limits and
real-time IEX quotes. The two are coupled (real-time polling only
makes sense once the limit is raised), which is why they belong in
one entry rather than two.
### Tier-aware rate limiting
The 50/hour cap is hardcoded in `Tiingo.init`
(`RateLimiter.perHour(io, 50)`), and the module docstring bakes in
"Free tier: 50 requests/hour and 1,000 requests/day." Paid tiers
raise both ceilings substantially, so a paying subscriber is being
throttled far below their entitlement. Today only the hourly bucket
is wired; the daily ceiling isn't enforced at all (the docstring
notes it's "far from binding" for bursty EOD usage - real-time
polling changes that calculus).
Work:
- Make the Tiingo limits configurable instead of hardcoded. Options:
explicit `ZFIN_TIINGO_RATE_PER_HOUR` (and per-day) numeric env
knobs, or a coarser `ZFIN_TIINGO_PLAN` = `free` (default) |
`power` | ... that maps to known limits. Lean toward explicit
numeric overrides so we aren't chasing Tiingo's published per-tier
numbers as they drift.
- `RateLimiter` already supports arbitrary `init(io, max, window_ns)`
plus `perDay`/`perHour` convenience ctors, so the limiter side is
cheap. Decide whether a paid plan needs both an hourly and a daily
bucket enforced, or whether hourly alone stays sufficient.
- Caveat from `RateLimiter`'s own docs: the bucket is in-memory and
per-process - it caps a single run's burst, not usage across
separate launches in the same window. Sustained real-time polling
(below) makes cross-process usage likelier, so revisit whether
per-process accounting is still good enough.
### Real-time IEX quotes (was: configurable live-quote provider)
The TUI refresh key (`r`) values the portfolio with live intraday
quotes via `DataService.loadLiveQuotes` (`service.zig`), which is
Yahoo-only: Yahoo is keyless, consolidated, and stays off every
rate-limit budget, so bursty refresh traffic costs nothing. The
tradeoffs are that Yahoo's unofficial feed is ~15-minute delayed and
"can break without notice."
Tiingo's IEX endpoint (`/iex/?tickers=A,B,C`) is a strong opt-in
alternative for a paid subscriber: it's genuinely real-time (IEX
last-sale, no 15-min delay), official/keyed, and bills per HTTP
request - one call returns the whole portfolio (confirmed
empirically: a 2-ticker batch decrements the daily quota by 1, not
2). Fields map cleanly: `tngoLast` to price, `prevClose` to
day-change. Caveats: IEX is a single venue (~2-3% of volume), so
`tngoLast` can sit stale between prints on illiquid names, and IEX
doesn't trade mutual funds, so those fall back to the candle close.
Proposal: a config knob (env var, e.g. `ZFIN_LIVE_QUOTE_PROVIDER` =
`yahoo` (default) | `tiingo`) that switches `loadLiveQuotes` to a new
`Tiingo.fetchQuotes(tickers)` batched call. A paid subscriber who
wants real-time and mashes `r` a lot (or once we add streaming)
reuses their existing `TIINGO_API_KEY` and gets real-time coverage;
everyone else keeps the keyless Yahoo default.
Implementation notes:
- `Tiingo.fetchQuotes` returns an array whose order is NOT guaranteed
to match the request order, so key results by the returned
`ticker` field, not by position.
- Live quotes share Tiingo's token bucket, so this is the concrete
reason the tier-aware rate-limiting work above has to land first
(or alongside): a batched quote call is only 1 request, but heavy
`r` use plus candle refreshes draining the free 50/hour bucket is
exactly the contention that raising the paid-tier limit relieves.
### Websocket streaming (follow-on)
Tiingo's IEX websocket would be the natural follow-on for true
push-based real-time, replacing poll-on-`r` entirely. Materially
bigger than the REST quote path (persistent connection, reconnect
handling, a background task feeding the TUI) and squarely a
paid-plan feature. Sequence it after the REST quote path proves out.
## Analysis: dividend equity / income-shaped equity - think about it
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
bucket as Equity in `analysis.bucketSector`. That's correct for
risk-exposure analysis - they drop with the market in a
2008-style crash, regardless of the dividend stream - but it
loses the income-vs-growth distinction that retirement-planning
tools care about.
Open question: is there a useful second dimension to add?
Possibilities:
- **Yield-weighted breakdown.** Aggregate `current_yield` per
position, weight by market value, report a portfolio-level
yield. Doesn't change the asset-class taxonomy; adds a new
metric.
- **Income coverage of expenses.** "My dividends + bond coupons
cover X% of projected retirement spending." Closer to what the
income-side framing actually wants - answers the question
rather than redefining the buckets.
- **Income-equity sub-bucket within Equity.** A sub-row in the
Asset Category breakdown, not a 5th top-level bucket. Would
need a way to mark funds as "income-shaped" - probably a
per-symbol opt-in in `metadata.srf`.
Not a bug. Not blocking anything. Could end up being a feature.
This is a note to revisit after using the 4-bucket view for a
while and seeing whether the missing dimension actually matters
in practice.
Resist the temptation to:
- **Add a 5th top-level bucket** ("Income Equity" / "Dividend
Equity"). The 4-bucket view is already the right answer for
"how much equity exposure do I have?". A 5th bucket
fragments the headline number.
- **Override SCHD to Fixed Income.** Wrong on risk grounds.
SCHD will lose 35-45% in an equity crash; treating it as FI
makes the user think they have downside protection they don't.
- **Add per-symbol "intent" metadata** (`held_for_income::true`).
Smell of putting framing into data. Intent is a property of
the holder's strategy, not the security.
If a fix lands, it's probably a separate analysis section (yield
breakdown, income coverage) - not a change to the asset-class
taxonomy.
The following items are acknowledged but not prioritized. Listed here
so they don't get lost; pick up opportunistically.
### Infra / performance
## Infra / performance
- **HTTP connection pooling.** Parallel server sync in `loadAllPrices`
spawns up to 8 threads, each with its own HTTP connection. Could

View file

@ -26,6 +26,15 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
// karlseguin/websocket.zig: blocking-socket + std.Thread websocket
// client used by the live-price stream (src/net/LiveStream.zig).
// Internal-only, so it's wired into the unified module's imports
// below but NOT into the public `zfin` library module.
const websocket_dep = b.dependency("websocket", .{
.target = target,
.optimize = optimize,
});
const srf_mod = srf_dep.module("srf");
const shiller_mod = b.addModule("shiller_year", .{
@ -61,6 +70,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "websocket", .module = websocket_dep.module("websocket") },
.{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod },
};

View file

@ -20,6 +20,10 @@
.url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
.hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
},
.websocket = .{
.url = "git+https://github.com/karlseguin/websocket.zig#99df0d3533a41cbcd5ff59b9af68bbfe6169cc62",
.hash = "websocket-0.1.0-ZPISdangBAAgWPap_MVU6Nk4rj3GT3xuJuF0IIv8HN6G",
},
},
.paths = .{
"build",

View file

@ -24,6 +24,28 @@ behind one interface -- commands ask for "candles for VTI," not "call
Tiingo." That's also what makes the cache and rate limiting uniform
across providers.
## Live quotes: free/delayed vs real-time
Intraday quotes -- the `r` refresh and the opt-in live stream (`L` in
the TUI, or [`quote --live`](../reference/cli/quote.md)) -- can come from
either feed, chosen by
[`ZFIN_LIVE_QUOTE_PROVIDER`](../reference/config/environment.md#live-quotes-and-streaming):
- **Yahoo** (default): keyless, consolidated, all tiers -- but ~15-min
delayed and unofficial.
- **Tiingo**: real-time IEX last-sale (`tngoLast`). Needs
`TIINGO_API_KEY` but works on **any** Tiingo tier -- the IEX
*reference price* (Tiingo's `thresholdLevel: 6`) needs no exchange
license, unlike raw IEX market data. Caveats: it's a single venue, so
a thinly-traded name can sit stale between prints, and IEX doesn't
carry mutual funds (those fall back to the candle close).
The same choice drives both the one-shot refresh and the websocket
stream. A stable stream costs a single request to connect, so it's
practical even on Tiingo's free 50/hour tier; the Power tier (10,000/hour)
just adds headroom for heavy use, which is why it's the auto-default
there.
## Fallback, not single-point-of-failure
Where a second source can stand in, zfin uses it:

View file

@ -25,7 +25,10 @@ Every projection runs the same two phases in order:
length comes from your retirement-date input. With no input, it's
zero years (an already-retired view).
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
default), no contributions. Its length is the configured `horizon`.
default), no contributions. Its length is the configured `horizon`,
or - when you set a `horizon_age` - the years until the *last
surviving* household member reaches that age of death (see
[Mortality](#mortality-the-surviving-spouse)).
Spending is flat in real terms unless you set
[`spending_change`](../reference/config/projections-srf.md#declining-spending-the-smile)
to taper or grow it year over year (the Blanchett "spending smile").
@ -103,14 +106,61 @@ When you set a `target_spending` instead of a date, zfin inverts the
question: for each (horizon x confidence) cell it searches for the
**earliest** accumulation length (up to `max_accumulation_years`, 50
years by default) that sustains your spending, and renders the grid of
answers. One cell is promoted to the
headline (see
answers. One cell is promoted to the headline (see
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
If no length within the cap works, the cell is **infeasible** -- shown
honestly rather than fudged. A young saver with a runway longer than 50
years can raise the cap via
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
For a plain `horizon` the distribution length is fixed, so a later
retirement means the money has to last from a later start to a fixed
number of years out. For an age-anchored `horizon_age` column the
distribution instead *shrinks* as retirement slides later, because the
end (the last survivor's death) is pinned: the total span from today is
constant and the column header reads `to age 95` rather than a year
count.
## Mortality: the surviving spouse
A `horizon_age` turns on a mortality model that a plain numeric horizon
doesn't have. It rests on the financial-planning standard for couples:
**the money must last until the last surviving member dies**, because
the household needs income for as long as *either* spouse is alive
(Blanchett, "How to Estimate 'The End' of Retirement," *Journal of
Financial Planning*, 2021). So the horizon is anchored on the
**youngest** member (who reaches the age of death latest), not the
oldest.
Two adjustments fire at the **first** death (the oldest member reaching
the age of death):
- **Spending steps down.** A surviving spouse needs less than the
couple but far more than half - shared costs (housing, utilities,
insurance) don't fall when one person dies. zfin scales base spending
by [`survivor_spending_pct`](../reference/config/projections-srf.md#survivor-spending-survivor_spending_pct)
(default 75%, a 25% cut). That default is the conservative edge of the
standard equivalence-scale range (the OECD-modified scale implies
~67%, the square-root scale ~71%); financial-planning software is
gentler at ~80%. The income side falls harder than the need side -
the Chicago Fed found household income drops ~37% at widowhood but the
standard-of-living-adjusted decline is only ~11% - which is why the
knob is about *need*, not income.
- **The deceased's income stops.** Each person's Social Security,
pension, and wages (and their own late-life expenses), entered as
`type::event`, terminate the year that person dies. Survivor benefits
that partly continue (a pension's survivor percentage, Social
Security's keep-the-higher rule) are modeled as a separate event on
the surviving spouse - see the
[config reference](../reference/config/projections-srf.md#modeling-survivor-benefits).
These two effects pull in opposite directions on the headline number:
the last-survivor horizon lengthens the plan (more conservative), while
the survivor spending cut and the (correct) retention of only the
survivor's own benefits shorten the funding need. Modeling both is more
faithful than either the old "stop at the first death" truncation or a
naive "fund the longer life at full couple spending."
## The caveat that matters most
zfin states this loudly by design, and so does this page:

View file

@ -101,6 +101,22 @@ zfin reports Sharpe at **3Y and 10Y** rather than 1Y. When the two
disagree, lean on the longer window to judge a holding's risk-adjusted
quality.
### Risk character vs. asset class
A dividend or low-volatility equity fund -- SCHD, VYM, USMV -- is still
equity: it falls with the market in a crash, so
[`analysis`](../reference/cli/analysis.md) buckets it under Equity, the
view that answers "how exposed am I to an equity drawdown?" But two
holdings of the *same* asset class can ride very differently, and that
difference lives in the volatility and max-drawdown columns of
[`review`](../reference/cli/review.md), not in the asset-class label. In
the [`pre-retirement-both`](../../examples/pre-retirement-both/) example,
SCHD and QQQ are both `US Large Cap`, yet SCHD's 5-year max drawdown runs
about half QQQ's at a few points lower volatility -- QQQ pays for the
bumpier ride with a higher return and Sharpe. Asset class answers *crash
exposure*; the risk columns answer *how bumpy the ride is*. They are
different questions, and zfin keeps them in different views on purpose.
## Portfolio-level returns
The portfolio summary's **Historical** line and the

View file

@ -186,15 +186,28 @@ Portfolio Audit (brokerage is source of truth)
`--verbose` prints the full comparison even when everything reconciles.
### Why "close" counts as a match
### Discrepancies, and which ones are muted
- **Cash matches to the penny.** It's an exact figure on both sides, so
any gap is real (e.g. money-market dividend accrual between updates)
and worth surfacing.
- **Securities get ~$1 of slack.** A sub-cent NAV-rounding difference on
a six-figure fund position can exceed a dollar without being
actionable, so small *value* deltas are tolerated -- but *share-count*
mismatches never are.
and is surfaced as a warning.
- **Securities get ~$1 of slack** for sub-cent NAV rounding on large
positions. Beyond that, a value delta is a **warning by default** --
your records should reconcile to the dollar.
- **Two cases are muted instead** -- still shown (and still in the
totals), just greyed out as "expected, you probably don't care." Muting
never hides a discrepancy:
- **CDs.** zfin carries a CD at face value while the broker marks it to
the secondary market. That gap is muted up to a band computed from
the CD's own `rate` and time to maturity (capped at one year's
coupon); anything larger still warns. We can't reproduce the broker's
exact mark without a live yield, so we bound it instead.
- **Options.** zfin tracks options at cost while the broker marks to
market, and that gap is unbounded without a live quote. So an account
holding open options mutes its account-level value delta -- drill into
the per-position export (`--schwab` / `--fidelity`) to check share
counts, which are never muted.
- **Share-count mismatches are never muted.**
### Institutional share classes
@ -215,7 +228,7 @@ treatment to track drift. See
CSVs.
- Matching is only as good as the `institution::` / `account_number::`
entries you keep in `accounts.srf`.
- Options and cash are reconciled separately from share counts.
- Options, CDs, and cash are reconciled separately from share counts.
None of this is a reason to skip it -- it's the single best way to keep
your records honest -- just know it expects some setup and an occasional

View file

@ -95,6 +95,13 @@ symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap,bucket::US Tot
symbol::SCHD,sector::Diversified,geo::US,asset_class::US Large Cap,bucket::US Dividend
```
Grouping like this also pays off in
[`zfin review`](read-your-portfolio.md#spotting-a-defensive-sleeve): a
`US Dividend` bucket typically shows lower volatility and a shallower
max drawdown than a broad-market or growth bucket, even when both are
the same asset class. Sorting `review` by sector lets you read each
sleeve's risk character at a glance.
## Verify
```bash

View file

@ -98,6 +98,12 @@ The Sector and Asset Category axes need [`metadata.srf`](classify-holdings.md);
the Tax Type axis needs [`accounts.srf`](set-up-accounts.md). Anything
missing classification lands under "Unclassified" / "Unknown."
One thing the Asset Category axis deliberately does *not* capture is
**risk character**. SCHD and QQQ here both land in Equity (`US Large
Cap`), yet one rides far calmer than the other -- that distinction lives
in [`review`](#review-per-holding-performance-and-risk) below, not in
the allocation breakdown.
### Umbrella exposure
A personal **umbrella insurance** policy covers liability -- lawsuits
@ -164,6 +170,22 @@ with the rest of your data. The CLI honors them -- `zfin review
--show-acked` includes acked findings in the table -- but only the TUI
can add or remove them.
### Spotting a defensive sleeve
Asset class tells you what falls in a downturn; the **Vol** and
**MaxDD** columns tell you how hard. The two come apart *within* a
single asset class. Here SCHD and QQQ are both `US Large Cap`, yet
`zfin review` shows SCHD riding far calmer -- its 5-year max drawdown is
roughly half QQQ's (about 16% vs. 33%), at a few points lower
volatility. QQQ earns more for the bumpier ride (higher return and
Sharpe); SCHD trades return for stability. That income/defensive
character is *measured* from each fund's own price history -- you don't
tag a fund with it, the risk columns reveal it. When several holdings
share that profile, give them a common
[`bucket::`](classify-holdings.md#fixing-uninformative-sectors) label
and sort by sector (`--sort sector`, the default) so they group
together and you can read the sleeve's risk character as a whole.
## `exposure`: look-through to a single symbol
```bash

View file

@ -4,7 +4,7 @@ Show the latest quote for a symbol, with a price chart and recent
history.
```
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>] [--live]
```
Prints the last price, the day's open/high/low, volume, and the
@ -29,6 +29,24 @@ Supports `--export-chart <PATH>` to render the chart as a 1920x1080
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
and the projections page).
## Live streaming (`--live`)
`zfin quote --live <SYMBOL>` opens a websocket and prints the price
continuously (one line per second) instead of a one-shot quote. It runs
until interrupted (Ctrl-C) or killed -- handy with `timeout`:
```bash
timeout 12 zfin quote --live BTC-USD # watch live ticks for 12s
```
The transport follows
[`ZFIN_LIVE_QUOTE_PROVIDER`](../config/environment.md#live-quotes-and-streaming):
free, keyless **Yahoo** by default, or real-time IEX via **Tiingo** when
selected (works on any Tiingo tier with a key). Outside market hours,
US equities stream a closing snapshot and then go quiet; 24/7 symbols
(e.g. `BTC-USD` on Yahoo) keep ticking. The chart / `--since` /
`--export-chart` options don't apply in `--live` mode.
## Example
```bash

View file

@ -30,12 +30,47 @@ unavailable. Quotes (Yahoo) and options (CBOE) need no key. See
[Data providers and API keys](../providers.md) for signup links and
free-tier limits.
## Tiingo plan
`ZFIN_TIINGO_PLAN` is your Tiingo subscription tier (not a key). It
sizes the hourly request budget for **all** Tiingo usage -- candle
history (the main consumer), real-time IEX quotes, and the live stream
-- so a paying subscriber isn't throttled to free-tier limits.
| Variable | Values | Default | Effect |
|--------------------|------------------|---------|-----------------------------------------------------------------|
| `ZFIN_TIINGO_PLAN` | `free` / `power` | `free` | Hourly Tiingo request cap: free = 50/hour, power = 10,000/hour. |
An unrecognized value logs a warning and falls back to `free`.
## Contact email
| Variable | Used for |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_USER_EMAIL` | The contact address SEC EDGAR requires in its `User-Agent` header. Enables ETF profiles and [`zfin enrich`](../cli/enrich.md). Not a key -- just your email. Without it, ETF profiles and enrichment are unavailable. |
## Live quotes and streaming
`ZFIN_LIVE_QUOTE_PROVIDER` chooses where intraday quotes come from --
both the `r`/F5 refresh and the opt-in live stream (the `L` toggle on
the TUI's Quote and Portfolio tabs, or [`quote --live`](../cli/quote.md)).
| Variable | Values | Default | Purpose |
|----------------------------|--------------------|-----------|--------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_LIVE_QUOTE_PROVIDER` | `yahoo` / `tiingo` | (derived) | Provider for live quotes + streaming. Unset: derived from the [Tiingo plan](#tiingo-plan) (Power -> Tiingo, else Yahoo). |
- **Yahoo** (default): keyless, works on all tiers, but ~15-minute
delayed and unofficial (no guarantees).
- **Tiingo**: real-time IEX last-sale (the `tngoLast` reference price);
requires `TIINGO_API_KEY`. The IEX reference feed works on **any**
Tiingo tier, free included -- set `ZFIN_LIVE_QUOTE_PROVIDER=tiingo` to
use it. It's the *automatic* default only on the Power plan, which has
the request headroom for heavy refresh/streaming use; on the free tier
it's opt-in (each websocket connect spends one of the 50/hour, but a
stable stream then costs nothing).
An unrecognized value logs a warning and falls back to the default.
## Paths and directories
| Variable | Default | Purpose |
@ -49,6 +84,7 @@ free-tier limits.
| Variable | Purpose |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_SERVER` | Optional URL of a remote [zfin-server](https://git.lerch.org/lobo/zfin-server) instance: a shared cache tier queried between the local cache and the providers. No-ops when unset. See [server sync](../../explanation/caching.md#server-sync-zfin_server). |
| `ZFIN_SERVER_API_KEY` | Optional API key sent as the `X-API-Key` header on every `ZFIN_SERVER` request. Set it when the server requires auth for cache endpoints; leave unset for an open server. No-ops when `ZFIN_SERVER` is unset. |
## Output

View file

@ -35,10 +35,10 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| Field | Type | Description |
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. |
| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. |
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
| `horizon` | num | Distribution-phase length in years (a fixed horizon, no mortality modeling). Repeat the line for multiple horizons. |
| `horizon_age` | num | Horizon expressed as an **age of death**. The distribution runs until the *last surviving* member reaches this age; the column also models the survivor spending step-down and per-person income/expense termination at death. See [Planning to an age of death](#planning-to-an-age-of-death). Repeatable. |
| `retirement_age` | num | Age the **oldest** configured person must reach to retire. |
| `retirement_at` | date | Absolute retirement date (`YYYY-MM-DD`). Wins over `retirement_age` if both set. |
| `annual_contribution` | num | Yearly accumulation-phase contribution, in today's dollars. |
@ -46,6 +46,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| `target_spending` | num | Desired retirement spending, in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
| `spending_change` | num | Signed annual *real* change in spending across the distribution phase, as a whole percent. Negative = declining (e.g. `-2` = -2%/yr, the "spending smile"); positive = rising. Default: absent = flat real spending. Magnitude clamped to 10%/yr. See [Declining spending](#declining-spending-the-smile). |
| `survivor_spending_pct` | num | Percent of the couple's joint spending the surviving spouse needs after the first death, for `horizon_age` columns. Default `75` (a 25% reduction). Only applies to a multi-person household with an age gap. See [Planning to an age of death](#planning-to-an-age-of-death). |
| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. |
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
@ -118,6 +119,122 @@ How it interacts with the rest of the model:
See the `post-retirement-smile/` example for a worked configuration.
### Planning to an age of death
A plain `horizon` is a fixed number of distribution years. A
`horizon_age` instead anchors the horizon to an **age of death**, and
turns on the mortality model:
```
type::config,horizon_age:num:95
type::birthdate,date::1962-03-01
type::birthdate,date::1967-08-15,person:num:2
```
This says "plan until we reach age 95." Three things follow:
1. **Last-survivor horizon.** The money must last until the *youngest*
member reaches the age of death (they reach it latest in calendar
time), not the oldest. For a couple this is the standard
financial-planning treatment: a household's assets must fund the
period for as long as *either* spouse is alive. (Anchoring on the
oldest would truncate the plan at the first death and silently
under-fund the survivor's remaining years.) A `horizon_age` requires
at least one `birthdate`.
2. **The horizon is coupled to the retirement date.** Distribution
length = `age_of_death - retirement_age`. With `target_spending` set,
the earliest-retirement search shrinks the distribution as it pushes
the retirement date later (the death date is fixed), so the column
header reads `to age 95` rather than a fixed year count.
3. **Per-person income and expenses end at death.** Each person's
Social Security, pension, wages, and their own late-life expenses
(entered as `type::event` with that `person`) stop the year that
person reaches the age of death - a deceased spouse no longer
collects their own benefit.
#### Survivor spending (`survivor_spending_pct`)
In a couple with an age gap, the first death (the *oldest* reaching the
age of death) steps household spending down to a surviving-spouse level
for the remaining years. `survivor_spending_pct` is the percent of the
couple's joint spending the survivor needs:
```
type::config,survivor_spending_pct:num:75
```
The **default is 75** (a 25% reduction). A surviving spouse needs *less*
than the couple (one fewer person) but far *more* than half, because
most costs - housing, property tax, utilities, insurance - are shared
and don't fall when one person dies. The default sits at the
conservative edge of the standard range:
- **OECD equivalence scales**, the standard economic adjustment for
household size: the OECD-modified scale (1.0 for the first adult,
0.5 for the second) implies a survivor needs ~67% (a 33% cut); the
square-root scale (needs proportional to sqrt of household size)
implies ~71% (a 29% cut). See Eurostat,
"[Equivalised disposable income](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=Glossary:Equivalised_disposable_income)".
- **Financial-planning convention** is gentler, ~80% (a 20% cut);
e.g. Kiplinger, ["Five Financial Changes That Happen When Your
Spouse Dies"](https://www.kiplinger.com/retirement/financial-changes-that-happen-when-your-spouse-dies)
(2024), which also warns that the right number is individual - a
survivor's spending can even rise.
- The income side falls much harder than the need side, which is why
this is about *need*, not income: the Chicago Fed (Fadlon, Ramnath &
Tong, *Chicago Fed Letter* No. 438, 2020) found household income
drops ~37% at widowhood but, after adjusting for the smaller
household, the standard-of-living decline was only ~11%.
The value is configurable because it is highly individual; 75% is a
defensible, slightly-conservative starting point. Any value `>= 0` is
honored (including above 100, for a survivor whose spending rises).
#### Modeling survivor benefits
Capping a deceased person's events at their death sets them to zero,
which is correct for benefits that die with the holder but not for
those that partly continue. Model the continuing portion as an event
tied to the **surviving** person:
- A pension with a 50% survivor benefit: enter the survivor's 50% as a
separate event on the surviving spouse.
- Social Security's "keep the higher" rule (the survivor keeps the
larger of the two benefits): if the higher earner dies first, add a
survivor-benefit event on the survivor starting at the first-death
age. Otherwise the default reads slightly conservative for that
household.
### Staggered retirement (one spouse retires earlier)
zfin models a single household retirement boundary: the accumulation
phase ends and the distribution phase begins at one date. To model one
spouse retiring before the other:
- If the early retirement has effectively already happened (one spouse
is no longer contributing), just set `annual_contribution` to the
remaining (solo) saver's amount. It applies across the whole
accumulation phase, which is exactly what you want.
- For a future step-down (both still working, retiring at different
dates), add a negative-contribution event at the early retiree's
date so the yearly inflow drops then:
```srf
type::event,name::Stop saving (A),start_age:num:62,person:num:1,amount:num:-20000
```
During accumulation a life event adjusts the portfolio's yearly
inflow, so a negative amount models the reduced saving directly.
Caveat: during accumulation, life-event *income* (a positive amount)
is **added to the portfolio** - modeled as saved, not consumed. So
model an early retiree whose Social Security or pension is *spent* by
lowering `annual_contribution`, not by adding a positive income event
(which would inflate savings). Reserve positive accumulation-phase
events for income that genuinely gets invested.
### Capping outlier returns
The **Projected return** shown by `zfin projections` (and the "Projected
@ -202,10 +319,13 @@ about the file:
When `target_spending` is set, the **earliest-retirement grid** shows,
for each (horizon x confidence) pair, the earliest year that sustains
the spending. The default promotion rule picks the headline cell by
walking horizons longest-to-shortest at 99% confidence, preferring the
longest horizon that keeps the oldest person under age 100. Override it
with a `retirement_target` annotation on one horizon line:
the spending. The default promotion rule picks the headline cell at 99%
confidence: it prefers an age-of-death-anchored (`horizon_age`) column
(the latest death, if several), since that's the "plan to the last
survivor" answer; with only plain numeric horizons it walks them
longest-to-shortest, preferring the longest that keeps the oldest
person under age 100. Override it with a `retirement_target` annotation
on one horizon line:
```srf
# use the 35yr x 95% cell as the headline

View file

@ -10,18 +10,19 @@ how keys are configured see
## Summary
| Data | Provider | Auth | Free-tier limit | Cache TTL |
|-----------------------|------------------|-------------------------------|----------------------------|------------|
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000 req/day, 50 req/hour | ~24h |
| Real-time quotes | Yahoo | none | unofficial | never |
| Quote fallback | TwelveData | `TWELVEDATA_API_KEY` | 8/min, 800/day | never |
| Dividends | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Options chains | CBOE | none | ~30/min (self-imposed) | 1 hour |
| Earnings | FMP | `FMP_API_KEY` | 250 req/day | 30 days |
| ETF profiles | SEC EDGAR | `ZFIN_USER_EMAIL` | 10/sec | ~90 days |
| Classification | Wikidata + EDGAR | `ZFIN_USER_EMAIL` | no daily quota | long-lived |
| CUSIP lookup | OpenFIGI | `OPENFIGI_API_KEY` (optional) | higher with key | indefinite |
| Data | Provider | Auth | Free-tier limit | Cache TTL |
|-----------------------|------------------|-------------------------------|------------------------------------------------|------------|
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000/day, 50/hour (free); 10,000/hour (Power) | ~24h |
| Real-time quotes | Yahoo | none | unofficial | never |
| Real-time IEX quotes | Tiingo | `TIINGO_API_KEY` | any tier (1 req per connect/batch) | never |
| Quote fallback | TwelveData | `TWELVEDATA_API_KEY` | 8/min, 800/day | never |
| Dividends | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Options chains | CBOE | none | ~30/min (self-imposed) | 1 hour |
| Earnings | FMP | `FMP_API_KEY` | 250 req/day | 30 days |
| ETF profiles | SEC EDGAR | `ZFIN_USER_EMAIL` | 10/sec | ~90 days |
| Classification | Wikidata + EDGAR | `ZFIN_USER_EMAIL` | no daily quota | long-lived |
| CUSIP lookup | OpenFIGI | `OPENFIGI_API_KEY` (optional) | higher with key | indefinite |
## Where to get a key
@ -40,8 +41,16 @@ how keys are configured see
funds. Candles are fetched from a fixed 2000-01-01 start so the cache
supports long `--as-of` projections. Its price series also carries
per-row dividend/split data that zfin merges into the Polygon view.
- **Yahoo** -- primary, keyless quote source, and the candle fallback
when Tiingo is unavailable.
Also serves **real-time IEX quotes** (the `tngoLast` reference price)
for the `r` refresh and the live stream when
[`ZFIN_LIVE_QUOTE_PROVIDER=tiingo`](config/environment.md#live-quotes-and-streaming);
the IEX reference feed works on any tier (no exchange license). Rate
limits are tier-aware via `ZFIN_TIINGO_PLAN` (free 50/hour, Power
10,000/hour) -- one batched quote call or one stream connect is a
single request.
- **Yahoo** -- primary, keyless quote source, the candle fallback when
Tiingo is unavailable, and the default live-quote/stream feed
(~15-min delayed, unofficial).
- **TwelveData** -- quote fallback only. It is no longer used for
candles (its split-adjusted closes proved unreliable for return math).
- **Polygon** -- primary for dividends and splits, including

View file

@ -62,8 +62,25 @@ current bindings). The global defaults:
| `page_down` / `Ctrl-f`, `page_up` / `Ctrl-b` | Page down / up |
Individual tabs add their own actions (e.g. the Projections tab's `d`
to set an as-of date and `o` to toggle the overlay). The `?` overlay
and `zfin i --default-keys` list every binding, global and tab-scoped.
to set an as-of date and `o` to toggle the overlay, or the Quote and
Portfolio tabs' `L` to toggle live streaming). The `?` overlay and
`zfin i --default-keys` list every binding, global and tab-scoped.
## Live streaming
On the **Quote** and **Portfolio** tabs, press `L` to toggle a live
price stream (off by default). While on, prices update in place every
~100ms: the Quote tab tracks the selected symbol; the Portfolio tab
re-values the whole position table and totals as held symbols tick.
Press `L` again (or switch tabs) to stop.
The feed follows
[`ZFIN_LIVE_QUOTE_PROVIDER`](config/environment.md#live-quotes-and-streaming):
keyless **Yahoo** (default, ~15-min delayed) or real-time IEX via
**Tiingo** (any keyed tier). Streaming covers what each feed prices
live -- stocks/ETFs (and crypto on Yahoo); mutual funds, CDs, options,
and cash hold their last value. The historical-return blocks stay as of
the last full refresh.
## Customizing

View file

@ -73,10 +73,11 @@ set, `target_spending` is not. Output renders:
- The **Accumulation phase** block is populated by **promoting one
cell** from the grid into the headline retirement line, plus the
median portfolio at retirement and p10-p90 range. The default
promotion rule walks horizons longest -> shortest and picks the
longest one whose end year keeps the oldest configured person
under age 100, at 99% confidence (most conservative). If even
the shortest horizon overshoots, it's used anyway.
promotion rule prefers an age-of-death (`horizon_age`) column - the
"plan to the last survivor" answer - at 99% confidence (most
conservative); with only numeric horizons it walks them longest ->
shortest and picks the longest whose end year keeps the oldest
configured person under age 100.
- The grid stays rendered for transparency - the user can see how
the headline cell compares to the rest of the matrix.

View file

@ -26,7 +26,8 @@ type::config,target_stock_pct:num:60
# flat real spending, the default.
type::config,spending_change:num:-2
# Distribution horizons - through age 90 (older partner first)
# Distribution horizons - plus a plan-to-age-95 column anchored on the
# youngest partner (the last survivor)
type::config,horizon:num:20
type::config,horizon:num:30
type::config,horizon_age:num:95

View file

@ -13,7 +13,8 @@
# Allocation target shifts more conservative in retirement
type::config,target_stock_pct:num:60
# Distribution horizons - through age 90 (older partner first)
# Distribution horizons - plus a plan-to-age-95 column anchored on the
# youngest partner (the last survivor)
type::config,horizon:num:20
type::config,horizon:num:30
type::config,horizon_age:num:95

View file

@ -16,7 +16,8 @@ type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - anchored on the youngest partner (the last
# survivor), so the money lasts until they reach 95.
type::config,horizon_age:num:95
# Target retirement date: oldest partner (Pat) reaches 65 in 2046

View file

@ -24,7 +24,8 @@ type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - anchored on the youngest partner (the last
# survivor), so the money lasts until they reach 95.
type::config,horizon_age:num:95
# Target retirement date: oldest partner (Pat) reaches 65 in 2046

View file

@ -11,10 +11,11 @@
# earliest accumulation length that sustains that spending at each
# configured (horizon × confidence) pair, renders the resulting
# grid, and promotes one cell into the Accumulation phase block as
# the headline. The default promotion rule is "longest configured
# horizon at 99% confidence, where the oldest person stays under
# age 100." See `pre-retirement-spending-target/` for the explicit-
# override variant.
# the headline. The default promotion rule prefers an age-of-death
# (`horizon_age`) column at 99% confidence - the "plan to the last
# survivor" answer - falling back to the longest numeric horizon that
# keeps the oldest person under age 100. See
# `pre-retirement-spending-target/` for the explicit-override variant.
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
type::config,target_stock_pct:num:80
@ -22,7 +23,8 @@ type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - anchored on the youngest partner (the last
# survivor), so the money lasts until they reach 95.
type::config,horizon_age:num:95
# Annual household contribution to retirement accounts

View file

@ -14,6 +14,7 @@
//! > `$HOME/.cache/zfin`.
const std = @import("std");
const builtin = @import("builtin");
const EnvMap = std.StringHashMap([]const u8);
@ -30,12 +31,31 @@ pub const default_portfolio_filename = "portfolio*.srf";
/// `default_portfolio_filename`.
pub const default_watchlist_filename = "watchlist.srf";
/// Tiingo subscription tier, sourced from `ZFIN_TIINGO_PLAN`. Drives the
/// provider's hourly rate-limit budget (see `tiingoHourlyLimit`): the
/// free tier is capped at 50 requests/hour, while paid tiers raise that
/// ceiling substantially. Defaults to `.free`.
pub const TiingoPlan = enum { free, power };
/// Which provider serves live intraday quotes (the TUI `r` refresh and
/// the streaming snapshot). `.yahoo` is keyless/free/consolidated but
/// ~15-min delayed and unofficial; `.tiingo` is real-time IEX last-sale
/// but needs a key and bills per request. Resolved by
/// `effectiveLiveQuoteProvider`.
pub const LiveQuoteProvider = enum { yahoo, tiingo };
// Fields
twelvedata_key: ?[]const u8 = null,
polygon_key: ?[]const u8 = null,
fmp_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null,
/// Tiingo subscription tier (`ZFIN_TIINGO_PLAN`). Drives the provider's
/// hourly rate-limit budget via `tiingoHourlyLimit`. Defaults to `.free`.
tiingo_plan: TiingoPlan = .free,
/// Explicit live-quote provider override (`ZFIN_LIVE_QUOTE_PROVIDER`).
/// Null means "derive from the plan" - see `effectiveLiveQuoteProvider`.
live_quote_provider_override: ?LiveQuoteProvider = null,
openfigi_key: ?[]const u8 = null,
/// User contact email used as the User-Agent / From header for
/// open-data providers that require politeness identification
@ -44,6 +64,13 @@ openfigi_key: ?[]const u8 = null,
user_email: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
/// Optional API key for the `server_url` instance above. Sent as the
/// `X-API-Key` header on every server-sync request. Sourced from
/// `ZFIN_SERVER_API_KEY` - the same variable the server reads to require
/// the key, so a host running both just sets it once. Null when unset:
/// requests then go out unauthenticated, which is correct against an open
/// server or one still in its pre-enforcement soft cutover.
server_api_key: ?[]const u8 = null,
cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
zfin_home: ?[]const u8 = null,
@ -94,9 +121,12 @@ pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std
self.polygon_key = self.resolve("POLYGON_API_KEY");
self.fmp_key = self.resolve("FMP_API_KEY");
self.tiingo_key = self.resolve("TIINGO_API_KEY");
self.tiingo_plan = parseTiingoPlan(self.resolve("ZFIN_TIINGO_PLAN"));
self.live_quote_provider_override = parseLiveQuoteProvider(self.resolve("ZFIN_LIVE_QUOTE_PROVIDER"));
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
self.user_email = self.resolve("ZFIN_USER_EMAIL");
self.server_url = self.resolve("ZFIN_SERVER");
self.server_api_key = self.resolve("ZFIN_SERVER_API_KEY");
const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: {
@ -373,6 +403,30 @@ pub fn hasAnyKey(self: @This()) bool {
self.tiingo_key != null;
}
/// Tiingo's hourly request budget for the configured plan. The free
/// tier is 50/hour; the Power tier raises it to 10,000/hour. Threaded
/// into the provider's rate limiter (see `service.getProvider`) so a
/// paying subscriber isn't throttled to free-tier limits.
pub fn tiingoHourlyLimit(self: @This()) usize {
return switch (self.tiingo_plan) {
.free => 50,
.power => 10_000,
};
}
/// Resolve the live-quote provider: an explicit `ZFIN_LIVE_QUOTE_PROVIDER`
/// override wins; otherwise the Power plan defaults to Tiingo (real-time
/// IEX) and everyone else to Yahoo. Tiingo requires `TIINGO_API_KEY`;
/// without it the result falls back to Yahoo so it never promises a path
/// that can't run. Yahoo stays the safe default - keyless, on no
/// rate-limit budget.
pub fn effectiveLiveQuoteProvider(self: @This()) LiveQuoteProvider {
const want = self.live_quote_provider_override orelse
(if (self.tiingo_plan == .power) LiveQuoteProvider.tiingo else LiveQuoteProvider.yahoo);
if (want == .tiingo and self.tiingo_key == null) return .yahoo;
return want;
}
// Internals
/// Look up a key: process environment first, then .env file fallback.
@ -384,6 +438,32 @@ fn resolve(self: *@This(), key: []const u8) ?[]const u8 {
return null;
}
/// Parse `ZFIN_TIINGO_PLAN` into a `TiingoPlan`. Absent -> `.free`. A
/// non-empty unrecognized value logs a warning and falls back to
/// `.free` so a typo (e.g. "pwoer") doesn't silently throttle a paying
/// subscriber to free-tier limits without explanation. The warning is
/// gated under `!builtin.is_test` so the fallback-path tests below don't
/// spam the test output.
fn parseTiingoPlan(value: ?[]const u8) TiingoPlan {
const v = value orelse return .free;
if (std.ascii.eqlIgnoreCase(v, "free")) return .free;
if (std.ascii.eqlIgnoreCase(v, "power")) return .power;
if (!builtin.is_test) std.log.scoped(.config).warn("ZFIN_TIINGO_PLAN=\"{s}\" not recognized (expected free|power); using free", .{v});
return .free;
}
/// Parse `ZFIN_LIVE_QUOTE_PROVIDER` into a `LiveQuoteProvider` override.
/// Absent -> null (derive from the plan). A non-empty unrecognized value
/// warns (gated under `!builtin.is_test`) and returns null so a typo
/// falls back to the plan default rather than silently picking a feed.
fn parseLiveQuoteProvider(value: ?[]const u8) ?LiveQuoteProvider {
const v = value orelse return null;
if (std.ascii.eqlIgnoreCase(v, "yahoo")) return .yahoo;
if (std.ascii.eqlIgnoreCase(v, "tiingo")) return .tiingo;
if (!builtin.is_test) std.log.scoped(.config).warn("ZFIN_LIVE_QUOTE_PROVIDER=\"{s}\" not recognized (expected yahoo|tiingo); using plan default", .{v});
return null;
}
/// Parse all KEY=VALUE pairs from .env content into a HashMap.
/// Values are slices into the original buffer (no extra allocations per entry).
fn parseEnvFile(allocator: std.mem.Allocator, data: []const u8) ?EnvMap {
@ -507,6 +587,61 @@ test "hasAnyKey: openfigi / server_url don't count as provider keys" {
try testing.expect(!c.hasAnyKey());
}
test "parseTiingoPlan: recognized values are case-insensitive" {
try testing.expectEqual(TiingoPlan.free, parseTiingoPlan("free"));
try testing.expectEqual(TiingoPlan.power, parseTiingoPlan("power"));
try testing.expectEqual(TiingoPlan.power, parseTiingoPlan("Power"));
try testing.expectEqual(TiingoPlan.free, parseTiingoPlan("FREE"));
}
test "parseTiingoPlan: absent or unrecognized defaults to free" {
// null (unset) is the common case and must not warn.
try testing.expectEqual(TiingoPlan.free, parseTiingoPlan(null));
// Non-empty unrecognized values fall back to free (the warning is
// gated under !builtin.is_test, so this stays quiet in tests).
try testing.expectEqual(TiingoPlan.free, parseTiingoPlan("enterprise"));
try testing.expectEqual(TiingoPlan.free, parseTiingoPlan(""));
}
test "tiingoHourlyLimit: free is 50/hr, power is 10,000/hr" {
var c: @This() = .{ .cache_dir = "/tmp" };
try testing.expectEqual(@as(usize, 50), c.tiingoHourlyLimit());
c.tiingo_plan = .power;
try testing.expectEqual(@as(usize, 10_000), c.tiingoHourlyLimit());
}
test "parseLiveQuoteProvider: recognized (case-insensitive), else null" {
try testing.expectEqual(LiveQuoteProvider.yahoo, parseLiveQuoteProvider("yahoo").?);
try testing.expectEqual(LiveQuoteProvider.tiingo, parseLiveQuoteProvider("Tiingo").?);
try testing.expect(parseLiveQuoteProvider(null) == null);
try testing.expect(parseLiveQuoteProvider("bogus") == null);
}
test "effectiveLiveQuoteProvider: plan default, override, and key guard" {
// Free plan, no override -> Yahoo.
var c: @This() = .{ .cache_dir = "/tmp" };
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
// Power plan + key -> Tiingo by default.
c.tiingo_plan = .power;
c.tiingo_key = "k";
try testing.expectEqual(LiveQuoteProvider.tiingo, c.effectiveLiveQuoteProvider());
// Power plan but no key -> falls back to Yahoo (never promise a path
// that can't run).
c.tiingo_key = null;
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
// Explicit override wins over the plan default - but still
// key-guarded for Tiingo.
c.tiingo_plan = .free;
c.tiingo_key = "k";
c.live_quote_provider_override = .tiingo;
try testing.expectEqual(LiveQuoteProvider.tiingo, c.effectiveLiveQuoteProvider());
c.tiingo_key = null;
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
}
test "ResolvedPath.deinit: frees when owned, no-op when not owned" {
const allocator = testing.allocator;

View file

@ -293,6 +293,29 @@ live_quotes_at_s: ?i64 = null,
/// render). Allocated in pd's arena.
watchlist_prices: ?std.StringHashMap(f64) = null,
// Re-value (live-streaming) state
//
// Captured by `load` and reused by `revalue` for the cheap
// price-overlay re-value used during live streaming: apply streamed
// prices over the base candle-close prices and recompute only the
// summary - no file re-read, no worker respawn. Both fields live in
// the main `arena` (reaped on reload), so they are valid only between
// loads; `revalue` bails when they are empty/null.
/// Parsed positions from the last load (shallow arena copy; symbol
/// slices point into `file`, which outlives them). Empty before the
/// first load.
revalue_positions: []const zfin.Position = &.{},
/// Base (candle-close) prices from the last load, keyed by held symbol
/// with arena-duped keys. Null before the first load. A `revalue`
/// overlays streamed prices onto a copy of this.
revalue_base_prices: ?std.StringHashMap(f64) = null,
/// Dedicated arena for `revalue`'s working price map + recomputed
/// summary, reset at the start of each call so a long streaming
/// session can't grow memory unbounded. Lives across reloads; released
/// on `deinit`.
revalue_arena: ArenaAllocator,
// Async data (access via methods)
//
// Each datum has a (future, data) pair. The future is what
@ -327,6 +350,7 @@ pub fn init(opts: InitOptions) PortfolioData {
return .{
.arena = .init(opts.gpa),
.candles_arena = .init(opts.gpa),
.revalue_arena = .init(opts.gpa),
.io = opts.io,
.svc = opts.svc,
};
@ -341,6 +365,7 @@ pub fn deinit(self: *PortfolioData) void {
// slice values - single bulk free is enough; no need to
// walk the map.
self.candles_arena.deinit();
self.revalue_arena.deinit();
self.arena.deinit();
self.* = undefined;
}
@ -558,6 +583,11 @@ pub fn load(
self.file = null;
_ = self.arena.reset(.retain_capacity);
self.paths = &.{};
// The reset above reaped the previous load's re-value state (it
// lived in this arena). Null it now so a stray `revalue` between
// here and the recapture below can't read freed memory.
self.revalue_positions = &.{};
self.revalue_base_prices = null;
self.summary = null;
self.latest_quote_date = null;
self.live_prices_applied = false;
@ -716,6 +746,14 @@ pub fn load(
}
self.watchlist_prices = wp;
// Capture re-value state for live streaming BEFORE buildFallbackPrices
// mutates `prices` (so the base is the candle-close + any startup
// overlay, without the manual/avg-cost fallbacks that revalue
// re-derives each call). Keys are duped into the arena since the
// load_all-borrowed keys don't outlive this function.
self.revalue_positions = dupePositions(arena_alloc, positions);
self.revalue_base_prices = dupePricesArena(arena_alloc, &prices);
// Build summary
var manual_price_set = zfin.valuation.buildFallbackPrices(arena_alloc, pf.lots, positions, &prices) catch
return error.OutOfMemory;
@ -797,6 +835,62 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo
return self.load(saved, today, opts);
}
/// Cheap live re-value: apply a streamed-price `overlay` over the base
/// (candle-close) prices captured by the last `load`, then recompute
/// ONLY the summary. No file I/O, no worker respawn - safe to call on a
/// sub-second tick while streaming.
///
/// `overlay` maps held symbol -> latest price; its keys must match the
/// summary's price keys (held symbols), and non-matching keys are
/// ignored. Symbols absent from the overlay keep their base price, so
/// non-streaming assets (mutual funds, CDs, cash) stay put.
///
/// Returns true when the summary was recomputed; false (a no-op) before
/// the first successful load, or if the previous load captured no
/// re-value state. On any internal failure the previous summary is left
/// intact and false is returned.
pub fn revalue(self: *PortfolioData, today: Date, overlay: *const std.StringHashMap(f64)) bool {
const pf = self.file orelse return false;
const base = if (self.revalue_base_prices) |*b| b else return false;
const positions = self.revalue_positions;
if (positions.len == 0) return false;
// Reset bounds memory across a long streaming session: each call
// reuses the same arena for its working price map and the new
// summary; the previous call's summary is reaped here.
_ = self.revalue_arena.reset(.retain_capacity);
const ra = self.revalue_arena.allocator();
// Working prices = base candle-close prices, with the streamed
// overlay applied over matching held symbols.
var prices = std.StringHashMap(f64).init(ra);
var bit = base.iterator();
while (bit.next()) |e| prices.put(e.key_ptr.*, e.value_ptr.*) catch return false;
// Streamed overlay: override a matching base price, or INSERT a new
// one. The insert path matters for holdings with no candle history
// (e.g. 24/7 crypto like BTC-USD) - otherwise they'd fall back to
// avg cost and silently ignore the live tick.
var oit = overlay.iterator();
while (oit.next()) |e| prices.put(e.key_ptr.*, e.value_ptr.*) catch return false;
const manual_price_set = zfin.valuation.buildFallbackPrices(ra, pf.lots, positions, &prices) catch return false;
const sum = zfin.valuation.portfolioSummary(today, ra, pf, positions, prices, manual_price_set) catch return false;
if (sum.allocations.len == 0) return false;
self.summary = sum;
// Flip the "as of" wording when the overlay re-priced at least one
// held position (mirrors the load-overlay path).
var applied = false;
for (positions) |pos| {
if (pos.shares > 0 and overlay.contains(pos.symbol)) {
applied = true;
break;
}
}
self.live_prices_applied = applied;
return true;
}
/// Cancel any in-flight load and pending background workers.
/// Safe to call at any time including when nothing is in-flight.
/// After cancel, snapshots / dividends / account_map data is
@ -929,6 +1023,20 @@ fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Posi
return dup;
}
/// Copy a price map into `arena` with arena-duped keys, so it outlives
/// the (load_all-borrowed) source keys. Used to capture the base prices
/// for `revalue`. Best-effort: a failed entry is skipped (a missing
/// base price just degrades that position to its fallback in revalue).
fn dupePricesArena(arena: Allocator, src: *std.StringHashMap(f64)) std.StringHashMap(f64) {
var out = std.StringHashMap(f64).init(arena);
var it = src.iterator();
while (it.next()) |e| {
const k = arena.dupe(u8, e.key_ptr.*) catch continue;
out.put(k, e.value_ptr.*) catch continue;
}
return out;
}
/// Adapter that captures the first 8 failed symbols into pd's
/// arena while still forwarding to the caller's progress callback.
const FailCapture = struct {
@ -1378,3 +1486,159 @@ test "PortfolioData.candles_data: soft load preserves the candle map" {
try testing.expect(pd.candles_data != null);
try testing.expect(pd.candles_data.?.contains("VTI"));
}
test "PortfolioData.revalue: overlays streamed prices and recomputes the total" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer {
// `file` below is an empty stack Portfolio, not heap-owned; clear
// it so deinit doesn't try to free it.
pd.file = null;
pd.deinit();
}
var positions = [_]zfin.Position{
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
.{ .symbol = "SPY", .shares = 5, .avg_cost = 400.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
pd.revalue_positions = &positions;
var base = std.StringHashMap(f64).init(testing.allocator);
defer base.deinit();
try base.put("AAPL", 200.0);
try base.put("SPY", 700.0);
pd.revalue_base_prices = base;
const today = Date.fromYmd(2026, 5, 8);
// No overlay: total = 10*200 + 5*700 = 5500; nothing overridden.
var no_overlay = std.StringHashMap(f64).init(testing.allocator);
defer no_overlay.deinit();
try testing.expect(pd.revalue(today, &no_overlay));
try testing.expectApproxEqAbs(@as(f64, 5500.0), pd.summary.?.total_value, 0.01);
try testing.expect(!pd.live_prices_applied);
// Overlay AAPL -> 210: total = 10*210 + 5*700 = 5600.
var overlay = std.StringHashMap(f64).init(testing.allocator);
defer overlay.deinit();
try overlay.put("AAPL", 210.0);
try testing.expect(pd.revalue(today, &overlay));
try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01);
try testing.expect(pd.live_prices_applied);
// A repeat call reuses the revalue arena (memory stays bounded) and
// yields the same result - exercises the per-call arena reset.
try testing.expect(pd.revalue(today, &overlay));
try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01);
}
test "PortfolioData.revalue: prices a candle-less holding from the overlay" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer {
pd.file = null;
pd.deinit();
}
// A 24/7 holding with NO candle history (absent from base prices).
var positions = [_]zfin.Position{
.{ .symbol = "BTC-USD", .shares = 2, .avg_cost = 50000.0, .total_cost = 100000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
pd.revalue_positions = &positions;
var base = std.StringHashMap(f64).init(testing.allocator); // empty: no candles
defer base.deinit();
pd.revalue_base_prices = base;
const today = Date.fromYmd(2026, 5, 8);
// No overlay -> avg-cost fallback: 2 * 50000 = 100000.
var no_overlay = std.StringHashMap(f64).init(testing.allocator);
defer no_overlay.deinit();
try testing.expect(pd.revalue(today, &no_overlay));
try testing.expectApproxEqAbs(@as(f64, 100000.0), pd.summary.?.total_value, 0.01);
try testing.expect(!pd.live_prices_applied);
// Overlay BTC-USD -> 60000 (inserted, not in base): 2 * 60000 = 120000.
var overlay = std.StringHashMap(f64).init(testing.allocator);
defer overlay.deinit();
try overlay.put("BTC-USD", 60000.0);
try testing.expect(pd.revalue(today, &overlay));
try testing.expectApproxEqAbs(@as(f64, 120000.0), pd.summary.?.total_value, 0.01);
try testing.expect(pd.live_prices_applied);
}
test "PortfolioData.revalue: no-op before any load" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer pd.deinit();
var overlay = std.StringHashMap(f64).init(testing.allocator);
defer overlay.deinit();
// No positions / base prices captured -> false, and summary stays null.
try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
try testing.expect(pd.summary == null);
}
test "PortfolioData.revalue: returns false when the recompute yields no allocations" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer {
pd.file = null;
pd.deinit();
}
// A single zero-share position: portfolioSummary skips shares <= 0,
// so the summary has no allocations and revalue reports failure
// (leaving any prior summary untouched).
var positions = [_]zfin.Position{
.{ .symbol = "ZERO", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 0, .realized_gain_loss = 0 },
};
pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
pd.revalue_positions = &positions;
var base = std.StringHashMap(f64).init(testing.allocator);
defer base.deinit();
pd.revalue_base_prices = base;
var overlay = std.StringHashMap(f64).init(testing.allocator);
defer overlay.deinit();
try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
}
test "dupePricesArena: copies entries with arena-owned keys" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var src = std.StringHashMap(f64).init(testing.allocator);
defer src.deinit();
try src.put("AAPL", 1.5);
try src.put("SPY", 2.5);
// Copy lives in the arena (keys duped there), so it survives `src`
// being freed; reaped wholesale by arena.deinit (no out.deinit).
var out = dupePricesArena(a, &src);
src.clearAndFree(); // drop the source + its keys
try testing.expectEqual(@as(usize, 2), out.count());
try testing.expectApproxEqAbs(@as(f64, 1.5), out.get("AAPL").?, 0.0001);
try testing.expectApproxEqAbs(@as(f64, 2.5), out.get("SPY").?, 0.0001);
}

View file

@ -470,10 +470,12 @@ pub fn bucketSector(sector: []const u8) []const u8 {
// bond-like income shape. The Asset Category breakdown
// answers "what's exposed to equity drawdowns?" - and
// dividend funds drop with the market in a 2008-style
// crash. The income-feels-like-bonds intuition belongs in
// a separate yield-weighted analysis (see TODO.md
// "Dividend equity / income-shaped equity"), not in the
// asset-class taxonomy.
// crash. Their calmer risk character (lower volatility,
// shallower drawdown) is real but shows up in the
// per-holding vol/drawdown columns of `review`, not in
// the asset-class taxonomy. See
// docs/explanation/returns-and-performance.md, "Risk
// character vs. asset class".
if (std.mem.startsWith(u8, sector, "Equity")) return bucket_equity;
if (std.mem.startsWith(u8, sector, "Debt")) return bucket_fixed_income;
if (std.mem.startsWith(u8, sector, "Loan")) return bucket_fixed_income;

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,73 @@ const BrokeragePosition = brokerage_types.BrokeragePosition;
pub const value_tolerance: f64 = 1.0;
pub const cash_tolerance: f64 = 0.01;
/// A CD's reconciliation allowance is capped at one year's coupon:
/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open
/// an unbounded muting window. (Near-maturity CDs - the common case -
/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.)
pub const cd_allowance_year_cap: f64 = 1.0;
/// Fallback band (percent of face) for a CD that carries no `rate`.
/// Rare, but we still want a non-zero allowance so a rate-less CD isn't
/// flagged on every routine dealer mark.
pub const cd_allowance_fallback_pct: f64 = 0.5;
/// Expected secondary-market mark on a single open CD, in dollars.
///
/// zfin values a CD at face (`shares`); the broker marks it to the
/// secondary market, which drifts from face by a small, bounded amount
/// driven by the remaining coupon and time to maturity. We can't
/// reproduce the broker's exact mark without a live yield, but we can
/// bound it: `face x (rate/100) x years_to_maturity` (years capped at
/// 1.0). A value delta within this band is an expected CD mark (mute);
/// beyond it, something else is off (warn). Returns 0 for non-CD lots.
pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 {
if (lot.security_type != .cd) return 0;
const face = @abs(lot.shares);
const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0);
const years: f64 = if (lot.maturity_date) |m| blk: {
const y = Date.yearsBetween(as_of, m);
break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap);
} else cd_allowance_year_cap;
return face * (rate / 100.0) * years;
}
/// Per-account, broker-agnostic classifier for "expected" value
/// differences between zfin and a brokerage. Reconcilers use it to
/// decide whether an account-level value delta should be muted
/// (expected) or warned (real discrepancy).
///
/// - `has_options`: the account holds >= 1 open option lot. zfin
/// values options at cost (premium) while brokers mark them to
/// market, so the gap is real but unbounded and unknowable without
/// a live option quote. At the totals-only summary level any value
/// delta is therefore "expected" - mute it. (The per-position path
/// carves options out symbol-by-symbol instead; this is the
/// totals-only fallback.)
/// - `cd_allowance`: summed `cdLotAllowance` over the account's open
/// CDs - the bounded band within which a value delta is explained
/// by CD dealer marks.
pub const AccountValueExpectation = struct {
has_options: bool = false,
cd_allowance: f64 = 0,
};
/// Walk an account's open lots and summarize its expected value
/// differences. See `AccountValueExpectation`.
pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation {
var result: AccountValueExpectation = .{};
for (portfolio.lots) |lot| {
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
if (!lot.isOpen(as_of)) continue;
switch (lot.security_type) {
.option => result.has_options = true,
.cd => result.cd_allowance += cdLotAllowance(lot, as_of),
else => {},
}
}
return result;
}
/// Resolved position value for audit display: effective per-share price
/// and total market value, with correct `price_ratio` handling based on
/// the price's provenance.
@ -84,6 +151,12 @@ pub const SymbolComparison = struct {
value_delta: ?f64,
is_cash: bool,
is_option: bool,
/// True when the matched portfolio lot(s) are a CD. A CD's value
/// delta is muted up to `cd_allowance`; beyond that it warns.
is_cd: bool = false,
/// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless
/// `is_cd`.
cd_allowance: f64 = 0,
only_in_brokerage: bool,
only_in_portfolio: bool,
};
@ -269,6 +342,8 @@ pub fn compareAccounts(
var pf_value: f64 = 0;
var pf_price: ?f64 = null;
var is_option = false;
var is_cd = false;
var cd_allowance: f64 = 0;
if (bp.is_cash) {
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
@ -306,6 +381,8 @@ pub fn compareAccounts(
pf_shares += lot.shares;
pf_value += lot.shares;
pf_price = 1.0;
is_cd = true;
cd_allowance += cdLotAllowance(lot, as_of);
},
.option => {
pf_shares += lot.shares;
@ -356,6 +433,8 @@ pub fn compareAccounts(
.value_delta = value_delta,
.is_cash = bp.is_cash,
.is_option = is_option,
.is_cd = is_cd,
.cd_allowance = cd_allowance,
.only_in_brokerage = pf_shares == 0 and pf_value == 0,
.only_in_portfolio = false,
});
@ -446,6 +525,7 @@ pub fn compareAccounts(
.value_delta = null,
.is_cash = is_cd,
.is_option = !is_cd,
.is_cd = is_cd,
.only_in_brokerage = false,
.only_in_portfolio = true,
});
@ -604,7 +684,13 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
// Classify this row
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
// A CD's value delta is an expected dealer mark up to its
// band; only a delta beyond what its terms can explain is a
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
if (is_real_mismatch) discrepancy_count += 1;
@ -1365,6 +1451,134 @@ test "compareAccounts: a broker CD row matches a portfolio CD lot" {
try std.testing.expect(!results[0].has_discrepancies);
}
test "cdLotAllowance: near-maturity CD yields a tight band" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CDNEAR",
.security_type = .cd,
.shares = 87000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0,
.rate = 3.8,
.maturity_date = Date.fromYmd(2026, 7, 15),
.account = "Sample IRA",
};
// 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to
// mute a dealer mark, far below a missing lot.
const band = cdLotAllowance(cd, as_of);
try std.testing.expect(band > 100 and band < 200);
}
test "cdLotAllowance: long CD is capped at one year's coupon" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CD5YR",
.security_type = .cd,
.shares = 100000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.0,
.rate = 4.0,
.maturity_date = Date.fromYmd(2031, 6, 28),
.account = "Sample IRA",
};
// ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000.
try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01);
}
test "cdLotAllowance: rate-less CD falls back to a small percent of face" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CDNORATE",
.security_type = .cd,
.shares = 10000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.0,
.maturity_date = Date.fromYmd(2026, 12, 31),
.account = "Sample IRA",
};
// No rate -> 0.5% of $10,000 = $50.
try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01);
}
test "cdLotAllowance: matured CD and non-CD lots yield zero" {
const as_of = Date.fromYmd(2026, 6, 28);
// Maturity already passed -> years clamp to 0 -> no band.
const matured = portfolio_mod.Lot{
.symbol = "CDOLD",
.security_type = .cd,
.shares = 50000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.rate = 5.0,
.maturity_date = Date.fromYmd(2026, 1, 1),
.account = "Sample IRA",
};
try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01);
const stock = portfolio_mod.Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150,
.account = "Sample Brokerage",
};
try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of));
}
test "accountValueExpectation: CDs sum into a band, an open option flips the flag" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 28);
var lots = [_]portfolio_mod.Lot{
// CD in Sample IRA, >1yr out so it caps at one year's coupon: 4% of 50000 = 2000.
.{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" },
// Open option in Sample IRA -> has_options.
.{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" },
// Plain stock in a different account.
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" },
// Matured CD in Sample IRA must be excluded (not open as-of).
.{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
const ira = accountValueExpectation(portfolio, as_of, "Sample IRA");
try std.testing.expect(ira.has_options);
try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01);
const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage");
try std.testing.expect(!brk.has_options);
try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance);
}
test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 28);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Broker marks the CD $40 under face (~2mo out -> band is hundreds of $).
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false },
};
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
const cmp = results[0].comparisons[0];
try std.testing.expect(cmp.is_cd);
try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within
try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01);
}
// displayResults rendering
test "displayResults: renders every row classification and the totals block" {
@ -1444,6 +1658,40 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
}
test "displayResults: CD value delta within band is muted+uncounted, beyond band warns+counts" {
// Within band: is_cd, |value_delta| <= cd_allowance -> shown but
// muted, and not counted in "to investigate".
{
const cmps = [_]SymbolComparison{
.{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.9996, .portfolio_value = 100000, .brokerage_value = 99960, .shares_delta = 0, .value_delta = -40, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 100000, .brokerage_total = 99960, .total_delta = -40, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
}
// Beyond band: the same CD marked $5,000 under face -> warn + count.
{
const cmps = [_]SymbolComparison{
.{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.95, .portfolio_value = 100000, .brokerage_value = 95000, .shares_delta = 0, .value_delta = -5000, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 100000, .brokerage_total = 95000, .total_delta = -5000, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
}
}
// displayRatioSuggestions
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {

View file

@ -36,6 +36,11 @@ pub const SchwabAccountComparison = struct {
portfolio_total: f64,
schwab_total: ?f64,
total_delta: ?f64,
/// Account holds open options: zfin values them at cost, the broker
/// at market, so a totals-level value delta is expected (mute it).
has_options: bool = false,
/// Bounded band within which a value delta is an expected CD mark.
cd_allowance: f64 = 0,
has_discrepancy: bool,
};
@ -98,10 +103,12 @@ pub fn compareSchwabSummary(
var pf_cash: f64 = 0;
var pf_total: f64 = 0;
var expectation: common.AccountValueExpectation = .{};
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
expectation = common.accountValueExpectation(portfolio, as_of, pa);
}
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
@ -120,6 +127,12 @@ pub fn compareSchwabSummary(
.portfolio_total = pf_total,
.schwab_total = sa.total_value,
.total_delta = total_delta,
.has_options = expectation.has_options,
.cd_allowance = expectation.cd_allowance,
// A value delta is a discrepancy regardless of cause: an
// audit must never hide it. Muting (below) is a display
// hint, not a suppression - so visibility stays keyed on the
// raw tolerance, same as before.
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
});
}
@ -157,9 +170,15 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
// A value delta is "exact" (no note) under $1, "expected" (muted)
// when the account holds options or the delta fits the CD band,
// and a real "warn" otherwise.
const band = @max(common.value_tolerance, r.cd_allowance);
const value_exact = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
const value_expected = r.has_options or (if (r.total_delta) |d| @abs(d) <= band else true);
const value_warn = !value_exact and !value_expected;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
const is_real_mismatch = !cash_ok or is_unmapped or value_warn;
if (is_real_mismatch) discrepancy_count += 1;
@ -183,9 +202,9 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total - colored if not just stale prices
// PF Total - colored only on a real (beyond-allowance) value mismatch
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
if (value_warn) {
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
} else {
@ -202,7 +221,13 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!total_ok) {
} else if (value_warn) {
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!value_exact) {
// Expected difference (CD mark or options present): shown so
// nothing is hidden, but muted - you probably don't care.
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
@ -709,6 +734,34 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
try std.testing.expectEqualStrings("", results[1].account_name);
}
test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 28);
var lots = [_]portfolio_mod.Lot{
// CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000.
.{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" },
// Open option -> has_options.
.{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var summary = [_]AccountSummary{
.{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 },
};
const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expect(results[0].has_options);
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01);
}
// displaySchwabResults rendering
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
@ -717,8 +770,9 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
// value-only mismatch (cash ok) -> muted "Value +$100.00", not a real mismatch
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
// value delta within the CD band (cash ok) -> muted "Value
// +$100.00", shown but not counted as a real mismatch
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .cd_allowance = 200, .has_discrepancy = false },
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
@ -758,6 +812,51 @@ test "displaySchwabResults: color=true emits ANSI and singular label" {
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
}
test "displaySchwabResults: value delta with no CD or options warns and counts" {
// The Kelly-IRA shape: cash matches to the penny, but the total is
// off (a missing lot). No CD, no options -> a real, counted warning.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
var seqbuf: [32]u8 = undefined;
const warn_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] });
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // shown
try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) != null); // warning-colored
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); // counted
}
test "displaySchwabResults: CD-band and option value deltas are muted, shown, and uncounted" {
// muted != hidden: both rows show their "Value" delta, but neither
// is warning-colored and neither counts as a mismatch.
const results = [_]SchwabAccountComparison{
// CD account: $8.70 mark sits within a $154 band -> expected/muted.
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true },
// Option account: cost-vs-market gap is unbounded -> muted, drill down.
.{ .account_name = "Sample Roth", .schwab_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
var warnbuf: [32]u8 = undefined;
const warn_seq = try std.fmt.bufPrint(&warnbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] });
var mutebuf: [32]u8 = undefined;
const mute_seq = try std.fmt.bufPrint(&mutebuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2] });
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // CD mark shown
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // option gap shown
try std.testing.expect(std.mem.indexOf(u8, out, mute_seq) != null); // muted styling present
try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) == null); // never escalated to warning
try std.testing.expect(std.mem.indexOf(u8, out, "mismatch") == null); // nothing to investigate
}
// displaySchwabSummaryRatioSuggestions
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {

View file

@ -5,6 +5,8 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
/// Main holdings-table column layout (widths + format strings).
const pl = views.PositionsLayout;
/// Visibility of expired options / matured CDs within the Options
/// and CDs sections.
@ -308,12 +310,8 @@ pub fn display(
// Column headers
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
});
try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
"", "", "", "", "", "", "", "", "",
});
try out.print(pl.header, pl.header_labels);
try out.print(pl.separator, pl.separator_fills);
try cli.reset(out, color);
// Position rows with lot detail
@ -345,19 +343,19 @@ pub fn display(
}
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} ", .{
a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10),
try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} ", .{
a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(pl.price_w),
});
try out.print("{f}", .{Money.from(a.current_price).padRight(10)});
try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)});
try out.print("{f}", .{Money.from(a.current_price).padRight(pl.price_w)});
try out.print(" {f} ", .{Money.from(a.market_value).padRight(pl.value_w)});
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) });
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(pl.gainloss_w - 1) });
if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING);
} else {
try cli.reset(out, color);
}
try out.print(" {d:>7.1}%", .{a.weight * 100.0});
try out.print(" " ++ pl.weight_num, .{a.weight * 100.0});
if (date_col_len > 0) {
try out.print(" {s}", .{date_col[0..date_col_len]});
}
@ -411,19 +409,17 @@ pub fn display(
}
// Totals line
try cli.printFg(out, color, cli.CLR_MUTED, " {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try cli.printFg(out, color, cli.CLR_MUTED, pl.total_sep, pl.total_sep_fills);
{
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{
"", "", "", "TOTAL", Money.from(summary.total_value).padRight(16),
try out.print(pl.total, .{
"", "", "", "TOTAL", Money.from(summary.total_value).padRight(pl.value_w),
});
try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
Money.from(gl_abs).padRight(13),
Money.from(gl_abs).padRight(pl.gainloss_w - 1),
});
try out.print(" {s:>7}\n", .{"100.0%"});
try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"});
}
if (summary.realized_gain_loss != 0) {
@ -593,7 +589,7 @@ pub fn display(
std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?"
else
"--";
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.price_str ++ "\n", .{ sym, ps });
}
}
@ -616,11 +612,11 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{
status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16),
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} " ++ pl.price_str ++ " {f} ", .{
status_str, lot.shares, Money.from(lot.open_price).padRight(pl.price_w), "", Money.from(lot.shares * use_price).padRight(pl.value_w),
});
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) });
try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col });
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(pl.gainloss_w - 1) });
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.weight_str ++ " {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col });
}
// Tests
@ -718,6 +714,98 @@ test "display shows header and summary" {
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display widens columns for large crypto values" {
// Regression guard for the portfolio-table column widening. A long
// crypto symbol (DOGE-USD, 8 chars), a large share count, and a high
// price must render in full without overflowing or shifting the
// symbol / shares / price / value columns.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "DOGE-USD", .shares = 10000, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 41500.0, .account = "Sample Account" },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .total_cost = 415000000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "DOGE-USD", .display_symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .current_price = 42000.0, .market_value = 420000000.0, .cost_basis = 415000000.0, .weight = 1.0, .unrealized_gain_loss = 5000000.0, .unrealized_return = 0.01205 },
};
const summary = testSummary(&allocs);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "crypto.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Full 8-char symbol present (not truncated).
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null);
// Symbol column is wide enough that DOGE-USD does not butt up against
// the next column: it is followed by >= 2 spaces (in-column padding
// plus the separator). At the old 7-wide column there would be only
// the single separator space, so this catches a narrow-column regression.
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD ") != null);
// Large share count, high price, and large market value render in full.
try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null);
try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "$420,000,000.00") != null);
// Gain/Loss of $5M fits its column without truncation.
try testing.expect(std.mem.indexOf(u8, out, "$5,000,000.00") != null);
// No ANSI codes when color is disabled.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
// The single position's market value equals the portfolio total, so
// it must appear at the same column in the data row and the TOTAL
// row. This guards the TOTAL line against drifting out of alignment
// when the data columns are widened.
var data_col: ?usize = null;
var total_col: ?usize = null;
var lit = std.mem.splitScalar(u8, out, '\n');
while (lit.next()) |line| {
const at = std.mem.indexOf(u8, line, "$420,000,000.00") orelse continue;
if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
data_col = at;
} else if (std.mem.indexOf(u8, line, "TOTAL") != null) {
total_col = at;
}
}
try testing.expect(data_col != null);
try testing.expect(total_col != null);
try testing.expectEqual(data_col.?, total_col.?);
// The Date column is left-justified text (like the symbol column), so
// the "Date" header sits over the START of the date values, and the
// Account column lines up too. Verify the header label and the data
// value share a column.
var hdr_date: ?usize = null;
var row_date: ?usize = null;
var hdr_acct: ?usize = null;
var row_acct: ?usize = null;
var dit = std.mem.splitScalar(u8, out, '\n');
while (dit.next()) |line| {
if (std.mem.indexOf(u8, line, "Symbol") != null) {
hdr_date = std.mem.indexOf(u8, line, "Date");
hdr_acct = std.mem.indexOf(u8, line, "Account");
} else if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
row_date = std.mem.indexOf(u8, line, "2024-01-02");
row_acct = std.mem.indexOf(u8, line, "Sample Account");
}
}
try testing.expect(hdr_date != null and row_date != null);
try testing.expectEqual(hdr_date.?, row_date.?);
try testing.expect(hdr_acct != null and row_acct != null);
try testing.expectEqual(hdr_acct.?, row_acct.?);
}
test "display with watchlist" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -966,7 +966,7 @@ pub fn runBands(
try out.print("\n", .{});
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.terminal_col_width)});
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
@ -980,7 +980,7 @@ pub fn runBands(
try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});
// Header row
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)});
// Withdrawal rows. When an accumulation phase is active the
// per-row % rate is suppressed (it would divide today's-dollars
@ -1818,9 +1818,9 @@ fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator,
{
var hdr: std.ArrayListUnmanaged(u8) = .empty;
try hdr.appendNTimes(va, ' ', label_width);
for (horizons) |h| {
for (horizons, 0..) |h, hi| {
var hbuf: [16]u8 = undefined;
const hlabel = view.fmtHorizonLabel(&hbuf, h);
const hlabel = view.fmtHorizonLabelAge(&hbuf, h, ctx.config.horizon_death_age[hi]);
try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len);
try hdr.appendSlice(va, hlabel);
}
@ -2508,3 +2508,91 @@ test "renderCompareRowPct: no ANSI when color=false" {
try renderCompareRowPct(&w, false, "X", 0.1, 0.2);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
// Render-block tests (synthetic context, in-memory writer)
/// Build a ProjectionContext from a config for render tests, using a
/// synthetic benchmark comparison (no network / DataService). Mirrors
/// the setup the view-model integration tests use.
fn buildCtxForTest(arena: std.mem.Allocator, config: projections.UserConfig, as_of: Date) !view.ProjectionContext {
const benchmark = @import("../analytics/benchmark.zig");
const comparison: benchmark.BenchmarkComparison = .{
.stock_returns = .{},
.bond_returns = .{},
.benchmark_returns = .{},
.portfolio_returns = .{},
.conservative_return = 0.07,
.stock_pct = 0.8,
.bond_pct = 0.2,
};
return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of);
}
test "renderEarliestBlock: age-anchored column renders 'to age N' header" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var config = projections.parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:95
\\type::config,target_spending:num:120000
\\type::config,survivor_spending_pct:num:75
\\type::birthdate,date::1965-03-01
\\type::birthdate,date::1968-08-15,person:num:2
);
const as_of = Date.fromYmd(2026, 6, 15);
try config.resolveHorizonAges(as_of);
const ctx = try buildCtxForTest(a, config, as_of);
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderEarliestBlock(&w, false, a, ctx, as_of);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Earliest retirement") != null);
try testing.expect(std.mem.indexOf(u8, out, "to age 95") != null);
try testing.expect(std.mem.indexOf(u8, out, "% confidence") != null);
// No ANSI when color is off.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "renderEarliestBlock: numeric horizon renders 'N Year' header" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const config = projections.parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon:num:30
\\type::config,target_spending:num:40000
);
const as_of = Date.fromYmd(2026, 6, 15);
const ctx = try buildCtxForTest(a, config, as_of);
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderEarliestBlock(&w, false, a, ctx, as_of);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "30 Year") != null);
try testing.expect(std.mem.indexOf(u8, out, "to age") == null);
}
test "renderAccumulationBlock: target-date config prints accumulation stats" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var config = projections.UserConfig{};
config.retirement_at = Date.fromYmd(2040, 7, 1);
config.annual_contribution = 60_000;
const as_of = Date.fromYmd(2026, 7, 1);
const ctx = try buildCtxForTest(a, config, as_of);
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderAccumulationBlock(&w, false, a, ctx);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Accumulation phase:") != null);
try testing.expect(std.mem.indexOf(u8, out, "Years until possible retirement") != null);
try testing.expect(std.mem.indexOf(u8, out, "Median portfolio at retirement") != null);
}

View file

@ -26,6 +26,11 @@ pub const ParsedArgs = struct {
/// "2025-01-15"), kept only to label the "Change (<span>)" detail
/// row. Null when `--since` was omitted.
since_raw: ?[]const u8 = null,
/// When set (`--live`), stream live prices for the symbol
/// continuously (Yahoo websocket) instead of printing a one-shot
/// quote. Runs until interrupted (Ctrl-C) or killed (e.g.
/// `timeout`). Ignores the chart / --since / --export-chart options.
live: bool = false,
};
pub const meta: framework.Meta = .{
@ -34,7 +39,7 @@ pub const meta: framework.Meta = .{
.synopsis = "Show latest quote with chart and 20-day history",
.uppercase_first_arg = true,
.help =
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>] [--live]
\\
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
\\plus a price chart over a recent window (an inline Kitty image
@ -58,12 +63,21 @@ pub const meta: framework.Meta = .{
\\ (1920x1080) and exit. No text output
\\ is emitted. Uses the TUI's default
\\ theme.
\\ --live Stream live prices for SYMBOL continuously
\\ (free Yahoo websocket) instead of a
\\ one-shot quote. Prints the latest price
\\ once per second until interrupted (Ctrl-C)
\\ or killed. Ignores the chart options above.
\\ Outside market hours, US equities stream a
\\ closing snapshot then go quiet; 24/7
\\ symbols (e.g. BTC-USD) keep ticking.
\\
\\Examples:
\\ zfin quote AAPL
\\ zfin quote spy # symbols are case-insensitive
\\ zfin quote AAPL --since 1Y
\\ zfin quote AAPL --export-chart aapl.png
\\ timeout 12 zfin quote --live BTC-USD # watch live ticks for 12s
\\
,
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate },
@ -85,6 +99,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
var export_chart: ?[]const u8 = null;
var since: ?zfin.Date = null;
var since_raw: ?[]const u8 = null;
var live: bool = false;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
@ -95,6 +110,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
const value = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
since = cli.parseRequiredDateOrStderr(ctx.io, value, ctx.today, "--since") catch return error.InvalidDate;
since_raw = value;
} else if (std.mem.eql(u8, a, "--live")) {
live = true;
} else if (a.len > 0 and a[0] == '-') {
// Reject ANY leading-dash token we don't recognize,
// including single-dash ones like `-x`. Previously only
@ -117,11 +134,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol;
}
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw };
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw, .live = live };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
// `--live` streams continuously and shares none of the one-shot
// quote's candle/chart/name machinery, so branch before any of it.
if (parsed.live) return runLive(ctx, svc, parsed.symbol);
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
// Fetch candle data for chart and history
const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) {
@ -233,6 +253,51 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, window_label, chart_render);
}
/// `--live`: open the live-price websocket stream for `symbol` and
/// print the latest price once per second until the process is
/// interrupted (Ctrl-C) or killed (e.g. `timeout 12 zfin quote --live
/// SPY`). Each line is flushed immediately so output appears in real
/// time and a SIGTERM mid-loop doesn't swallow buffered lines.
///
/// This is the streaming counterpart to the one-shot quote above and
/// the simplest end-to-end exercise of the Yahoo stream transport.
/// Note: outside market/extended hours, US equities stream their
/// closing snapshot once and then go quiet; 24/7 symbols (e.g.
/// `BTC-USD`) keep ticking.
fn runLive(ctx: *framework.RunCtx, svc: *zfin.DataService, symbol: []const u8) !void {
const syms = [_][]const u8{symbol};
svc.startLiveStream(&syms) catch |err| {
cli.stderrPrint(ctx.io, "Error: failed to start live stream for ");
cli.stderrPrint(ctx.io, symbol);
cli.stderrPrint(ctx.io, ": ");
cli.stderrPrint(ctx.io, @errorName(err));
cli.stderrPrint(ctx.io, "\n");
return;
};
defer svc.stopLiveStream();
try ctx.out.print("Streaming live quotes for {s} - Ctrl-C (or timeout) to stop.\n", .{symbol});
try ctx.out.flush();
var prices = std.StringHashMap(f64).init(ctx.allocator);
defer prices.deinit();
var elapsed: usize = 0;
while (true) {
prices.clearRetainingCapacity();
svc.liveStreamSnapshot(&syms, &prices);
if (prices.get(symbol)) |p| {
try ctx.out.print(" [{d:>4}s] {s} {f}\n", .{ elapsed, symbol, Money.from(p) });
} else {
try ctx.out.print(" [{d:>4}s] {s} (waiting for first tick...)\n", .{ elapsed, symbol });
}
try ctx.out.flush();
// Cancelable; on cancel, break so `defer stopLiveStream` runs.
ctx.io.sleep(.fromSeconds(1), .real) catch break;
elapsed += 1;
}
}
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
/// written slice. Fund/security names fit easily in 256 bytes.
fn clampName(buf: []u8, s: []const u8) []const u8 {
@ -505,6 +570,23 @@ test "parseArgs: --since with an invalid value is rejected" {
try std.testing.expectError(error.InvalidDate, parseArgs(&ctx, &args));
}
test "parseArgs: --live sets the live flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--live" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("SPY", parsed.symbol);
try std.testing.expect(parsed.live);
}
test "parseArgs: live defaults to false when --live is omitted" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"SPY"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(!parsed.live);
}
test "display with candles only" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -16,11 +16,6 @@ const PerformanceResult = @import("analytics/performance.zig").PerformanceResult
// Layout constants
/// Width of the symbol column in portfolio view (CLI + TUI).
pub const sym_col_width = 7;
/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}".
pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});
/// Width of the account name column in cash section (CLI + TUI).
pub const cash_acct_width = 30;
@ -592,6 +587,20 @@ pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
return buf[0..len];
}
/// Case-insensitive lookup: return the first element of `haystack` equal
/// to `needle` ignoring ASCII case, or null. Returns the HAYSTACK's own
/// slice (not `needle`), so callers can use the result as a stable key
/// when `needle` is borrowed/transient - e.g. mapping a provider's
/// echoed-back ticker to the caller's symbol slice, or verifying a
/// returned ticker is one that was requested. O(n) linear scan, intended
/// for small lists (symbol sets: tens to hundreds).
pub fn findIgnoreCase(haystack: []const []const u8, needle: []const u8) ?[]const u8 {
for (haystack) |item| {
if (std.ascii.eqlIgnoreCase(item, needle)) return item;
}
return null;
}
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
var last_buf: [12]u8 = undefined;
@ -927,6 +936,18 @@ test "fmtIntCommas" {
try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567));
}
test "findIgnoreCase: returns the haystack slice, case-insensitive" {
const haystack = [_][]const u8{ "aapl", "Spy", "VTI" };
// Returns the haystack's own slice (a stable key), not the needle.
try std.testing.expectEqualStrings("aapl", findIgnoreCase(&haystack, "AAPL").?);
try std.testing.expectEqualStrings("Spy", findIgnoreCase(&haystack, "SPY").?);
try std.testing.expectEqualStrings("VTI", findIgnoreCase(&haystack, "vti").?);
// Not present -> null (membership test = `!= null`).
try std.testing.expect(findIgnoreCase(&haystack, "TSLA") == null);
// Empty haystack -> null.
try std.testing.expect(findIgnoreCase(&.{}, "AAPL") == null);
}
test "fmtLargeNum" {
// Sub-million: formatted as raw number
const small = fmtLargeNum(12345.0);

View file

@ -501,7 +501,7 @@ fn runCli(init: std.process.Init) !u8 {
// loader resolve + union-merge the same way the CLI does.
// This is the load-bearing fix for "CLI and TUI report
// different totals" - there's exactly one code path now.
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, resolved_theme, cmd_args, today) catch |err| switch (err) {
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, resolved_theme, cmd_args, today, globals.refresh_policy) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
// panic / stack trace.

805
src/net/LiveStream.zig Normal file
View file

@ -0,0 +1,805 @@
//! Live-price websocket stream (push-based intraday quotes).
//!
//! A `LiveStream` holds a single persistent websocket on a background
//! thread, parses incoming price ticks, and exposes the latest price
//! per ticker via `snapshotInto`. The TUI/CLI poll the snapshot; they
//! never block on the socket.
//!
//! ## Dual transport
//!
//! The stream is parameterized by a `Transport` enum; both arms share
//! all the scaffolding:
//!
//! - **Yahoo** (`streamer.finance.yahoo.com`): keyless, free, works on
//! every tier. Subscribe with `{"subscribe":[...]}`; each frame is
//! `{"type":"pricing","message":"<base64 protobuf>"}` whose protobuf
//! carries field 1 = ticker (string) and field 2 = price (fixed32
//! float). It is an UNOFFICIAL feed (the browser uses it) and can
//! break without notice - hence the official Tiingo backup.
//! - **Tiingo** (`api.tiingo.com/iex`): official IEX feed at
//! `thresholdLevel: 6` (derived `tngoLast` reference price; needs no
//! IEX license agreement - unlike raw levels 0/5 - so it works on ANY
//! Tiingo account, free tier included). Subscribe carries the API
//! key; each frame is a JSON `"A"` message whose `data` is
//! `[datetime, ticker, price]`. Each connect spends one request
//! against the hourly quota (a stable stream is then free), so it's
//! the default for Power subscribers (reconnect headroom) but any
//! keyed account can opt in.
//!
//! Which one runs is chosen by `DataService.startLiveStream` from
//! `Config.effectiveLiveQuoteProvider()`, mirroring the REST live-quote
//! provider. The per-transport surface is exactly three things: the
//! connect target + handshake headers (`transportConfig`), the
//! subscribe message (`buildSubscribe`), and the frame parser
//! (`serverMessage`). Everything else - thread, lock, snapshot,
//! reconnect/backoff, client lifecycle - is shared.
//!
//! ## Why a raw background thread (not std.Io tasks)
//!
//! The read loop is a single long-lived loop on its own OS thread that
//! coexists with the app's `Io.Threaded` event loop. The pinned
//! `websocket.zig` client is itself Io-aware (connect, TLS, reads,
//! writes go through `std.Io`, which is safe to call from this thread);
//! we thread `io` in for it.
//!
//! ## Threading and shutdown
//!
//! `prices` is guarded by `mutex` (a `std.atomic.Mutex` spinlock, since
//! the read loop runs outside the Io task model and critical sections
//! are single hashmap ops held for microseconds).
//!
//! Shutdown is COOPERATIVE: the read loop polls with a short read
//! timeout (`read_poll_ms`) and re-checks `should_stop` between reads,
//! so `stop()` just sets `should_stop` and `join`s - the read thread
//! returns on its OWN thread and tears the client down there. We do NOT
//! close the socket from another thread: closing an Io-aware TLS stream
//! while a read is in flight crashes the std TLS reader (the read
//! thread faults dereferencing the half-torn-down client). The only
//! cost is up to one `read_poll_ms` of latency on quit, plus (if a quit
//! lands during the blocking connect/handshake) that operation's own
//! timeout.
const std = @import("std");
const websocket = @import("websocket");
const json_utils = @import("../providers/json_utils.zig");
const jsonStr = json_utils.jsonStr;
const optFloat = json_utils.optFloat;
const log = std.log.scoped(.live_stream);
const LiveStream = @This();
const tls_port: u16 = 443;
const yahoo_host = "streamer.finance.yahoo.com";
const yahoo_path = "/?version=2";
const yahoo_origin = "https://finance.yahoo.com";
const tiingo_host = "api.tiingo.com";
const tiingo_path = "/iex";
/// Tickers are short; 24 bytes covers stocks, class suffixes, index
/// (`^GSPC`), crypto (`BTC-USD`), and futures (`ES=F`) shapes.
const max_ticker_len = 24;
const handshake_timeout_ms = 10_000;
const max_message_size = 512 * 1024;
const read_buffer_size = 32 * 1024;
/// Read-poll timeout (ms). The read loop wakes at least this often to
/// re-check `should_stop`, bounding quit latency while streaming. Short
/// enough to feel instant, long enough to be free when idle. Untyped:
/// coerces to the `readTimeout` argument type at the call site.
const read_poll_ms = 250;
const reconnect_backoff_ms = 2_000;
/// Yahoo pricing protobufs are ~60-90 bytes decoded; 1 KiB is ample.
/// A frame whose decoded size exceeds this is skipped (returns null).
const max_decoded_message = 1024;
/// Which live-quote feed the stream connects to.
///
/// - `.yahoo`: keyless, free, all tiers; base64+protobuf "pricing"
/// frames. Unofficial - can break without notice.
/// - `.tiingo`: official IEX feed via `wss://api.tiingo.com/iex` at
/// `thresholdLevel: 6` (the derived `tngoLast` reference price, which
/// - unlike raw levels 0/5 - needs no IEX license agreement, so it
/// works on ANY Tiingo account incl. the free tier). Requires an API
/// key in the subscribe message; each connect spends one request
/// against the hourly quota, so it's the default for Power
/// subscribers but any keyed account can opt in.
pub const Transport = enum { yahoo, tiingo };
/// A single parsed price update. The ticker is stored inline so the
/// value owns no heap memory.
pub const StreamTick = struct {
ticker_buf: [max_ticker_len]u8 = @splat(0),
ticker_len: usize = 0,
price: f64 = 0,
pub fn ticker(self: *const StreamTick) []const u8 {
return self.ticker_buf[0..self.ticker_len];
}
};
const TransportConfig = struct {
host: []const u8,
port: u16,
path: []const u8,
/// Raw handshake header block (CRLF-separated, no trailing CRLF).
/// The library does not auto-add Host, so it is included here.
headers: []const u8,
};
// Fields
allocator: std.mem.Allocator,
/// Threaded into the websocket client (the pinned `websocket.zig` is
/// Io-aware: connect, TLS, reads, and writes all go through it).
/// `std.Io` is safe to use from the read thread.
io: std.Io,
transport: Transport,
/// Owned copy of the API token, or null for keyless transports
/// (Yahoo). Decoupled from Config's lifetime.
api_key: ?[]u8 = null,
/// Owned, duped tickers to subscribe. Set in `start`, freed in `stop`.
tickers: [][]u8 = &.{},
/// Guards `prices`. Spinlock (not an Io mutex) because the read loop is
/// a raw OS thread outside the Io task model; critical sections are one
/// hashmap op or a short pointer read.
mutex: std.atomic.Mutex = .unlocked,
/// Latest price per ticker, keyed by UPPERCASED ticker (owned dup).
/// Written by the read thread, read via `snapshotInto`.
prices: std.StringHashMap(f64),
thread: ?std.Thread = null,
should_stop: std.atomic.Value(bool) = .init(false),
// Lifecycle
/// Allocate and initialize a stream. The returned pointer is stable
/// (the background thread captures it), so callers must keep it
/// heap-allocated and call `destroy` (not move it). `api_key` is copied
/// when non-null; pass null for keyless transports.
pub fn create(allocator: std.mem.Allocator, io: std.Io, transport: Transport, api_key: ?[]const u8) !*LiveStream {
const self = try allocator.create(LiveStream);
errdefer allocator.destroy(self);
const key_dup: ?[]u8 = if (api_key) |k| try allocator.dupe(u8, k) else null;
self.* = .{
.allocator = allocator,
.io = io,
.transport = transport,
.api_key = key_dup,
.prices = std.StringHashMap(f64).init(allocator),
};
return self;
}
/// Stop the stream (if running) and free everything.
pub fn destroy(self: *LiveStream) void {
self.stop();
var it = self.prices.keyIterator();
while (it.next()) |k| self.allocator.free(k.*);
self.prices.deinit();
if (self.api_key) |k| self.allocator.free(k);
self.allocator.destroy(self);
}
/// Start streaming `symbols`. Idempotent: a no-op if already running
/// (the symbol set is fixed for the stream's lifetime; change it with
/// `stop` + `start`). `symbols` is copied.
pub fn start(self: *LiveStream, symbols: []const []const u8) !void {
if (self.thread != null) return;
const tk = try self.allocator.alloc([]u8, symbols.len);
errdefer self.allocator.free(tk);
var n: usize = 0;
errdefer for (tk[0..n]) |t| self.allocator.free(t);
for (symbols) |s| {
tk[n] = try self.allocator.dupe(u8, s);
n += 1;
}
self.tickers = tk;
self.should_stop.store(false, .release);
self.thread = try std.Thread.spawn(.{}, threadMain, .{self});
}
/// Signal the read thread to stop and join it, then free the
/// subscribed-ticker list. Cooperative: the read loop notices
/// `should_stop` within one `read_poll_ms` and returns on its own
/// thread (we never touch the socket/client from here - see the
/// shutdown note at the top). Safe to call when not running.
pub fn stop(self: *LiveStream) void {
self.should_stop.store(true, .release);
if (self.thread) |t| {
t.join();
self.thread = null;
}
for (self.tickers) |t| self.allocator.free(t);
self.allocator.free(self.tickers);
self.tickers = &.{};
}
pub fn isRunning(self: *LiveStream) bool {
return self.thread != null;
}
/// Spin-acquire `mutex`. Critical sections are O(1) hashmap ops (or an
/// O(symbols) snapshot scan), so the spin is effectively uncontended.
fn lock(self: *LiveStream) void {
while (!self.mutex.tryLock()) std.atomic.spinLoopHint();
}
fn unlock(self: *LiveStream) void {
self.mutex.unlock();
}
/// Copy the latest streamed prices for `symbols` into `out`, keyed by
/// the caller's original slices (so `out`'s keys borrow `symbols`).
/// Matching is case-insensitive (we store UPPERCASED tickers). Symbols
/// with no streamed price yet are left absent (caller falls back to the
/// cached close).
pub fn snapshotInto(self: *LiveStream, symbols: []const []const u8, out: *std.StringHashMap(f64)) void {
self.lock();
defer self.unlock();
var upper: [max_ticker_len]u8 = undefined;
for (symbols) |s| {
if (s.len > upper.len) continue;
const key = std.ascii.upperString(upper[0..], s);
if (self.prices.get(key)) |p| {
out.put(s, p) catch |err| log.warn("snapshot put({s}): {t}", .{ s, err });
}
}
}
/// Parse one frame and update `prices`. Never fails: a bad/unparseable
/// frame is simply ignored (returns without touching `prices`) so the
/// connection stays up. Returns `void` precisely because there's no
/// error worth propagating to the read loop.
pub fn serverMessage(self: *LiveStream, data: []const u8) void {
const tick = switch (self.transport) {
.yahoo => parseYahooMessage(self.allocator, data),
.tiingo => parseTiingoMessage(self.allocator, data),
} orelse return;
var upper: [max_ticker_len]u8 = undefined;
if (tick.ticker().len > upper.len) return;
const key = std.ascii.upperString(upper[0..], tick.ticker());
self.lock();
defer self.unlock();
const gop = self.prices.getOrPut(key) catch return;
if (!gop.found_existing) {
gop.key_ptr.* = self.allocator.dupe(u8, key) catch {
// Roll back the entry whose key is still the stack slice.
_ = self.prices.remove(key);
return;
};
}
gop.value_ptr.* = tick.price;
}
// Read-thread internals
fn threadMain(self: *LiveStream) void {
while (!self.should_stop.load(.acquire)) {
self.connectAndRun() catch |err| log.debug("stream connection ended: {t}", .{err});
if (self.should_stop.load(.acquire)) break;
self.backoffSleep(reconnect_backoff_ms);
}
}
fn transportConfig(self: *LiveStream) TransportConfig {
return switch (self.transport) {
.yahoo => .{
.host = yahoo_host,
.port = tls_port,
.path = yahoo_path,
.headers = "Host: " ++ yahoo_host ++ "\r\nOrigin: " ++ yahoo_origin,
},
.tiingo => .{
.host = tiingo_host,
.port = tls_port,
.path = tiingo_path,
.headers = "Host: " ++ tiingo_host,
},
};
}
fn connectAndRun(self: *LiveStream) !void {
const cfg = self.transportConfig();
const cptr = try self.allocator.create(websocket.Client);
cptr.* = websocket.Client.init(self.io, self.allocator, .{
.host = cfg.host,
.port = cfg.port,
.tls = true,
.max_size = max_message_size,
.buffer_size = read_buffer_size,
}) catch |err| {
self.allocator.destroy(cptr);
return err;
};
// The client is created, used, and torn down entirely on THIS (read)
// thread - no other thread ever touches it. That's what makes the
// cooperative shutdown safe (see the shutdown note at the top): on
// `should_stop`, `readUntilStopped` returns and this defer closes +
// frees the client here, not from the quitting thread.
defer {
cptr.deinit();
self.allocator.destroy(cptr);
}
try cptr.handshake(cfg.path, .{
.timeout_ms = handshake_timeout_ms,
.headers = cfg.headers,
});
try self.sendSubscribe(cptr);
try self.readUntilStopped(cptr);
}
/// Cooperative read loop. Polls with a `read_poll_ms` timeout so a
/// no-data wait returns `null` (WouldBlock) and we re-check
/// `should_stop` between reads - letting the thread exit on its own
/// when asked to stop. Replaces websocket.zig's `readLoop`, which
/// blocks indefinitely (read timeout 0) and trips `unreachable` on a
/// timeout, so it can only be unblocked by a cross-thread close - the
/// exact thing that crashes the Io-aware TLS reader. We replicate its
/// frame handling: text/binary -> parser; ping -> pong; close -> done.
fn readUntilStopped(self: *LiveStream, c: *websocket.Client) !void {
try c.readTimeout(read_poll_ms);
while (!self.should_stop.load(.acquire)) {
const message = c.read() catch |err| switch (err) {
error.Closed => return,
else => return err,
} orelse continue; // timeout / no data yet -> re-check should_stop
defer c.done(message);
switch (message.type) {
.text, .binary => self.serverMessage(message.data),
// @constCast is safe: data points into our own read buffer.
.ping => c.writeFrame(.pong, @constCast(message.data)) catch |err| log.debug("pong: {t}", .{err}),
.close => return,
.pong => {},
}
}
}
fn sendSubscribe(self: *LiveStream, c: *websocket.Client) !void {
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(self.allocator);
try buildSubscribe(self.transport, self.allocator, self.api_key, self.tickers, &buf);
// write() masks the buffer in place, so it must be a mutable buffer
// we own; we discard it right after.
try c.write(buf.items);
}
/// Build the transport's subscribe message into `buf`. Pure (no I/O),
/// so it's unit-testable without a live connection. `api_key` is used
/// only by the Tiingo transport (its subscribe carries authorization);
/// Yahoo ignores it.
fn buildSubscribe(transport: Transport, allocator: std.mem.Allocator, api_key: ?[]const u8, tickers: []const []const u8, buf: *std.ArrayList(u8)) !void {
switch (transport) {
.yahoo => {
// {"subscribe":["SPY","AAPL"]}
// Tickers are alphanumeric plus '.'/'-'/'^'/'='/'=X', so no
// JSON string escaping is required.
try buf.appendSlice(allocator, "{\"subscribe\":[");
for (tickers, 0..) |t, i| {
if (i != 0) try buf.append(allocator, ',');
try buf.append(allocator, '"');
try buf.appendSlice(allocator, t);
try buf.append(allocator, '"');
}
try buf.appendSlice(allocator, "]}");
},
.tiingo => {
// {"eventName":"subscribe","authorization":"<key>",
// "eventData":{"thresholdLevel":6,"tickers":["spy",...]}}
// The key and tickers are alphanumeric (plus '.'/'-'), so no
// JSON string escaping is required.
try buf.appendSlice(allocator, "{\"eventName\":\"subscribe\",\"authorization\":\"");
try buf.appendSlice(allocator, api_key orelse "");
try buf.appendSlice(allocator, "\",\"eventData\":{\"thresholdLevel\":6,\"tickers\":[");
for (tickers, 0..) |t, i| {
if (i != 0) try buf.append(allocator, ',');
try buf.append(allocator, '"');
try buf.appendSlice(allocator, t);
try buf.append(allocator, '"');
}
try buf.appendSlice(allocator, "]}}");
},
}
}
/// Sleep `ms` in small chunks, bailing early if asked to stop, so a
/// quit during reconnect backoff doesn't wait the full interval. Uses
/// the `.awake` (monotonic) clock so a wall-clock jump can't skip or
/// extend the backoff.
fn backoffSleep(self: *LiveStream, ms: u64) void {
var remaining = ms;
while (remaining > 0 and !self.should_stop.load(.acquire)) {
const chunk = @min(remaining, 200);
self.io.sleep(.fromMilliseconds(@intCast(chunk)), .awake) catch return;
remaining -= chunk;
}
}
// Yahoo frame parsing
/// Parse one Yahoo streamer frame. Returns a tick only for
/// `{"type":"pricing","message":"<base64 protobuf>"}` frames whose
/// protobuf carries field 1 (ticker) and field 2 (price). Any other
/// type, or a malformed/oversized frame, yields null. Pure and
/// allocator-scoped (parse memory freed before return), so fully
/// fixture-testable.
fn parseYahooMessage(allocator: std.mem.Allocator, body: []const u8) ?StreamTick {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return null;
defer parsed.deinit();
const obj = switch (parsed.value) {
.object => |o| o,
else => return null,
};
// Only "pricing" frames carry a price payload.
const typ = jsonStr(obj.get("type")) orelse return null;
if (!std.mem.eql(u8, typ, "pricing")) return null;
const b64 = jsonStr(obj.get("message")) orelse return null;
// Base64-decode the protobuf into a stack buffer. Standard alphabet
// with padding (Yahoo emits '+', '/', '=').
const dec = std.base64.standard.Decoder;
const decoded_len = dec.calcSizeForSlice(b64) catch return null;
if (decoded_len > max_decoded_message) return null;
var buf: [max_decoded_message]u8 = undefined;
dec.decode(buf[0..decoded_len], b64) catch return null;
return parsePricingProto(buf[0..decoded_len]);
}
/// Walk a Yahoo PricingData protobuf for field 1 (ticker, length-
/// delimited string) and field 2 (price, fixed32 little-endian float),
/// skipping every other field. Returns null if either is absent or the
/// buffer is malformed.
fn parsePricingProto(pb: []const u8) ?StreamTick {
var i: usize = 0;
var ticker: ?[]const u8 = null;
var price: ?f64 = null;
while (i < pb.len) {
const tag = readVarint(pb, &i) orelse break;
const field = tag >> 3;
const wire: u3 = @intCast(tag & 0x7);
switch (wire) {
0 => _ = readVarint(pb, &i) orelse break, // varint
1 => { // fixed64
if (i + 8 > pb.len) break;
i += 8;
},
2 => { // length-delimited
const len = readVarint(pb, &i) orelse break;
if (i + len > pb.len) break;
const bytes = pb[i .. i + len];
i += len;
if (field == 1) ticker = bytes;
},
5 => { // fixed32
if (i + 4 > pb.len) break;
const raw = std.mem.readInt(u32, pb[i..][0..4], .little);
i += 4;
if (field == 2) price = @as(f64, @as(f32, @bitCast(raw)));
},
else => break, // groups (3/4) are deprecated/unsupported
}
if (ticker != null and price != null) break;
}
const t = ticker orelse return null;
const p = price orelse return null;
var tick: StreamTick = .{ .price = p };
const n = @min(t.len, tick.ticker_buf.len);
@memcpy(tick.ticker_buf[0..n], t[0..n]);
tick.ticker_len = n;
return tick;
}
/// Decode a base-128 protobuf varint starting at `i.*`, advancing `i`
/// past it. Returns null on truncation or an over-long (> 64-bit)
/// encoding.
fn readVarint(buf: []const u8, i: *usize) ?u64 {
var result: u64 = 0;
var shift: usize = 0;
while (i.* < buf.len) {
const byte = buf[i.*];
i.* += 1;
if (shift >= 64) return null; // malformed: more than 10 bytes
result |= @as(u64, byte & 0x7f) << @intCast(shift);
if (byte & 0x80 == 0) return result;
shift += 7;
}
return null; // truncated
}
// Tiingo frame parsing
/// Parse one Tiingo IEX text frame. Returns a tick only for
/// `thresholdLevel: 6` "A" (new-data) reference-price messages, whose
/// `data` array is `[datetime, ticker, referencePrice]`. Heartbeats
/// ("H"), subscription acks ("I"), and any malformed/unknown frame
/// yield null. Pure and allocator-scoped (parse memory freed before
/// return), so fully fixture-testable.
fn parseTiingoMessage(allocator: std.mem.Allocator, body: []const u8) ?StreamTick {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return null;
defer parsed.deinit();
const obj = switch (parsed.value) {
.object => |o| o,
else => return null,
};
// Only "A" carries price data; "H" (heartbeat) / "I" (ack) do not.
const mt = jsonStr(obj.get("messageType")) orelse return null;
if (!std.mem.eql(u8, mt, "A")) return null;
const data = switch (obj.get("data") orelse return null) {
.array => |a| a.items,
else => return null,
};
// Reference format: data[0]=datetime, data[1]=ticker, data[2]=price.
if (data.len < 3) return null;
const tkr = jsonStr(data[1]) orelse return null;
const price = optFloat(data[2]) orelse return null;
var tick: StreamTick = .{ .price = price };
const n = @min(tkr.len, tick.ticker_buf.len);
@memcpy(tick.ticker_buf[0..n], tkr[0..n]);
tick.ticker_len = n;
return tick;
}
// Tests
test "parseYahooMessage: AAPL pricing frame yields ticker + price" {
const body =
\\{"type":"pricing","message":"CgRBQVBMFUgBjUMY4JKAz+JnKgNOTVMwCDgBRQisH79InK3UKmUAj+K/sAFQ2AEE"}
;
const tick = parseYahooMessage(std.testing.allocator, body) orelse return error.TestExpectedTick;
try std.testing.expectEqualStrings("AAPL", tick.ticker());
// field 2 is a fixed32 float: exactly 282.010009765625.
try std.testing.expectApproxEqAbs(@as(f64, 282.010009765625), tick.price, 0.0001);
}
test "parseYahooMessage: SPY pricing frame yields ticker + price" {
const body =
\\{"type":"pricing","message":"CgNTUFkVUhg5RBiwooDP4mcqA1BDWDAUOAFF9v3HP0jQ+bonZYA9NkGwAVDYAQQ="}
;
const tick = parseYahooMessage(std.testing.allocator, body) orelse return error.TestExpectedTick;
try std.testing.expectEqualStrings("SPY", tick.ticker());
try std.testing.expectApproxEqAbs(@as(f64, 740.3800048828125), tick.price, 0.0001);
}
test "parseYahooMessage: non-pricing and malformed frames yield null" {
const a = std.testing.allocator;
// Not JSON.
try std.testing.expect(parseYahooMessage(a, "not json") == null);
// JSON array root, not object.
try std.testing.expect(parseYahooMessage(a, "[1,2,3]") == null);
// Pricing-shaped but wrong type.
try std.testing.expect(parseYahooMessage(a,
\\{"type":"heartbeat","message":"CgRBQVBM"}
) == null);
// Missing message field.
try std.testing.expect(parseYahooMessage(a,
\\{"type":"pricing"}
) == null);
// message present but not valid base64.
try std.testing.expect(parseYahooMessage(a,
\\{"type":"pricing","message":"!!!not-base64!!!"}
) == null);
}
test "parsePricingProto: missing field 1 or field 2 yields null" {
// Only field 1 (ticker "AB"), no price field.
const ticker_only = [_]u8{ 0x0a, 0x02, 'A', 'B' };
try std.testing.expect(parsePricingProto(&ticker_only) == null);
// Only field 2 (price), no ticker.
var price_only: [5]u8 = undefined;
price_only[0] = 0x15; // field 2, wire type 5 (fixed32)
std.mem.writeInt(u32, price_only[1..5], @bitCast(@as(f32, 12.5)), .little);
try std.testing.expect(parsePricingProto(&price_only) == null);
}
test "parsePricingProto: skips intervening fields to reach price" {
// field 1 = "X", field 3 varint (skipped), field 2 = 9.5 fixed32.
var pb: std.ArrayList(u8) = .empty;
defer pb.deinit(std.testing.allocator);
const a = std.testing.allocator;
try pb.appendSlice(a, &.{ 0x0a, 0x01, 'X' }); // field 1, len 1, "X"
try pb.appendSlice(a, &.{ 0x18, 0x96, 0x01 }); // field 3 varint = 150
try pb.append(a, 0x15); // field 2, wire 5
var price_bytes: [4]u8 = undefined;
std.mem.writeInt(u32, &price_bytes, @bitCast(@as(f32, 9.5)), .little);
try pb.appendSlice(a, &price_bytes);
const tick = parsePricingProto(pb.items) orelse return error.TestExpectedTick;
try std.testing.expectEqualStrings("X", tick.ticker());
try std.testing.expectApproxEqAbs(@as(f64, 9.5), tick.price, 0.0001);
}
test "parsePricingProto: skips a fixed64 field before reaching the price" {
var pb: std.ArrayList(u8) = .empty;
defer pb.deinit(std.testing.allocator);
const a = std.testing.allocator;
try pb.appendSlice(a, &.{ 0x0a, 0x01, 'Y' }); // field 1, "Y"
try pb.append(a, 0x21); // field 4, wire type 1 (fixed64)
try pb.appendSlice(a, &.{ 0, 0, 0, 0, 0, 0, 0, 0 }); // 8 bytes, skipped
try pb.append(a, 0x15); // field 2, wire type 5 (fixed32)
var price_bytes: [4]u8 = undefined;
std.mem.writeInt(u32, &price_bytes, @bitCast(@as(f32, 3.25)), .little);
try pb.appendSlice(a, &price_bytes);
const tick = parsePricingProto(pb.items) orelse return error.TestExpectedTick;
try std.testing.expectEqualStrings("Y", tick.ticker());
try std.testing.expectApproxEqAbs(@as(f64, 3.25), tick.price, 0.0001);
}
test "parsePricingProto: unsupported wire type (group) stops the walk" {
// field 1 = "Z", then a group-start tag (wire type 3) -> break.
// Price is never seen, so the result is null.
const pb = [_]u8{ 0x0a, 0x01, 'Z', 0x0b }; // 0x0b = field 1, wire type 3
try std.testing.expect(parsePricingProto(&pb) == null);
}
test "parsePricingProto: over-long ticker is truncated to capacity" {
var pb: std.ArrayList(u8) = .empty;
defer pb.deinit(std.testing.allocator);
const a = std.testing.allocator;
const long = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // 36 chars
try pb.append(a, 0x0a); // field 1, wire 2
try pb.append(a, @intCast(long.len));
try pb.appendSlice(a, long);
try pb.append(a, 0x15); // field 2, wire 5
var price_bytes: [4]u8 = undefined;
std.mem.writeInt(u32, &price_bytes, @bitCast(@as(f32, 1.0)), .little);
try pb.appendSlice(a, &price_bytes);
const tick = parsePricingProto(pb.items) orelse return error.TestExpectedTick;
try std.testing.expectEqual(@as(usize, max_ticker_len), tick.ticker().len);
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWX", tick.ticker());
}
test "readVarint: multi-byte and truncated encodings" {
var i: usize = 0;
// 150 = 0x96 0x01
const ok = [_]u8{ 0x96, 0x01 };
try std.testing.expectEqual(@as(?u64, 150), readVarint(&ok, &i));
try std.testing.expectEqual(@as(usize, 2), i);
// Truncated (continuation bit set, no follow-on byte).
i = 0;
const trunc = [_]u8{0x80};
try std.testing.expect(readVarint(&trunc, &i) == null);
}
test "LiveStream: create/destroy lifecycle, keyless" {
const s = try LiveStream.create(std.testing.allocator, std.testing.io, .yahoo, null);
try std.testing.expect(!s.isRunning());
try std.testing.expect(s.api_key == null);
s.destroy(); // testing.allocator would flag any leak
}
test "LiveStream: create dupes and frees an api key" {
// Yahoo ignores the key, but the dupe/free branches must be exercised.
const s = try LiveStream.create(std.testing.allocator, std.testing.io, .yahoo, "secret-token");
try std.testing.expect(s.api_key != null);
try std.testing.expectEqualStrings("secret-token", s.api_key.?);
s.destroy();
}
test "LiveStream: serverMessage stores prices; snapshotInto matches case-insensitively" {
const s = try LiveStream.create(std.testing.allocator, std.testing.io, .yahoo, null);
defer s.destroy();
s.serverMessage(
\\{"type":"pricing","message":"CgRBQVBMFUgBjUMY4JKAz+JnKgNOTVMwCDgBRQisH79InK3UKmUAj+K/sAFQ2AEE"}
);
s.serverMessage(
\\{"type":"pricing","message":"CgNTUFkVUhg5RBiwooDP4mcqA1BDWDAUOAFF9v3HP0jQ+bonZYA9NkGwAVDYAQQ="}
);
// A non-pricing frame is ignored (early return, no entry, no crash).
s.serverMessage("not a frame");
var out = std.StringHashMap(f64).init(std.testing.allocator);
defer out.deinit();
// lowercase request hits the UPPERCASED stored key; an unseen symbol
// is absent; an over-long symbol is skipped without overflowing.
const syms = [_][]const u8{ "aapl", "spy", "TSLA", "THIS_TICKER_IS_TOO_LONG_TO_FIT_IN_BUF" };
s.snapshotInto(&syms, &out);
try std.testing.expectApproxEqAbs(@as(f64, 282.010009765625), out.get("aapl").?, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 740.3800048828125), out.get("spy").?, 0.0001);
try std.testing.expect(out.get("TSLA") == null);
}
test "LiveStream: serverMessage overwrites an existing ticker (found_existing path)" {
const s = try LiveStream.create(std.testing.allocator, std.testing.io, .yahoo, null);
defer s.destroy();
const frame =
\\{"type":"pricing","message":"CgRBQVBMFUgBjUMY4JKAz+JnKgNOTVMwCDgBRQisH79InK3UKmUAj+K/sAFQ2AEE"}
;
s.serverMessage(frame);
s.serverMessage(frame); // second time reuses the existing key
var out = std.StringHashMap(f64).init(std.testing.allocator);
defer out.deinit();
const syms = [_][]const u8{"AAPL"};
s.snapshotInto(&syms, &out);
try std.testing.expectEqual(@as(usize, 1), out.count());
}
test "LiveStream: yahoo transportConfig carries the Origin header and version path" {
const s = try LiveStream.create(std.testing.allocator, std.testing.io, .yahoo, null);
defer s.destroy();
const cfg = s.transportConfig();
try std.testing.expectEqualStrings(yahoo_host, cfg.host);
try std.testing.expectEqualStrings(yahoo_path, cfg.path);
try std.testing.expectEqual(@as(u16, 443), cfg.port);
try std.testing.expect(std.mem.indexOf(u8, cfg.headers, "Origin: https://finance.yahoo.com") != null);
try std.testing.expect(std.mem.indexOf(u8, cfg.headers, "Host: " ++ yahoo_host) != null);
}
test "buildSubscribe: yahoo message for one and many tickers" {
const a = std.testing.allocator;
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(a);
try buildSubscribe(.yahoo, a, null, &.{"SPY"}, &buf);
try std.testing.expectEqualStrings("{\"subscribe\":[\"SPY\"]}", buf.items);
buf.clearRetainingCapacity();
try buildSubscribe(.yahoo, a, null, &.{ "SPY", "AAPL", "BTC-USD" }, &buf);
try std.testing.expectEqualStrings("{\"subscribe\":[\"SPY\",\"AAPL\",\"BTC-USD\"]}", buf.items);
// No tickers -> empty array (degenerate but well-formed).
buf.clearRetainingCapacity();
try buildSubscribe(.yahoo, a, null, &.{}, &buf);
try std.testing.expectEqualStrings("{\"subscribe\":[]}", buf.items);
}
test "buildSubscribe: tiingo message carries auth + thresholdLevel 6" {
const a = std.testing.allocator;
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(a);
try buildSubscribe(.tiingo, a, "secret-key", &.{ "spy", "aapl" }, &buf);
try std.testing.expectEqualStrings(
"{\"eventName\":\"subscribe\",\"authorization\":\"secret-key\",\"eventData\":{\"thresholdLevel\":6,\"tickers\":[\"spy\",\"aapl\"]}}",
buf.items,
);
}
test "parseTiingoMessage: threshold-6 A message yields ticker + price" {
const body =
\\{"messageType":"A","service":"iex","data":["2026-06-29T13:33:45.383-05:00","spy",612.595]}
;
const tick = parseTiingoMessage(std.testing.allocator, body) orelse return error.TestExpectedTick;
try std.testing.expectEqualStrings("spy", tick.ticker());
try std.testing.expectApproxEqAbs(@as(f64, 612.595), tick.price, 0.0001);
}
test "parseTiingoMessage: heartbeat / ack / malformed frames yield null" {
const a = std.testing.allocator;
// Heartbeat "H" and ack "I" carry no price data.
try std.testing.expect(parseTiingoMessage(a,
\\{"messageType":"H","response":{"code":200,"message":"HeartBeat"}}
) == null);
try std.testing.expect(parseTiingoMessage(a,
\\{"messageType":"I","response":{"code":200},"data":{"subscriptionId":1}}
) == null);
// "A" but data too short.
try std.testing.expect(parseTiingoMessage(a,
\\{"messageType":"A","data":["2026-06-29","spy"]}
) == null);
// "A" but price isn't a number.
try std.testing.expect(parseTiingoMessage(a,
\\{"messageType":"A","data":["2026-06-29","spy","oops"]}
) == null);
// Not JSON / array root.
try std.testing.expect(parseTiingoMessage(a, "not json") == null);
try std.testing.expect(parseTiingoMessage(a, "[1,2,3]") == null);
}

View file

@ -15,7 +15,7 @@ const std = @import("std");
io: std.Io,
/// Maximum tokens (requests) in the bucket
max_tokens: u32,
max_tokens: usize,
/// Current available tokens
tokens: f64,
/// Tokens added per nanosecond
@ -27,7 +27,7 @@ const RateLimiter = @This();
/// Create a rate limiter.
/// `max_per_window` is the max requests allowed in `window_ns` nanoseconds.
pub fn init(io: std.Io, max_per_window: u32, window_ns: u64) RateLimiter {
pub fn init(io: std.Io, max_per_window: usize, window_ns: u64) RateLimiter {
return .{
.io = io,
.max_tokens = max_per_window,
@ -39,14 +39,14 @@ pub fn init(io: std.Io, max_per_window: u32, window_ns: u64) RateLimiter {
/// Convenience: N requests per minute.
/// Starts with 1 token (no burst) to stay within provider sliding-window limits.
pub fn perMinute(io: std.Io, n: u32) RateLimiter {
pub fn perMinute(io: std.Io, n: usize) RateLimiter {
var rl = init(io, n, std.time.ns_per_min);
rl.tokens = 1.0;
return rl;
}
/// Convenience: N requests per day
pub fn perDay(io: std.Io, n: u32) RateLimiter {
pub fn perDay(io: std.Io, n: usize) RateLimiter {
return init(io, n, std.time.ns_per_day);
}
@ -59,7 +59,7 @@ pub fn perDay(io: std.Io, n: u32) RateLimiter {
/// In-memory and per-process: it caps a single run's burst at N, which
/// is the common case (one cron invocation). It does not coordinate
/// across separate process launches within the same hour.
pub fn perHour(io: std.Io, n: u32) RateLimiter {
pub fn perHour(io: std.Io, n: usize) RateLimiter {
return init(io, n, std.time.ns_per_hour);
}

View file

@ -377,14 +377,7 @@ fn parse(
// Verify ticker is one we asked for. Wikidata can return
// surprising matches (foreign exchanges); skip those.
var matched = false;
for (expected_symbols) |s| {
if (std.ascii.eqlIgnoreCase(s, ticker)) {
matched = true;
break;
}
}
if (!matched) continue;
if (fmt.findIgnoreCase(expected_symbols, ticker) == null) continue;
const existing_or_new = try by_symbol.getOrPut(ticker);
if (!existing_or_new.found_existing) {

View file

@ -1,9 +1,11 @@
//! Tiingo provider -- official REST API for end-of-day prices and corporate actions.
//!
//! Free tier: 50 requests/hour and 1,000 requests/day. We enforce the
//! hourly cap with a 50/hour token bucket (`RateLimiter.perHour`,
//! which starts full so a nightly refresh burst runs unthrottled);
//! the daily ceiling is far from binding for zfin's bursty usage.
//! Rate limiting is plan-driven: the hourly token bucket is sized by
//! `Config.tiingoHourlyLimit()` (free tier 50/hour, Power tier
//! 10,000/hour) and passed to `init` via `Options.per_hour`. The bucket
//! starts full so a nightly refresh burst runs unthrottled; sustained
//! usage beyond the cap blocks in `acquire`. The daily ceiling is far
//! from binding for zfin's bursty usage.
//! Covers stocks, ETFs, and mutual funds with same-day NAV updates
//! (mutual fund NAVs available after midnight ET).
//!
@ -59,6 +61,7 @@ const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://api.tiingo.com/tiingo/daily";
const iex_url = "https://api.tiingo.com/iex";
/// Combined fetch result: candles, dividends, and splits parsed from
/// a single `/daily/<sym>/prices` response. Caller owns all three
@ -70,21 +73,48 @@ pub const CandleAndCorporateActions = struct {
splits: []Split,
};
/// One real-time quote row from Tiingo's `/iex` batch endpoint. The
/// ticker is stored inline so the value owns no heap memory - a
/// `[]IexQuote` slice is freed with a single `allocator.free`.
pub const IexQuote = struct {
/// IEX trades equities/ETFs only; tickers are short. 16 bytes
/// covers symbols plus class suffixes.
ticker_buf: [16]u8 = @splat(0),
ticker_len: usize = 0,
/// IEX-derived reference price (Tiingo `tngoLast`). Null when IEX
/// doesn't trade the symbol (mutual funds) or has no print yet.
tngo_last: ?f64 = null,
/// Prior session close (`prevClose`), for day-change display.
prev_close: ?f64 = null,
pub fn ticker(self: *const IexQuote) []const u8 {
return self.ticker_buf[0..self.ticker_len];
}
};
pub const Tiingo = struct {
client: http.Client,
allocator: std.mem.Allocator,
api_key: []const u8,
/// Free-tier hourly cap (50/hour). Starts full so a nightly
/// refresh burst (one candle file per held symbol) isn't paced;
/// sustained usage beyond 50/hour blocks in `acquire`.
/// Hourly request cap from the configured plan (free 50/hour, Power
/// 10,000/hour - see `Config.tiingoHourlyLimit`). Starts full so a
/// nightly refresh burst (one candle file per held symbol) isn't
/// paced; sustained usage beyond the cap blocks in `acquire`.
rate_limiter: RateLimiter,
pub fn init(io: std.Io, allocator: std.mem.Allocator, api_key: []const u8) Tiingo {
/// Plan-driven construction options.
pub const Options = struct {
/// Hourly request budget, from `Config.tiingoHourlyLimit()`.
/// Defaults to the free-tier 50/hour.
per_hour: usize = 50,
};
pub fn init(io: std.Io, allocator: std.mem.Allocator, api_key: []const u8, opts: Options) Tiingo {
return .{
.client = http.Client.init(io, allocator),
.allocator = allocator,
.api_key = api_key,
.rate_limiter = RateLimiter.perHour(io, 50),
.rate_limiter = RateLimiter.perHour(io, opts.per_hour),
};
}
@ -176,6 +206,36 @@ pub const Tiingo = struct {
Dividend.freeSlice(allocator, triple.dividends);
return triple.splits;
}
/// Fetch real-time IEX quotes for a batch of `tickers` in ONE HTTP
/// request (Tiingo bills the whole batch as a single call against
/// the hourly quota). Returns one `IexQuote` per row Tiingo sends,
/// keyed by the returned `ticker` - the response order is NOT
/// guaranteed to match the request, so callers match by ticker.
/// Symbols IEX can't price (mutual funds) come back with a null
/// `tngo_last`. Caller owns the slice (`allocator.free`).
pub fn fetchQuotes(self: *Tiingo, allocator: std.mem.Allocator, tickers: []const []const u8) ![]IexQuote {
var ticker_csv: std.ArrayList(u8) = .empty;
defer ticker_csv.deinit(allocator);
for (tickers, 0..) |t, i| {
if (i != 0) try ticker_csv.append(allocator, ',');
try ticker_csv.appendSlice(allocator, t);
}
const url = try http.buildUrl(allocator, iex_url, &.{
.{ "tickers", ticker_csv.items },
.{ "token", self.api_key },
});
defer allocator.free(url);
// One batched call = one request against the hourly bucket.
self.rate_limiter.acquire();
var response = try self.client.get(url);
defer response.deinit();
return parseQuotes(allocator, response.body);
}
};
/// Walk Tiingo's JSON array of price rows once, emitting candles,
@ -266,6 +326,43 @@ fn parseDate(val: ?std.json.Value) ?Date {
return Date.parse(s[0..10]) catch null;
}
/// Parse Tiingo's `/iex` batch response: a JSON array of per-ticker
/// quote objects. Extracts `ticker`, `tngoLast`, and `prevClose`; the
/// ticker is copied inline (truncated to capacity). Pure and
/// allocator-scoped, so it's fully fixture-testable. A non-array root
/// (Tiingo errors return an object with `detail`) is `RequestFailed`.
fn parseQuotes(allocator: std.mem.Allocator, body: []const u8) ![]IexQuote {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const items = switch (parsed.value) {
.array => |a| a.items,
else => return error.RequestFailed,
};
var quotes: std.ArrayList(IexQuote) = .empty;
errdefer quotes.deinit(allocator);
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const tkr = jsonStr(obj.get("ticker")) orelse continue;
var q: IexQuote = .{
.tngo_last = optFloat(obj.get("tngoLast")),
.prev_close = optFloat(obj.get("prevClose")),
};
const n = @min(tkr.len, q.ticker_buf.len);
@memcpy(q.ticker_buf[0..n], tkr[0..n]);
q.ticker_len = n;
try quotes.append(allocator, q);
}
return quotes.toOwnedSlice(allocator);
}
// -- Tests --
test "parseAll basic candles, no events" {
@ -473,3 +570,78 @@ test "parseAll empty array" {
try std.testing.expectEqual(@as(usize, 0), triple.dividends.len);
try std.testing.expectEqual(@as(usize, 0), triple.splits.len);
}
test "parseQuotes: batch of IEX quotes (ticker + tngoLast + prevClose)" {
const body =
\\[
\\ {"ticker":"AAPL","tngoLast":175.25,"prevClose":172.00,"last":175.25,"open":174.0},
\\ {"ticker":"SPY","tngoLast":612.50,"prevClose":610.00}
\\]
;
const a = std.testing.allocator;
const quotes = try parseQuotes(a, body);
defer a.free(quotes);
try std.testing.expectEqual(@as(usize, 2), quotes.len);
try std.testing.expectEqualStrings("AAPL", quotes[0].ticker());
try std.testing.expectApproxEqAbs(@as(f64, 175.25), quotes[0].tngo_last.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 172.00), quotes[0].prev_close.?, 0.001);
try std.testing.expectEqualStrings("SPY", quotes[1].ticker());
try std.testing.expectApproxEqAbs(@as(f64, 612.50), quotes[1].tngo_last.?, 0.001);
}
test "parseQuotes: null tngoLast (a symbol IEX doesn't trade)" {
const body =
\\[{"ticker":"VTSAX","tngoLast":null,"prevClose":110.0}]
;
const a = std.testing.allocator;
const quotes = try parseQuotes(a, body);
defer a.free(quotes);
try std.testing.expectEqual(@as(usize, 1), quotes.len);
try std.testing.expect(quotes[0].tngo_last == null);
try std.testing.expectApproxEqAbs(@as(f64, 110.0), quotes[0].prev_close.?, 0.001);
}
test "parseQuotes: error object (non-array root) -> RequestFailed" {
const a = std.testing.allocator;
try std.testing.expectError(error.RequestFailed, parseQuotes(a,
\\{"detail":"Not authorized."}
));
}
test "parseQuotes: malformed JSON -> ParseError" {
const a = std.testing.allocator;
try std.testing.expectError(error.ParseError, parseQuotes(a, "not json"));
}
test "parseQuotes: empty array" {
const a = std.testing.allocator;
const quotes = try parseQuotes(a, "[]");
defer a.free(quotes);
try std.testing.expectEqual(@as(usize, 0), quotes.len);
}
test "parseQuotes: over-long ticker is truncated to capacity" {
const body =
\\[{"ticker":"ABCDEFGHIJKLMNOPQRSTUVWXYZ","tngoLast":1.0}]
;
const a = std.testing.allocator;
const quotes = try parseQuotes(a, body);
defer a.free(quotes);
try std.testing.expectEqual(@as(usize, 16), quotes[0].ticker().len);
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", quotes[0].ticker());
}
test "parseQuotes: skips ticker-less rows and non-object array elements" {
// A row missing "ticker" and a stray non-object element are both
// skipped; only the well-formed row survives.
const body =
\\[{"tngoLast":5.0}, 42, {"ticker":"AAPL","tngoLast":1.0}]
;
const a = std.testing.allocator;
const quotes = try parseQuotes(a, body);
defer a.free(quotes);
try std.testing.expectEqual(@as(usize, 1), quotes.len);
try std.testing.expectEqualStrings("AAPL", quotes[0].ticker());
try std.testing.expectApproxEqAbs(@as(f64, 1.0), quotes[0].tngo_last.?, 0.001);
}

View file

@ -41,6 +41,10 @@ const performance = @import("analytics/performance.zig");
const http = @import("net/http.zig");
const atomic = @import("atomic.zig");
const market = @import("market.zig");
// File-as-struct: the import binds the whole file struct, so its inline
// tests are discovered by the test runner (see AGENTS.md "Test
// discovery").
const LiveStream = @import("net/LiveStream.zig");
// Wall-clock policy
//
@ -328,6 +332,12 @@ pub const DataService = struct {
wikidata: ?Wikidata = null,
edgar: ?Edgar = null,
/// Live-price websocket stream (push-based intraday quotes), lazily
/// created by `startLiveStream`. Heap-owned (the stream's background
/// thread captures the pointer, so it must be stable). Torn down by
/// `stopLiveStream` / `deinit`.
live_stream: ?*LiveStream = null,
/// Test-only guard: when true, any code path that would touch
/// the network panics with a clear message. Used by offline-mode
/// tests to verify that `FetchOptions.skip_network = true`
@ -379,6 +389,7 @@ pub const DataService = struct {
}
pub fn deinit(self: *DataService) void {
if (self.live_stream) |s| s.destroy();
if (self.td) |*td| td.deinit();
if (self.pg) |*pg| pg.deinit();
if (self.fmp) |*fmp| fmp.deinit();
@ -403,6 +414,15 @@ pub const DataService = struct {
// headers per each provider's politeness contract.
const email = self.config.user_email orelse return DataError.NoApiKey;
@field(self, field_name) = T.init(self.io, self.allocator, email);
} else if (T == Tiingo) {
// Tiingo takes a plan-driven hourly rate limit alongside the
// key. The cap comes from `Config.tiingoHourlyLimit` (free
// 50/hour, Power 10,000/hour) so a paying subscriber isn't
// throttled to free-tier limits.
const key = self.config.tiingo_key orelse return DataError.NoApiKey;
@field(self, field_name) = Tiingo.init(self.io, self.allocator, key, .{
.per_hour = self.config.tiingoHourlyLimit(),
});
} else {
// All we're doing here is lower casing the type name, then
// appending _key to it, so Tiingo -> tiingo_key
@ -737,6 +757,64 @@ pub const DataService = struct {
}
}
// Live-price stream (push-based intraday quotes)
//
// The transport mirrors the REST live-quote provider
// (Config.effectiveLiveQuoteProvider): a paid Tiingo subscriber
// streams via Tiingo's official IEX feed, everyone else via Yahoo's
// free (unofficial) feed.
/// Start (or ensure running) the live-price stream for `symbols`.
/// Idempotent while running: the symbol set is fixed for the
/// stream's lifetime, so to change it call `stopLiveStream` first.
/// `symbols` is copied by the stream, so the caller need not keep it
/// alive.
///
/// This is the push counterpart to a one-shot quote fetch: once
/// running, `liveStreamSnapshot` returns continuously-updated prices
/// with no further requests. Transport follows
/// `effectiveLiveQuoteProvider`: `.tiingo` (official real-time IEX,
/// key-guarded) for a Power subscriber or anyone who set
/// `ZFIN_LIVE_QUOTE_PROVIDER=tiingo` (IEX level-6 works on the free
/// tier too - one request per connect), else `.yahoo` (keyless, all
/// tiers, but ~15-min delayed/unofficial).
pub fn startLiveStream(self: *DataService, symbols: []const []const u8) DataError!void {
self.assertNetworkAllowed("startLiveStream");
if (self.live_stream == null) {
// `effectiveLiveQuoteProvider` already key-guards Tiingo, so
// `.tiingo` here implies `tiingo_key != null`.
const use_tiingo = self.config.effectiveLiveQuoteProvider() == .tiingo;
const transport: LiveStream.Transport = if (use_tiingo) .tiingo else .yahoo;
const key: ?[]const u8 = if (use_tiingo) self.config.tiingo_key else null;
self.live_stream = LiveStream.create(self.allocator, self.io, transport, key) catch return DataError.OutOfMemory;
}
self.live_stream.?.start(symbols) catch |err| {
log.warn("startLiveStream: {t}", .{err});
return DataError.FetchFailed;
};
}
/// Stop and tear down the live stream if running.
pub fn stopLiveStream(self: *DataService) void {
if (self.live_stream) |s| {
s.destroy();
self.live_stream = null;
}
}
/// True if the live stream is currently running.
pub fn liveStreamActive(self: *DataService) bool {
return if (self.live_stream) |s| s.isRunning() else false;
}
/// Copy the latest streamed prices for `symbols` into `out` (keys
/// borrow `symbols`, mirroring a one-shot quote map). No-op when the
/// stream isn't running; symbols not yet seen are left absent so the
/// caller falls back to the last cached close.
pub fn liveStreamSnapshot(self: *DataService, symbols: []const []const u8, out: *std.StringHashMap(f64)) void {
if (self.live_stream) |s| s.snapshotInto(symbols, out);
}
/// Fetch daily candles for a symbol (10+ years for trailing returns).
/// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
/// Uses incremental updates: when the cache is stale, only fetches
@ -2248,10 +2326,10 @@ pub const DataService = struct {
return result;
}
/// Fetch live intraday quotes for `symbols` in parallel, returning
/// a map of symbol -> live last price. Symbols whose quote fetch
/// fails (or that the provider can't price) are simply absent; the
/// caller falls back to the last cached close.
/// Fetch live intraday quotes for `symbols`, returning a map of
/// symbol -> live last price. Symbols whose quote fetch fails (or
/// that the provider can't price) are simply absent; the caller
/// falls back to the last cached close.
///
/// This is a pure live-price fetch: quotes are never cached, so it
/// neither reads nor writes the candle cache. It exists for the
@ -2259,17 +2337,12 @@ pub const DataService = struct {
/// distinct from candle-history maintenance (TTL/startup) and from
/// `--refresh-data=force` (incremental candle top-up).
///
/// Unlike `getQuote` (single-symbol, Yahoo->TwelveData fallback),
/// this is Yahoo-only: Yahoo is keyless with no shared rate
/// limiter, so each worker can safely own its HTTP client.
/// TwelveData's shared rate limiter makes it unsafe to fan out, and
/// its fallback role isn't worth the complexity for a bulk refresh.
///
/// Concurrency mirrors `parallelServerSync`: one task per symbol in
/// a single `std.Io.Group`, each with its own `Yahoo` client (a
/// shared `std.http.Client` is not safe across threads - see
/// `tryOneSync`). Relies on a thread-safe `allocator`/`io`, the
/// same assumption the server-sync fan-out already makes.
/// Provider is `Config.effectiveLiveQuoteProvider()`:
/// - `.yahoo` (default): keyless, parallel per-symbol fan-out.
/// - `.tiingo`: a single batched `/iex` request returning Tiingo's
/// real-time IEX reference price (`tngoLast`). On ANY failure
/// (no key, network, auth, parse) it degrades to the Yahoo path
/// so a refresh never hard-fails.
///
/// The returned map's keys borrow `symbols`: keep `symbols` alive
/// while using the map, and `deinit()` the map when done.
@ -2279,8 +2352,27 @@ pub const DataService = struct {
self.assertNetworkAllowed("loadLiveQuotes");
switch (self.config.effectiveLiveQuoteProvider()) {
.yahoo => self.loadLiveQuotesYahoo(symbols, &prices),
.tiingo => self.loadLiveQuotesTiingo(symbols, &prices) catch |err| {
log.warn("loadLiveQuotes: Tiingo path failed ({t}); falling back to Yahoo", .{err});
// Drop any partial fill so we never mix providers.
prices.clearRetainingCapacity();
self.loadLiveQuotesYahoo(symbols, &prices);
},
}
return prices;
}
/// Yahoo live-quote fan-out: one task per symbol in a single
/// `std.Io.Group`, each with its own `Yahoo` client (a shared
/// `std.http.Client` is not safe across threads - see `tryOneSync`).
/// Yahoo is keyless with no shared rate limiter, so per-worker
/// clients are safe. Relies on a thread-safe `allocator`/`io`, the
/// same assumption the server-sync fan-out makes.
fn loadLiveQuotesYahoo(self: *DataService, symbols: []const []const u8, prices: *std.StringHashMap(f64)) void {
const QuoteSlot = struct { symbol: []const u8, price: ?f64 = null };
const slots = self.allocator.alloc(QuoteSlot, symbols.len) catch return prices;
const slots = self.allocator.alloc(QuoteSlot, symbols.len) catch return;
defer self.allocator.free(slots);
for (slots, 0..) |*slot, i| slot.* = .{ .symbol = symbols[i] };
@ -2302,7 +2394,29 @@ pub const DataService = struct {
for (slots) |slot| {
if (slot.price) |p| prices.put(slot.symbol, p) catch |err| log.warn("loadLiveQuotes put({s}): {t}", .{ slot.symbol, err });
}
return prices;
}
/// Tiingo live-quote path: a single batched `/iex` request for all
/// `symbols`, returning the real-time IEX reference price
/// (`tngoLast`). Results are keyed back to the caller's `symbols`
/// slices (case-insensitive, since Tiingo upper-cases tickers and
/// returns them in arbitrary order), so the map's keys still borrow
/// `symbols`. Symbols Tiingo can't price (mutual funds) or with a
/// null reference price are absent -> candle-close fallback. Errors
/// propagate to `loadLiveQuotes`, which degrades to Yahoo.
fn loadLiveQuotesTiingo(self: *DataService, symbols: []const []const u8, prices: *std.StringHashMap(f64)) !void {
const tg = try self.getProvider(Tiingo);
const quotes = try tg.fetchQuotes(self.allocator, symbols);
defer self.allocator.free(quotes);
for (quotes) |q| {
const price = q.tngo_last orelse continue;
// Map Tiingo's echoed-back (often upper-cased, arbitrary
// order) ticker to the caller's own slice so the result
// map's keys borrow `symbols` per the contract.
const key = fmt.findIgnoreCase(symbols, q.ticker()) orelse continue;
prices.put(key, price) catch |err| log.warn("loadLiveQuotes put({s}): {t}", .{ key, err });
}
}
/// Parallel server sync via `std.Io.Group`.
@ -2710,6 +2824,18 @@ pub const DataService = struct {
}
}
/// Build the auth header slice for a server-sync request. Writes the
/// `X-API-Key` header into `buf` (caller-owned; must outlive the
/// request) and returns a one-element slice of it, or an empty slice
/// when no key is configured. The key points into `Config`
/// (process-lifetime), so the value slice stays valid. Free function
/// (not a method) so it's unit-testable without a `DataService`.
fn serverAuthHeaders(server_api_key: ?[]const u8, buf: *[1]std.http.Header) []const std.http.Header {
const key = server_api_key orelse return &.{};
buf[0] = .{ .name = "X-API-Key", .value = key };
return buf[0..1];
}
/// L2 seam: fetch the whole CUSIP->ticker map from the server via
/// `GET {server}/cusips`. Returns the raw SRF body (caller frees
/// with `self.allocator`) or null on any failure. Best-effort: no
@ -2723,7 +2849,9 @@ pub const DataService = struct {
var client = http.Client.init(self.io, self.allocator);
defer client.deinit();
var response = client.get(url) catch |err| {
var hdr_buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const extra_headers = serverAuthHeaders(self.config.server_api_key, &hdr_buf);
var response = client.request(.GET, url, null, extra_headers) catch |err| {
log.debug("cusips server sync failed: {s}", .{@errorName(err)});
return null;
};
@ -2878,7 +3006,9 @@ pub const DataService = struct {
var client = http.Client.init(self.io, self.allocator);
defer client.deinit();
var response = client.get(full_url) catch |err| {
var hdr_buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const extra_headers = serverAuthHeaders(self.config.server_api_key, &hdr_buf);
var response = client.request(.GET, full_url, null, extra_headers) catch |err| {
const elapsed_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
// Operator-visible: surfaces meaningful failures
// (`NoAddressReturned`, `ConnectionRefused`,
@ -3034,6 +3164,20 @@ pub const DataService = struct {
// Tests
test "serverAuthHeaders: key present yields one X-API-Key header" {
var buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const h = DataService.serverAuthHeaders("s3cret", &buf);
try std.testing.expectEqual(@as(usize, 1), h.len);
try std.testing.expectEqualStrings("X-API-Key", h[0].name);
try std.testing.expectEqualStrings("s3cret", h[0].value);
}
test "serverAuthHeaders: null key yields no headers" {
var buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const h = DataService.serverAuthHeaders(null, &buf);
try std.testing.expectEqual(@as(usize, 0), h.len);
}
test "isPermanentProviderFailure: NotFound is permanent" {
try std.testing.expect(isPermanentProviderFailure(error.NotFound));
}
@ -3088,6 +3232,114 @@ test "DataService init/deinit lifecycle" {
try std.testing.expect(svc.tg == null);
}
test "DataService live-stream accessors no-op when no stream is running" {
const allocator = std.testing.allocator;
const config = Config{
.cache_dir = "/tmp/zfin-test-cache",
};
var svc = DataService.init(std.testing.io, allocator, config);
defer svc.deinit();
// No stream created yet: active is false, snapshot leaves `out`
// empty, and stop is a safe no-op (must not crash on null).
try std.testing.expect(!svc.liveStreamActive());
var out = std.StringHashMap(f64).init(allocator);
defer out.deinit();
const syms = [_][]const u8{ "SPY", "AAPL" };
svc.liveStreamSnapshot(&syms, &out);
try std.testing.expectEqual(@as(usize, 0), out.count());
svc.stopLiveStream();
try std.testing.expect(!svc.liveStreamActive());
}
test "getProvider(Tiingo): hourly rate limit follows the configured plan" {
const allocator = std.testing.allocator;
// Free plan -> 50/hour token bucket.
{
var svc = DataService.init(std.testing.io, allocator, .{
.cache_dir = "/tmp/zfin-test-cache",
.tiingo_key = "test-key",
.tiingo_plan = .free,
});
defer svc.deinit();
const tg = try svc.getProvider(Tiingo);
try std.testing.expectEqual(@as(usize, 50), tg.rate_limiter.max_tokens);
}
// Power plan -> 10,000/hour token bucket.
{
var svc = DataService.init(std.testing.io, allocator, .{
.cache_dir = "/tmp/zfin-test-cache",
.tiingo_key = "test-key",
.tiingo_plan = .power,
});
defer svc.deinit();
const tg = try svc.getProvider(Tiingo);
try std.testing.expectEqual(@as(usize, 10_000), tg.rate_limiter.max_tokens);
}
}
test "getProvider(Tiingo): missing key -> NoApiKey" {
const allocator = std.testing.allocator;
var svc = DataService.init(std.testing.io, allocator, .{ .cache_dir = "/tmp/zfin-test-cache" });
defer svc.deinit();
try std.testing.expectError(DataError.NoApiKey, svc.getProvider(Tiingo));
}
test "getProvider: keyless/keyed dispatch and lazy caching" {
const allocator = std.testing.allocator;
var svc = DataService.init(std.testing.io, allocator, .{
.cache_dir = "/tmp/zfin-test-cache",
.twelvedata_key = "td-key",
});
defer svc.deinit();
// Keyless provider (Yahoo): no key needed; lazily created, then
// cached - a second call returns the same pointer.
const yh1 = try svc.getProvider(Yahoo);
const yh2 = try svc.getProvider(Yahoo);
try std.testing.expect(yh1 == yh2);
// Keyed provider via the generic branch (TwelveData, key present).
_ = try svc.getProvider(TwelveData);
// Keyed provider whose key is unset -> NoApiKey (Polygon).
try std.testing.expectError(DataError.NoApiKey, svc.getProvider(Polygon));
}
test "getProvider: open-data providers need a contact email" {
const allocator = std.testing.allocator;
// With an email, Wikidata/Edgar init via the email branch.
{
var svc = DataService.init(std.testing.io, allocator, .{
.cache_dir = "/tmp/zfin-test-cache",
.user_email = "test@example.com",
});
defer svc.deinit();
_ = try svc.getProvider(Wikidata);
_ = try svc.getProvider(Edgar);
}
// Without an email -> NoApiKey.
{
var svc = DataService.init(std.testing.io, allocator, .{ .cache_dir = "/tmp/zfin-test-cache" });
defer svc.deinit();
try std.testing.expectError(DataError.NoApiKey, svc.getProvider(Wikidata));
}
}
test "loadLiveQuotes: empty symbol list returns empty without touching the network" {
const allocator = std.testing.allocator;
// panic_on_network_attempt asserts the early return happens BEFORE
// any network gate is reached.
var svc = DataService.init(std.testing.io, allocator, .{ .cache_dir = "/tmp/zfin-test-cache" });
svc.panic_on_network_attempt = true;
defer svc.deinit();
var prices = svc.loadLiveQuotes(&.{});
defer prices.deinit();
try std.testing.expectEqual(@as(usize, 0), prices.count());
}
test "DataService store helper creates valid store" {
const allocator = std.testing.allocator;
const config = Config{

View file

@ -502,6 +502,12 @@ pub const App = struct {
local_tz: zfin.market.TimeZone,
config: zfin.Config,
svc: *zfin.DataService,
/// Cache-vs-network policy from the global `--refresh-data` flag,
/// mapped once at init. Threaded into every automatic data fetch
/// (the initial `portfolio.load` and each tab's `loadData`) so the
/// TUI honors `--refresh-data=never` (offline) and `=force` the same
/// way the CLI commands do. Default `.{}` is auto (respect TTL).
fetch_options: zfin.FetchOptions = .{},
keymap: keybinds.KeyMap,
theme: theme.Theme,
active_tab: Tab = .portfolio,
@ -2510,6 +2516,7 @@ pub fn run(
app_theme: theme.Theme,
args: []const []const u8,
today: zfin.Date,
refresh_policy: framework.RefreshPolicy,
) !void {
const watchlist_path: ?[]const u8 = global_watchlist_path;
@ -2593,6 +2600,7 @@ pub fn run(
.local_tz = local_tz,
.config = config,
.svc = svc,
.fetch_options = cli.fetchOptionsFromPolicy(refresh_policy),
.keymap = keymap,
.theme = app_theme,
.symbol = symbol,
@ -2684,6 +2692,8 @@ pub fn run(
.progress = symbol_progress.callback(),
.aggregate_progress = aggregate_progress.callback(),
.watchlist_syms = watch_syms.items,
.force_refresh = app_inst.fetch_options.force_refresh,
.skip_network = app_inst.fetch_options.skip_network,
}) catch |err| blk: {
std.log.scoped(.tui).warn("portfolio load failed: {t}", .{err});
break :blk null;

View file

@ -87,7 +87,10 @@ pub const tab = struct {
/// frees current payload, clears flags, and re-runs the
/// fetch path.
pub fn reload(state: *State, app: *App) !void {
if (app.symbol.len > 0) {
// Offline mode (--refresh-data=never): skip cache invalidation.
// loadData can't re-fetch, so dropping the entry would just blank
// the tab; instead re-render from the cached entry.
if (app.symbol.len > 0 and !app.fetch_options.skip_network) {
app.svc.invalidate(app.symbol, .earnings);
}
// Clear every flag so loadData has the same starting
@ -135,7 +138,7 @@ fn loadData(state: *State, app: *App) void {
state.loaded = true;
state.error_msg = null;
const result = app.svc.getEarnings(app.symbol, .{}) catch |err| {
const result = app.svc.getEarnings(app.symbol, app.fetch_options) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => {
state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";

View file

@ -143,7 +143,10 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
if (app.symbol.len > 0) {
// Offline mode (--refresh-data=never): skip cache invalidation.
// loadData can't re-fetch, so dropping the entry would just blank
// the tab; instead re-render from the cached entry.
if (app.symbol.len > 0 and !app.fetch_options.skip_network) {
app.svc.invalidate(app.symbol, .options);
}
// Drop chains first so loadData starts clean.
@ -298,7 +301,7 @@ fn loadData(state: *State, app: *App) void {
}
state.chains = null;
const result = app.svc.getOptions(app.symbol, .{}) catch |err| {
const result = app.svc.getOptions(app.symbol, app.fetch_options) catch |err| {
switch (err) {
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
else => app.setStatus("Error loading options"),

View file

@ -119,7 +119,7 @@ fn loadData(state: *State, app: *App) void {
app.symbol_data.trailing_me_price = null;
app.symbol_data.trailing_me_total = null;
const result = app.svc.getTrailingReturns(app.symbol, .{}) catch |err| {
const result = app.svc.getTrailingReturns(app.symbol, app.fetch_options) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"),
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
@ -155,7 +155,7 @@ fn loadData(state: *State, app: *App) void {
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!app.symbol_data.etf_loaded) {
app.symbol_data.etf_loaded = true;
if (app.svc.getEtfProfile(app.symbol, .{})) |etf_result| {
if (app.svc.getEtfProfile(app.symbol, app.fetch_options)) |etf_result| {
if (etf_result.data.isEtf()) {
// Take ownership of the EtfProfile data. We
// deliberately don't call etf_result.deinit

View file

@ -4,6 +4,8 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
/// Main holdings-table column layout (widths + per-column specs).
const pl = views.PositionsLayout;
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
@ -13,10 +15,14 @@ const StyledLine = tui.StyledLine;
const colLabel = tui.colLabel;
// Portfolio column layout (display columns).
// Each column width includes its trailing separator space.
// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account
// Row shape: prefix(4) + sym + shares + avgcost + price + mv + gl + weight + date + account.
// Every non-prefix column width is owned by views/portfolio_sections.zig
// (PositionsLayout: symbol_w, shares_w, price_w, value_w, gainloss_w,
// weight_w, date_w); each column adds a 1-col separator space. The
// row/header format strings and the col_end_* hit-test offsets below all
// derive from those same widths, so they can't drift apart.
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;
const sw: usize = pl.symbol_w;
// Portfolio-specific types
@ -140,6 +146,11 @@ pub const Action = enum {
/// on the same symbol; re-target when open and cursor is on
/// a different symbol.
toggle_overlay,
/// Toggle opt-in live streaming: subscribe all held + watchlist
/// symbols to the websocket feed and re-value the portfolio on
/// each tick. Off by default. See the streaming helpers below for
/// the scope of what updates live.
toggle_stream,
};
// Tab-private state
@ -256,6 +267,17 @@ pub const State = struct {
/// `handleMouse` / `drawContent` / `drawStatusBar` check
/// this and route accordingly.
modal: Modal = .none,
/// Opt-in live streaming (default off). Toggled with
/// `toggle_stream`. While true the App polls `tick`, which snapshots
/// the streamed prices and calls `PortfolioData.revalue` to refresh
/// the position table + totals. The stream is owned by
/// `DataService`; torn down on `deactivate` / `deinit`.
streaming: bool = false,
/// Owned, duped symbols subscribed to the stream (held + watchlist),
/// captured when streaming starts so the per-tick snapshot has
/// stable key strings. Freed (and emptied) when streaming stops.
stream_syms: [][]const u8 = &.{},
};
// Tab framework contract
@ -275,6 +297,7 @@ pub const meta: framework.TabMeta(Action) = .{
// (or re-targets) the symbol-info overlay for the
// cursor row's symbol.
.{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
.{ .action = .toggle_stream, .key = .{ .codepoint = 'L' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse position",
@ -285,6 +308,7 @@ pub const meta: framework.TabMeta(Action) = .{
.clear_account_filter = "Clear account filter",
.select_symbol = "Select symbol",
.toggle_overlay = "Show symbol details",
.toggle_stream = "Toggle live stream",
}),
.status_hints = &.{
.sort_col_prev,
@ -292,6 +316,7 @@ pub const meta: framework.TabMeta(Action) = .{
.sort_reverse,
.open_account_picker,
.toggle_overlay,
.toggle_stream,
},
};
@ -305,6 +330,7 @@ pub const tab = struct {
}
pub fn deinit(state: *State, app: *App) void {
if (state.streaming) stopStreaming(state, app);
state.rows.deinit(app.allocator);
if (state.prepared_options) |*opts| opts.deinit();
if (state.prepared_cds) |*cds| cds.deinit();
@ -328,7 +354,20 @@ pub const tab = struct {
loadPortfolioData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
/// Leaving the tab stops any live stream so it doesn't keep a
/// socket + thread alive in the background (and frees the single
/// shared `DataService` stream for another tab). Re-enabled by
/// toggling again on return.
pub fn deactivate(state: *State, app: *App) void {
if (state.streaming) stopStreaming(state, app);
}
/// Framework poll-tick gate: true only while streaming, so the App
/// keeps a 100ms tick armed to pull fresh prices and re-value.
pub fn wantsPollTick(state: *State, app: *App) bool {
_ = app;
return state.streaming;
}
/// Disabled only when the TUI was opened on a specific symbol
/// (`zfin AAPL`) with no portfolio loaded: there's nothing to show
@ -404,7 +443,26 @@ pub const tab = struct {
loadPortfolioData(state, app);
}
pub const tick = framework.noopTick(State);
/// Poll hook: while streaming, snapshot the latest streamed prices
/// for the subscribed symbols, re-value the portfolio against them
/// (cheap - no file I/O), and rebuild the row table. The historical-
/// return block and worker-backed data stay as of the last full
/// load; only the position table + totals reflect the live prices.
pub fn tick(state: *State, app: *App, frame: u64) void {
_ = frame;
if (!state.streaming or state.stream_syms.len == 0) return;
var overlay = std.StringHashMap(f64).init(app.allocator);
defer overlay.deinit();
app.svc.liveStreamSnapshot(state.stream_syms, &overlay);
if (overlay.count() == 0) return; // no ticks yet; nothing to re-value
if (app.portfolio.revalue(app.today, &overlay)) {
// wall-clock required: stamp the live re-value instant so the
// portfolio footer's "(as of H:MM PM ET)" reflects the stream,
// not the last full load.
app.portfolio.live_quotes_at_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
loadPortfolioData(state, app);
}
}
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
@ -473,6 +531,20 @@ pub const tab = struct {
};
app.toggleOverlay(sym);
},
.toggle_stream => {
if (state.streaming) {
stopStreaming(state, app);
app.setStatus("Live stream: off");
} else if (app.portfolio.file == null) {
app.setStatus("No portfolio to stream");
} else if (startStreaming(state, app)) |n| {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Live stream: on ({d} symbols, Yahoo)", .{n}) catch "Live stream: on";
app.setStatus(msg);
} else {
app.setStatus("Live stream: nothing to subscribe");
}
},
}
}
@ -709,14 +781,17 @@ fn toggleExpandAtCursor(state: *State, app: *App) void {
}
/// Cumulative column end positions for click-to-sort hit testing.
pub const col_end_symbol: usize = prefix_cols + sw + 1;
pub const col_end_shares: usize = col_end_symbol + 9;
pub const col_end_avg_cost: usize = col_end_shares + 11;
pub const col_end_price: usize = col_end_avg_cost + 11;
pub const col_end_market_value: usize = col_end_price + 17;
pub const col_end_gain_loss: usize = col_end_market_value + 15;
pub const col_end_weight: usize = col_end_gain_loss + 9;
pub const col_end_date: usize = col_end_weight + 14;
/// Each offset is the previous column's end plus this column's content
/// width (from format.zig) plus the 1-col separator space, so the
/// hit-test grid stays locked to the row/header format strings.
const col_end_symbol: usize = prefix_cols + sw + 1;
const col_end_shares: usize = col_end_symbol + pl.shares_w + 1;
const col_end_avg_cost: usize = col_end_shares + pl.price_w + 1;
const col_end_price: usize = col_end_avg_cost + pl.price_w + 1;
const col_end_market_value: usize = col_end_price + pl.value_w + 1;
const col_end_gain_loss: usize = col_end_market_value + pl.gainloss_w + 1;
const col_end_weight: usize = col_end_gain_loss + pl.weight_w + 1;
const col_end_date: usize = col_end_weight + pl.date_w + 1;
// Gain/loss column start position (used for alt-style coloring)
const gl_col_start: usize = col_end_market_value;
@ -759,6 +834,84 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
/// owns. Visiting portfolio after analysis pre-loaded the data
/// will still rebuild the row list - cheap.)
// Live-stream helpers
/// Gather held stock symbols + watchlist (deduped), dup them into
/// `state.stream_syms` (owned, so the per-tick snapshot has stable
/// keys), and start the live stream. Returns the subscribed symbol
/// count on success, or null when there's nothing to subscribe or the
/// stream failed to start (in which case `streaming` stays false).
fn startStreaming(state: *State, app: *App) ?usize {
const held_owned: ?[][]const u8 = if (app.portfolio.file) |pf|
(pf.stockSymbols(app.allocator) catch |err| ret: {
std.log.scoped(.tui).debug("stockSymbols for stream: {t}", .{err});
break :ret null;
})
else
null;
defer if (held_owned) |h| app.allocator.free(h); // outer slice; gather dups the strings
const owned = gatherStreamSymbols(
app.allocator,
held_owned orelse &.{},
app.watchlist orelse &.{},
) catch return null;
if (owned.len == 0) {
app.allocator.free(owned);
return null;
}
app.svc.startLiveStream(owned) catch |err| {
std.log.scoped(.tui).warn("portfolio startLiveStream: {t}", .{err});
for (owned) |s| app.allocator.free(s);
app.allocator.free(owned);
return null;
};
state.stream_syms = owned;
state.streaming = true;
return owned.len;
}
/// Build the owned, deduped subscribe list: every held symbol, then any
/// watchlist symbol not already present (held wins). Each string is
/// duped with `allocator`; the caller owns the result and its strings.
/// Pure (allocator + two slices), so it's unit-testable without an App.
fn gatherStreamSymbols(
allocator: std.mem.Allocator,
held: []const []const u8,
watchlist: []const []const u8,
) ![][]const u8 {
var syms: std.ArrayList([]const u8) = .empty;
errdefer {
for (syms.items) |s| allocator.free(s);
syms.deinit(allocator);
}
for (held) |sym| {
try syms.append(allocator, try allocator.dupe(u8, sym));
}
for (watchlist) |sym| {
var seen = false;
for (syms.items) |existing| {
if (std.mem.eql(u8, existing, sym)) {
seen = true;
break;
}
}
if (seen) continue;
try syms.append(allocator, try allocator.dupe(u8, sym));
}
return syms.toOwnedSlice(allocator);
}
/// Stop the live stream (if running) and free the subscribed symbols.
/// Idempotent.
fn stopStreaming(state: *State, app: *App) void {
app.svc.stopLiveStream();
for (state.stream_syms) |s| app.allocator.free(s);
app.allocator.free(state.stream_syms);
state.stream_syms = &.{};
state.streaming = false;
}
pub fn loadPortfolioData(state: *State, app: *App) void {
// Summary is populated synchronously by pd.load; if it's
// null here, no portfolio is loaded (welcome screen).
@ -1517,16 +1670,16 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
var mv_hdr_buf: [24]u8 = undefined;
var gl_hdr_buf: [24]u8 = undefined;
var wt_hdr_buf: [16]u8 = undefined;
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", pl.symbol_w, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", pl.shares_w, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", pl.price_w, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", pl.price_w, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", pl.value_w, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", pl.gainloss_w, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", pl.weight_w, false, if (sf == .weight) si else null);
const acct_ind: []const u8 = if (sf == .account) si else "";
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} " ++ pl.date_str ++ " {s}{s}", .{
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
});
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
@ -1607,7 +1760,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
else
a.weight;
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{
arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
});
@ -1621,7 +1774,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
.style = base_style,
.alt_style = gl_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
.alt_end = gl_col_start + pl.gainloss_w,
});
}
}
@ -1655,7 +1808,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
const acct_col: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
const text = try std.fmt.allocPrint(arena, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{
status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col,
});
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
@ -1665,7 +1818,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
.style = base_style,
.alt_style = gl_col_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
.alt_end = gl_col_start + pl.gainloss_w,
});
}
},
@ -1676,7 +1829,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
const text = try std.fmt.allocPrint(arena, " {s}" ++ pl.symbol_spec ++ " " ++ pl.shares_str ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str, .{
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
});
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
@ -2557,3 +2710,41 @@ test "ensureCursorVisible: zero visible height is a no-op for the lower bound" {
// cursor_row = 0 < 5 -> scroll = 0. Lower-bound branch fires.
try testing.expectEqual(@as(usize, 0), scroll);
}
test "gatherStreamSymbols: held first, watchlist deduped against held" {
const a = testing.allocator;
const held = [_][]const u8{ "AAPL", "SPY" };
const watchlist = [_][]const u8{ "SPY", "QQQ", "AAPL", "VTI" };
const got = try gatherStreamSymbols(a, &held, &watchlist);
defer {
for (got) |s| a.free(s);
a.free(got);
}
// Held first (AAPL, SPY), then only watchlist entries not already
// present (QQQ, VTI); SPY and AAPL are deduped.
try testing.expectEqual(@as(usize, 4), got.len);
try testing.expectEqualStrings("AAPL", got[0]);
try testing.expectEqualStrings("SPY", got[1]);
try testing.expectEqualStrings("QQQ", got[2]);
try testing.expectEqualStrings("VTI", got[3]);
}
test "gatherStreamSymbols: empty held + watchlist yields empty list" {
const a = testing.allocator;
const got = try gatherStreamSymbols(a, &.{}, &.{});
defer a.free(got);
try testing.expectEqual(@as(usize, 0), got.len);
}
test "gatherStreamSymbols: watchlist-only when no holdings" {
const a = testing.allocator;
const watchlist = [_][]const u8{ "BTC-USD", "ETH-USD" };
const got = try gatherStreamSymbols(a, &.{}, &watchlist);
defer {
for (got) |s| a.free(s);
a.free(got);
}
try testing.expectEqual(@as(usize, 2), got.len);
try testing.expectEqualStrings("BTC-USD", got[0]);
try testing.expectEqualStrings("ETH-USD", got[1]);
}

View file

@ -1214,7 +1214,7 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}),
.style = th.headerStyle(),
});
@ -1262,7 +1262,7 @@ fn appendSwrTable(
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}),
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, pctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)}),
.style = th.headerStyle(),
});
@ -1417,9 +1417,9 @@ fn appendAccumulationBlocks(
{
var hdr: std.ArrayListUnmanaged(u8) = .empty;
try hdr.appendNTimes(arena, ' ', label_width);
for (horizons) |h| {
for (horizons, 0..) |h, hi| {
var hbuf: [16]u8 = undefined;
const hlabel = view.fmtHorizonLabel(&hbuf, h);
const hlabel = view.fmtHorizonLabelAge(&hbuf, h, pctx.config.horizon_death_age[hi]);
try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len);
try hdr.appendSlice(arena, hlabel);
}
@ -2095,7 +2095,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Column header
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}),
.style = th.headerStyle(),
});
@ -2325,3 +2325,105 @@ test "appendSwrTable: accumulation suppresses rate rows and adds one footnote" {
try testing.expectEqual(@as(usize, 0), counts.rates);
try testing.expectEqual(@as(usize, 1), counts.footnotes);
}
/// Build an age-of-death-anchored, target-spending context for the
/// TUI render tests (couple + `horizon_age` + survivor cut). Synthetic
/// benchmark comparison, no DataService.
fn buildAgeAnchoredTestCtx(arena: std.mem.Allocator, as_of: zfin.Date) !view.ProjectionContext {
const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig");
var config = projections.parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:95
\\type::config,target_spending:num:120000
\\type::config,survivor_spending_pct:num:75
\\type::birthdate,date::1965-03-01
\\type::birthdate,date::1968-08-15,person:num:2
\\type::event,name::Social Security (A),start_age:num:70,person:num:1,amount:num:38400
\\type::event,name::College Tuition,start_age:num:62,person:num:1,duration:num:4,amount:num:-55000
\\type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000,inflation_adjusted:bool:false
);
try config.resolveHorizonAges(as_of);
const comparison: benchmark.BenchmarkComparison = .{
.stock_returns = .{},
.bond_returns = .{},
.benchmark_returns = .{},
.portfolio_returns = .{},
.conservative_return = 0.07,
.stock_pct = 0.8,
.bond_pct = 0.2,
};
return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of);
}
test "appendAccumulationBlocks: age-anchored target spending renders grid with 'to age' header" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const as_of = zfin.Date.fromYmd(2026, 6, 15);
const ctx = try buildAgeAnchoredTestCtx(a, as_of);
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of);
var found_accum = false;
var found_earliest = false;
var found_age = false;
for (lines.items) |line| {
if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true;
if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true;
if (std.mem.indexOf(u8, line.text, "to age 95") != null) found_age = true;
}
try testing.expect(found_accum);
try testing.expect(found_earliest);
// The "to age N" header is a plain text line (the per-confidence
// date rows are grapheme-rendered, so they're not searched here).
try testing.expect(found_age);
}
test "appendAccumulationBlocks: numeric target-date config renders accumulation stats, no grid" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const as_of = zfin.Date.fromYmd(2026, 7, 1);
const ctx = try buildSwrTestCtx(a, zfin.Date.fromYmd(2040, 7, 1), 60_000, as_of);
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of);
var found_accum = false;
var found_earliest = false;
for (lines.items) |line| {
if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true;
if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true;
}
try testing.expect(found_accum);
// Target-date input has no earliest-retirement grid.
try testing.expect(!found_earliest);
}
test "appendEventSummary: renders a Life Events line per configured event" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const as_of = zfin.Date.fromYmd(2026, 6, 15);
const ctx = try buildAgeAnchoredTestCtx(a, as_of);
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try appendEventSummary(&lines, as_of, a, theme.default_theme, ctx);
var found_header = false;
var found_ss = false;
var found_tuition = false;
var found_nominal = false;
for (lines.items) |line| {
if (std.mem.indexOf(u8, line.text, "Life Events") != null) found_header = true;
if (std.mem.indexOf(u8, line.text, "Social Security") != null) found_ss = true;
if (std.mem.indexOf(u8, line.text, "College Tuition") != null) found_tuition = true;
if (std.mem.indexOf(u8, line.text, "nominal") != null) found_nominal = true;
}
try testing.expect(found_header);
try testing.expect(found_ss);
try testing.expect(found_tuition); // expense event (negative amount)
try testing.expect(found_nominal); // the non-inflation-adjusted pension
}

View file

@ -86,6 +86,7 @@ pub const ChartState = struct {
pub const Action = enum {
chart_timeframe_next,
chart_timeframe_prev,
toggle_live,
};
// Tab-private state
@ -104,6 +105,16 @@ pub const State = struct {
/// only the quote tab uses it; perf renders its own braille
/// chart from `app.symbol_data.candles` directly.
chart: ChartState = .{},
/// Opt-in live websocket streaming (default off). Toggled with the
/// `toggle_live` action. While true the App polls `tick`, which
/// pulls the latest streamed price into `stream_price`. The stream
/// itself is owned by `DataService` and torn down on `deactivate` /
/// symbol change / `deinit`.
streaming: bool = false,
/// Latest streamed price for the active symbol, or null until the
/// first tick (or when not streaming). When set, it headlines the
/// quote as the most-current mark.
stream_price: ?f64 = null,
};
// Tab framework contract
@ -113,13 +124,16 @@ pub const meta: framework.TabMeta(Action) = .{
.default_bindings = &.{
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
.{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
.{ .action = .toggle_live, .key = .{ .codepoint = 'L' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.chart_timeframe_next = "Chart: next timeframe",
.chart_timeframe_prev = "Chart: previous timeframe",
.toggle_live = "Toggle live stream",
}),
.status_hints = &.{
.chart_timeframe_next,
.toggle_live,
},
};
@ -137,6 +151,17 @@ fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_ag
return session == .open and quote_age_s > quote_live_stale_s;
}
/// The price to headline for the quote tab, in precedence order: a live
/// streamed tick (the most-current mark), then the REST quote's close,
/// then the latest candle close. Null only when none are available.
/// Pure so the policy is unit-testable.
fn headlinePrice(stream_price: ?f64, quote: ?zfin.Quote, candles: []const zfin.Candle) ?f64 {
if (stream_price) |p| return p;
if (quote) |q| return q.close;
if (candles.len > 0) return candles[candles.len - 1].close;
return null;
}
/// 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
@ -149,7 +174,7 @@ fn refreshLiveQuote(state: *State, app: *App, force: bool) void {
// "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| {
if (app.svc.getQuote(app.symbol, app.fetch_options)) |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 });
@ -165,6 +190,7 @@ pub const tab = struct {
}
pub fn deinit(state: *State, app: *App) void {
if (state.streaming) app.svc.stopLiveStream();
state.chart.freeCache(app.allocator);
state.* = .{};
}
@ -197,7 +223,21 @@ pub const tab = struct {
refreshLiveQuote(state, app, false);
}
pub const deactivate = framework.noopDeactivate(State);
/// Leaving the tab stops any live stream so it doesn't keep a
/// socket + thread alive in the background (and frees the single
/// shared `DataService` stream for another tab to use). Re-enabled
/// by toggling again on return.
pub fn deactivate(state: *State, app: *App) void {
stopStream(state, app);
}
/// Framework poll-tick gate: true only while streaming, so the App
/// keeps a 100ms tick armed to pull fresh prices; switching away
/// (which stops the stream) lets the poll timer wind down.
pub fn wantsPollTick(state: *State, app: *App) bool {
_ = app;
return state.streaming;
}
/// Refresh (r/F5): reset the quote-only state, delegate to
/// performance.reload (which owns the shared candle/dividend data,
@ -211,7 +251,19 @@ pub const tab = struct {
refreshLiveQuote(state, app, true);
}
pub const tick = framework.noopTick(State);
/// Poll hook: while streaming, copy the latest streamed price for
/// the active symbol into `state.stream_price`. Runs ~10x/sec (only
/// while `wantsPollTick` is true), so the headline price tracks the
/// live feed. A symbol with no tick yet leaves `stream_price` as-is.
pub fn tick(state: *State, app: *App, frame: u64) void {
_ = frame;
if (!state.streaming or app.symbol.len == 0) return;
var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit();
const syms = [_][]const u8{app.symbol};
app.svc.liveStreamSnapshot(&syms, &prices);
if (prices.get(app.symbol)) |p| state.stream_price = p;
}
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
@ -225,6 +277,18 @@ pub const tab = struct {
state.chart.dirty = true;
app.setStatus(state.chart.timeframe.label());
},
.toggle_live => {
if (state.streaming) {
stopStream(state, app);
app.setStatus("Live stream: off");
} else if (app.symbol.len == 0) {
app.setStatus("No symbol to stream");
} else if (startStream(state, app)) {
app.setStatus("Live stream: on (Yahoo)");
} else {
app.setStatus("Live stream: failed to start");
}
},
}
}
@ -281,9 +345,41 @@ pub const tab = struct {
state.timestamp = 0;
state.chart.dirty = true;
state.chart.freeCache(app.allocator);
// Re-point an active stream at the new symbol (stop + start).
if (state.streaming) {
stopStream(state, app);
if (app.symbol.len > 0) _ = startStream(state, app);
}
}
};
// Live-stream helpers
/// Start the live stream for the active symbol. Returns true on
/// success. On failure the error is debug-logged and `streaming` stays
/// false so the tab silently falls back to the REST/close price.
fn startStream(state: *State, app: *App) bool {
const syms = [_][]const u8{app.symbol};
app.svc.startLiveStream(&syms) catch |err| {
std.log.scoped(.quote_tab).debug("{s}: live stream start failed: {t}", .{ app.symbol, err });
state.streaming = false;
state.stream_price = null;
return false;
};
state.streaming = true;
state.stream_price = null;
return true;
}
/// Stop the live stream if running and clear the streamed price.
/// Idempotent.
fn stopStream(state: *State, app: *App) void {
if (!state.streaming) return;
app.svc.stopLiveStream();
state.streaming = false;
state.stream_price = null;
}
// Rendering
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
@ -329,9 +425,13 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
// selected chart period (the chart's left edge -> now); the less
// relevant 1-day change lives in the detail section below the chart.
const cur_tf = app.states.quote.chart.timeframe;
const cur_price: ?f64 = if (app.states.quote.live) |q| q.close else if (c.len > 0) c[c.len - 1].close else null;
const sp = app.states.quote.stream_price;
const cur_price: ?f64 = headlinePrice(sp, app.states.quote.live, c);
if (app.states.quote.live) |q| {
if (sp) |p| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (live)", .{ sym_label, p });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
} else if (app.states.quote.live) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
} else if (c.len > 0) {
@ -623,7 +723,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
const latest = c[c.len - 1];
const quote_data = app.states.quote.live;
const price = if (quote_data) |q| q.close else latest.close;
const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
// `cur_tf` (the selected timeframe) is computed once at the
@ -651,6 +751,10 @@ pub const QuoteHeaderSource = union(enum) {
close: zfin.Date,
/// No timing info - just the symbol.
none,
/// Real-time websocket stream (NOT the ~15-min-delayed REST quote).
/// Carries the latest streamed price so the headline shows a live,
/// changing number even for symbols with no cached candles.
streaming: f64,
};
/// Format the quote tab's header line. Pure function over
@ -672,6 +776,7 @@ pub fn formatQuoteHeader(
.live => |ago| std.fmt.allocPrint(arena, " {s}{s} (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }),
.close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }),
.none => std.fmt.allocPrint(arena, " {s}{s}", .{ symbol, name_part }),
.streaming => |price| std.fmt.allocPrint(arena, " {s}{s} ${d:.2} (live)", .{ symbol, name_part, price }),
};
}
@ -702,7 +807,9 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
var ago_buf: [16]u8 = undefined;
const name = quoteTabName(app);
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
if (app.states.quote.streaming and app.states.quote.stream_price != null) {
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .streaming = app.states.quote.stream_price.? }), .style = th.headerStyle() });
} else if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
// wall-clock required: per-frame "now" for the data-age readout.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s);
@ -718,6 +825,13 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const quote_data = app.states.quote.live;
const c = app.symbol_data.candles orelse {
// Streaming with no cached candle history (e.g. BTC-USD): the
// header already carries the live price, so just note the
// absence of chart/history rather than the misleading "No data".
if (app.states.quote.streaming and app.states.quote.stream_price != null) {
try lines.append(arena, .{ .text = " Live price above. No chart history cached for this symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (quote_data) |q| {
// No candle data but have a quote - show it
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
@ -740,7 +854,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
}
// Use real-time quote price if available, otherwise latest candle
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse c[c.len - 1].close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
const latest = c[c.len - 1];
@ -1036,3 +1150,55 @@ test "shouldFetchLiveQuote: outside regular hours a held quote is never refetche
try testing.expect(!shouldFetchLiveQuote(.afterhours, true, 100_000));
try testing.expect(!shouldFetchLiveQuote(.closed, true, 100_000));
}
/// A `Quote` with only `close` meaningful; other fields are zeroed.
/// Enough to exercise the price-precedence helpers.
fn testQuote(close: f64) zfin.Quote {
return .{
.symbol = "X",
.exchange = "",
.datetime = "",
.close = close,
.open = 0,
.high = 0,
.low = 0,
.volume = 0,
.previous_close = 0,
.change = 0,
.percent_change = 0,
.average_volume = 0,
.fifty_two_week_low = 0,
.fifty_two_week_high = 0,
};
}
test "headlinePrice: a streamed tick wins over the quote and candle close" {
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 },
};
try testing.expectEqual(@as(?f64, 282.5), headlinePrice(282.5, testQuote(100.0), &candles));
}
test "headlinePrice: falls back quote -> candle close -> null" {
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 },
};
// No stream -> REST quote close.
try testing.expectEqual(@as(?f64, 100.0), headlinePrice(null, testQuote(100.0), &candles));
// No stream, no quote -> latest candle close.
try testing.expectEqual(@as(?f64, 50.0), headlinePrice(null, null, &candles));
// Nothing available -> null.
const empty = [_]zfin.Candle{};
try testing.expect(headlinePrice(null, null, &empty) == null);
}
test "formatQuoteHeader: streaming source shows a real-time price and (live) tag" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings(" AAPL $282.01 (live)", try formatQuoteHeader(arena, "AAPL", null, .{ .streaming = 282.01 }));
try testing.expectEqualStrings(
" AAPL Apple Inc. $282.01 (live)",
try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .streaming = 282.01 }),
);
}

View file

@ -1,4 +1,4 @@
//! View models for portfolio sections (Options, CDs).
//! View models for portfolio sections (Positions, Options, CDs).
//! Produces renderer-agnostic display data consumed by both CLI and TUI.
//! Column widths, format strings, computed values, and style decisions
//! are defined here. Renderers are thin adapters that map StyleIntent
@ -10,6 +10,86 @@ const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
// Positions (main holdings table)
/// Column layout for the main holdings table - the portfolio's primary
/// table, shared by the CLI (commands/portfolio.zig) and the TUI
/// (tui/portfolio_tab.zig). This is the single source of truth for the
/// column widths, so the CLI header/separator/rows, the TUI header/rows,
/// and the TUI click-to-sort hit-test offsets (portfolio_tab.col_end_*)
/// all derive from the same numbers and can't drift apart.
///
/// Unlike the Options / CDs layouts, the rows don't collapse into one
/// `data_row` string: the gain/loss cell is colored (so the CLI splits
/// the row across several prints) and the shares / weight cells render a
/// float inline in data rows but a pre-formatted string in headers and
/// the TUI's pre-rendered cells. So the per-column specs are exposed
/// individually alongside the composed header / separator / total
/// strings.
pub const PositionsLayout = struct {
const cp = std.fmt.comptimePrint;
// Column content widths. The 1-col separating space between columns
// is added at layout time, not included here.
pub const symbol_w = 10;
pub const shares_w = 12;
pub const price_w = 12; // avg cost and current price
pub const value_w = 16; // market value
pub const gainloss_w = 14; // sign + amount
pub const weight_w = 8; // "NNN.N%"
pub const date_w = 13;
pub const account_w = 8; // separator rule only; header/data are natural width
// Per-column format specs. `symbol_spec` and `date_str` left-justify
// their text columns (symbol and date read left-to-right, so their
// headers sit over the start of the data); the other specs right-
// justify their numeric columns. `shares_num` / `weight_num` render
// a float in place (data rows); the `*_str` specs right-justify a
// pre-formatted string (headers and the TUI's pre-rendered cells).
pub const symbol_spec = cp("{{s:<{d}}}", .{symbol_w});
pub const shares_num = cp("{{d:>{d}.1}}", .{shares_w});
pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1});
pub const shares_str = cp("{{s:>{d}}}", .{shares_w});
pub const price_str = cp("{{s:>{d}}}", .{price_w});
pub const value_str = cp("{{s:>{d}}}", .{value_w});
pub const gainloss_str = cp("{{s:>{d}}}", .{gainloss_w});
pub const weight_str = cp("{{s:>{d}}}", .{weight_w});
pub const date_str = cp("{{s:<{d}}}", .{date_w});
// CLI header + dashed separator (9 columns, see header_labels).
pub const header = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++
" " ++ price_str ++ " " ++ value_str ++ " " ++ gainloss_str ++
" " ++ weight_str ++ " " ++ date_str ++ " {s}\n";
pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" };
pub const separator = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++
" " ++ cp("{{s:->{d}}}", .{shares_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{value_w}) ++
" " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++
" " ++ cp("{{s:->{d}}}", .{weight_w}) ++
" " ++ cp("{{s:->{d}}}", .{date_w}) ++
" " ++ cp("{{s:->{d}}}", .{account_w}) ++ "\n";
pub const separator_fills = .{ "", "", "", "", "", "", "", "", "" };
// CLI TOTAL row: a sum rule spanning Symbol..Weight, then the totals
// row. `total` blanks the symbol / shares / avg-cost cells, right-
// justifies "TOTAL" in the price cell, and renders the market-value
// cell (4 string args + the market-value `{f}`); the caller prints
// the gain/loss and weight cells separately because gain/loss needs
// color.
pub const total_sep = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++
" " ++ cp("{{s:->{d}}}", .{shares_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{value_w}) ++
" " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++
" " ++ cp("{{s:->{d}}}", .{weight_w}) ++ "\n";
pub const total_sep_fills = .{ "", "", "", "", "", "", "" };
pub const total = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++
" " ++ price_str ++ " {f} ";
};
// Options
/// Column layout for the Options section.

View file

@ -100,6 +100,18 @@ pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??";
}
/// Format a horizon column header, age-aware. When `death_age` is
/// non-zero the column is age-of-death-anchored and renders "to age
/// N" (the horizon shrinks as retirement slides, so the fixed-year
/// label would be misleading). Otherwise falls back to the numeric
/// "N Year" label.
pub fn fmtHorizonLabelAge(buf: []u8, horizon: u16, death_age: u16) []const u8 {
if (death_age != 0) {
return std.fmt.bufPrint(buf, "to age {d}", .{death_age}) catch "??";
}
return fmtHorizonLabel(buf, horizon);
}
// Allocation summary
/// Result of formatting the allocation note.
@ -329,8 +341,6 @@ pub const ProjectionInputs = enum {
pub const ProjectionData = projections.ProjectionData;
pub const runProjectionGrid = projections.runProjectionGrid;
pub fn buildProjectionContext(
alloc: std.mem.Allocator,
config: projections.UserConfig,
@ -352,13 +362,43 @@ pub fn buildProjectionContext(
var retirement = config.resolveRetirement(as_of);
const accumulation_years: u16 = retirement.accumulation_years;
const data = try runProjectionGrid(
const horizons = config.getHorizons();
// Parallel age-of-death provenance: 0 = plain numeric horizon
// (no mortality), non-zero = age-anchored column.
const death_ages = config.horizon_death_age[0..horizons.len];
// Build per-column grid specs. Numeric columns share the uncapped
// `events` and carry no mortality (today's behavior). Age-anchored
// columns derive their distribution from the retirement boundary,
// terminate each person's events at their own death, and carry the
// survivor spending step-down at the first death.
const columns = try alloc.alloc(projections.GridColumn, horizons.len);
defer alloc.free(columns);
// Backing storage for age columns' capped events; only age slots
// are populated and read (numeric columns point at `events`).
const col_events = try alloc.alloc([projections.UserConfig.max_events]projections.ResolvedEvent, horizons.len);
defer alloc.free(col_events);
for (horizons, 0..) |h, i| {
if (death_ages[i] == 0) {
columns[i] = .{ .distribution_years = h, .events = events };
} else {
const m = projections.columnMortality(&config, as_of, death_ages[i], accumulation_years);
col_events[i] = config.resolveEventsToAge(as_of, death_ages[i]);
columns[i] = .{
.distribution_years = m.distribution_years,
.events = col_events[i][0..config.event_count],
.survivor_factor = m.survivor_factor,
.first_death_year = m.first_death_year,
};
}
}
const data = try projections.runProjectionGridColumns(
alloc,
config.getHorizons(),
columns,
config.getConfidenceLevels(),
total_value,
sim_stock_pct,
events,
accumulation_years,
config.annual_contribution,
config.contribution_inflation_adjusted,
@ -371,7 +411,6 @@ pub fn buildProjectionContext(
// available; same boundary year for all horizons).
var accumulation_stats: ?AccumulationStats = null;
if (accumulation_years > 0) {
const horizons = config.getHorizons();
if (horizons.len > 0) {
const last_band = data.bands[horizons.len - 1];
if (last_band) |b| {
@ -389,31 +428,57 @@ pub fn buildProjectionContext(
}
}
// Earliest retirement grid: when `target_spending` is set,
// search for the earliest retirement year per (horizon ×
// confidence) pair.
// Earliest retirement grid: when `target_spending` is set, search
// for the earliest retirement year per (horizon x confidence)
// pair. Numeric columns use the fixed-horizon search; age-anchored
// columns use the to-age search (distribution derived from the
// retirement date, mortality threaded through).
var earliest: ?[]projections.EarliestRetirement = null;
if (config.target_spending) |target| {
const horizons = config.getHorizons();
const confs = config.getConfidenceLevels();
const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len);
for (confs, 0..) |conf, ci| {
for (horizons, 0..) |h, hi| {
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
alloc,
total_value,
sim_stock_pct,
config.annual_contribution,
config.contribution_inflation_adjusted,
target,
config.target_spending_inflation_adjusted,
h,
conf,
events,
config.max_accumulation_years,
sim_expense_ratio,
config.spending_real_change orelse 0,
);
if (death_ages[hi] == 0) {
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
alloc,
total_value,
sim_stock_pct,
config.annual_contribution,
config.contribution_inflation_adjusted,
target,
config.target_spending_inflation_adjusted,
h,
conf,
events,
config.max_accumulation_years,
sim_expense_ratio,
config.spending_real_change orelse 0,
);
} else {
// The earliest search varies the retirement date,
// so the distribution is derived per-N from
// `total_span` (pass accumulation 0 here).
const m = projections.columnMortality(&config, as_of, death_ages[hi], 0);
cells[ci * horizons.len + hi] = try projections.findEarliestRetirementToAge(
alloc,
total_value,
sim_stock_pct,
config.annual_contribution,
config.contribution_inflation_adjusted,
target,
config.target_spending_inflation_adjusted,
m.total_span,
m.first_death_year,
m.survivor_factor,
death_ages[hi],
conf,
col_events[hi][0..config.event_count],
config.max_accumulation_years,
sim_expense_ratio,
config.spending_real_change orelse 0,
);
}
}
}
earliest = cells;
@ -437,7 +502,6 @@ pub fn buildProjectionContext(
// target-spending answer below it.
if (inputs == .target_spending) {
if (earliest) |grid| {
const horizons = config.getHorizons();
const confs = config.getConfidenceLevels();
if (projections.pickPromotedCell(&config, as_of, confs)) |pc| {
const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index];
@ -1071,13 +1135,17 @@ pub const TableRow = struct {
style: StyleIntent,
};
/// Build a column header row for a given set of horizons and column width.
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
/// Build a column header row for a given set of horizons and column
/// width. `death_ages` is parallel to `horizons` (0 = numeric column,
/// non-zero = age-anchored, rendered "to age N"); pass an
/// all-zero/empty-equivalent slice for a purely numeric grid.
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, death_ages: []const u16, col_width: usize) ![]const u8 {
var row: std.ArrayListUnmanaged(u8) = .empty;
try row.appendNTimes(arena, ' ', withdrawal_label_width);
for (horizons) |h| {
for (horizons, 0..) |h, hi| {
var hbuf: [16]u8 = undefined;
const hlabel = fmtHorizonLabel(&hbuf, h);
const da: u16 = if (hi < death_ages.len) death_ages[hi] else 0;
const hlabel = fmtHorizonLabelAge(&hbuf, h, da);
try row.appendNTimes(arena, ' ', col_width -| hlabel.len);
try row.appendSlice(arena, hlabel);
}
@ -1682,11 +1750,26 @@ test "buildHeaderRow formats horizons" {
const a = arena.allocator();
const horizons = [_]u16{ 30, 45 };
const result = try buildHeaderRow(a, &horizons, withdrawal_col_width);
const result = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width);
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null);
}
test "buildHeaderRow renders age-anchored columns as 'to age N'" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{ 30, 33 };
const death_ages = [_]u16{ 0, 95 }; // col 0 numeric, col 1 age-anchored
const result = try buildHeaderRow(a, &horizons, &death_ages, withdrawal_col_width);
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "to age 95") != null);
// The age column does NOT show its raw year count.
try std.testing.expect(std.mem.indexOf(u8, result, "33 Year") == null);
}
test "buildHeaderRow uses terminal column width" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
@ -1694,8 +1777,8 @@ test "buildHeaderRow uses terminal column width" {
const a = arena.allocator();
const horizons = [_]u16{20};
const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
const narrow = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width);
const wide = try buildHeaderRow(a, &horizons, &.{}, terminal_col_width);
try std.testing.expect(wide.len > narrow.len);
}
@ -1722,6 +1805,57 @@ test "buildWithdrawalRows produces amount and rate" {
try std.testing.expect(rows.rate.style == .muted);
}
test "buildPercentileRow renders '--' for null and empty bands" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const filled = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 100, .p25 = 200, .p50 = 300, .p75 = 400, .p90 = 500 },
};
const empty: []const projections.YearPercentiles = &.{};
const all_bands = [_]?[]const projections.YearPercentiles{ &filled, null, empty };
const row = try buildPercentileRow(a, "Median (p50)", 1, &all_bands, .normal);
// First column has data -> the p50 of the last year ($300).
try std.testing.expect(std.mem.indexOf(u8, row.text, "$300") != null);
// The null and empty columns each render the "--" sentinel.
try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null);
}
test "fmtEventLine: income/expense, timing, duration, and nominal branches" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const ages = [_]u16{60};
// Future income (start in the future) - positive style, "(in Nyr)".
var ss = projections.LifeEvent{ .start_age = 70, .person = 0, .annual_amount = 38_400 };
ss.name_len = @intCast((std.fmt.bufPrint(&ss.name, "Social Security", .{}) catch unreachable).len);
const ss_line = try fmtEventLine(a, &ss, &ages);
try std.testing.expect(ss_line.style == .positive);
try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "Social Security") != null);
try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "in 10yr") != null);
// Current expense with duration and nominal flag - negative style,
// "(now)", ", Nyr", ", nominal".
var exp = projections.LifeEvent{ .start_age = 60, .person = 0, .duration = 4, .annual_amount = -55_000, .inflation_adjusted = false };
exp.name_len = @intCast((std.fmt.bufPrint(&exp.name, "Tuition", .{}) catch unreachable).len);
const exp_line = try fmtEventLine(a, &exp, &ages);
try std.testing.expect(exp_line.style == .negative);
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "now") != null);
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "4yr") != null);
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "nominal") != null);
// Out-of-range person -> startYear null -> "age N" fallback (no timing).
const orphan = projections.LifeEvent{ .start_age = 67, .person = 3, .annual_amount = 1000 };
const orphan_line = try fmtEventLine(a, &orphan, &ages);
try std.testing.expect(std.mem.indexOf(u8, orphan_line.text, "age 67") != null);
}
test "swrRateNote: null without accumulation, present with accumulation" {
// Distribution-only (already retired): the rate is a correct
// withdrawal rate against the current portfolio, so render it.
@ -1786,6 +1920,14 @@ test "fmtHorizonLabel" {
try std.testing.expectEqualStrings("30 Year", label);
}
test "fmtHorizonLabelAge" {
var buf: [16]u8 = undefined;
// death_age 0 -> numeric "N Year" label.
try std.testing.expectEqualStrings("30 Year", fmtHorizonLabelAge(&buf, 30, 0));
// death_age non-zero -> "to age N", ignoring the (variable) year count.
try std.testing.expectEqualStrings("to age 95", fmtHorizonLabelAge(&buf, 33, 95));
}
// Accumulation phase / earliest retirement view tests
test "fmtRetirementLine: none" {
@ -2125,6 +2267,58 @@ test "buildProjectionContext: both_targets inputs when both fields configured" {
try std.testing.expect(ctx.earliest != null);
}
test "buildProjectionContext: age-anchored horizon flows through to the earliest grid" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
// Couple: born 1962 (~63) and 1967 (~58) as of mid-2026; plan to
// age 95 (horizon_age), targeting $50k/yr with a survivor cut.
var config = projections.parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:95
\\type::config,target_spending:num:50000
\\type::config,survivor_spending_pct:num:70
\\type::birthdate,date::1962-03-01
\\type::birthdate,date::1967-08-15,person:num:2
);
const as_of = Date.fromYmd(2026, 6, 15);
try config.resolveHorizonAges(as_of);
const comparison: benchmark.BenchmarkComparison = .{
.stock_returns = .{},
.bond_returns = .{},
.benchmark_returns = .{},
.portfolio_returns = .{},
.conservative_return = 0.07,
.stock_pct = 0.75,
.bond_pct = 0.25,
};
const ctx = try buildProjectionContext(
arena.allocator(),
config,
comparison,
0.75,
0.25,
3_000_000,
&.{},
as_of,
);
try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs);
try std.testing.expect(ctx.earliest != null);
// One age horizon x 3 confidences = 3 cells, all flagged age-anchored
// at 95 (the youngest reaches 95 last, setting the horizon).
try std.testing.expectEqual(@as(usize, 3), ctx.earliest.?.len);
for (ctx.earliest.?) |cell| {
try std.testing.expectEqual(@as(u16, 95), cell.death_age);
}
// The promoted headline comes from the age column.
try std.testing.expect(ctx.retirement.source == .promoted or
ctx.retirement.source == .promoted_infeasible);
}
// Overlay-actuals tests
/// Build a TimelinePoint with just the date and liquid value