Compare commits
19 commits
020fb2db77
...
44f940c882
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f940c882 | |||
| 3a1500bfbb | |||
| baf5e2a86f | |||
| bb04d7babd | |||
| fd5af31114 | |||
| 0f09c6d31a | |||
| a73966ad39 | |||
| 051ab35975 | |||
| eadba4696c | |||
| 279659188f | |||
| 123bd3eb46 | |||
| b2128bfcdd | |||
| 1bca0d596d | |||
| a978b2352e | |||
| 027f3b67ab | |||
| 0202652d09 | |||
| 4ed15c7cf4 | |||
| e22e987121 | |||
| 00482da00f |
47 changed files with 4498 additions and 592 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
202
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
10
build.zig
10
build.zig
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
src/Config.zig
135
src/Config.zig
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
805
src/net/LiveStream.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
290
src/service.zig
290
src/service.zig
|
|
@ -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{
|
||||
|
|
|
|||
10
src/tui.zig
10
src/tui.zig
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue