Compare commits

...

10 commits

28 changed files with 3797 additions and 268 deletions

View file

@ -21,7 +21,7 @@ repos:
language: pygrep
entry: ' — '
files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$'
exclude: '^(\.pre-commit-config\.yaml|src/format\.zig|src/views/projections\.zig|docs/reference/cli/milestones\.md)$'
exclude: '^(\.pre-commit-config\.yaml|src/format\.zig|src/views/projections\.zig|docs/reference/cli/milestones\.md|docs/reference/cli/compare\.md)$'
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:

View file

@ -24,6 +24,7 @@ ZFIN_HOME=examples/pre-retirement-both zfin portfolio
Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
========================================
Value: $1,383,137.81 Cost: $658,837.01 Gain/Loss: +$724,300.80 (109.9%)
Yesterday: -$1,845.22 (-0.13%)
Lots: 13 open, 0 closed Positions: 5 symbols
Historical: 1M: +3.2% 3M: +13.3% 1Y: +24.5% 3Y: +56.4% 5Y: +56.1% 10Y: +182.6%
@ -37,6 +38,13 @@ How to read it:
- **The header line** is your liquid total, total cost, and aggregate
gain/loss.
- **The session line** is a single trading day's move, equities only.
It is labelled by the session it actually describes: `Day` when the
prices are today's, `Yesterday` when the newest available closes are
yesterday's (the usual case mid-session, since this prices from cached
closes), or an explicit date after a weekend or with a stale cache.
Its percentage is against the previous session's whole account value,
so it lines up with the Gain/Loss percentage above.
- **Historical** is the portfolio's blended price return over trailing
windows -- a quick "how have my holdings done" gut check.
- **Each position** shows aggregated shares, average cost, current

View file

@ -40,6 +40,57 @@ Liquid: $2,350,000.00 -> $2,580,000.00 +$230,000.00 +9.79%
With symbols held on both dates, a per-symbol price-change table
appears, sorted by percentage move.
### Mixed share classes
A symbol can cover holdings in more than one share class -- for example
a direct-indexing sleeve, a 401(k) collective trust and plain retail
shares all priced through the same `ticker::` alias with different
`price_ratio` values. Those trade at different per-share prices, so the
group has no single price to show and the price columns render as
`—`. The percentage and dollar figures come from the change in market
value instead.
Whether that percentage is trustworthy depends on one thing: **did the
share count move?**
**Share count unchanged** -- the percentage is a real return, and the row
sorts inline with everything else. A group's value is its underlying
price times a fixed basket, so with the basket held still the change in
value *is* the change in price. You just don't get a per-share price
beside it:
```
SPYM — -> — +9.40% +$110,000.00
```
**Shares bought or sold** -- the figure now mixes market movement with
your own cash flow and there is no way to separate them. Those rows are
tallied separately, named in a footnote, and always sorted last so you
can disregard them as a block:
```
SPYM — -> — +30.69% +$419,605.63
18 gainers, 3 losers, 1 share count changed
SPYM: spans share classes AND the share count moved, so the figure is total value change - part market, part shares bought or sold.
```
In that second example much of the `+30.69%` is a retail purchase made
partway through the window, not market movement.
Ordinary (non-mixed) rows are never affected by this: their percentage is
a pure price ratio, so buying or selling during the window cannot
distort it.
One caveat the tool cannot detect: restating a `price_ratio` without
changing shares also breaks the equivalence, because the ratio itself is
not recorded in a snapshot. If you have just re-based a proxied holding,
treat percentages spanning that date with suspicion.
This split is a limitation of what snapshots record rather than a display
choice: `price_ratio` is folded into each lot's stored price and the
underlying base price is never written, so a single meaningful price for
the group cannot be reconstructed after the fact.
## See also
- [Snapshots and history](../../guides/snapshots-and-history.md)

View file

@ -22,6 +22,7 @@ ZFIN_HOME=examples/pre-retirement-both zfin portfolio
Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
========================================
Value: $1,383,137.81 Cost: $658,837.01 Gain/Loss: +$724,300.80 (109.9%)
Yesterday: -$1,845.22 (-0.13%)
Lots: 13 open, 0 closed Positions: 5 symbols
Historical: 1M: +3.2% 3M: +13.3% 1Y: +24.5% 3Y: +56.4% 5Y: +56.1% 10Y: +182.6%
@ -38,6 +39,42 @@ Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
Manual-priced rows render in warning color (the price may be stale).
### The session-change line
The second line is the move over a single trading session -- a brokerage
"day change". It covers equity holdings only; cash, CDs and options have
no daily price move and contribute zero. The percentage is measured
against the *previous session's whole account value*, cash included, so
it is comparable with the Gain/Loss percentage above it.
**The label names the session it describes, which is not always today:**
| label | meaning |
|-------|---------|
| `Day` | prices are from today -- live quotes, or today's close once it has posted |
| `Yesterday` | the newest prices available are yesterday's close |
| `2026-08-21` | older still: a weekend, a holiday, or a stale cache |
So a `zfin portfolio` run during Tuesday's session normally shows
`Yesterday`, because it prices from cached closes and Tuesday's bar does
not exist yet. In the TUI's live mode (`L`) it shows `Day`, updating as
ticks arrive.
A trailing marker appears when a security that *should* have a daily
price series didn't have one, and names the market value the figure is
therefore short by:
```
Day: +$12,345.67 (+0.85%) [$1.2M no history]
```
Holdings you price by hand with `price::` are **not** counted as a gap --
they can never have a daily series, so flagging them every run would be
noise. They already render in warning color. The marker is reserved for
the fixable case: try [`zfin doctor`](doctor.md) or
[`zfin cache stale`](cache.md) to find out why a security has no candle
history.
## See also
- [Read your portfolio](../../guides/read-your-portfolio.md) -- how to interpret this.

View file

@ -48,8 +48,39 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| `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`. |
| `benchmark_stock` | str | Symbol for the stock leg of the benchmark-comparison table. Default `SPY`. Max 16 characters; longer values are ignored with a warning. See [Choosing benchmark symbols](#choosing-benchmark-symbols). |
| `benchmark_bond` | str | Symbol for the bond leg of the benchmark-comparison table. Default `AGG`. Same 16-character limit. |
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
### Choosing benchmark symbols
`zfin projections` prints a **Benchmark comparison** table: the stock
leg's trailing returns, the bond leg's, a blend weighted by your actual
equity/fixed-income split, and your portfolio's own weighted return. The
two legs default to `SPY` and `AGG`:
```
type::config,benchmark_stock::SPYM
type::config,benchmark_bond::BND
```
Either may be set independently; an absent field keeps its default.
**Prefer a symbol you actually hold.** zfin only keeps candles and
dividends warm for symbols the portfolio holds (plus `watchlist.srf`).
A benchmark held nowhere is deliberately excluded from the routine
`zfin cache refresh` sweep -- it is fetched on demand when `zfin
projections` runs, and `zfin cache stale` reports it under "Not
checked". Its candles therefore stay current, but its **dividends** are
read from cache only and never refreshed, so its total returns drift
toward price-only over time. Pointing `benchmark_stock` at something
already in the portfolio avoids that entirely.
Note that the benchmark symbols affect only this comparison table. The
**Projected return** figure and the Monte Carlo simulation are computed
from your own holdings' trailing returns and are unaffected by this
setting.
### Choosing an `expense_ratio`
The expense ratio is the annual fund-fee drag on the portfolio. zfin

View file

@ -74,6 +74,12 @@ price stream (off by default). While on, prices update in place every
re-values the whole position table and totals as held symbols tick.
Press `L` again (or switch tabs) to stop.
The Portfolio tab's session-change line follows along: outside live mode
it reads `Yesterday` (or a date), because prices come from cached closes;
with the stream on it reads `Day` and tracks today's move as ticks
arrive. See [`zfin portfolio`](cli/portfolio.md#the-session-change-line)
for how that figure is defined.
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

View file

@ -319,6 +319,19 @@ revalue_positions: []const zfin.Position = &.{},
/// 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,
/// The equity portfolio's move over the session the current prices
/// belong to - the "Day" figure in the portfolio header. Null until the
/// candles worker has landed (day-over-day needs candle history), and
/// recomputed on every `revalue` so it tracks streamed ticks.
///
/// `_data` per this file's convention for worker-produced state: READ IT
/// VIA `dayChange()`, never directly. A direct read renders nothing on
/// the first paint and then has the figure appear once the worker lands -
/// which is precisely the bug that shipped.
///
/// See `valuation.DayChange`: it is keyed on the PRICED date, not on
/// today, so it stays correct outside live mode.
day_change_data: ?zfin.valuation.DayChange = 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
@ -420,6 +433,23 @@ pub fn snapshots(self: *PortfolioData) ?[HistoricalPeriod.all.len]HistoricalSnap
return self.snapshots_data;
}
/// The equity portfolio's move over the session the current prices belong
/// to. Blocks on the snapshots worker, which is where it is first computed
/// (day-over-day needs the candle history that worker waits on).
///
/// MUST be read through this rather than off the field: the field is null
/// until that worker lands, so a plain read renders nothing on the first
/// paint and then the figure silently appears once the user switches tabs
/// and comes back. `snapshots()` already pays this same await on the same
/// render, so there is no extra latency - the two land together.
///
/// `revalue` refreshes the field directly on every streamed tick
/// thereafter, by which point the future has long since resolved.
pub fn dayChange(self: *PortfolioData) ?zfin.valuation.DayChange {
self.awaitWorkerTimed(&self.snapshots_future, "snapshots (from dayChange)");
return self.day_change_data;
}
/// Per-symbol cached dividends. Blocks on the dividends worker.
pub fn dividends(self: *PortfolioData) ?*const std.StringHashMap([]const Dividend) {
self.awaitWorkerTimed(&self.dividends_future, "dividends");
@ -622,6 +652,7 @@ pub fn load(
// here and the recapture below can't read freed memory.
self.revalue_positions = &.{};
self.revalue_base_prices = null;
self.day_change_data = null;
self.summary = null;
self.latest_quote_date = null;
self.live_prices_applied = false;
@ -955,9 +986,49 @@ pub fn revalue(self: *PortfolioData, today: Date, overlay: *const std.StringHash
}
}
self.live_prices_applied = applied;
self.recomputeDayChange(today, prices, manual_price_set);
return true;
}
/// Recompute `day_change_data` from the price map the summary was just built
/// from, so the two can never disagree about what a position is worth.
///
/// Silently leaves `day_change_data` alone when the inputs aren't there yet:
/// the candles map arrives on a background worker, so on a cold start the
/// header simply omits the figure until it lands - the same gating the
/// `Historical:` line already has.
///
/// The session being reported is the one the CURRENT prices belong to, not
/// today: with a live overlay applied that is today, otherwise it is the
/// newest quote date across held symbols. Getting this wrong is what makes
/// a weekend view report a $0.00 day - see `valuation.DayChange`.
fn recomputeDayChange(
self: *PortfolioData,
today: Date,
prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
) void {
const candle_map = self.candles_data orelse return;
if (self.revalue_positions.len == 0) return;
// Lots are needed only to tell a hand-priced holding (permanently
// un-day-changeable, so not a gap) from a security that should have
// priced and didn't.
const pf = self.file orelse return;
const priced_date = if (self.live_prices_applied)
today
else
self.latest_quote_date orelse return;
self.day_change_data = zfin.valuation.computeDayChange(
priced_date,
self.revalue_positions,
pf.lots,
prices,
manual_prices,
candle_map,
);
}
/// 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
@ -987,6 +1058,8 @@ pub fn cancelLoad(self: *PortfolioData) void {
if (self.snapshots_future) |*f| _ = f.cancel(self.io);
self.snapshots_future = null;
self.snapshots_data = null;
// Derived from the snapshots worker's inputs, so it dies with them.
self.day_change_data = null;
if (self.dividends_future) |*f| _ = f.cancel(self.io);
self.dividends_future = null;
self.dividends_data = null;
@ -1045,20 +1118,42 @@ fn snapshotsWorker(self: *PortfolioData, as_of: Date, positions: []const zfin.Po
// read.
self.io.checkCancel() catch return;
const candle_map = self.candles_data orelse return;
const summary_ref = self.summary orelse return;
var prices = std.StringHashMap(f64).init(self.arena.child_allocator);
defer prices.deinit();
for (summary_ref.allocations) |alloc| {
prices.put(alloc.symbol, alloc.current_price) catch return;
}
// `computeHistoricalSnapshots` wants RAW base-ticker prices - it applies
// each position's `price_ratio` itself. `revalue_base_prices` is exactly
// that map (candle closes, captured in `load` before the fallback
// derivation), and it is what the CLI path passes too, so both paths now
// agree by construction.
//
// This used to rebuild the map from `summary.allocations[].current_price`,
// which is NOT raw: for an unmerged allocation that price already has the
// lot's ratio folded in, so applying `price_ratio` again squared it. The
// historical side comes from raw candles and was correct, so the ratio
// between the two was wrong and every percentage on the TUI's
// `Historical:` line broke for a lone `ticker::` + `price_ratio::` lot.
// See the "Per-LOT display rows" block in `models/portfolio.zig` for the
// same trap in the lot-row renderers.
const prices = self.revalue_base_prices orelse return;
self.snapshots_data = zfin.valuation.computeHistoricalSnapshots(
as_of,
positions,
prices,
// `prices` here is the pre-fallback candle-close map, so it holds no
// overrides and needs no manual set.
null,
candle_map,
);
// First chance to compute the day change: it needs candle history for
// the prior close, which is what this worker just waited on. `revalue`
// refreshes it on every streamed tick thereafter.
//
// `prices` here is the pre-fallback base map, so a position priced only
// by a manual override or avg-cost fallback is absent and lands in
// `positions_total` without contributing - which is honest, since
// neither has a day-over-day meaning. Hence no manual set to pass.
self.recomputeDayChange(as_of, prices, null);
}
/// Warm the dividend cache, then read it into the map.
@ -1233,6 +1328,48 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
try testing.expect(pd.classification_map_data == null);
}
test "PortfolioData.dayChange: reads through the accessor, not the raw field" {
// The regression: the portfolio header read the raw field as a plain
// field. It is populated by the snapshots worker, so on the FIRST paint
// it was still null and the session-change line was simply missing -
// then appeared once the user switched tabs and came back and the
// worker had landed. `snapshots()` avoids that by awaiting the same
// future; `dayChange()` must do the same.
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-daychange-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer pd.deinit();
// Nothing loaded: null, and no hang on the absent future.
try testing.expect(pd.dayChange() == null);
// Once the worker has produced a figure, the accessor surfaces it.
pd.day_change_data = .{
.priced_date = Date.fromYmd(2026, 8, 26),
.change = -7180.04,
.prev_stock_value = 100_000,
.curr_stock_value = 92_819.96,
.positions_covered = 23,
.positions_priceable = 23,
.positions_total = 25,
.uncovered_value = 0,
};
const dc = pd.dayChange() orelse return error.TestUnexpectedResult;
try testing.expectApproxEqAbs(@as(f64, -7180.04), dc.change, 0.001);
try testing.expect(dc.hasData());
// 23 of 23 POSSIBLE positions covered; the other 2 are hand-priced and
// can never be day-changed, so this counts as complete and the header
// shows no shortfall marker.
try testing.expect(dc.complete());
// cancelLoad drops it with the rest of the worker-derived state.
pd.cancelLoad();
try testing.expect(pd.dayChange() == null);
}
test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" {
var svc: DataService = .{
.allocator = testing.allocator,

View file

@ -1082,7 +1082,20 @@ pub fn analyzePortfolio(
},
.cash => lot.shares,
.cd => lot.shares, // face value
.option => @abs(lot.shares) * lot.open_price,
// Premium at open. `multiplier` (100 for standard US equity
// options) is NOT optional - omitting it counted 1/100th of
// the premium and left every option-holding account's row
// short by 99% of it, so `zfin analysis`'s By Account
// section disagreed with its own Options sector row and
// with the `kind::account` totals in every snapshot.
//
// Must stay identical to `Portfolio.nonStockValueForAccount`
// and the `.option` arm of `commands/snapshot.zig:buildSnapshot`.
// All three use `@abs`, so a WRITTEN (short) option counts as
// a positive asset rather than a liability. That is a
// deliberate shared convention, not an oversight - do not
// "fix" it at one site alone.
.option => @abs(lot.shares) * lot.open_price * lot.multiplier,
.illiquid, .watch => continue,
};
const prev = acct_map.get(acct) orelse 0;
@ -2200,6 +2213,61 @@ fn testTaxRollup(
);
}
test "analyzePortfolio: an option's account value includes the contract multiplier" {
// The regression. This arm used to be `@abs(shares) * open_price`
// with no `multiplier`, so it counted $2.05 where every other site
// counted $205.00 - leaving each option-holding account short by 99%
// of its premium, and making `zfin analysis`'s By Account section
// disagree with its own Options sector row and with the
// `kind::account` rows written into every snapshot.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{
.symbol = "SPY",
.shares = 1000,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.account = "Sample Brokerage",
},
// A written call: -2 contracts at $2.05 premium, multiplier 100.
// Premium = |-2| * 2.05 * 100 = $410.00, NOT $4.10.
// Opened before `testTaxRollup`'s as_of (2026-08-01) and maturing
// after it, so `lotIsOpenAsOf` includes it.
.{
.security_type = .option,
.symbol = "SPY 09/18/2026 700.00 C",
.shares = -2,
.open_date = Date.fromYmd(2026, 7, 21),
.maturity_date = Date.fromYmd(2026, 9, 18),
.open_price = 2.05,
.option_type = .call,
.underlying = "SPY",
.strike = 700,
.account = "Sample Brokerage",
},
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 100_000);
defer result.deinit(std.testing.allocator);
const acct = testBreakdownValue(result.account, "Sample Brokerage").?;
// $100,000 of stock + $410.00 of premium. Under the bug: $100,004.10.
try std.testing.expectApproxEqAbs(@as(f64, 100_410.0), acct, 0.005);
// And it must agree with the other two copies of this formula. This is
// the invariant that actually matters - the three sites drifted once.
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
const non_stock = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 8, 26), "Sample Brokerage");
try std.testing.expectApproxEqAbs(@as(f64, 410.0), non_stock, 0.005);
try std.testing.expectApproxEqAbs(acct, 100_000.0 + non_stock, 0.005);
}
test "analyzePortfolio: a mixed account splits its value across tax-type rows" {
// The headline case. One 401(k) reporting a single $400k balance that
// is really 75% pre-tax and 25% Roth, plus a $100k taxable brokerage.

View file

@ -358,32 +358,53 @@ pub const UserConfig = struct {
/// `retirement_at` derives its accumulation years directly and
/// ignores this cap.
max_accumulation_years: u16 = default_max_accumulation_years,
/// Stock benchmark symbol used in the projection's
/// benchmark-comparison table and bands. Defaults to "SPY".
/// Stock benchmark symbol for the projection's benchmark-comparison
/// table and bands, as an inline buffer + length. Read it through
/// `benchmarkStock()`, never directly: `benchmark_stock_len == 0`
/// means "no override, use `default_benchmark_stock`".
///
/// Override via `type::config,benchmark_stock::SYMBOL` in
/// `projections.srf`. The slice points into
/// `benchmark_stock_buf` when overridden, or into a string
/// literal in the binary's read-only data segment for the
/// default - either way, valid for the lifetime of the
/// `UserConfig`.
benchmark_stock: []const u8 = "SPY",
/// Backing buffer for an overridden `benchmark_stock`. Untouched
/// (and unread) when the default is in effect. Sized to fit
/// reasonable ticker lengths.
// SAFETY: only read when `benchmark_stock` points into this buffer
// (i.e. when the user has overridden the default); otherwise the
// backing slice points at a literal and this buffer is unobserved.
/// `projections.srf`.
///
/// This USED to be a `[]const u8` that pointed into the sibling
/// buffer when overridden. That is a self-referential struct, and
/// `parseProjectionsConfig` returns `UserConfig` BY VALUE - so the
/// returned copy's slice pointed into the dead local's frame. An
/// override therefore arrived downstream as N bytes of whatever the
/// stack had been reused for (observed: NULs), silently blanking the
/// stock leg of the benchmark table. The defaults never broke
/// because string literals live in .rodata, which is why a feature
/// that had probably never worked went unnoticed.
///
/// Do not reintroduce a stored slice here. The codebase convention
/// is inline-buffer + length + accessor precisely so a value type
/// stays copy-safe - see `LifeEvent.getName` below, plus
/// `models/quote.zig:Quote.name`, `analytics/analysis.zig:Annotation`,
/// `commands/cache.zig:FileInfo.lastDate`, and `tui.zig:ParsedArgs.symbol`.
// SAFETY: paired with `benchmark_stock_len`; only
// `benchmark_stock_buf[0..benchmark_stock_len]` is ever read, and
// `benchmarkStock()` returns the default literal when the length is 0.
benchmark_stock_buf: [16]u8 = undefined,
/// Bond benchmark symbol. Same lifetime / override mechanics
/// as `benchmark_stock`.
benchmark_bond: []const u8 = "AGG",
// SAFETY: same override-only read pattern as `benchmark_stock_buf`.
/// Length of the `benchmark_stock` override; 0 = use the default.
benchmark_stock_len: u8 = 0,
/// Bond benchmark symbol. Same buffer + length + accessor mechanics
/// as `benchmark_stock`; read via `benchmarkBond()`.
// SAFETY: paired with `benchmark_bond_len`; same read-only-the-prefix
// invariant as `benchmark_stock_buf`.
benchmark_bond_buf: [16]u8 = undefined,
/// Length of the `benchmark_bond` override; 0 = use the default.
benchmark_bond_len: u8 = 0,
const max_horizons: usize = 8;
const max_persons: usize = 4;
pub const max_events: usize = 16;
/// Benchmark symbols used when `projections.srf` declares no
/// override. Public so the fetch-policy layer and tests can name
/// them instead of duplicating the literals.
pub const default_benchmark_stock: []const u8 = "SPY";
pub const default_benchmark_bond: []const u8 = "AGG";
/// Errors that can arise when resolving age-based horizons.
pub const ResolveError = error{
/// `type::config,horizon_age:num:N` was specified in projections.srf
@ -391,6 +412,26 @@ pub const UserConfig = struct {
HorizonAgeWithoutBirthdate,
};
/// The stock benchmark symbol: the user's override, else
/// `default_benchmark_stock`.
///
/// Derives the slice from `self` at call time, which is what makes
/// `UserConfig` copy-safe. Returning a stored slice into
/// `benchmark_stock_buf` instead is the bug documented on that
/// field - don't.
pub fn benchmarkStock(self: *const UserConfig) []const u8 {
if (self.benchmark_stock_len == 0) return default_benchmark_stock;
return self.benchmark_stock_buf[0..self.benchmark_stock_len];
}
/// The bond benchmark symbol: the user's override, else
/// `default_benchmark_bond`. Same copy-safety note as
/// `benchmarkStock`.
pub fn benchmarkBond(self: *const UserConfig) []const u8 {
if (self.benchmark_bond_len == 0) return default_benchmark_bond;
return self.benchmark_bond_buf[0..self.benchmark_bond_len];
}
pub fn getHorizons(self: *const UserConfig) []const u16 {
return self.horizons[0..self.horizon_count];
}
@ -706,16 +747,17 @@ pub const max_abs_spending_real_change: f64 = 0.10;
/// demand" stays a fetch-policy question and the cache sweep does not have to
/// know that the answer happens to come from projections config.
///
/// The strings are DUPED into `arena`. An overridden symbol lives in a `[16]u8`
/// field inside the returned `UserConfig`, so a borrowed slice dangles the
/// moment that struct goes out of scope - which is the whole reason this exists
/// rather than callers reading the config themselves.
/// The strings are DUPED into `arena` so the result outlives the local
/// `UserConfig` this reads them from. (An overridden symbol lives in an
/// inline `[16]u8` inside that config, so borrowing would tie the
/// caller's lifetime to a function-local; `UserConfig` itself is
/// copy-safe, but a slice into one particular copy of it is not.)
pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8) []const []const u8 {
const data = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024)) catch null;
const cfg = parseProjectionsConfig(data);
const pair = arena.alloc([]const u8, 2) catch return &.{};
pair[0] = arena.dupe(u8, cfg.benchmark_stock) catch return &.{};
pair[1] = arena.dupe(u8, cfg.benchmark_bond) catch return &.{};
pair[0] = arena.dupe(u8, cfg.benchmarkStock()) catch return &.{};
pair[1] = arena.dupe(u8, cfg.benchmarkBond()) catch return &.{};
return pair;
}
@ -904,10 +946,14 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
} else {
// Dupe into our own buffer so the slice
// outlives the SRF iterator's backing data.
// Copy into our own buffer + length so the value
// outlives both the SRF iterator's backing data
// AND this function's frame. Storing a slice into
// the buffer instead would dangle the moment
// `config` is returned by value. Same shape as
// the `.event` arm's name handling below.
@memcpy(config.benchmark_stock_buf[0..sym.len], sym);
config.benchmark_stock = config.benchmark_stock_buf[0..sym.len];
config.benchmark_stock_len = @intCast(sym.len);
}
}
if (c.benchmark_bond) |sym| {
@ -915,7 +961,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
} else {
@memcpy(config.benchmark_bond_buf[0..sym.len], sym);
config.benchmark_bond = config.benchmark_bond_buf[0..sym.len];
config.benchmark_bond_len = @intCast(sym.len);
}
}
},
@ -2867,8 +2913,11 @@ test "parseProjectionsConfig rejects negative return_cap" {
test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
const config = parseProjectionsConfig(null);
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
try std.testing.expectEqualStrings("SPY", config.benchmarkStock());
try std.testing.expectEqualStrings("AGG", config.benchmarkBond());
// len == 0 is what "no override" means; the buffers stay unread.
try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len);
try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len);
}
test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
@ -2878,8 +2927,54 @@ test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
\\type::config,benchmark_bond::BND
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("VTI", config.benchmark_stock);
try std.testing.expectEqualStrings("BND", config.benchmark_bond);
try std.testing.expectEqualStrings("VTI", config.benchmarkStock());
try std.testing.expectEqualStrings("BND", config.benchmarkBond());
}
test "parseProjectionsConfig: an override survives copying the config by value" {
// THE REGRESSION TEST. `benchmark_stock` used to be a `[]const u8`
// pointing into `benchmark_stock_buf` - a self-reference - while
// `parseProjectionsConfig` returns `UserConfig` BY VALUE. Every copy
// after the first therefore carried a slice into a dead frame.
//
// The pre-existing tests all read the symbol one statement after the
// parse call, in the frame that received the return value, so the
// dead bytes were still intact and all of them passed. This one
// copies the struct, scribbles over the stack, and only then reads -
// which is what production does via `ProjectionContext`.
const data =
\\#!srfv1
\\type::config,benchmark_stock::VTI
\\type::config,benchmark_bond::BND
;
var copies: [4]UserConfig = undefined;
copies[0] = parseProjectionsConfig(data);
// Copy through a chain, the way buildContextFromParts ->
// buildProjectionContext -> ProjectionContext does.
copies[1] = copies[0];
copies[2] = copies[1];
copies[3] = copies[2];
// Churn the stack that `parseProjectionsConfig` used, so a dangling
// pointer reads garbage rather than stale-but-correct bytes.
stackChurn();
for (copies) |c| {
try std.testing.expectEqualStrings("VTI", c.benchmarkStock());
try std.testing.expectEqualStrings("BND", c.benchmarkBond());
}
}
/// Overwrite a chunk of stack so a dangling slice into a returned-by-value
/// struct reads scribble instead of stale-but-intact bytes. `noinline` and
/// the volatile-ish sum keep the optimizer from eliding it.
noinline fn stackChurn() void {
var scratch: [16 * 1024]u8 = undefined;
@memset(&scratch, 0xAA);
var sum: usize = 0;
for (scratch) |b| sum +%= b;
std.mem.doNotOptimizeAway(sum);
}
test "parseProjectionsConfig partial benchmark override falls back to default" {
@ -2889,8 +2984,9 @@ test "parseProjectionsConfig partial benchmark override falls back to default" {
\\type::config,benchmark_stock::QQQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("QQQ", config.benchmark_stock);
try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
try std.testing.expectEqualStrings("QQQ", config.benchmarkStock());
try std.testing.expectEqualStrings("AGG", config.benchmarkBond());
try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len);
}
test "parseProjectionsConfig rejects oversized benchmark symbol" {
@ -2900,7 +2996,18 @@ test "parseProjectionsConfig rejects oversized benchmark symbol" {
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
try std.testing.expectEqualStrings("SPY", config.benchmarkStock());
try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len);
}
test "UserConfig: a 16-char symbol fits exactly (boundary)" {
const data =
\\#!srfv1
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOP
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", config.benchmarkStock());
try std.testing.expectEqual(@as(u8, 16), config.benchmark_stock_len);
}
test "parseProjectionsConfig parses both retirement_age and retirement_at" {

View file

@ -205,7 +205,27 @@ pub const Allocation = struct {
shares: f64,
/// Weighted average cost per share across all lots (cost_basis / shares).
avg_cost: f64,
/// Latest price from API (or manual fallback), before price_ratio adjustment.
/// Latest price from API (or manual fallback). WHETHER
/// `price_ratio` HAS BEEN APPLIED DEPENDS ON `price_ratio` ITSELF -
/// read this together with that field, never alone:
///
/// - `price_ratio != 1.0` (UNMERGED): this is the EFFECTIVE price,
/// `pos.effectivePrice(raw, is_manual)`, ratio already applied.
/// - `price_ratio == 1.0`: either a plain unratioed position, or a
/// group that `mergeAllocsBySymbol` folded - in which case this
/// is the RAW base-ticker price (`total_mv / norm_shares`) and
/// `shares` are in base-ticker-equivalent units.
///
/// Either way `shares * current_price == market_value` holds, which
/// is what makes the position row self-consistent.
///
/// Never feed this into a per-lot calculation: a lot row that
/// multiplies its own raw shares by this price is off by the lot's
/// ratio in the merged case, and squares it if you "fix" that by
/// keying on `is_manual_price` instead. Per-lot display sites go
/// through `views/portfolio_sections.zig:effectivePriceFor`, which
/// discriminates on `price_ratio`. See the "Per-LOT display rows"
/// section of the pricing-model block in `models/portfolio.zig`.
current_price: f64,
/// Total current value: shares * current_price * price_ratio.
/// May be reduced by adjustForCoveredCalls for ITM sold calls
@ -227,6 +247,18 @@ pub const Allocation = struct {
account: []const u8 = "",
/// Price ratio applied (for display context; 1.0 means no ratio).
price_ratio: f64 = 1.0,
/// True when `mergeAllocsBySymbol` folded two or more ratio variants
/// of one ticker into this row. Distinguishes the two ways
/// `price_ratio == 1.0` can arise - a plain unratioed position versus
/// a merged group - which `price_ratio` alone cannot express and
/// which determines whether `current_price` is the lot's effective
/// price or the raw base-ticker price.
///
/// Consumers that only need "is `current_price` raw?" can keep using
/// `price_ratio == 1.0`; this exists for consumers that must know the
/// price's UNITS, e.g. deciding whether it is comparable against a
/// snapshot's ratio-scaled per-lot price.
merged: bool = false,
};
/// Net worth = liquid (stocks + cash + CDs + options) + illiquid assets.
@ -423,6 +455,7 @@ fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Al
.is_manual_price = is_manual,
.account = "Multiple",
.price_ratio = 1.0, // normalized to base units
.merged = true,
});
}
@ -637,6 +670,222 @@ pub const HistoricalPeriod = enum {
};
};
/// A single session's move in the portfolio's equity holdings - the
/// brokerage "Day Change" figure.
///
/// ## Which session
///
/// `priced_date` is the date the CURRENT prices belong to, and the whole
/// figure describes that day's move. It is deliberately NOT "today":
///
/// - Streaming live on a Tuesday: current prices are live ticks, so
/// `priced_date` is Tuesday and this is Tuesday's move so far.
/// - Not streaming, Tuesday mid-session: the newest candle is Monday's,
/// so current prices ARE Monday's closes, `priced_date` is Monday, and
/// this is Monday's completed move.
/// - Saturday: the newest candle is Friday's, so this is Friday's move.
///
/// Keying on the priced date instead of today is what keeps the figure
/// correct outside live mode. Anchoring the previous close to `today - 1`
/// instead collapses to zero every weekend, because Friday's bar is both
/// "the current price" and "the last close before today".
///
/// Use `recency()` to label it - a renderer should say "Day" only when the
/// priced date really is today.
///
/// ## What is included
///
/// Equity positions only. Cash, CDs, options and illiquid assets have no
/// daily price move and contribute zero, matching how
/// `adjustForNonStockAssets` keeps them out of `unrealized_gain_loss`.
/// They DO belong in the percentage's denominator - see `pctOfAccount`.
pub const DayChange = struct {
/// The session this describes. See the type doc.
priced_date: Date,
/// `curr_stock_value - prev_stock_value`. Signed.
change: f64,
/// Covered positions valued at the previous session's closes.
prev_stock_value: f64,
/// The same positions at current prices.
curr_stock_value: f64,
/// Positions with BOTH a current price and a previous close.
positions_covered: usize,
/// Positions that COULD have a daily series - everything except the
/// ones the user prices by hand.
///
/// A holding with an explicit `price::` and no candle history can never
/// be day-changed: the only price zfin will ever have for it is the one
/// typed in, and no amount of fixing changes that. Counting it as a gap
/// means the shortfall marker is permanently lit, which trains the
/// reader to ignore it. So those are excluded here, and
/// `positions_total - positions_priceable` is the impossible remainder.
///
/// `covered < positions_priceable` is therefore the only interesting
/// condition: a security that SHOULD have priced and didn't.
positions_priceable: usize,
/// All equity positions considered, impossible ones included.
positions_total: usize,
/// Market value of the priceable-but-uncovered positions - how much the
/// dollar figure is short by. Reported instead of a count because two
/// missing securities could be $3k or $3M, and only the former is
/// ignorable.
///
/// Valued at the current price when there is one, else at `avg_cost`
/// (the same last-resort the summary itself falls back to), so this
/// stays meaningful even on the first paint where the price map holds
/// candle closes only.
uncovered_value: f64,
/// The move as a fraction of YESTERDAY'S WHOLE ACCOUNT value, cash and
/// CDs and options included.
///
/// `total_value_now` is the summary's `total_value`; since the non-stock
/// part cannot move intraday, `total_value_now - change` is exactly the
/// prior session's account value. Dilutes by the non-equity weight -
/// the same dilution `unrealized_return` already carries, and the same
/// basis a brokerage reports an account-level day change on, so this
/// figure is comparable with the Gain/Loss percentage beside it.
///
/// Positions excluded from `change` sit in this denominator, which
/// implicitly treats them as flat. Measured on a real portfolio the
/// difference against excluding them was 0.1277% vs 0.1323% - identical
/// once rendered to one decimal - so the simpler whole-account basis
/// wins.
///
/// Zero when the prior value works out to zero.
pub fn pctOfAccount(self: DayChange, total_value_now: f64) f64 {
const prev_total = total_value_now - self.change;
if (prev_total == 0) return 0;
return self.change / prev_total;
}
/// True when every position that COULD be day-changed was. Impossible
/// ones don't count against it - see `positions_priceable`.
pub fn complete(self: DayChange) bool {
return self.positions_covered == self.positions_priceable;
}
/// Whether anything was priced at all. A renderer should suppress the
/// figure entirely when this is false rather than show `+$0.00`.
pub fn hasData(self: DayChange) bool {
return self.positions_covered > 0;
}
pub const Recency = enum { today, yesterday, older };
/// How `priced_date` relates to `today`, so a renderer can choose
/// between "Day", "Yesterday" and naming the date outright.
pub fn recency(self: DayChange, today: Date) Recency {
if (self.priced_date.eql(today)) return .today;
if (self.priced_date.eql(today.addDays(-1))) return .yesterday;
return .older;
}
};
/// Whether the user prices this symbol by hand, i.e. some lot carries an
/// explicit `price::`.
///
/// Deliberately keyed on `lot.price`, NOT on the `manual_prices` set from
/// `buildFallbackPrices`: that set also collects avg-cost fallbacks, which
/// are the opposite case - a security that should have priced and didn't.
/// Using it here would silently hide exactly the failures worth surfacing.
fn userPricedSymbol(lots: []const portfolio_mod.Lot, price_symbol: []const u8) bool {
for (lots) |lot| {
if (lot.security_type != .stock) continue;
if (lot.price == null) continue;
if (std.mem.eql(u8, lot.priceSymbol(), price_symbol)) return true;
}
return false;
}
/// Compute the equity portfolio's move over the session ending at
/// `priced_date`.
///
/// `current_prices` maps symbol -> price. Entries are **RAW base-ticker
/// prices** except those named in `manual_prices`, which are already in the
/// lot's own share class; the ratio is applied here, so mislabelling one as
/// raw squares it.
///
/// `lots` is used only to tell an IMPOSSIBLE gap from a FIXABLE one: a
/// hand-priced holding with no candle history can never be day-changed and
/// is left out of `positions_priceable`, whereas a security that should
/// have priced and didn't is counted so the caller can flag it. See
/// `DayChange.positions_priceable`.
///
/// The previous close comes from `candleCloseOnOrBefore(candles,
/// priced_date - 1)`, which snaps backward over weekends and holidays and
/// - crucially - excludes `priced_date`'s own bar, so the same call is
/// correct whether or not today's candle has been written yet.
pub fn computeDayChange(
priced_date: Date,
positions: []const portfolio_mod.Position,
lots: []const portfolio_mod.Lot,
current_prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
candle_map: std.StringHashMap([]const Candle),
) DayChange {
var prev_value: f64 = 0;
var curr_value: f64 = 0;
var uncovered: f64 = 0;
var covered: usize = 0;
var priceable: usize = 0;
var total: usize = 0;
const prev_target = priced_date.addDays(-1);
for (positions) |pos| {
if (pos.shares <= 0) continue;
total += 1;
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
const maybe_curr = current_prices.get(pos.symbol);
// This position's contribution to the shortfall if it turns out to
// be uncovered. Falls back to avg_cost the same way
// `Portfolio.totalForAccount` does, so the figure survives the
// first paint, where the price map holds candle closes only.
const pos_value = if (maybe_curr) |p|
pos.marketValue(p, is_manual)
else
pos.marketValue(pos.avg_cost, true);
const candles = candle_map.get(pos.symbol);
// Impossible rather than missing: hand-priced with no daily series.
// Not counted as priceable, so it can never light the marker.
if (candles == null and userPricedSymbol(lots, pos.symbol)) continue;
priceable += 1;
const raw_curr = maybe_curr orelse {
uncovered += pos_value;
continue;
};
const cs = candles orelse {
uncovered += pos_value;
continue;
};
const prev = candleCloseOnOrBefore(cs, prev_target) orelse {
uncovered += pos_value;
continue;
};
curr_value += pos.marketValue(raw_curr, is_manual);
// Candle closes are always raw, so the ratio applies.
prev_value += pos.marketValue(prev.close, false);
covered += 1;
}
return .{
.priced_date = priced_date,
.change = curr_value - prev_value,
.prev_stock_value = prev_value,
.curr_stock_value = curr_value,
.positions_covered = covered,
.positions_priceable = priceable,
.positions_total = total,
.uncovered_value = uncovered,
};
}
/// One snapshot of portfolio value at a historical date.
pub const HistoricalSnapshot = struct {
period: HistoricalPeriod,
@ -675,12 +924,27 @@ fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
/// Compute historical portfolio snapshots for all standard lookback periods.
/// `candle_map` maps symbol -> sorted candle slice.
/// `current_prices` maps symbol -> current price.
///
/// `current_prices` maps symbol -> price. Entries are **RAW base-ticker
/// prices** except those named in `manual_prices`, which are already in the
/// lot's own share class. NEVER pass
/// `summary.allocations[].current_price`: for an unmerged allocation that
/// already has the lot's `price_ratio` folded in and would get it applied a
/// second time below, squaring it. That shipped in the TUI path and silently
/// broke every percentage on the `Historical:` line for a lone `ticker::` +
/// `price_ratio::` lot.
///
/// `manual_prices` may be null when the caller's map is known to hold candle
/// closes only - e.g. `PortfolioData.revalue_base_prices`, captured before
/// `buildFallbackPrices` folds in overrides. Pass the set whenever the map
/// went through that function.
///
/// Only equity positions are considered.
pub fn computeHistoricalSnapshots(
as_of: Date,
positions: []const portfolio_mod.Position,
current_prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
candle_map: std.StringHashMap([]const Candle),
) [HistoricalPeriod.all.len]HistoricalSnapshot {
var result: [HistoricalPeriod.all.len]HistoricalSnapshot = undefined;
@ -697,10 +961,13 @@ pub fn computeHistoricalSnapshots(
const candles = candle_map.get(pos.symbol) orelse continue;
const hist_price = findPriceAtDate(candles, target) orelse continue;
// Both prices come from candle history (live API provenance),
// so apply the share-class price_ratio - `is_preadjusted = false`.
// The historical side always comes from candle history, so it is
// raw and the share-class ratio applies. The current side is raw
// too UNLESS it is a manual override, which is already in the
// lot's own share class.
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
hist_value += pos.marketValue(hist_price, false);
curr_value += pos.marketValue(curr_price, false);
curr_value += pos.marketValue(curr_price, is_manual);
count += 1;
}
@ -723,6 +990,571 @@ fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}
fn dcPos(symbol: []const u8, shares: f64, ratio: f64) portfolio_mod.Position {
return .{
.symbol = symbol,
.shares = shares,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.price_ratio = ratio,
};
}
test "computeDayChange: previous close is the session before the priced date" {
const a = std.testing.allocator;
// Fri 2024-06-07 close 100, Mon 2024-06-10 close 110.
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 6, 7), 100),
makeCandle(Date.fromYmd(2024, 6, 10), 110),
};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110); // current = Monday's close
// Priced date is Monday: the previous close is FRIDAY's, snapping back
// over the weekend. 10 * (110 - 100) = +$100.
const dc = computeDayChange(Date.fromYmd(2024, 6, 10), &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.prev_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1100), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete());
try std.testing.expect(dc.hasData());
}
test "computeDayChange: a weekend view reports Friday's move, not zero" {
// The bug the priced-date design exists to avoid. It is Saturday; the
// newest candle is Friday's, so the current price IS Friday's close.
// Anchoring the previous close to `today - 1` (= Friday) would match
// Friday's own bar and report a $0.00 day. Anchoring to
// `priced_date - 1` correctly picks Thursday.
const a = std.testing.allocator;
const thu = Date.fromYmd(2024, 6, 6);
const fri = Date.fromYmd(2024, 6, 7);
const sat = Date.fromYmd(2024, 6, 8);
const candles = [_]Candle{ makeCandle(thu, 100), makeCandle(fri, 105) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 105);
const good = computeDayChange(fri, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 50), good.change, 0.01);
// ...and it is labelled as Friday's move, not today's.
try std.testing.expectEqual(DayChange.Recency.yesterday, good.recency(sat));
// What anchoring on `today` would have produced.
const bad = computeDayChange(sat, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 0), bad.change, 0.01);
}
test "computeDayChange: live intraday prices against the prior close" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
// Only Monday's bar exists - Tuesday's has not been written yet.
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 103); // streamed tick
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 30), dc.change, 0.01);
try std.testing.expectEqual(DayChange.Recency.today, dc.recency(tue));
}
test "computeDayChange: the share-class ratio is applied exactly once" {
// Same trap as computeHistoricalSnapshots: both sides take RAW prices.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 20)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 22); // raw; institutional NAV is 110
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
// 100 * 20 * 5 = 10,000 -> 100 * 22 * 5 = 11,000. Ratio once, not twice.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), dc.prev_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 11_000), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.change, 0.01);
}
test "computeDayChange: a manual price is preadjusted, ratio not reapplied" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ORCX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ORCX", 10, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ORCX", 110); // user-entered NAV, already in lot terms
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("ORCX", {});
const dc = computeDayChange(tue, &positions, &.{}, prices, manual, cm);
// Current: 10 * 110 (no ratio). Previous: 10 * 100 * 5 (raw candle).
try std.testing.expectApproxEqAbs(@as(f64, 1100), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 5000), dc.prev_stock_value, 0.01);
}
test "computeDayChange: positions without candles are counted but excluded" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
// FUNDX has a price but no candle history - a mutual fund the quote
// providers cannot price. Its dollars are missing from the figure, so
// the renderer must be able to say the total is short.
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("FUNDX", 100, 1.0),
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("FUNDX", 50);
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expectEqual(@as(usize, 2), dc.positions_total);
try std.testing.expect(!dc.complete());
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
}
test "buildFallbackPrices: a pre-inserted manual price loses its flag" {
// Why pre-multiplying a manual override into the shared map is unsound,
// demonstrated on the pure function rather than asserted in prose.
//
// The gap-fill is guarded by `if (!prices.contains(sym))`, so an entry
// someone else already inserted is skipped - and therefore never lands
// in `manual_price_set`. Downstream `effectivePrice(price, is_manual)`
// then sees `is_manual == false` and applies `price_ratio` to a value
// that already had it folded in, squaring it.
//
// `commands/snapshot.zig` did exactly that until the pre-multiply loop
// was deleted; `commands/audit.zig` still does, and cannot stop until
// the reconcile path grows a preadjusted set. See "The pre-multiply
// anti-pattern" in models/portfolio.zig.
const a = std.testing.allocator;
const ratio: f64 = 5.0;
const typed: f64 = 144.04; // what the user reads off the statement
const lots = [_]portfolio_mod.Lot{.{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = ratio,
.price = typed,
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
}};
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, ratio)};
// CORRECT: hand it an empty map. The override goes in untouched and the
// symbol is flagged, so the ratio is skipped and the value is exact.
{
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(manual.contains("VTTHX"));
const p = prices.get("VTTHX").?;
try std.testing.expectApproxEqAbs(typed, p, 0.001); // stored RAW
const value = positions[0].marketValue(p, manual.contains("VTTHX"));
try std.testing.expectApproxEqAbs(@as(f64, 14_404.0), value, 0.01);
}
// BROKEN: pre-multiply the same override in first. The gap-fill skips
// it, the flag never appears, and the ratio lands twice.
{
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", typed * ratio); // the pre-multiply
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(!manual.contains("VTTHX")); // flag lost
const p = prices.get("VTTHX").?;
const value = positions[0].marketValue(p, manual.contains("VTTHX"));
// shares * (p * ratio) * ratio = 100 * 144.04 * 25 = 360,100.
// The error factor is the ratio SQUARED - 25x here, not 5x.
try std.testing.expectApproxEqAbs(@as(f64, 360_100.0), value, 0.01);
}
}
test "buildFallbackPrices: a candle price is never overridden or flagged" {
// The guard's legitimate purpose: a live close already in the map wins
// over a manual override, and must NOT be flagged preadjusted - candle
// closes are raw and do need the ratio.
const a = std.testing.allocator;
const lots = [_]portfolio_mod.Lot{.{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = 5.0,
.price = 999.0, // stale override, should lose
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
}};
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 28.808); // raw candle close
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(!manual.contains("VTTHX"));
try std.testing.expectApproxEqAbs(@as(f64, 28.808), prices.get("VTTHX").?, 0.001);
// Ratio applied once: 100 * 28.808 * 5 = 14,404.
const value = positions[0].marketValue(prices.get("VTTHX").?, false);
try std.testing.expectApproxEqAbs(@as(f64, 14_404.0), value, 0.01);
}
test "computeDayChange: a hand-priced holding is IMPOSSIBLE, not a gap" {
// The headline behaviour. A 529 fund the user prices by hand on Saturdays
// has no candle history and never will: the only price zfin will ever
// hold for it is the typed one. Counting it as a shortfall would keep the
// marker permanently lit and train the reader to ignore it.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 100), makeCandle(tue, 110) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("ORCX", 100, 1.0), // hand-priced, no candles
};
// The `price::` on the lot is the signal - see `userPricedSymbol`.
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "ABC", .shares = 10, .open_date = mon, .open_price = 100 },
.{ .symbol = "ORCX", .shares = 100, .open_date = mon, .open_price = 18, .price = 19.01 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("ORCX", 19.01);
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 2), dc.positions_total);
// ORCX is not even a candidate, so coverage is 1 of 1 POSSIBLE.
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete()); // -> renderer shows no marker
try std.testing.expectApproxEqAbs(@as(f64, 0), dc.uncovered_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
}
test "computeDayChange: a security that SHOULD have priced is a real gap" {
// No `price::` on the lot, no candles - something went wrong (fetch
// failure, negative cache entry, brand-new symbol). That is fixable and
// must be surfaced, valued so the reader can judge materiality.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 100), makeCandle(tue, 110) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("BROKEN", 100, 1.0),
};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "ABC", .shares = 10, .open_date = mon, .open_price = 100 },
.{ .symbol = "BROKEN", .shares = 100, .open_date = mon, .open_price = 40 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("BROKEN", 50);
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 2), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(!dc.complete()); // -> renderer shows the marker
// 100 shares at the current 50 = $5,000 missing from the figure.
try std.testing.expectApproxEqAbs(@as(f64, 5000), dc.uncovered_value, 0.01);
}
test "computeDayChange: an avg-cost fallback is FIXABLE, not hidden" {
// THE TRAP. `buildFallbackPrices` puts BOTH hand-typed overrides and
// avg-cost fallbacks into its `manual_prices` set, so keying the
// impossible/fixable split on that set would silently hide genuine
// pricing failures. The split keys on `lot.price` instead.
//
// Here BROKEN has no `price::` but IS in `manual_prices` (second pass,
// priced at avg_cost). It must still count as a real gap.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
const positions = [_]portfolio_mod.Position{dcPos("BROKEN", 100, 1.0)};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "BROKEN", .shares = 100, .open_date = mon, .open_price = 40 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("BROKEN", 40); // avg_cost fallback
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("BROKEN", {}); // flagged by the SECOND pass
const dc = computeDayChange(tue, &positions, &lots, prices, manual, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 0), dc.positions_covered);
try std.testing.expect(!dc.complete());
try std.testing.expectApproxEqAbs(@as(f64, 4000), dc.uncovered_value, 0.01);
}
test "computeDayChange: a hand-priced lot WITH candles is day-changeable" {
// `ticker::` + `price::` together: `stockSymbols` still fetches candles
// for it, and `buildFallbackPrices` leaves the override unused because a
// candle price already exists. So it is priceable and covered normally -
// the `price::` alone must not exempt it.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 20), makeCandle(tue, 22) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "02315N600", .ticker = "VTTHX", .price_ratio = 5.0, .price = 999.0, .shares = 100, .open_date = mon, .open_price = 106.99 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 22); // candle close won; the override is inert
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete());
// Ratio once on each side: 100*20*5 -> 100*22*5.
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.change, 0.01);
}
test "computeHistoricalSnapshots: a manual price is not re-ratioed" {
// Contract hardening. The current-price map is raw EXCEPT entries named
// in `manual_prices`, which are already in the lot's own share class.
// Only reachable with a back-dated as_of (candles present but starting
// after the target), but the contract should hold regardless.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), 20),
makeCandle(as_of, 22),
};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 110); // hand-typed institutional NAV
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("VTTHX", {});
const m1 = computeHistoricalSnapshots(as_of, &positions, prices, manual, cm)[0];
// Historical side is a raw candle -> ratio applies: 100 * 20 * 5.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), m1.historical_value, 0.01);
// Current side is preadjusted -> ratio must NOT apply: 100 * 110.
try std.testing.expectApproxEqAbs(@as(f64, 11_000), m1.current_value, 0.01);
// Passing null instead would square it to 55,000.
const wrong = computeHistoricalSnapshots(as_of, &positions, prices, null, cm)[0];
try std.testing.expectApproxEqAbs(@as(f64, 55_000), wrong.current_value, 0.01);
}
test "computeDayChange: nothing priced yields hasData false" {
const a = std.testing.allocator;
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
const dc = computeDayChange(Date.fromYmd(2024, 6, 11), &positions, &.{}, prices, null, cm);
try std.testing.expect(!dc.hasData());
try std.testing.expectEqual(@as(usize, 0), dc.positions_covered);
try std.testing.expectEqual(@as(usize, 1), dc.positions_total);
}
test "DayChange.pctOfAccount: dilutes by the non-equity weight" {
// $100k account, $95k of it equities that rose 1% ($950), $5k cash.
const dc = DayChange{
.priced_date = Date.fromYmd(2024, 6, 11),
.change = 950,
.prev_stock_value = 95_000,
.curr_stock_value = 95_950,
.positions_covered = 1,
.positions_priceable = 1,
.positions_total = 1,
.uncovered_value = 0,
};
// Equities alone moved 1.0%...
try std.testing.expectApproxEqAbs(@as(f64, 0.01), dc.change / dc.prev_stock_value, 1e-9);
// ...but the ACCOUNT moved 950 / 100,000 = 0.95%, which is the figure
// shown beside a whole-account Gain/Loss percentage.
try std.testing.expectApproxEqAbs(@as(f64, 0.0095), dc.pctOfAccount(100_950), 1e-9);
}
test "DayChange.pctOfAccount: zero prior account value doesn't divide" {
const dc = DayChange{
.priced_date = Date.fromYmd(2024, 6, 11),
.change = 100,
.prev_stock_value = 0,
.curr_stock_value = 100,
.positions_covered = 1,
.positions_priceable = 1,
.positions_total = 1,
.uncovered_value = 0,
};
try std.testing.expectEqual(@as(f64, 0), dc.pctOfAccount(100));
}
test "DayChange.recency: today, yesterday, older" {
const today = Date.fromYmd(2024, 6, 11);
const mk = struct {
fn f(d: Date) DayChange {
return .{ .priced_date = d, .change = 0, .prev_stock_value = 0, .curr_stock_value = 0, .positions_covered = 1, .positions_priceable = 1, .positions_total = 1, .uncovered_value = 0 };
}
}.f;
try std.testing.expectEqual(DayChange.Recency.today, mk(today).recency(today));
try std.testing.expectEqual(DayChange.Recency.yesterday, mk(today.addDays(-1)).recency(today));
try std.testing.expectEqual(DayChange.Recency.older, mk(today.addDays(-4)).recency(today));
}
test "computeHistoricalSnapshots: current_prices must be RAW, ratio applied once" {
// The regression. A lone `ticker::` + `price_ratio::` lot produces an
// UNMERGED allocation whose `current_price` already has the ratio folded
// in. Feeding that in here made `pos.marketValue(.., false)` apply the
// ratio a SECOND time, squaring it - while the historical side, coming
// from raw candles, stayed correct. So the ratio between the two sides
// was wrong and every percentage on the TUI's `Historical:` line broke.
//
// Contract: pass RAW base-ticker prices. Then a pure price move of
// +10% must read as +10% regardless of the share-class ratio.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const raw_then: f64 = 20.0;
const raw_now: f64 = 22.0; // +10%
const ratio: f64 = 5.0;
// One month back is the first period in `HistoricalPeriod.all`.
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), raw_then),
makeCandle(as_of, raw_now),
};
var candle_map = std.StringHashMap([]const Candle).init(a);
defer candle_map.deinit();
try candle_map.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{.{
.symbol = "VTTHX",
.shares = 100,
.avg_cost = 90,
.total_cost = 9000,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.price_ratio = ratio,
}};
var raw_prices = std.StringHashMap(f64).init(a);
defer raw_prices.deinit();
try raw_prices.put("VTTHX", raw_now);
const snaps = computeHistoricalSnapshots(as_of, &positions, raw_prices, null, candle_map);
const m1 = snaps[0]; // 1M
try std.testing.expectEqual(@as(usize, 1), m1.position_count);
// Ratio applied exactly once on each side: 100 * 20 * 5 and 100 * 22 * 5.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), m1.historical_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 11_000), m1.current_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), m1.changePct(), 1e-9);
// What the buggy caller did: hand in the EFFECTIVE price instead.
// The ratio gets squared, the percentage inflates wildly, and the
// dollar figures become nonsense.
var effective_prices = std.StringHashMap(f64).init(a);
defer effective_prices.deinit();
try effective_prices.put("VTTHX", raw_now * ratio);
const bad = computeHistoricalSnapshots(as_of, &positions, effective_prices, null, candle_map)[0];
try std.testing.expectApproxEqAbs(@as(f64, 55_000), bad.current_value, 0.01); // 5x too big
try std.testing.expect(bad.changePct() > 400.0); // vs the true +10%
}
test "computeHistoricalSnapshots: an unratioed position is unaffected either way" {
// Blast-radius guard: the fix must not move the needle for ordinary
// holdings, where raw and effective prices are the same number.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), 100),
makeCandle(as_of, 110),
};
var candle_map = std.StringHashMap([]const Candle).init(a);
defer candle_map.deinit();
try candle_map.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{.{
.symbol = "ABC",
.shares = 10,
.avg_cost = 90,
.total_cost = 900,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
}};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
const m1 = computeHistoricalSnapshots(as_of, &positions, prices, null, candle_map)[0];
try std.testing.expectApproxEqAbs(@as(f64, 1000), m1.historical_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1100), m1.current_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), m1.changePct(), 1e-9);
}
test "findPriceAtDate exact match" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),

View file

@ -208,15 +208,26 @@ pub fn collect(
// and not an orphan (something does refresh it). Taking the exclusion
// here rather than at each caller means both the sweep and `doctor` skip
// them by construction instead of by remembering to.
for (exclude) |ex| {
if (std.mem.eql(u8, key, ex)) continue :outer;
//
// ...but ONLY if the symbol is not otherwise tracked. A symbol that is
// both held and the configured benchmark is subject to the routine
// refresh like any other holding, so its staleness IS a finding and
// dropping it would hide a real position. This bit the moment someone
// pointed `benchmark_stock` at a symbol they actually hold - which is
// the recommended way to configure it, because a benchmark held nowhere
// never gets its dividends warmed.
const is_tracked = tracked.contains(key);
if (!is_tracked) {
for (exclude) |ex| {
if (std.mem.eql(u8, key, ex)) continue :outer;
}
}
const cm = store.readCandleMeta(key);
try out.append(allocator, .{
.symbol = key,
.kind = market.classify(key),
.last_date = if (cm) |m| m.meta.last_date else null,
.tracked = tracked.contains(key),
.tracked = is_tracked,
});
}
return out.toOwnedSlice(allocator);
@ -604,6 +615,50 @@ test "scan: every allocation-failure path unwinds cleanly" {
);
}
test "collect: a benchmark that is ALSO held stays in the corpus" {
// The regression. `collect` used to drop every excluded key
// unconditionally, so pointing `benchmark_stock` at a symbol you hold
// made that holding invisible to `cache stale`, `cache refresh` and
// `doctor` - the exact opposite of the intent, since the reason to
// choose a held symbol as your benchmark is that held symbols are the
// only ones whose dividends get warmed.
const a = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
defer a.free(dir_path);
var store = cache.Store.init(io, a, dir_path);
var tracked = std.StringHashMap(void).init(a);
defer tracked.deinit();
try tracked.put("HELDBM", {}); // held AND the configured benchmark
try tracked.put("PLAIN", {}); // an ordinary holding
const keys = [_][]const u8{ "HELDBM", "PLAIN", "PUREBM" };
const exclude = [_][]const u8{ "HELDBM", "PUREBM" }; // the benchmark pair
const entries = try collect(a, &store, &keys, &tracked, &exclude);
defer a.free(entries);
// PUREBM is benchmark-only -> dropped. The other two survive.
try testing.expectEqual(@as(usize, 2), entries.len);
var saw_held = false;
var saw_plain = false;
for (entries) |e| {
if (std.mem.eql(u8, e.symbol, "HELDBM")) {
saw_held = true;
// ...and it is reported as tracked, so a stale finding is a
// real finding rather than an orphan verdict.
try testing.expect(e.tracked);
}
if (std.mem.eql(u8, e.symbol, "PLAIN")) saw_plain = true;
try testing.expect(!std.mem.eql(u8, e.symbol, "PUREBM"));
}
try testing.expect(saw_held);
try testing.expect(saw_plain);
}
test "collect-style exclusion: an excluded symbol produces no finding at all" {
const a = testing.allocator;
// A demand-fetched symbol makes every classification wrong: not stale

View file

@ -175,7 +175,16 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
// Pre-multiply - see "Pricing model" in models/portfolio.zig.
// KNOWN GAP, see "The pre-multiply anti-pattern" in
// models/portfolio.zig. Pre-multiplying here is unsound
// - `resolvePositionValue` applies the ratio again - so
// a manual price on a lot with a non-unit `price_ratio`
// reads high by that ratio SQUARED (25x at ratio 5, not
// 5x). It stays for now because the
// reconcile path has no preadjusted-set to thread
// through; snapshot.zig fixed its copy by deleting the
// pre-multiply and letting `buildFallbackPrices` flag
// the symbol instead, which this path cannot do yet.
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}

View file

@ -287,11 +287,22 @@ fn sweep(ctx: *framework.RunCtx) !Sweep {
}
const entries = try freshness.collect(arena, &store, keys, &tracked, bench);
// Report only the symbols `collect` actually dropped. A benchmark that
// is ALSO held stays in the corpus and gets swept like any other
// holding, so listing it under "Not checked" would be a lie - and the
// recommended way to configure `benchmark_stock` is to point it at
// something you hold.
var excluded = std.ArrayList([]const u8).empty;
for (bench) |sym| {
if (!tracked.contains(sym)) try excluded.append(arena, sym);
}
return .{
.arena_state = arena_state,
.store = store,
.report = try freshness.scan(arena, entries, now_s),
.excluded = bench,
.excluded = try excluded.toOwnedSlice(arena),
.empty = false,
};
}

View file

@ -775,9 +775,34 @@ fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareVi
if (cv.loser_count == 1) "" else "s",
});
if (cv.flat_count > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, ", {d} flat\n", .{cv.flat_count});
} else {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, ", {d} flat", .{cv.flat_count});
}
// Rows whose percentage can't be trusted as a return get their own
// tally rather than hiding inside "flat": a mixed-class group whose
// share count moved has a figure that is part market, part cash flow.
if (cv.unreliable_count > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, ", {d} share count changed", .{cv.unreliable_count});
}
try out.print("\n", .{});
// Name them and say why. Without this the reader has no way to know
// that the last row(s) answer a different question from the rest -
// the columns are unlabelled.
if (cv.unreliable_count > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " ", .{});
var written: usize = 0;
for (cv.symbols) |s| {
if (s.pct_reliable) continue;
try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}", .{ if (written == 0) "" else ", ", s.symbol });
written += 1;
}
try cli.printFg(
out,
color,
cli.CLR_MUTED,
": spans share classes AND the share count moved, so {s} total value change - part market, part shares bought or sold.\n",
.{if (cv.unreliable_count == 1) "the figure is" else "the figures are"},
);
}
}
@ -1344,6 +1369,100 @@ test "renderCompare: gainer/loser summary includes flat when present" {
try testing.expect(std.mem.indexOf(u8, out, "2 flat") != null);
}
test "renderCompare: unreliable rows are tallied and footnoted" {
// The footnote is the only thing telling the reader that the last
// row's percentage answers a different question from the rest - the
// columns are unlabelled - so it needs a test.
const symbols = [_]view.SymbolChange{
.{
.symbol = "UP",
.price_then = 100,
.price_now = 110,
.shares_held_throughout = 1,
.pct_change = 0.10,
.dollar_change = 10,
.style = .positive,
},
.{
.symbol = "BENCH",
.price_then = 700,
.price_now = 90,
.shares_held_throughout = 100,
.pct_change = 0.25, // VALUE basis, share count moved
.dollar_change = 50_000,
.price_comparable = false,
.pct_reliable = false,
.style = .positive,
},
};
const cv = view.CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 1, 22),
.days_between = 7,
.now_is_live = true,
.liquid = view.buildTotalsRow(300, 310),
.symbols = @constCast(&symbols),
.held_count = 2,
.added_count = 0,
.removed_count = 0,
.gainer_count = 1,
.loser_count = 0,
.flat_count = 0,
.unreliable_count = 1,
};
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Separate tally, not folded into gainers/losers/flat.
try testing.expect(std.mem.indexOf(u8, out, "1 share count changed") != null);
// Footnote names the symbol and states why it is set aside.
try testing.expect(std.mem.indexOf(u8, out, "BENCH: spans share classes") != null);
try testing.expect(std.mem.indexOf(u8, out, "the share count moved") != null);
try testing.expect(std.mem.indexOf(u8, out, "part market, part shares bought or sold") != null);
// Singular verb for one symbol.
try testing.expect(std.mem.indexOf(u8, out, "the figure is") != null);
// The value-basis percentage IS rendered; the prices are not.
try testing.expect(std.mem.indexOf(u8, out, "+25.00%") != null);
try testing.expect(std.mem.indexOf(u8, out, "$700.00") == null);
try testing.expect(std.mem.indexOf(u8, out, "$90.00") == null);
}
test "renderCompare: no unreliable rows means no footnote at all" {
const symbols = [_]view.SymbolChange{.{
.symbol = "UP",
.price_then = 100,
.price_now = 110,
.shares_held_throughout = 1,
.pct_change = 0.10,
.dollar_change = 10,
.style = .positive,
}};
const cv = view.CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 1, 22),
.days_between = 7,
.now_is_live = true,
.liquid = view.buildTotalsRow(300, 310),
.symbols = @constCast(&symbols),
.held_count = 1,
.added_count = 0,
.removed_count = 0,
.gainer_count = 1,
.loser_count = 0,
.flat_count = 0,
};
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "share count changed") == null);
try testing.expect(std.mem.indexOf(u8, out, "spans share classes") == null);
}
// run() entry-point validation tests
fn makeTestSvc() zfin.DataService {

View file

@ -193,10 +193,18 @@ pub fn classify(o: Observed) Verdict {
}
return .current;
}
// A benchmark outranks both: it is genuinely untracked, but saying so would
// send the operator hunting for a config fix when the answer is simply that
// `projections` fetches it on demand.
if (o.benchmark) return .demand_fetched;
// A benchmark outranks the TTL: it is genuinely untracked, but saying so
// would send the operator hunting for a config fix when the answer is
// simply that `projections` fetches it on demand.
//
// Unless it is ALSO tracked. A symbol that is both held and the configured
// benchmark is refreshed by every routine run like any other holding, so
// the demand-fetch story would misdirect in the opposite direction - and
// pointing `benchmark_stock` at something you hold is the recommended
// configuration, because a benchmark held nowhere never gets its dividends
// warmed. `null` (no portfolio to ask) stays on the demand-fetched path,
// which is the safe direction: we cannot claim it is tracked.
if (o.benchmark and !(o.tracked orelse false)) return .demand_fetched;
// Untracked outranks the TTL: a lapsed TTL is never even consulted for a
// symbol no code path requests.
if (o.tracked) |t| {
@ -318,7 +326,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
if (cli.trackedSymbols(ctx, arena, l.portfolio)) |set| {
obs.tracked = set.contains(symbol);
} else |_| {}
if (obs.benchmark) {
if (obs.benchmark and (obs.tracked orelse false)) {
// Both held and the configured benchmark. The routine sweep
// covers it, so lead with that - the demand-fetch path is
// merely an additional refresher, not the only one.
try out.print("tracked yes - a normal run fetches this symbol (also a projections benchmark)\n", .{});
} else if (obs.benchmark) {
try out.print("tracked on demand - a projections benchmark symbol\n", .{});
} else if (obs.tracked) |t| {
try out.print("tracked {s}\n", .{if (t) "yes - a normal run fetches this symbol" else "NO - no normal run fetches this symbol"});
@ -769,6 +782,38 @@ test "classify: being tracked does not mask the real cause" {
}));
}
test "classify: a benchmark that is ALSO held is judged like any holding" {
// Pointing `benchmark_stock` at a symbol you hold is the recommended
// configuration (a benchmark held nowhere never gets its dividends
// warmed). Such a symbol IS refreshed by every routine run, so
// reporting "fetched on demand by projections" would misdirect - and
// it used to, because the benchmark check short-circuited ahead of the
// tracked check.
const v = classify(.{
.local = d(2026, 8, 6),
.local_fresh = false,
.peer = d(2026, 8, 11),
.server = d(2026, 8, 11),
.provider = d(2026, 8, 11),
.tracked = true, // held
.benchmark = true, // and the configured benchmark
});
try testing.expect(v != .demand_fetched);
try testing.expect(v != .not_tracked);
// A benchmark we cannot prove is held stays on the demand-fetched
// path - the safe direction when there is no portfolio to ask.
try testing.expectEqual(Verdict.demand_fetched, classify(.{
.local = d(2026, 8, 6),
.local_fresh = false,
.peer = d(2026, 8, 11),
.server = d(2026, 8, 11),
.provider = d(2026, 8, 11),
.tracked = null,
.benchmark = true,
}));
}
test "classify: a benchmark is demand-fetched, not untracked" {
// AGG. Genuinely absent from the routine fetch set, so `not_tracked` is
// literally true - and misleading, because it sends the operator hunting for

View file

@ -293,6 +293,39 @@ pub fn display(
try out.print("\n", .{});
}
// Session change. Labelled by what it actually describes rather than
// assumed to be today: this path prices from candle closes, so during a
// trading day the newest bar is usually the PREVIOUS session's, which
// reads as "Yesterday". Mirrors the TUI portfolio header.
if (pf_data.day_change) |dc| {
if (dc.hasData()) {
var label_buf: [16]u8 = undefined;
const label: []const u8 = switch (dc.recency(as_of)) {
.today => "Day",
.yesterday => "Yesterday",
.older => std.fmt.bufPrint(&label_buf, "{f}", .{dc.priced_date}) catch "Day",
};
var fig_buf: [64]u8 = undefined;
const figure = fmt.fmtPriceChange(&fig_buf, dc.change, dc.pctOfAccount(summary.total_value) * 100.0);
try out.print(" {s}: ", .{label});
try cli.printGainLoss(out, color, dc.change, "{s}", .{figure});
if (!dc.complete()) {
// Only a FIXABLE gap gets a marker: a security that should
// have had a daily series and didn't. Hand-priced holdings
// are excluded from `positions_priceable` upstream, so they
// never light this - otherwise it would be permanently on
// and the reader would learn to ignore it.
//
// Dollars rather than a count, because two missing
// securities could be $3k or $3M and only one of those is
// ignorable.
const lg = fmt.fmtLargeNumOpts(dc.uncovered_value, .{ .thousands = true });
try cli.printFg(out, color, cli.CLR_MUTED, " [${s} no history]", .{std.mem.trimEnd(u8, &lg, " ")});
}
try out.print("\n", .{});
}
}
// Lot counts (stocks/ETFs only)
var open_lots: u32 = 0;
var closed_lots: u32 = 0;
@ -415,13 +448,13 @@ pub fn display(
if (!has_drip) {
// No DRIP: show all individually
for (lots_for_sym.items) |lot| {
try printLotRow(as_of, out, color, lot, a.current_price, w);
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
}
} else {
// Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(as_of, out, color, lot, a.current_price, w);
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
}
}
@ -647,13 +680,21 @@ pub fn display(
try out.print("\n", .{});
}
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !void {
/// Render one lot-detail row under its position row.
///
/// `effective_price` is the LOT's price, not its position's: pass
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
/// `price_ratio` and resolves `close_price`. Passing an
/// `Allocation.current_price` straight through is the bug this
/// parameter name exists to prevent - that price is the raw base-ticker
/// price, so a ratio'd lot's Value and Gain/Loss come out scaled by its
/// ratio (a 0.2387 sleeve rendered +$3.39M against a real +$433K).
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, effective_price: f64, w: views.PositionsWidths) !void {
const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed";
const acct_col: []const u8 = lot.account orelse "";
const use_price = lot.close_price orelse current_price;
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
const gl = lot.effectiveShares() * (effective_price - lot.effectiveOpenPrice());
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
@ -669,9 +710,16 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin
try out.writeByte(' ');
try out.print("{f}", .{Money.from(lot.effectiveOpenPrice()).padRight(w.price_w)});
try out.writeByte(' ');
try views.writeCol(out, "", w.price_w, true); // blank current-price cell
// Current-price cell: normally blank (the position row above
// already shows it), but a ratio'd lot prices off its own
// institutional NAV, so show that.
if (views.hasOwnPrice(lot)) {
try out.print("{f}", .{Money.from(effective_price).padRight(w.price_w)});
} else {
try views.writeCol(out, "", w.price_w, true);
}
try out.writeByte(' ');
try out.print("{f}", .{Money.from(lot.effectiveShares() * use_price).padRight(w.value_w)});
try out.print("{f}", .{Money.from(lot.effectiveShares() * effective_price).padRight(w.value_w)});
try out.writeByte(' ');
try cli.reset(out, color);
// Colored gain/loss cell.
@ -722,9 +770,119 @@ fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.S
.summary = summary,
.candle_map = candle_map,
.snapshots = null,
.day_change = null,
};
}
fn testDayChange(priced: zfin.Date, change: f64, covered: usize, priceable: usize, uncovered: f64) zfin.valuation.DayChange {
return .{
.priced_date = priced,
.change = change,
.prev_stock_value = 100_000,
.curr_stock_value = 100_000 + change,
.positions_covered = covered,
.positions_priceable = priceable,
// Two extra hand-priced positions that can never be day-changed.
.positions_total = priceable + 2,
.uncovered_value = uncovered,
};
}
test "display: the session change renders labelled by its priced date" {
var buf: [8192]u8 = undefined;
var lots = [_]zfin.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 15), .open_price = 150.0 },
};
var portfolio = testPortfolio(&lots);
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 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 1.0, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
};
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const today = zfin.Date.fromYmd(2026, 8, 27);
// Priced yesterday -> "Yesterday", and the figure is comma-grouped.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(today.addDays(-1), -7180.04, 3, 3, 0);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Yesterday: -$7,180.04") != null);
try testing.expect(std.mem.indexOf(u8, out, "Day:") == null);
// Every POSSIBLE position covered -> no marker, even though two
// hand-priced holdings are absent from the figure. This is the
// headline behaviour: an impossible gap must not nag.
try testing.expect(std.mem.indexOf(u8, out, "no history") == null);
}
// Priced today -> "Day", positive sign, and a partial-coverage marker.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(today, 12345.67, 18, 21, 1_200_000);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Day: +$12,345.67") != null);
// A real gap reports how much value it is short by, not a count.
try testing.expect(std.mem.indexOf(u8, out, "[$1.2M no history]") != null);
}
// Marker scales: the `k` tier matters, or a $308k gap would render as
// an uninformative "$0.3M".
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(today, 500.0, 2, 3, 308_408.77);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "[$308k no history]") != null);
}
// Sub-thousand gap: plain dollars, no suffix.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(today, 500.0, 2, 3, 640.0);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "[$640 no history]") != null);
}
// Older than yesterday -> the date itself, so a stale cache can't
// masquerade as today's move.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(zfin.Date.fromYmd(2026, 8, 21), 100.0, 3, 3, 0);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2026-08-21: +$100.00") != null);
}
// Nothing priced -> the line is suppressed rather than showing $0.00.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
pf_data.day_change = testDayChange(today, 0, 0, 3, 0);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Day:") == null);
try testing.expect(std.mem.indexOf(u8, out, "Yesterday:") == null);
}
// Absent entirely (candles worker never landed) -> also suppressed.
{
var w: std.Io.Writer = .fixed(&buf);
var pf_data = testPortfolioData(testSummary(&allocs), candle_map);
try display(testing.allocator, &w, false, "p.srf", &portfolio, &positions, &pf_data, &.{}, watch_prices, today, .hide);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Day:") == null);
}
}
test "display shows header and summary" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -1325,4 +1483,54 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
try std.testing.expect(std.mem.indexOf(u8, out, "1000.0") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$4.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "120,000") != null);
// Ratio 1.0 -> the current-price cell stays blank, so $120.00
// appears nowhere. (Guards the `hasOwnPrice` gate: a bare
// "$120.00" would mean the cell got filled unconditionally.)
try std.testing.expect(std.mem.indexOf(u8, out, "$120.00") == null);
}
test "printLotRow: a ratio'd lot values off its effective price and shows it" {
// The bug this pins: the caller must pass the LOT's effective price
// (`views.effectivePriceFor`), not the position's raw base-ticker price.
// Here the sleeve holds 5075.077 shares of a $765.91 base ticker at
// a 0.2387 ratio. Valuing at the raw price gives $3.89M / +$3.39M;
// the truth is $927,824 / +$433,004.
var lots = [_]zfin.Lot{.{
.symbol = "AGG-LC",
.ticker = "BENCH",
.price_ratio = 0.2386960690140873,
.shares = 5075.077,
.open_date = zfin.Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
.account = "Sample 401(k)",
}};
const allocs = [_]zfin.valuation.Allocation{.{
.symbol = "BENCH",
.display_symbol = "BENCH",
.shares = 1211.402,
.avg_cost = 408.47,
.current_price = 765.91, // RAW base-ticker price
.market_value = 927824.086,
.cost_basis = 494820.008,
.weight = 1.0,
.unrealized_gain_loss = 433004.078,
.unrealized_return = 0.875,
}};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "$927,824") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "+$433,004") != null);
// The ratio'd effective price fills the normally-blank Price cell.
try std.testing.expect(std.mem.indexOf(u8, out, "$182.82") != null);
// And the ratio-skipped figures appear nowhere.
try std.testing.expect(std.mem.indexOf(u8, out, "3,887,052") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "3,392,232") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "$765.91") == null);
}

View file

@ -859,7 +859,7 @@ pub fn runBands(
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmark_stock, ctx.stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmarkStock(), ctx.stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -868,7 +868,7 @@ pub fn runBands(
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmark_bond, ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmarkBond(), ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,

View file

@ -294,26 +294,29 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
}
// Build the flat-price map that `portfolioSummary` expects, plus
// apply manual `price::` overrides from portfolio.srf (which win
// over the candle lookup).
// Build the flat-price map that `portfolioSummary` expects: RAW
// base-ticker closes, keyed by symbol.
//
// Manual `price::` overrides are deliberately NOT applied here.
// `captureSnapshot` runs `buildFallbackPrices`, which gap-fills them
// correctly - it stores the raw value AND records the symbol in its
// `manual_set`, so the downstream `lot.effectivePrice(raw, is_manual)`
// knows to skip the share-class ratio (a hand-typed price is already in
// the lot's own share class).
//
// This used to pre-multiply overrides into the map here, which was wrong
// twice over. The map is keyed by SYMBOL while `price_ratio` is per LOT,
// so folding one lot's ratio in poisons every other lot sharing that
// ticker. And `buildFallbackPrices` gap-fills only when the symbol is
// absent, so the pre-inserted entry was skipped and never flagged -
// leaving `is_manual` false downstream and the ratio applied a second
// time, squaring it.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var sp_it = symbol_prices.iterator();
while (sp_it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.close);
}
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
// Pre-multiply manual overrides so the shared `prices`
// map holds share-class-correct values - see the
// "Pricing model / caching pre-multiply pattern" note
// in models/portfolio.zig.
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}
}
// Derive output path.
var as_of_buf: [10]u8 = undefined;
@ -870,6 +873,9 @@ fn buildSnapshot(
});
},
.option => {
// One of three copies of this premium formula - see
// `Portfolio.nonStockValueForAccount` for the invariant
// and why `@abs` is deliberate.
const opt_value = @abs(lot.shares) * lot.open_price * lot.multiplier;
try lots_list.append(allocator, .{
.kind = "lot",

View file

@ -103,8 +103,22 @@ pub fn loadSnapshotSide(
/// brings forward. For a plain pre-feature snapshot `value == shares x
/// price`, so this degrades exactly to the raw share count.
///
/// Price is taken from the first lot seen (all stock lots of a symbol
/// share the same `price` field in a given snapshot).
/// Price is taken from the first lot seen. That is only meaningful when
/// every stock lot of a symbol shares one `price` in the snapshot, which
/// is USUALLY but not always true: `snapshot.buildSnapshot` keys
/// `LotRow.symbol` on `priceSymbol()` (no `price_ratio` in the key) while
/// writing a ratio-scaled per-lot `.price`, so two lots aliasing one
/// `ticker::` at different `price_ratio`s land under the same symbol with
/// different prices - a direct-indexing sleeve at $775.34 next to retail
/// shares at $90.17. When that happens the group is flagged
/// `mixed_class`, `shares` is a sum across share classes, and the compare
/// view refuses to derive a price move from it.
///
/// A correct single price is NOT recoverable here: `price_ratio` is
/// folded into `.price` and the base price is never recorded, so
/// base-equivalent share counts cannot be reconstructed from a snapshot.
/// Hence flag-and-abstain rather than a silent wrong answer. `value` is
/// exact either way, which is what the mixed-class rows fall back to.
///
/// Lives here rather than in `history.zig` because it emits a
/// `view.HoldingMap` - a compare-view-shaped type. The projection-
@ -126,18 +140,59 @@ pub fn aggregateSnapshotStocks(
const eff_shares = if (price != 0) lot.value / price else lot.shares;
if (out_map.getPtr(lot.symbol)) |h| {
h.shares += eff_shares;
// price is already set from first-seen; leave it.
h.value += lot.value;
// A second lot at a DIFFERENT price means this symbol spans
// share classes: the accumulated `shares` is now a sum of
// counts in different units and `h.price` describes only one
// of them. Keep first-seen (nothing better exists) but mark
// the group so no price comparison is drawn from it.
if (h.price != price) h.mixed_class = true;
} else {
try out_map.put(lot.symbol, .{ .shares = eff_shares, .price = price });
try out_map.put(lot.symbol, .{
.shares = eff_shares,
.price = price,
.value = lot.value,
});
}
}
}
// Live-portfolio aggregation
/// Walk the live portfolio's stock lots, group by `priceSymbol()`,
/// and look up the current price from `prices`. Mirrors the snapshot
/// aggregation so the two sides are apples-to-apples.
/// Walk the live portfolio's stock lots, group by `priceSymbol()`, and
/// look up the current price from `prices`.
///
/// - `price` = the raw base-ticker price, identical for every lot of
/// the symbol regardless of share class.
/// - `shares` = `SUM(effectiveShares)` - each lot's OWN split-adjusted
/// count, NOT normalized by `price_ratio`.
/// - `value` = `SUM(lot.marketValue(raw_price, false))` - ratio-correct.
///
/// `shares` is deliberately in own-lot units so it matches what the
/// snapshot side recovers (`value / price` per lot is that lot's own
/// count). That makes the two sides' share counts COMPARABLE, which is
/// what lets `buildSymbolChange` ask "did composition change?" - the
/// question that decides whether a mixed group's value-based return can
/// be trusted as a price return.
///
/// Consequence: `shares * price == value` holds only when no share-class
/// conversion is involved (every `price_ratio` 1.0), which is exactly the
/// case where `price` is used. For a `mixed_class` group `value` is
/// authoritative and `shares` is a comparability signal, not a quantity
/// to multiply.
///
/// This used to keep the FIRST lot's ratio-scaled price while summing raw
/// share counts - neither factor coherent, and disagreeing with
/// `mergeAllocsBySymbol` on the same portfolio.
///
/// ## Why a ratio'd symbol is flagged `mixed_class` here
///
/// This side's `price` is the BASE price; the snapshot side's is a
/// ratio-SCALED per-lot price (`price_ratio` is folded into
/// `LotRow.price` and the base is never recorded). Those two are the same
/// number only when every ratio involved is 1.0, so any symbol with a
/// non-unit ratio is flagged and the per-share price columns are
/// suppressed for it.
///
/// `out_map` keys borrow from the portfolio's lot data (via
/// `priceSymbol()`). Caller must keep the portfolio alive as long as
@ -153,11 +208,20 @@ pub fn aggregateLiveStocks(
if (!lot.lotIsOpenAsOf(as_of)) continue;
const sym = lot.priceSymbol();
const raw_price = prices.get(sym) orelse continue;
const eff_price = lot.effectivePrice(raw_price, false);
const own_shares = lot.effectiveShares();
const value = lot.marketValue(raw_price, false);
const ratioed = lot.price_ratio != 1.0;
if (out_map.getPtr(sym)) |h| {
h.shares += lot.effectiveShares();
h.shares += own_shares;
h.value += value;
if (ratioed) h.mixed_class = true;
} else {
try out_map.put(sym, .{ .shares = lot.effectiveShares(), .price = eff_price });
try out_map.put(sym, .{
.shares = own_shares,
.price = raw_price,
.value = value,
.mixed_class = ratioed,
});
}
}
}
@ -228,7 +292,7 @@ pub fn adjustThenForSplits(
const testing = std.testing;
test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" {
test "aggregateSnapshotStocks: sums shares and value, filters non-stock" {
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
@ -304,6 +368,145 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares);
try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price);
try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares);
// Values accumulate, and both symbols are single-share-class.
try testing.expectEqual(@as(f64, 22500), (map.get("AAPL") orelse unreachable).value);
try testing.expectEqual(@as(f64, 10000), (map.get("MSFT") orelse unreachable).value);
try testing.expect(!(map.get("AAPL") orelse unreachable).mixed_class);
try testing.expect(!(map.get("MSFT") orelse unreachable).mixed_class);
}
test "aggregateSnapshotStocks: two prices under one symbol flags mixed_class" {
// The bug. `snapshot.buildSnapshot` keys `LotRow.symbol` on
// `priceSymbol()` with NO `price_ratio` in the key, while writing a
// ratio-scaled per-lot `.price`. So two lots aliasing one `ticker::` at
// different ratios land under one symbol at different prices - and the
// old code silently kept whichever came FIRST (i.e. portfolio file
// order) while summing `value/price` across share classes.
//
// The resulting `shares` is a mixed-unit sum and the retained `price`
// describes ~31% of the value. Compared against another date it
// reported a -17.8% move on a position whose underlying rose 9.4%.
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
const lots = [_]snapshot_model.LotRow{
// Direct-indexing sleeve: institutional NAV.
.{
.symbol = "BENCH",
.lot_symbol = "DI-IDX",
.account = "Sample Trust",
.security_type = "Stock",
.shares = 709.235272,
.open_price = 461.240208,
.cost_basis = 327_127.82,
.value = 549_901.00,
.price = 775.34,
.quote_date = Date.fromYmd(2026, 8, 26),
},
// 401k core fund: a different share class of the same index.
.{
.symbol = "BENCH",
.lot_symbol = "AGG-LC",
.account = "Sample 401(k)",
.security_type = "Stock",
.shares = 5075.077,
.open_price = 97.50,
.cost_basis = 494_820.01,
.value = 928_030.02,
.price = 182.86,
.quote_date = Date.fromYmd(2026, 8, 26),
},
// Plain retail shares of the same ticker.
.{
.symbol = "BENCH",
.lot_symbol = "BENCH",
.account = "Sample Roth",
.security_type = "Stock",
.shares = 3426,
.open_price = 90.42,
.cost_basis = 309_778.92,
.value = 308_922.42,
.price = 90.17,
.quote_date = Date.fromYmd(2026, 8, 26),
},
};
const snap = snapshot_model.Snapshot{
.meta = .{
.snapshot_version = 1,
.as_of_date = Date.fromYmd(2026, 8, 26),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = &.{},
.tax_types = &.{},
.accounts = &.{},
.lots = @constCast(&lots),
};
try aggregateSnapshotStocks(&snap, &map);
const got = map.get("BENCH") orelse return error.TestUnexpectedResult;
try testing.expect(got.mixed_class);
// `value` is exact regardless - it is what the mixed rows fall back to.
try testing.expectApproxEqAbs(@as(f64, 1_786_853.44), got.value, 0.02);
// ...whereas shares * price is not the value, which is precisely why
// the price columns must not be rendered for this row.
try testing.expect(@abs(got.shares * got.price - got.value) > 1000);
}
test "aggregateSnapshotStocks: same symbol at the SAME price is not mixed" {
// Blast-radius guard: two lots of one ordinary holding across two
// accounts share a price and must stay comparable.
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
const lots = [_]snapshot_model.LotRow{
.{
.symbol = "ABC",
.lot_symbol = "ABC",
.account = "Sample IRA",
.security_type = "Stock",
.shares = 100,
.open_price = 50,
.cost_basis = 5000,
.value = 6000,
.price = 60.0,
.quote_date = Date.fromYmd(2026, 8, 26),
},
.{
.symbol = "ABC",
.lot_symbol = "ABC",
.account = "Sample Roth",
.security_type = "Stock",
.shares = 50,
.open_price = 55,
.cost_basis = 2750,
.value = 3000,
.price = 60.0,
.quote_date = Date.fromYmd(2026, 8, 26),
},
};
const snap = snapshot_model.Snapshot{
.meta = .{
.snapshot_version = 1,
.as_of_date = Date.fromYmd(2026, 8, 26),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = &.{},
.tax_types = &.{},
.accounts = &.{},
.lots = @constCast(&lots),
};
try aggregateSnapshotStocks(&snap, &map);
const got = map.get("ABC") orelse return error.TestUnexpectedResult;
try testing.expect(!got.mixed_class);
try testing.expectApproxEqAbs(@as(f64, 150.0), got.shares, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 9000.0), got.value, 1e-9);
try testing.expectApproxEqAbs(got.value, got.shares * got.price, 1e-9);
}
test "aggregateSnapshotStocks: derives effective shares from value/price (split-captured snapshot)" {
@ -498,12 +701,13 @@ test "aggregateLiveStocks: skips lots with no price in map" {
try testing.expect(map.get("OBSCURE") == null);
}
test "aggregateLiveStocks: applies price_ratio via effectivePrice" {
test "aggregateLiveStocks: a ratio'd lot keeps own-unit shares and the base price" {
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
const today = Date.fromYmd(2026, 5, 8);
// CUSIP-style lot with price_ratio: raw price * ratio = effective.
// CUSIP-style lot with price_ratio: 100 institutional shares whose NAV
// is 5x the retail sibling's $30 quote.
const lots = [_]zfin.Lot{
.{
.symbol = "02315N600",
@ -518,15 +722,113 @@ test "aggregateLiveStocks: applies price_ratio via effectivePrice" {
var prices: std.StringHashMap(f64) = .init(testing.allocator);
defer prices.deinit();
try prices.put("VTTHX", 30.0); // raw price
try prices.put("VTTHX", 30.0); // raw base-ticker price
try aggregateLiveStocks(today, &portfolio, &prices, &map);
// priceSymbol() returns "VTTHX" (the ticker), not the CUSIP.
const h = map.get("VTTHX") orelse return error.TestUnexpectedResult;
try testing.expectApproxEqAbs(@as(f64, 100), h.shares, 0.01);
// effective price = raw * price_ratio = 30 * 5 = 150
try testing.expectApproxEqAbs(@as(f64, 150.0), h.price, 0.01);
const got = map.get("VTTHX") orelse return error.TestUnexpectedResult;
// `shares` is the lot's OWN count (100), matching what the snapshot
// side recovers as value/price - that is what makes the two sides'
// counts comparable, and hence what lets the view ask whether
// composition changed.
try testing.expectApproxEqAbs(@as(f64, 100.0), got.shares, 0.01);
// `value` is ratio-correct: 100 * 30 * 5.
try testing.expectApproxEqAbs(@as(f64, 15_000.0), got.value, 0.01);
// `price` is the raw base price, not the institutional NAV.
try testing.expectApproxEqAbs(@as(f64, 30.0), got.price, 0.01);
// Non-unit ratio -> flagged, so the price columns are suppressed and
// `shares * price != value` here is harmless (nothing multiplies them).
try testing.expect(got.mixed_class);
try testing.expect(@abs(got.shares * got.price - got.value) > 1.0);
}
test "aggregateLiveStocks: three share classes under one ticker sum by value" {
// The shape that motivated the fix: a direct-indexing sleeve, a 401k
// core fund and plain retail shares all aliased to one ticker at
// wildly different ratios. `value` is the ratio-correct total; `shares`
// is the own-unit sum, kept comparable with the snapshot side.
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
const today = Date.fromYmd(2026, 8, 26);
const base: f64 = 90.17;
const lots = [_]zfin.Lot{
.{
.symbol = "DI-IDX",
.ticker = "BENCH",
.price_ratio = 8.598686503842965,
.shares = 709.235272,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Sample Trust",
},
.{
.symbol = "AGG-LC",
.ticker = "BENCH",
.price_ratio = 2.0279503665333483,
.shares = 5075.077,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
.account = "Sample 401(k)",
},
.{
.symbol = "BENCH",
.shares = 3426,
.open_date = Date.fromYmd(2026, 8, 6),
.open_price = 90.42,
.account = "Sample Roth",
},
};
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
var prices: std.StringHashMap(f64) = .init(testing.allocator);
defer prices.deinit();
try prices.put("BENCH", base);
try aggregateLiveStocks(today, &portfolio, &prices, &map);
try testing.expectEqual(@as(usize, 1), map.count());
const got = map.get("BENCH") orelse return error.TestUnexpectedResult;
var want_shares: f64 = 0;
var want_value: f64 = 0;
for (lots) |lot| {
want_shares += lot.effectiveShares(); // own units
want_value += lot.marketValue(base, false); // ratio-correct
}
try testing.expectApproxEqRel(want_shares, got.shares, 1e-9);
try testing.expectApproxEqRel(want_value, got.value, 1e-9);
try testing.expectApproxEqRel(base, got.price, 1e-9);
try testing.expect(got.mixed_class);
// 1,786,853 of value against 9,210 own-unit shares - the product is
// meaningless, which is why `mixed_class` suppresses the price cells
// and the view works from `value`.
try testing.expect(@abs(got.shares * got.price - got.value) > 1000);
}
test "aggregateLiveStocks: an unratioed symbol is NOT flagged mixed" {
// Regression guard on the flag's blast radius: the sentinel must not
// leak onto the ~20 ordinary holdings in a real portfolio.
var map: view.HoldingMap = .init(testing.allocator);
defer map.deinit();
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]zfin.Lot{
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50, .account = "Sample IRA" },
.{ .symbol = "ABC", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 55, .account = "Sample Roth" },
};
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
var prices: std.StringHashMap(f64) = .init(testing.allocator);
defer prices.deinit();
try prices.put("ABC", 60.0);
try aggregateLiveStocks(today, &portfolio, &prices, &map);
const got = map.get("ABC") orelse return error.TestUnexpectedResult;
try testing.expect(!got.mixed_class);
try testing.expectApproxEqAbs(@as(f64, 150.0), got.shares, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 60.0), got.price, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 9000.0), got.value, 1e-9);
}
test "aggregateLiveStocks: empty portfolio yields empty map" {

View file

@ -943,11 +943,20 @@ pub fn fmtCandleRow(buf: []u8, candle: Candle) []const u8 {
}
/// Format a price change with sign: "+$3.50 (+2.04%)" or "-$3.50 (-2.04%)".
///
/// The dollar part goes through `Money`, so it is comma-grouped
/// ("+$12,345.67"). That is invisible for per-share moves, which is what the
/// quote views use it for, and necessary for portfolio-scale figures like
/// the header's session change - hence one helper rather than two.
///
/// `pct` is a whole percent (2.04 means 2.04%) and is expected to carry its
/// own sign when negative; the explicit "+" is added only on the positive
/// branch, matching the sign already on the dollar figure.
pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
if (change >= 0) {
return std.fmt.bufPrint(buf, "+${d:.2} (+{d:.2}%)", .{ change, pct }) catch "?";
return std.fmt.bufPrint(buf, "{f} (+{d:.2}%)", .{ Money.from(change).signed(), pct }) catch "?";
} else {
return std.fmt.bufPrint(buf, "-${d:.2} ({d:.2}%)", .{ -change, pct }) catch "?";
return std.fmt.bufPrint(buf, "{f} ({d:.2}%)", .{ Money.from(change).signed(), pct }) catch "?";
}
}

View file

@ -46,15 +46,36 @@ const Split = split.Split;
// snapshot.zig, audit/, and valuation.zig route through these - do
// not reintroduce inline `price * price_ratio` expressions.
//
// ## Caching pre-multiply pattern
// ## The pre-multiply anti-pattern (do not add new instances)
//
// When manual overrides (2b) get folded into a shared `prices` map
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
// time (see `commands/snapshot.zig:buildSnapshot` and
// `commands/audit/`). This normalizes the cached value so later
// readers can treat every entry uniformly as "price in whichever terms
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
// tells readers which entries are preadjusted.
// A tempting shortcut is to fold `price_ratio` into a shared `prices` map
// at insert time - "pre-multiplying" a manual override - so later readers
// can treat every entry uniformly. It is unsound, for two reasons:
//
// 1. The map is keyed by SYMBOL while `price_ratio` is per LOT. Folding
// one lot's ratio into a symbol-keyed entry corrupts every other lot
// sharing that ticker at a different ratio - which is exactly the
// `ticker::` aliasing this whole mechanism exists to support.
// 2. It collides with `buildFallbackPrices`, whose first pass gap-fills
// only `if (!prices.contains(sym))`. A pre-inserted entry is skipped,
// so it never lands in `manual_set`, so the downstream
// `effectivePrice(raw, is_manual)` sees `is_manual == false` and
// applies the ratio a SECOND time, squaring it.
//
// The correct shape is raw value + a companion "preadjusted" set:
// `buildFallbackPrices` stores the override untouched and records the
// symbol in `manual_set`; readers pass that through as `is_preadjusted`.
// `commands/snapshot.zig` does this (it used to pre-multiply, and squared
// the ratio for any manual-priced lot with a non-unit one).
//
// KNOWN GAP: `commands/audit.zig` still pre-multiplies, because the
// reconcile path has no `manual_set` equivalent -
// `reconcile/common.zig:resolvePositionValue` applies the ratio to
// whatever is in the map with no way to opt out. Fixing it properly means
// threading preadjusted-ness through the reconcile signatures. Until then
// a manual `price::` combined with a non-unit `price_ratio` reads high by
// the ratio in `zfin audit` only. Do not "fix" it by dividing the ratio
// out at insert - that just moves the corruption onto sibling lots.
//
// ## avg_cost fallback
//
@ -65,6 +86,44 @@ const Split = split.Split;
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
// with a matching `is_preadjusted` flag per branch.
//
// ## Per-LOT display rows
//
// `valuation.Allocation` is a POSITION-level view, and its
// `current_price` is NOT unconditionally raw. Which it is depends on
// whether `mergeAllocsBySymbol` folded the row:
//
// - UNMERGED (`alloc.price_ratio != 1.0`): the allocation carries the
// lot's own ratio, and `portfolioSummary` already applied it -
// `current_price` is the EFFECTIVE price.
// - MERGED, or plainly unratioed (`alloc.price_ratio == 1.0`):
// `current_price` is the RAW base-ticker price and `shares` are in
// base-ticker-equivalent units.
//
// So a lot-detail row cannot just multiply its own raw shares by
// `current_price`: in the merged case it is wrong by exactly the lot's
// ratio. That shipped - a direct-indexing sleeve with
// `price_ratio:num:0.2387` rendered a +$3.39M gain against a real
// +$433K, and the lot rows under a position summed to three times the
// position's own market value. Three sites had independently
// hand-rolled the same broken expression.
//
// The first fix for that got the OTHER half wrong: it keyed provenance
// on `alloc.is_manual_price`, which is orthogonal to merging, so every
// unmerged live-priced ratio'd lot then had its ratio applied twice.
// That is the single-CIT-lot case this file's `ticker` + `price_ratio`
// docs describe as the primary use, and no test caught it because every
// fixture left `Allocation.price_ratio` at 1.0 - indistinguishable from
// a merged group.
//
// So: per-lot display sites MUST price through
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
// which resolves `close_price`, discriminates on `price_ratio`, and
// applies the ratio via `Lot.effectivePrice`. Never read
// `Allocation.current_price` into a per-lot calculation, and never
// reintroduce `is_manual_price` as the provenance signal. Current
// callers: the CLI holdings table, the TUI portfolio tab's lot rows,
// and the shared column-width pass.
// Share model (split adjustment)
//
@ -761,6 +820,16 @@ pub const Portfolio = struct {
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
/// Only includes open lots (respects close_date and maturity_date).
///
/// The option arm below is one of THREE copies of the same premium
/// formula - the others are `analytics/analysis.zig`'s account
/// breakdown and the `.option` arm of
/// `commands/snapshot.zig:buildSnapshot`. They must agree; `analysis`
/// once dropped `multiplier` and silently under-reported every
/// option-holding account by 99% of its premium. All three use
/// `@abs`, so a written (short) option counts as a positive asset
/// rather than a liability - a deliberate shared convention. Change
/// either detail at all three sites or none.
pub fn nonStockValueForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
@ -930,8 +999,11 @@ pub const Portfolio = struct {
/// `stockSymbols`, which borrows from the portfolio, the inputs here have
/// mixed and shorter lifetimes - notably a benchmark override lives in a
/// `[16]u8` field inside a stack `UserConfig`, so borrowing it would
/// dangle the moment that config went out of scope. Caller owns the
/// result; free the slices and the outer slice, or use an arena.
/// dangle the moment that config went out of scope. (`UserConfig` is
/// itself copy-safe - it stores buffer + length, not a self-slice - but a
/// slice into one particular copy of it is only as long-lived as that
/// copy, which is exactly why this dupes.) Caller owns the result; free
/// the slices and the outer slice, or use an arena.
pub fn fetchedSymbols(
self: Portfolio,
allocator: std.mem.Allocator,

View file

@ -525,6 +525,12 @@ pub const PortfolioData = struct {
summary: zfin.valuation.PortfolioSummary,
candle_map: std.StringHashMap([]const zfin.Candle),
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
/// The equity portfolio's move over the session the prices belong to.
/// Null when no held symbol has candle history to derive a prior close
/// from. Keyed on the newest candle date rather than on `as_of`, so a
/// weekend or stale-cache run reports the last real session instead of
/// a $0.00 day - see `valuation.DayChange`.
day_change: ?zfin.valuation.DayChange,
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
self.summary.deinit(allocator);
@ -534,6 +540,24 @@ pub const PortfolioData = struct {
}
};
/// Newest candle date across every symbol with history, or null when none
/// has any.
///
/// This is the "what session are the current prices from" question for the
/// candle-close pricing path: the answer is the most recent bar anyone has,
/// which during a trading day is usually the PREVIOUS session's. Symbols
/// with an empty slice are skipped rather than treated as day zero.
fn newestCandleDate(candle_map: std.StringHashMap([]const zfin.Candle)) ?zfin.Date {
var newest: ?zfin.Date = null;
var it = candle_map.valueIterator();
while (it.next()) |cs| {
if (cs.*.len == 0) continue;
const d = cs.*[cs.*.len - 1].date;
if (newest == null or newest.?.lessThan(d)) newest = d;
}
return newest;
}
/// Build portfolio summary, candle map, and historical snapshots from
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
@ -583,13 +607,25 @@ pub fn buildPortfolioData(
as_of,
positions,
prices.*,
manual_price_set,
candle_map,
);
// Session change. The priced date is the newest candle date across the
// holdings, NOT `as_of`: this path prices from candle closes, so on a
// Tuesday mid-session the newest bar is Monday's and the figure is
// Monday's completed move. Anchoring to `as_of` would compare Monday's
// close against itself over a weekend and report nothing moved.
const day_change: ?zfin.valuation.DayChange = if (newestCandleDate(candle_map)) |priced|
zfin.valuation.computeDayChange(priced, positions, portfolio.lots, prices.*, manual_price_set, candle_map)
else
null;
return .{
.summary = summary,
.candle_map = candle_map,
.snapshots = snapshots,
.day_change = day_change,
};
}
@ -799,6 +835,37 @@ fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const [
return owned;
}
test "newestCandleDate: picks the max, skips empty, null on nothing" {
const a = testing.allocator;
const mk = struct {
fn c(d: zfin.Date) zfin.Candle {
return .{ .date = d, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 1 };
}
}.c;
var empty = std.StringHashMap([]const zfin.Candle).init(a);
defer empty.deinit();
try testing.expectEqual(@as(?zfin.Date, null), newestCandleDate(empty));
// A symbol present but with no bars must not read as day zero.
var only_blank = std.StringHashMap([]const zfin.Candle).init(a);
defer only_blank.deinit();
try only_blank.put("ABC", &.{});
try testing.expectEqual(@as(?zfin.Date, null), newestCandleDate(only_blank));
// Max across symbols, regardless of insertion order. A mutual fund
// lagging a day behind must not drag the reference date backwards.
const stale = [_]zfin.Candle{ mk(zfin.Date.fromYmd(2026, 8, 24)), mk(zfin.Date.fromYmd(2026, 8, 25)) };
const fresh = [_]zfin.Candle{ mk(zfin.Date.fromYmd(2026, 8, 25)), mk(zfin.Date.fromYmd(2026, 8, 26)) };
var m = std.StringHashMap([]const zfin.Candle).init(a);
defer m.deinit();
try m.put("FUNDX", &stale);
try m.put("ABC", &fresh);
try m.put("NOHIST", &.{});
const got = newestCandleDate(m) orelse return error.TestUnexpectedResult;
try testing.expect(got.eql(zfin.Date.fromYmd(2026, 8, 26)));
}
test "loadFromBytes: union of two synthetic SRF files" {
const allocator = testing.allocator;

View file

@ -803,7 +803,19 @@ fn aggregateFromSummary(
for (summary.allocations) |a| {
if (a.shares == 0) continue;
const price = a.market_value / a.shares;
try out.put(a.symbol, .{ .shares = a.shares, .price = price });
try out.put(a.symbol, .{
.shares = a.shares,
.price = price,
.value = a.market_value,
// Mirrors `compare.aggregateLiveStocks`: this side's price is
// only comparable against a snapshot's ratio-scaled per-lot
// price when no share-class conversion is in play. A merged
// group's price is the raw base price; an unmerged ratio'd
// position's is its own effective price. Neither lines up
// with the snapshot convention reliably, so abstain and let
// the exact value delta carry the row.
.mixed_class = a.merged or a.price_ratio != 1.0,
});
}
}
@ -1686,6 +1698,97 @@ const testing = std.testing;
const Date = zfin.Date;
const snapshot = @import("../models/snapshot.zig");
test "aggregateFromSummary: carries value and flags share-class conversions" {
// This is the TUI's live side of a snapshot-vs-live compare. It had no
// tests at all, and it constructs `compare_view.Holding` by hand - so
// when `Holding` grew `value`, forgetting to set it here would have
// made every mixed row's dollar delta `0 - then_value`.
const allocs = [_]zfin.valuation.Allocation{
// Ordinary holding: comparable.
.{
.symbol = "ABC",
.display_symbol = "ABC",
.shares = 100,
.avg_cost = 50,
.current_price = 60,
.market_value = 6000,
.cost_basis = 5000,
.weight = 0.5,
.unrealized_gain_loss = 1000,
.unrealized_return = 0.2,
},
// Merged group: price is the RAW base price, shares are
// base-equivalent - not the convention snapshots record.
.{
.symbol = "BENCH",
.display_symbol = "BENCH",
.shares = 200,
.avg_cost = 25,
.current_price = 30,
.market_value = 6000,
.cost_basis = 5000,
.weight = 0.5,
.unrealized_gain_loss = 1000,
.unrealized_return = 0.2,
.merged = true,
},
// Unmerged but ratio'd: price is its own effective price.
.{
.symbol = "CIT",
.display_symbol = "CIT",
.shares = 10,
.avg_cost = 100,
.current_price = 150,
.market_value = 1500,
.cost_basis = 1000,
.weight = 0.1,
.unrealized_gain_loss = 500,
.unrealized_return = 0.5,
.price_ratio = 5.0,
},
// Zero shares: skipped (would divide by zero).
.{
.symbol = "GONE",
.display_symbol = "GONE",
.shares = 0,
.avg_cost = 0,
.current_price = 0,
.market_value = 0,
.cost_basis = 0,
.weight = 0,
.unrealized_gain_loss = 0,
.unrealized_return = 0,
},
};
const summary = zfin.valuation.PortfolioSummary{
.allocations = @constCast(&allocs),
.total_value = 13500,
.total_cost = 11000,
.unrealized_gain_loss = 2500,
.unrealized_return = 0.227,
.realized_gain_loss = 0,
};
var map: compare_view.HoldingMap = .init(testing.allocator);
defer map.deinit();
try aggregateFromSummary(summary, &map);
try testing.expectEqual(@as(u32, 3), map.count()); // GONE skipped
const abc = map.get("ABC") orelse return error.TestUnexpectedResult;
try testing.expectApproxEqAbs(@as(f64, 6000), abc.value, 1e-9);
try testing.expectApproxEqAbs(abc.value, abc.shares * abc.price, 1e-9);
try testing.expect(!abc.mixed_class);
const bench = map.get("BENCH") orelse return error.TestUnexpectedResult;
try testing.expectApproxEqAbs(@as(f64, 6000), bench.value, 1e-9);
try testing.expect(bench.mixed_class);
const cit = map.get("CIT") orelse return error.TestUnexpectedResult;
try testing.expectApproxEqAbs(@as(f64, 1500), cit.value, 1e-9);
try testing.expect(cit.mixed_class);
}
test "formatSelectionStatus: count 1 includes commit key" {
var buf: [128]u8 = undefined;
const msg = try formatSelectionStatus(&buf, 1, "c");

View file

@ -1614,26 +1614,124 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
/// fetch instant - and flag it when the market was closed at that
/// moment, so a weekend/after-hours refresh no longer implies live
/// intraday data. Otherwise fall back to the candle close date.
fn appendAsOfLine(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), app: *App) !void {
const th = app.theme;
/// The "(as of ...)" provenance stamp, or null when there is nothing to
/// say. Returned without a leading indent so callers can either emit it as
/// its own line or append it to one.
fn asOfText(arena: std.mem.Allocator, app: *App) !?[]const u8 {
if (app.portfolio.live_prices_applied) {
if (app.portfolio.live_quotes_at_s) |ts| {
var clk: [16]u8 = undefined;
const stamp = zfin.market.fmtClockET(&clk, ts);
const text = if (zfin.market.marketSession(ts) != .open)
try std.fmt.allocPrint(arena, " (as of {s}, market closed)", .{stamp})
return if (zfin.market.marketSession(ts) != .open)
try std.fmt.allocPrint(arena, "(as of {s}, market closed)", .{stamp})
else
try std.fmt.allocPrint(arena, " (as of {s})", .{stamp});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
} else {
// Live overlay applied but no fetch timestamp was threaded
// (defensive fallback).
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
try std.fmt.allocPrint(arena, "(as of {s})", .{stamp});
}
} else if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
// Live overlay applied but no fetch timestamp was threaded
// (defensive fallback).
return "(as of intraday quote today)";
}
if (app.portfolio.latest_quote_date) |d| {
return try std.fmt.allocPrint(arena, "(as of close on {f})", .{d});
}
return null;
}
fn appendAsOfLine(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), app: *App) !void {
const th = app.theme;
if (try asOfText(arena, app)) |t| {
const text = try std.fmt.allocPrint(arena, " {s}", .{t});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
}
}
/// Widest the Day line may get before the provenance stamp is spilled onto
/// its own line. Lines are hard-truncated rather than wrapped, so this is a
/// real ceiling, not a preference. 78 leaves a little slack inside the
/// classic 80-column terminal.
const day_line_budget: usize = 78;
/// Label for the session-change line: what the figure actually describes.
/// "Day" only when the priced date really is today - otherwise "Yesterday",
/// or the date itself when it is older still (a weekend, a holiday, or a
/// stale cache). Never claims to be today's move when it isn't.
fn dayChangeLabel(arena: std.mem.Allocator, dc: zfin.valuation.DayChange, today: zfin.Date) ![]const u8 {
return switch (dc.recency(today)) {
.today => "Day",
.yesterday => "Yesterday",
.older => try std.fmt.allocPrint(arena, "{f}", .{dc.priced_date}),
};
}
/// Whether the provenance stamp still fits on the session-change line.
/// `+ 2` is the two-space separator before it.
fn dayLineFitsStamp(fig_end: usize, tail_len: usize, stamp_len: usize) bool {
return fig_end + tail_len + 2 + stamp_len <= day_line_budget;
}
/// Append the session-change ("Day") line, and report whether it absorbed
/// the provenance stamp so the caller can skip emitting it separately.
///
/// Label tracks what the figure actually describes rather than assuming
/// today: streaming intraday it is "Day", but with prices from the last
/// close it is "Yesterday", and over a weekend or a stale cache it names
/// the date outright. See `valuation.DayChange` for why the underlying
/// figure keys on the priced date.
///
/// The dollar/percent run is coloured by sign; the label, the coverage
/// marker and the stamp stay muted, so the eye lands on the number.
fn appendDayChangeLine(
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
app: *App,
total_value: f64,
) !bool {
const th = app.theme;
const dc = app.portfolio.dayChange() orelse return false;
if (!dc.hasData()) return false;
const label = try dayChangeLabel(arena, dc, app.today);
var fig_buf: [64]u8 = undefined;
const figure = fmt.fmtPriceChange(&fig_buf, dc.change, dc.pctOfAccount(total_value) * 100.0);
const head = try std.fmt.allocPrint(arena, " {s}: ", .{label});
// Byte offsets of the coloured run, for the alt-style span.
const fig_start = head.len;
const fig_end = fig_start + figure.len;
var tail: []const u8 = "";
if (!dc.complete()) {
// Only a FIXABLE gap gets a marker - a security that should have had
// a daily series and didn't. Hand-priced holdings are excluded from
// `positions_priceable` upstream, so they never light this;
// otherwise it would be permanently on and get tuned out. Dollars
// rather than a count, because two missing securities could be $3k
// or $3M and only one of those is ignorable.
const lg = fmt.fmtLargeNumOpts(dc.uncovered_value, .{ .thousands = true });
tail = try std.fmt.allocPrint(arena, " [${s} no history]", .{std.mem.trimEnd(u8, &lg, " ")});
}
const stamp = try asOfText(arena, app);
var absorbed = false;
var stamp_part: []const u8 = "";
if (stamp) |t| {
if (dayLineFitsStamp(fig_end, tail.len, t.len)) {
stamp_part = try std.fmt.allocPrint(arena, " {s}", .{t});
absorbed = true;
}
}
const text = try std.fmt.allocPrint(arena, "{s}{s}{s}{s}", .{ head, figure, tail, stamp_part });
const fig_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{
.text = text,
.style = th.mutedStyle(),
.alt_style = fig_style,
.alt_start = fig_start,
.alt_end = fig_end,
});
return absorbed;
}
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
@ -1698,8 +1796,13 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator
try appendAsOfLine(arena, &lines, app);
// Session change, with the "as of" stamp folded in when it
// fits. Only the unfiltered view for now - the filtered path
// maintains its own totals and has no per-account day change.
const day_absorbed_asof = try appendDayChangeLine(arena, &lines, app, s.total_value);
// "as of" date indicator, unless the Day line already carries it
if (!day_absorbed_asof) try appendAsOfLine(arena, &lines, app);
// Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio.file) |pf| {
@ -1840,10 +1943,10 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined;
const mv_str = std.fmt.bufPrint(&mv_buf, "{f}", .{Money.from(display_mv)}) catch "$?";
var cost_buf2: [24]u8 = undefined;
const cost_str = std.fmt.bufPrint(&cost_buf2, "{f}", .{Money.from(display_avg_cost)}) catch "$?";
var price_buf2: [24]u8 = undefined;
const price_str = std.fmt.bufPrint(&price_buf2, "{f}", .{Money.from(a.current_price)}) catch "$?";
var cost_buf: [24]u8 = undefined;
const cost_str = std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(display_avg_cost)}) catch "$?";
var price_buf: [24]u8 = undefined;
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(a.current_price)}) catch "$?";
// Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined;
@ -1874,29 +1977,24 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
else
a.weight;
// Pad each dynamic cell to this frame's column
// widths (weight + date stay fixed-spec). Symbol
// is left-justified; the numeric cells right.
var sym_buf: [64]u8 = undefined;
const sym_cell = blk: {
const sym_src = std.fmt.bufPrint(&sym_buf, "{s}", .{a.display_symbol}) catch a.display_symbol;
break :blk fmt.padRightToCols(&sym_buf, sym_src, cw.symbol_w);
};
var shr_raw_buf: [48]u8 = undefined;
const shr_raw = std.fmt.bufPrint(&shr_raw_buf, "{d:.1}", .{display_shares}) catch "?";
var shr_buf: [64]u8 = undefined;
const shr_cell = fmt.padLeftToCols(&shr_buf, shr_raw, cw.shares_w);
var cost_pad: [64]u8 = undefined;
const cost_cell = fmt.padLeftToCols(&cost_pad, cost_str, cw.price_w);
var prc_pad: [64]u8 = undefined;
const prc_cell = fmt.padLeftToCols(&prc_pad, price_str, cw.price_w);
var mv_pad: [64]u8 = undefined;
const mv_cell = fmt.padLeftToCols(&mv_pad, mv_str, cw.value_w);
var gl_pad: [64]u8 = undefined;
const gl_cell = fmt.padLeftToCols(&gl_pad, pnl_str, cw.gainloss_w);
// Pad each dynamic cell to this frame's column
// widths (weight + date stay fixed-spec). Named in
// column order so this arm and the lot / watchlist
// arms can't disagree about which value goes where.
const cells = try views.padRowCells(arena, cw, .{
.symbol = a.display_symbol,
.shares = shr_raw,
.cost = cost_str,
.price = price_str,
.value = mv_str,
.gainloss = pnl_str,
});
const text = try std.fmt.allocPrint(arena, "{s}{s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{
arrow, star, sym_cell, shr_cell, cost_cell, prc_cell, mv_cell, gl_cell, display_weight * 100.0, date_col, acct_col,
arrow, star, cells.symbol, cells.shares, cells.cost, cells.price, cells.value, cells.gainloss, display_weight * 100.0, date_col, acct_col,
});
// base: neutral text for main cols, green/red only for gain/loss col
@ -1921,48 +2019,54 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
// Compute lot gain/loss and market value if we have a price
var lot_gl_str: []const u8 = "";
var lot_mv_str: []const u8 = "";
var lot_value_str: []const u8 = "";
var lot_price_str: []const u8 = "";
var lot_positive = true;
if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) {
const price = s.allocations[row.pos_idx].current_price;
const use_price = lot.close_price orelse price;
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
Money.from(if (gl >= 0) gl else -gl),
});
lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.effectiveShares() * use_price)});
// The LOT's effective price, not its position's -
// `Allocation.current_price` is the raw
// base-ticker price, so a ratio'd lot must go
// through `effectivePriceFor` or its Value and
// Gain/Loss come out scaled by its ratio.
const use_price = views.effectivePriceFor(s.allocations, lot);
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
Money.from(if (gl >= 0) gl else -gl),
});
lot_value_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.effectiveShares() * use_price)});
// Price column: normally blank, since the position
// row above already shows it. A ratio'd lot prices
// off its own institutional NAV, so show that.
if (views.hasOwnPrice(lot)) {
lot_price_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(use_price)});
}
}
var price_str2: [24]u8 = undefined;
const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.effectiveOpenPrice())}) catch "$?";
// Avg Cost column: this lot's own per-share cost basis.
var lot_cost_buf: [24]u8 = undefined;
const lot_cost_str = std.fmt.bufPrint(&lot_cost_buf, "{f}", .{Money.from(lot.effectiveOpenPrice())}) catch "$?";
const status_str: []const u8 = if (lot.isOpen(app.today)) "open" else "closed";
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 "";
var lot_sym_buf: [64]u8 = undefined;
const lot_sym_cell = blk: {
const sym_src = std.fmt.bufPrint(&lot_sym_buf, "{s}", .{status_str}) catch status_str;
break :blk fmt.padRightToCols(&lot_sym_buf, sym_src, cw.symbol_w);
};
var lot_shr_raw: [48]u8 = undefined;
const lot_shr_raw_s = std.fmt.bufPrint(&lot_shr_raw, "{d:.1}", .{lot.effectiveShares()}) catch "?";
var lot_shr_buf: [64]u8 = undefined;
const lot_shr_cell = fmt.padLeftToCols(&lot_shr_buf, lot_shr_raw_s, cw.shares_w);
var lot_cost_pad: [64]u8 = undefined;
const lot_cost_cell = fmt.padLeftToCols(&lot_cost_pad, lot_price_str, cw.price_w);
var lot_prc_pad: [64]u8 = undefined;
const lot_prc_cell = fmt.padLeftToCols(&lot_prc_pad, "", cw.price_w); // blank current-price cell
var lot_mv_pad: [64]u8 = undefined;
const lot_mv_cell = fmt.padLeftToCols(&lot_mv_pad, lot_mv_str, cw.value_w);
var lot_gl_pad: [64]u8 = undefined;
const lot_gl_cell = fmt.padLeftToCols(&lot_gl_pad, lot_gl_str, cw.gainloss_w);
var lot_shr_buf: [48]u8 = undefined;
const lot_shr_str = std.fmt.bufPrint(&lot_shr_buf, "{d:.1}", .{lot.effectiveShares()}) catch "?";
const cells = try views.padRowCells(arena, cw, .{
// The Symbol column carries open/closed status on a
// lot row rather than a symbol.
.symbol = status_str,
.shares = lot_shr_str,
.cost = lot_cost_str,
.price = lot_price_str,
.value = lot_value_str,
.gainloss = lot_gl_str,
});
const text = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{
lot_sym_cell, lot_shr_cell, lot_cost_cell, lot_prc_cell, lot_mv_cell, lot_gl_cell, "", lot_date_col, acct_col,
cells.symbol, cells.shares, cells.cost, cells.price, cells.value, cells.gainloss, "", lot_date_col, acct_col,
});
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
@ -1976,29 +2080,24 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
}
},
.watchlist => {
var price_str3: [16]u8 = undefined;
var watch_price_buf: [16]u8 = undefined;
const ps: []const u8 = if (app.portfolio.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
(if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&watch_price_buf, "{f}", .{Money.from(p)}) catch "$?") else "--")
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
var w_sym_buf: [64]u8 = undefined;
const w_sym_cell = blk: {
const sym_src = std.fmt.bufPrint(&w_sym_buf, "{s}", .{row.symbol}) catch row.symbol;
break :blk fmt.padRightToCols(&w_sym_buf, sym_src, cw.symbol_w);
};
var w_shr_buf: [64]u8 = undefined;
const w_shr_cell = fmt.padLeftToCols(&w_shr_buf, "--", cw.shares_w);
var w_avg_buf: [64]u8 = undefined;
const w_avg_cell = fmt.padLeftToCols(&w_avg_buf, "--", cw.price_w);
var w_prc_buf: [64]u8 = undefined;
const w_prc_cell = fmt.padLeftToCols(&w_prc_buf, ps, cw.price_w);
var w_mv_buf: [64]u8 = undefined;
const w_mv_cell = fmt.padLeftToCols(&w_mv_buf, "--", cw.value_w);
var w_gl_buf: [64]u8 = undefined;
const w_gl_cell = fmt.padLeftToCols(&w_gl_buf, "--", cw.gainloss_w);
// A watchlist row has no position, so only Symbol and Price
// carry data; the rest are placeholders.
const cells = try views.padRowCells(arena, cw, .{
.symbol = row.symbol,
.shares = "--",
.cost = "--",
.price = ps,
.value = "--",
.gainloss = "--",
});
const text = try std.fmt.allocPrint(arena, " {s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str, .{
star2, w_sym_cell, w_shr_cell, w_avg_cell, w_prc_cell, w_mv_cell, w_gl_cell, "watch", "",
star2, cells.symbol, cells.shares, cells.cost, cells.price, cells.value, cells.gainloss, "watch", "",
});
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style });
@ -2728,6 +2827,57 @@ fn applyAccountPickerSelection(state: *State, app: *App) void {
const testing = std.testing;
fn tdc(priced: zfin.Date, change: f64, covered: usize, total: usize) zfin.valuation.DayChange {
return .{
.priced_date = priced,
.change = change,
.prev_stock_value = 100_000,
.curr_stock_value = 100_000 + change,
.positions_covered = covered,
.positions_priceable = total,
.positions_total = total,
.uncovered_value = 0,
};
}
test "dayChangeLabel: never claims 'Day' for a session that isn't today" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const today = zfin.Date.fromYmd(2026, 8, 27);
try testing.expectEqualStrings("Day", try dayChangeLabel(a, tdc(today, 1, 1, 1), today));
try testing.expectEqualStrings("Yesterday", try dayChangeLabel(a, tdc(today.addDays(-1), 1, 1, 1), today));
// A weekend or stale cache names the date rather than implying today.
try testing.expectEqualStrings("2026-08-21", try dayChangeLabel(a, tdc(zfin.Date.fromYmd(2026, 8, 21), 1, 1, 1), today));
}
test "dayLineFitsStamp: the stamp is folded in only when it fits" {
const close_stamp = "(as of close on 2026-08-26)".len; // 27, the longest
const live_stamp = "(as of 3:42 PM ET)".len; // 18
const marker = " [18/21 priced]".len; // 16
// The realistic case, measured from actual output: " Yesterday: " (13)
// + "-$7,180.04 (-0.08%)" (19) = 32. Everything fits, even with the
// coverage marker AND the longest stamp (32+16+2+27 = 77).
try testing.expect(dayLineFitsStamp(32, 0, close_stamp));
try testing.expect(dayLineFitsStamp(32, 0, live_stamp));
try testing.expect(dayLineFitsStamp(32, marker, close_stamp));
try testing.expect(dayLineFitsStamp(32, marker, live_stamp));
// An eight-figure move on a dated label overflows, so the stamp spills
// to its own line rather than being silently truncated off the end:
// " 2026-08-21: " (14) + "-$12,345,678.90 (-12.34%)" (25) = 39,
// + 16 + 2 + 27 = 84.
try testing.expect(!dayLineFitsStamp(39, marker, close_stamp));
// Dropping the marker brings it back under (39+2+27 = 68).
try testing.expect(dayLineFitsStamp(39, 0, close_stamp));
// Boundary: exactly the budget fits, one more does not.
try testing.expect(dayLineFitsStamp(0, 0, day_line_budget - 2));
try testing.expect(!dayLineFitsStamp(0, 0, day_line_budget - 1));
}
test "PortfolioSortField next/prev" {
// next from first field
try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);

View file

@ -94,13 +94,10 @@ pub const State = struct {
/// disabled state). Distinct from `ctx != null` because failed
/// loads still mark loaded.
loaded: bool = false,
/// User-tunable inputs to the projection engine (annual
/// contribution, target spending, retirement target percentile,
/// etc.). Driven by annotations on the portfolio file.
config: @import("../analytics/projections.zig").UserConfig = .{},
/// Loaded projection context: bands, withdrawal tables,
/// horizon configs, optional overlay actuals. Owned by State;
/// freed via `freeLoaded`.
/// freed via `freeLoaded`. The `UserConfig` travels inside this -
/// there is deliberately no second copy on State.
ctx: ?@import("../views/projections.zig").ProjectionContext = null,
/// Currently-focused horizon row in the terminal-value table.
/// (Reserved for future expansion; not consumed today.)
@ -1142,7 +1139,7 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -1152,7 +1149,7 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, pctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), pctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,
@ -1892,7 +1889,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -1902,7 +1899,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,

View file

@ -37,7 +37,31 @@
//! don't contribute (matching the "don't count adds" intent),
//! shares sold don't either.
//!
//! Sorted by `pct_change` descending - biggest winners first.
//! ### Mixed share classes
//!
//! A symbol whose lots span share classes (a `ticker::` alias shared by
//! holdings at different `price_ratio`s) has no single meaningful
//! per-share price. Such a row is marked `price_comparable = false`: the
//! two price cells render as the no-data sentinel, and `pct_change` /
//! `dollar_change` switch to a VALUE basis - `value_now / value_then - 1`
//! and `value_now - value_then`.
//!
//! Whether that percentage is a real return then depends on one thing:
//! did the share count move? A group's value is
//! `base_price * SUM(shares_i * ratio_i)`, so with shares and ratios
//! fixed the value ratio reduces EXACTLY to the underlying price ratio.
//! Such a row is `pct_reliable` and sorts inline with everything else -
//! its percentage is as good as any price-derived one, it just has no
//! single price to show alongside.
//!
//! If shares were bought or sold, the figure absorbs that flow and no
//! longer isolates the market. Those rows are `pct_reliable = false`:
//! tallied separately, sorted last, and named in a footnote, so they are
//! easy to set aside as a block. `CompareView.unreliable_count` exists so
//! renderers can surface that.
//!
//! Sorted by `pct_change` descending - biggest winners first, with
//! unreliable rows last.
//!
//! ## Contract
//!
@ -68,15 +92,57 @@ pub const SymbolChange = struct {
price_then: f64,
price_now: f64,
/// `min(shares_then, shares_now)` - the continuously-held floor.
/// Drives `dollar_change`.
/// Drives `dollar_change` when `price_comparable`.
shares_held_throughout: f64,
/// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by
/// 100 at format time via `fmtSignedPercentBuf` or similar.
///
/// BASIS DEPENDS ON `price_comparable`:
/// - true: `price_now / price_then - 1` - price-only, unaffected by
/// share-count changes between the dates.
/// - false: `value_now / value_then - 1` - total value change, which
/// ALSO includes anything bought or sold in the window.
/// No price-only figure is derivable for such a group.
pct_change: f64,
/// `shares_held_throughout * (price_now - price_then)`. Signed.
/// Signed. Meaning depends on `price_comparable`:
/// - true: `shares_held_throughout * (price_now - price_then)` -
/// the price-only impact on continuously-held shares.
/// - false: `value_now - value_then` - the TOTAL value change, on
/// the same basis as `pct_change` above.
dollar_change: f64,
/// `.positive` when pct_change > 0, `.negative` when < 0,
/// `.muted` when exactly zero.
/// False when either side of the comparison is a `mixed_class`
/// holding, meaning no single per-share price describes the group.
/// `buildSymbolRowCells` emits the no-data sentinel for the two price
/// cells, and `pct_change` / `dollar_change` switch to a VALUE basis.
///
/// Gates on EITHER side on purpose: a snapshot's per-lot price and the
/// live side's base-ticker price are not comparable quantities, so one
/// mixed side poisons the pair even if the other is clean.
price_comparable: bool = true,
/// Whether `pct_change` can be read as a genuine return.
///
/// Always true for `price_comparable` rows - a price ratio is
/// share-count-independent by construction.
///
/// For a mixed-class row it is true exactly when the share count did
/// NOT move between the two dates. That is not a heuristic: a group's
/// value is `base_price * SUM(shares_i * ratio_i)`, so with the shares
/// and ratios fixed, `value_now / value_then` reduces exactly to
/// `base_now / base_then` - the real underlying return. Once shares
/// move, the ratio also absorbs whatever was bought or sold and no
/// longer isolates the market.
///
/// Renderers use this to decide sort position and whether to warn:
/// reliable rows sort inline with everything else and count toward
/// gainers/losers, unreliable ones sort last and are called out.
///
/// Caveat: a `price_ratio` RESTATEMENT (rewriting the ratio without
/// changing shares) also breaks the reduction, and this cannot detect
/// that - the ratio is not recorded in a snapshot.
pct_reliable: bool = true,
/// `.positive` when the driving figure is > 0, `.negative` when < 0,
/// `.muted` when zero. Driven by `pct_change` when comparable, by
/// `dollar_change` otherwise.
style: StyleIntent,
};
@ -141,12 +207,26 @@ pub const CompareView = struct {
removed_count: usize,
/// Number of held-throughout symbols with `pct_change > flat_threshold`.
/// Intended for the per-symbol summary footer ("21 gainers, 5 losers").
/// Counts only `price_comparable` rows.
gainer_count: usize = 0,
/// Number of held-throughout symbols with `pct_change < -flat_threshold`.
/// Counts only `price_comparable` rows.
loser_count: usize = 0,
/// Number of held-throughout symbols with `|pct_change| <= flat_threshold`.
/// `gainer_count + loser_count + flat_count == held_count`.
/// Counts only `price_comparable` rows.
flat_count: usize = 0,
/// Number of held-throughout symbols whose percentage cannot be read
/// as a return (`SymbolChange.pct_reliable == false`): a mixed-class
/// group whose share count moved, so the figure mixes market movement
/// with whatever was bought or sold.
///
/// Its own bucket rather than folded into `flat_count`, which would
/// understate the gainers/losers tally. Note a mixed-class row with a
/// STATIC share count is NOT counted here - it buckets as a normal
/// gainer/loser/flat, because its value ratio is a genuine return.
///
/// `gainer_count + loser_count + flat_count + unreliable_count == held_count`.
unreliable_count: usize = 0,
/// Optional contributions-vs-gains breakdown of `liquid.delta`.
/// Populated by the CLI from `computeAttributionSpec` when a git repo
/// is available; always null in unit-tested / TUI flows.
@ -221,12 +301,34 @@ pub fn buildBucketLabel(
/// table crosses the threshold.
pub const flat_threshold: f64 = 0.0001;
/// One entry in a holdings snapshot - total shares held of `symbol` and
/// the per-share price at that moment. Caller-populated; the view model
/// doesn't know or care where the numbers came from.
/// One entry in a holdings snapshot - total shares held of `symbol`, the
/// per-share price at that moment, and the total value. Caller-populated;
/// the view model doesn't know or care where the numbers came from.
///
/// `shares * price == value` must hold. That is not decoration: it is the
/// only thing that makes `price` meaningful, and it is exactly what broke
/// when a symbol's lots spanned share classes. See `mixed_class`.
pub const Holding = struct {
shares: f64,
price: f64,
/// Total market value. Carried explicitly rather than recomputed as
/// `shares * price`, because for a `mixed_class` group that product
/// is meaningless while the value is still exact.
value: f64 = 0,
/// True when this symbol's lots did NOT share a single per-share
/// price on this side of the comparison - i.e. the aggregation summed
/// across share classes (a direct-indexing sleeve at $775.34, a 401k
/// CIT at $182.86 and retail shares at $90.17 can all sit under one
/// `ticker::`). For such a group `shares` is a sum of counts in
/// different units and no single `price` describes it, so
/// `buildSymbolChange` refuses to compare prices and the renderer
/// shows the no-data sentinel instead of a fabricated move.
///
/// A single price genuinely cannot be recovered for these: snapshot
/// `LotRow` folds `price_ratio` into its per-lot `price` and never
/// records the base price, so base-equivalent share counts are not
/// derivable from an existing snapshot at all.
mixed_class: bool = false,
};
/// Symbol -> Holding. String keys are caller-owned; keep them alive as
@ -235,27 +337,50 @@ pub const HoldingMap = std.StringHashMap(Holding);
// Pure builders
/// Compute a single per-symbol change from the raw inputs.
/// Compute a single per-symbol change from the two sides' holdings.
///
/// The pct-change denominator is `price_then`. If `price_then` is zero
/// (shouldn't happen for stocks but guards against bad data), the
/// pct_change is reported as 0 rather than a NaN/Inf leaking into the
/// sort comparator.
pub fn buildSymbolChange(
symbol: []const u8,
shares_then: f64,
price_then: f64,
shares_now: f64,
price_now: f64,
) SymbolChange {
const held = @min(shares_then, shares_now);
const pct = if (price_then != 0) (price_now / price_then - 1.0) else 0.0;
const dollar = held * (price_now - price_then);
/// The pct-change denominator is `then.price`. If it is zero (shouldn't
/// happen for stocks but guards against bad data), the pct_change is
/// reported as 0 rather than a NaN/Inf leaking into the sort comparator.
///
/// When EITHER side is `mixed_class` no single per-share price describes
/// the group, so the price cells are suppressed and both figures switch
/// to a VALUE basis: `pct_change = value_now / value_then - 1`,
/// `dollar_change = value_now - value_then`.
///
/// Whether that percentage is a trustworthy return then depends on
/// whether the share count moved - see `SymbolChange.pct_reliable`. With
/// composition fixed the value ratio reduces exactly to the underlying
/// price ratio, so the row belongs inline with the price-basis rows. If
/// shares were bought or sold, the figure absorbs that flow and the row
/// is marked unreliable so a renderer can set it aside.
pub fn buildSymbolChange(symbol: []const u8, then: Holding, now: Holding) SymbolChange {
const held = @min(then.shares, now.shares);
const comparable = !then.mixed_class and !now.mixed_class;
if (!comparable) {
const dollar = now.value - then.value;
const pct = if (then.value != 0) (now.value / then.value - 1.0) else 0.0;
return .{
.symbol = symbol,
.price_then = then.price,
.price_now = now.price,
.shares_held_throughout = held,
.pct_change = pct,
.dollar_change = dollar,
.price_comparable = false,
.pct_reliable = sharesUnchanged(then.shares, now.shares),
.style = if (dollar > 0) .positive else if (dollar < 0) .negative else .muted,
};
}
const pct = if (then.price != 0) (now.price / then.price - 1.0) else 0.0;
const dollar = held * (now.price - then.price);
const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted;
return .{
.symbol = symbol,
.price_then = price_then,
.price_now = price_now,
.price_then = then.price,
.price_now = now.price,
.shares_held_throughout = held,
.pct_change = pct,
.dollar_change = dollar,
@ -263,6 +388,22 @@ pub fn buildSymbolChange(
};
}
/// Whether two share counts are the same position rather than a
/// purchase, sale or DRIP.
///
/// Relative tolerance, because both sides arrive through float
/// arithmetic: the snapshot side recovers each lot's count as
/// `value / price` and the live side sums `effectiveShares()`, so a
/// genuinely static holding can differ in the last bits. The absolute
/// floor covers counts near zero.
///
/// A real purchase is orders of magnitude above this - the smallest
/// meaningful move is a fractional DRIP share, not 1e-6 of a share.
fn sharesUnchanged(a: f64, b: f64) bool {
const diff = @abs(a - b);
return diff <= @max(1e-6, @abs(a) * 1e-6);
}
/// Compute the liquid totals row. Safe when `then == 0` (pct -> 0 rather
/// than NaN).
pub fn buildTotalsRow(then: f64, now: f64) TotalsRow {
@ -298,6 +439,11 @@ pub fn forwardAdjustThen(then_map: *HoldingMap, factors: *const std.StringHashMa
if (f == 1.0 or f == 0.0) continue;
e.value_ptr.shares *= f;
e.value_ptr.price /= f;
// `value` is deliberately untouched: a split changes the share
// count and the per-share price by reciprocal factors, so the
// holding's total value is unchanged. This keeps the
// `shares * price == value` invariant intact through the
// adjustment.
}
}
@ -338,35 +484,43 @@ pub fn buildCompareView(
const sym = e.key_ptr.*;
const then_h = e.value_ptr.*;
if (now_map.get(sym)) |now_h| {
try changes.append(allocator, buildSymbolChange(
sym,
then_h.shares,
then_h.price,
now_h.shares,
now_h.price,
));
try changes.append(allocator, buildSymbolChange(sym, then_h, now_h));
} else {
removed += 1;
}
}
// Sort by pct_change descending. Stable is fine; stability isn't
// semantically relevant here but is cheaper in the not-all-unique case.
// Sort by pct_change descending, with rows whose percentage can't be
// trusted as a return pushed to the bottom. Those are mixed-class
// groups whose share count moved, so the figure absorbs whatever was
// bought or sold - parking them last keeps them out of the "biggest
// mover" slot and makes them easy to disregard as a block.
std.mem.sort(SymbolChange, changes.items, {}, struct {
fn lt(_: void, a: SymbolChange, b: SymbolChange) bool {
if (a.pct_reliable != b.pct_reliable) return a.pct_reliable;
return a.pct_change > b.pct_change;
}
}.lt);
// Bucket held-throughout rows into gainers / losers / flat using
// `flat_threshold` so that cent-rounding noise on a high-priced
// position doesn't get counted as a win or a loss. Computed after
// the sort purely for locality - buckets are independent of order.
// Bucket rows into gainers / losers / flat using `flat_threshold` so
// that cent-rounding noise on a high-priced position doesn't get
// counted as a win or a loss. Computed after the sort purely for
// locality - buckets are independent of order.
//
// A mixed-class row with a STATIC share count buckets normally: its
// value ratio reduces exactly to the underlying price ratio, so it is
// a real return even though no single per-share price exists to
// display. Only rows whose share count moved are set aside, because
// there the percentage is part market and part cash flow with no way
// to separate them.
var gainers: usize = 0;
var losers: usize = 0;
var flats: usize = 0;
var unreliable: usize = 0;
for (changes.items) |c| {
if (c.pct_change > flat_threshold) {
if (!c.pct_reliable) {
unreliable += 1;
} else if (c.pct_change > flat_threshold) {
gainers += 1;
} else if (c.pct_change < -flat_threshold) {
losers += 1;
@ -389,6 +543,7 @@ pub fn buildCompareView(
.gainer_count = gainers,
.loser_count = losers,
.flat_count = flats,
.unreliable_count = unreliable,
};
}
@ -470,8 +625,20 @@ test "buildBucketLabel: missing bucket_start on non-daily tier falls back to ISO
try testing.expectEqualStrings("2025-03-28", lbl);
}
/// A single-share-class `Holding` for tests: `value` derived so the
/// `shares * price == value` invariant holds, `mixed_class` false.
fn h(shares: f64, price: f64) Holding {
return .{ .shares = shares, .price = price, .value = shares * price };
}
/// A mixed-share-class `Holding`: `value` given explicitly because
/// `shares * price` is meaningless for such a group.
fn hMixed(shares: f64, price: f64, value: f64) Holding {
return .{ .shares = shares, .price = price, .value = value, .mixed_class = true };
}
test "buildSymbolChange: positive price move, shares stable" {
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
const c = buildSymbolChange("AAPL", h(100, 150.0), h(100, 165.0));
try testing.expectEqualStrings("AAPL", c.symbol);
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9);
@ -480,14 +647,14 @@ test "buildSymbolChange: positive price move, shares stable" {
}
test "buildSymbolChange: negative move" {
const c = buildSymbolChange("NFLX", 10, 500.0, 10, 400.0);
const c = buildSymbolChange("NFLX", h(10, 500.0), h(10, 400.0));
try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.negative, c.style);
}
test "buildSymbolChange: zero price move -> muted style, zero dollar" {
const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0);
const c = buildSymbolChange("VTI", h(50, 240.0), h(50, 240.0));
try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.muted, c.style);
@ -495,7 +662,7 @@ test "buildSymbolChange: zero price move -> muted style, zero dollar" {
test "buildSymbolChange: shares_held is min (added shares between dates)" {
// Started with 100, added 50, now at 150. Held-throughout floor = 100.
const c = buildSymbolChange("MSFT", 100, 400.0, 150, 420.0);
const c = buildSymbolChange("MSFT", h(100, 400.0), h(150, 420.0));
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
// dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000
try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9);
@ -503,14 +670,14 @@ test "buildSymbolChange: shares_held is min (added shares between dates)" {
test "buildSymbolChange: shares_held is min (sold shares between dates)" {
// Started with 200, sold down to 50. Held-throughout floor = 50.
const c = buildSymbolChange("GOOG", 200, 160.0, 50, 180.0);
const c = buildSymbolChange("GOOG", h(200, 160.0), h(50, 180.0));
try testing.expectEqual(@as(f64, 50), c.shares_held_throughout);
// dollar = 50 * (180-160) = 1000
try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: zero price_then doesn't NaN" {
const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0);
const c = buildSymbolChange("BAD", h(10, 0.0), h(10, 50.0));
try testing.expectEqual(@as(f64, 0.0), c.pct_change);
try testing.expectEqual(StyleIntent.muted, c.style);
// dollar_change is still 10 * 50 = 500 - that's the true held-throughout
@ -518,6 +685,210 @@ test "buildSymbolChange: zero price_then doesn't NaN" {
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: a mixed side abstains from the price comparison" {
// The two prices here are the real ones from the SPYM case: the "then"
// snapshot's first-seen lot was the direct-index sleeve at $708.72, the
// "now" side's base price is $90.17. Subtracting those is meaningless -
// it reported -87% on a position whose underlying rose.
const then = hMixed(5793.5618, 708.72, 1_367_247.82);
const now = hMixed(19_816.496, 90.17, 1_786_853.45);
const c = buildSymbolChange("BENCH", then, now);
try testing.expect(!c.price_comparable);
// Both figures switch to a VALUE basis - exact, but answering a
// different question than the price-basis rows (it includes the 3,426
// retail shares bought partway through the window).
try testing.expectApproxEqAbs(@as(f64, 419_605.63), c.dollar_change, 0.02);
try testing.expectApproxEqAbs(@as(f64, 0.306898), c.pct_change, 1e-5);
try testing.expectEqual(StyleIntent.positive, c.style);
// The old price-basis math would have produced this instead:
const old_dollar = @min(then.shares, now.shares) * (now.price - then.price);
try testing.expect(old_dollar < 0); // wrong sign, on a position that GAINED
const old_pct = now.price / then.price - 1.0;
try testing.expect(old_pct < 0); // and a wrong-signed percentage
}
test "buildSymbolChange: one mixed side is enough to abstain" {
// Gates on EITHER side: a snapshot's ratio-scaled per-lot price and the
// live side's base price are not comparable quantities, so one mixed
// side poisons the pair.
const clean = h(100, 50.0);
const mixed = hMixed(100, 500.0, 12_345.0);
const a = buildSymbolChange("X", mixed, clean);
try testing.expect(!a.price_comparable);
const b = buildSymbolChange("X", clean, mixed);
try testing.expect(!b.price_comparable);
// Both clean -> normal comparison restored.
const ok = buildSymbolChange("X", clean, h(100, 55.0));
try testing.expect(ok.price_comparable);
try testing.expectApproxEqAbs(@as(f64, 0.10), ok.pct_change, 1e-9);
}
test "buildSymbolChange: a mixed row with a value loss reads negative" {
const c = buildSymbolChange("BENCH", hMixed(100, 700.0, 200_000.0), hMixed(200, 90.0, 150_000.0));
try testing.expect(!c.price_comparable);
try testing.expectApproxEqAbs(@as(f64, -50_000.0), c.dollar_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, -0.25), c.pct_change, 1e-9);
try testing.expectEqual(StyleIntent.negative, c.style);
}
test "buildSymbolChange: static shares make a mixed row's percentage reliable" {
// The whole justification: value = base_price * SUM(shares_i * ratio_i).
// Hold the shares and ratios still and value scales EXACTLY with the
// underlying, so value_now/value_then IS the price return - even though
// no single per-share price exists to display.
//
// Here the group is worth 1000 then and 1100 now on an unchanged 10
// shares, so +10% is the real move.
const c = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(10, 800.0, 1100.0));
try testing.expect(!c.price_comparable); // still no price to show
try testing.expect(c.pct_reliable); // ...but the return is sound
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
}
test "buildSymbolChange: a moved share count makes it unreliable" {
const bought = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(25, 800.0, 1500.0));
try testing.expect(!bought.pct_reliable);
const sold = buildSymbolChange("BENCH", hMixed(25, 100.0, 2500.0), hMixed(10, 800.0, 1100.0));
try testing.expect(!sold.pct_reliable);
// Even a fractional DRIP share counts - it is still money in.
const drip = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(10.25, 800.0, 1100.0));
try testing.expect(!drip.pct_reliable);
}
test "buildSymbolChange: float noise in a static share count stays reliable" {
// The two sides reach their counts by different arithmetic - the
// snapshot side divides value by price, the live side sums
// effectiveShares - so a genuinely static holding can differ in the
// last bits. That must not read as a purchase.
const c = buildSymbolChange(
"BENCH",
hMixed(709.235272, 100.0, 1000.0),
hMixed(709.2352720000001, 800.0, 1100.0),
);
try testing.expect(c.pct_reliable);
}
test "buildSymbolChange: a price-comparable row is always reliable" {
// A price ratio is share-count-independent by construction, so buying
// more of an ordinary holding must NOT demote it.
const c = buildSymbolChange("MSFT", h(100, 400.0), h(150, 420.0));
try testing.expect(c.price_comparable);
try testing.expect(c.pct_reliable);
try testing.expectApproxEqAbs(@as(f64, 0.05), c.pct_change, 1e-9);
}
test "buildSymbolChange: a mixed row with zero then-value doesn't NaN" {
// Value basis divides by `then.value`; guard it the same way the price
// basis guards `then.price`.
const c = buildSymbolChange("BENCH", hMixed(0, 0, 0), hMixed(10, 5, 500.0));
try testing.expect(!c.price_comparable);
try testing.expectEqual(@as(f64, 0), c.pct_change);
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
}
test "buildSymbolRowCells: a non-comparable row renders sentinels but a real dollar" {
var p_then: [24]u8 = undefined;
var p_now: [24]u8 = undefined;
var p_pct: [16]u8 = undefined;
var p_dollar: [32]u8 = undefined;
const s = buildSymbolChange("BENCH", hMixed(100, 700.0, 200_000.0), hMixed(200, 90.0, 250_000.0));
const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);
// The two PRICE cells are sentinels, padded to the column's DISPLAY
// width rather than its byte width - the sentinel is one column in
// three bytes, and the row templates pad by bytes, so an unpadded
// sentinel would skew every column to its right.
try testing.expectEqual(@as(usize, price_w), fmt.displayCols(cells.price_then));
try testing.expectEqual(@as(usize, price_w), fmt.displayCols(cells.price_now));
try testing.expect(std.mem.indexOf(u8, cells.price_then, fmt.no_data_sentinel) != null);
try testing.expect(std.mem.indexOf(u8, cells.price_now, fmt.no_data_sentinel) != null);
// Justification matches the spec each cell is fed to.
try testing.expect(std.mem.endsWith(u8, cells.price_then, fmt.no_data_sentinel));
try testing.expect(std.mem.startsWith(u8, cells.price_now, fmt.no_data_sentinel));
// The PERCENT cell is a real value-basis figure, rendered exactly like
// any other row (the row template pads it), NOT a sentinel.
// 250,000 / 200,000 - 1 = +25%.
try testing.expectEqualStrings("+25.00%", cells.pct);
// The dollar cell stays real - it is the exact value delta.
try testing.expectEqualStrings("+$50,000.00", cells.dollar);
// And no fabricated price leaks through.
try testing.expect(std.mem.indexOf(u8, cells.price_then, "700") == null);
try testing.expect(std.mem.indexOf(u8, cells.price_now, "90") == null);
}
test "buildCompareView: mixed rows get their own bucket and sort last" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
try then_map.put("WIN", h(10, 100.0));
try now_map.put("WIN", h(10, 120.0)); // +20%
try then_map.put("LOSE", h(10, 100.0));
try now_map.put("LOSE", h(10, 80.0)); // -20%
try then_map.put("FLAT", h(10, 100.0));
try now_map.put("FLAT", h(10, 100.0)); // 0%
// Mixed class, share count STATIC. Its value ratio reduces exactly to
// the underlying price ratio (1100/1000 = +10%), so it is a real
// return and belongs inline with the price-basis rows - it just has no
// single per-share price to display beside it.
try then_map.put("MIXSTATIC", hMixed(10, 100.0, 1000.0));
try now_map.put("MIXSTATIC", hMixed(10, 800.0, 1100.0));
// Mixed class, share count MOVED (10 -> 25). Its +50% is part market
// and part purchase with no way to separate them, so it is unreliable.
try then_map.put("MIXBOUGHT", hMixed(10, 100.0, 1000.0));
try now_map.put("MIXBOUGHT", hMixed(25, 800.0, 1500.0));
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 8, 1),
false,
100_000,
110_000,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 5), view.held_count);
// MIXSTATIC counts as a normal gainer alongside WIN.
try testing.expectEqual(@as(usize, 2), view.gainer_count);
try testing.expectEqual(@as(usize, 1), view.loser_count);
try testing.expectEqual(@as(usize, 1), view.flat_count);
try testing.expectEqual(@as(usize, 1), view.unreliable_count);
// The documented invariant.
try testing.expectEqual(
view.held_count,
view.gainer_count + view.loser_count + view.flat_count + view.unreliable_count,
);
// Reliable rows first, descending by pct - MIXSTATIC sorts INLINE at
// +10%, between WIN (+20%) and FLAT (0%). Only the share-count change
// banishes a row to the bottom.
try testing.expectEqualStrings("WIN", view.symbols[0].symbol);
try testing.expectEqualStrings("MIXSTATIC", view.symbols[1].symbol);
try testing.expectEqualStrings("FLAT", view.symbols[2].symbol);
try testing.expectEqualStrings("LOSE", view.symbols[3].symbol);
try testing.expectEqualStrings("MIXBOUGHT", view.symbols[4].symbol);
// MIXSTATIC: no price to show, but a trustworthy return.
try testing.expect(!view.symbols[1].price_comparable);
try testing.expect(view.symbols[1].pct_reliable);
try testing.expectApproxEqAbs(@as(f64, 0.10), view.symbols[1].pct_change, 1e-9);
// MIXBOUGHT: pinned last despite the largest percentage in the table,
// which is exactly the point - it is not comparable with the rest.
try testing.expect(!view.symbols[4].price_comparable);
try testing.expect(!view.symbols[4].pct_reliable);
try testing.expectApproxEqAbs(@as(f64, 0.50), view.symbols[4].pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 500.0), view.symbols[4].dollar_change, 1e-9);
try testing.expect(view.symbols[4].pct_change > view.symbols[0].pct_change);
}
test "buildTotalsRow: positive delta" {
const t = buildTotalsRow(1_000_000.0, 1_050_000.0);
try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6);
@ -830,6 +1201,36 @@ pub fn buildSymbolRowCells(
pct_buf: *[16]u8,
dollar_buf: *[32]u8,
) SymbolRowCells {
// A mixed-share-class row has no meaningful per-share price on either
// side, so those two cells get the no-data sentinel rather than two
// numbers that invite subtraction. The percent and dollar cells DO
// render - on a value basis (see `SymbolChange.pct_change`) - because
// a total value change is exactly computable, and when the share count
// held still it IS the underlying return. `SymbolChange.pct_reliable`
// and `CompareView.unreliable_count` let the renderer flag the rows
// where it is not.
//
// Pad the sentinel to display columns here. The row templates use
// byte-based `{s:>N}` specs, and the sentinel is one display column in
// three bytes, so leaving it unpadded under-pads the cell by two
// columns and skews every column to its right. Padding to exactly
// `price_w` makes the byte-based spec a no-op and keeps the table
// square. Justification matches the spec each cell is fed to:
// `price_right_fmt` right-justifies, `price_left_fmt` left.
if (!s.price_comparable) {
// `padRightToCols` appends in place and requires its content to
// already sit at the start of the buffer, so stage the sentinel
// there first. (`padLeftToCols` copies, hence the asymmetry.)
const staged = std.fmt.bufPrint(price_now_buf, "{s}", .{fmt.no_data_sentinel}) catch fmt.no_data_sentinel;
return .{
.symbol = s.symbol,
.price_then = fmt.padLeftToCols(price_then_buf, fmt.no_data_sentinel, price_w),
.price_now = fmt.padRightToCols(price_now_buf, staged, price_w),
.pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change),
.dollar = std.fmt.bufPrint(dollar_buf, "{f}", .{Money.from(s.dollar_change).signed()}) catch "$?",
.style = s.style,
};
}
return .{
.symbol = s.symbol,
.price_then = std.fmt.bufPrint(price_then_buf, "{f}", .{Money.from(s.price_then)}) catch "$?",

View file

@ -117,7 +117,13 @@ pub fn computeWidths(
if (lot.security_type != .stock) continue;
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol());
const use_price = effectivePriceFor(allocations, lot);
// A ratio'd lot renders its own effective price in the Price
// cell (see `hasOwnPrice`), and that price can exceed every
// allocation's raw price - an 8.6x institutional ratio puts a
// $90 base ticker at $775. Observe it or the cell overflows its
// column.
if (hasOwnPrice(lot)) w.price_w = @max(w.price_w, moneyCols(use_price));
w.value_w = @max(w.value_w, moneyCols(lot.effectiveShares() * use_price));
w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.effectiveShares() * (use_price - lot.effectiveOpenPrice())));
}
@ -161,16 +167,171 @@ fn gainLossCols(amount: f64) usize {
return 1 + moneyCols(if (amount < 0) -amount else amount);
}
/// Current price for `symbol` from the allocations (linear scan;
/// portfolios are small). Returns 0 when absent, which yields a
/// trivially small cell that widens nothing.
fn currentPriceFor(allocations: []const Allocation, symbol: []const u8) f64 {
// Holdings-row cells
/// The six variable-width cells of one holdings-table row, in COLUMN
/// ORDER - matching `PositionsLayout.header_labels`:
///
/// Symbol | Shares | Avg Cost | Price | Market Value | Gain/Loss
///
/// Used both for the raw (unpadded) values a renderer computes and for the
/// padded result of `padRowCells`.
///
/// The point of naming these is that the TUI used to hand-roll the same six
/// cells three times - once each for position, lot and watchlist rows - with
/// per-arm local names that drifted apart. The lot arm ended up calling its
/// Avg Cost value `lot_price_str` and its Price value `lot_eff_price_str`,
/// so "price" named a cost and the two arms disagreed about the same two
/// columns. Nothing caught it, because a TUI row needs a live `App` to
/// render and there is no harness for that. A struct with named fields
/// can't be transposed silently the way two similar locals can.
pub const RowCells = struct {
symbol: []const u8,
shares: []const u8,
/// Avg Cost column: per-share cost basis.
cost: []const u8,
/// Price column: current per-share price.
price: []const u8,
value: []const u8,
gainloss: []const u8,
};
/// Pad each cell of `raw` to this frame's column widths.
///
/// Symbol is left-justified; every numeric cell is right-justified, which
/// is the one place the columns differ in treatment. Padding is
/// DISPLAY-COLUMN aware (`padRightToCols` / `padLeftToCols`), so a
/// multibyte cell - the `` no-data sentinel, or any glyph a caller
/// substitutes - occupies its true width instead of being under-padded by
/// two columns the way a byte-counting `{s:>N}` would.
///
/// Cells are allocated in `arena` rather than caller stack buffers: twelve
/// scratch buffers in one function was what forced the `_buf2` / `_str3`
/// name suffixes, and the arena is already per-frame.
///
/// A cell wider than its column is returned unchanged - over-wide is the
/// safe direction, since `computeWidths` sizes columns from the same data
/// and the row renderer truncates at terminal width anyway.
pub fn padRowCells(
arena: std.mem.Allocator,
w: PositionsWidths,
raw: RowCells,
) !RowCells {
return .{
.symbol = try padCell(arena, raw.symbol, w.symbol_w, .left),
.shares = try padCell(arena, raw.shares, w.shares_w, .right),
.cost = try padCell(arena, raw.cost, w.price_w, .right),
.price = try padCell(arena, raw.price, w.price_w, .right),
.value = try padCell(arena, raw.value, w.value_w, .right),
.gainloss = try padCell(arena, raw.gainloss, w.gainloss_w, .right),
};
}
const Justify = enum { left, right };
/// Pad one cell to `cols` display columns, allocating in `arena`.
fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify: Justify) ![]const u8 {
const have = fmt.displayCols(content);
if (have >= cols) return content;
// Worst case is all-single-column content, so `cols - have` bytes of
// padding is always enough; multibyte content needs less.
const buf = try arena.alloc(u8, content.len + (cols - have));
switch (justify) {
// `padRightToCols` appends in place and requires its content to
// already sit at the start of the buffer; `padLeftToCols` copies.
.left => {
@memcpy(buf[0..content.len], content);
return fmt.padRightToCols(buf, buf[0..content.len], cols);
},
.right => return fmt.padLeftToCols(buf, content, cols),
}
}
/// The effective price of a single LOT: the base-ticker price from
/// `allocations` with this lot's `price_ratio` applied. The free-function
/// counterpart to `Lot.effectivePrice`, which takes the raw price as an
/// argument - this one resolves it from the allocations first. Returns 0
/// when no allocation matches the lot's `priceSymbol()` (an orphan lot),
/// which yields a trivially small cell that widens nothing and a
/// zero-value row rather than a crash.
///
/// EVERY per-lot display site MUST price through this instead of
/// reading `Allocation.current_price` directly.
/// `Allocation.current_price` is the RAW base-ticker price (see its doc
/// comment in `analytics/valuation.zig`), and `mergeAllocsBySymbol`
/// normalizes a merged group's `shares` into base-ticker-equivalent
/// units. A lot row that multiplies its own RAW shares by that RAW
/// price is therefore off by exactly the lot's ratio. That shipped: a
/// direct-indexing sleeve with `price_ratio:num:0.2387` rendered a
/// +$3.39M gain against a real +$433K, and the lot rows under a
/// position summed to three times the position's own market value.
///
/// Provenance follows the `is_preadjusted` rule from the pricing-model
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
/// is answering ONE question: has this allocation been merged?
///
/// - `close_price` short-circuits first. It is the price the lot
/// actually closed at, already in the lot's own terms, so the ratio
/// is NOT reapplied. Matches how the contributions pipeline values
/// closed lots (`effectivePrice(close_price, true)`).
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
/// carries this lot's own ratio (`positionsAsOf` groups by
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
/// `portfolioSummary` set its `current_price` to
/// `pos.effectivePrice(raw, is_manual)` - the ratio is ALREADY
/// APPLIED - so applying it again would square it.
/// - `a.price_ratio == 1.0` means either a plain unratioed position
/// (apply 1.0, a no-op) or a MERGED group, where
/// `mergeAllocsBySymbol` normalized shares into base-ticker units,
/// set `current_price` to the raw base price, and reset the ratio
/// to 1.0. Both want the lot's own ratio applied.
///
/// Do NOT use `a.is_manual_price` for this. It looks like a provenance
/// flag and reads like the right answer, but it is orthogonal: a
/// manual `price::` is stored raw in the prices map and flagged, then
/// `portfolioSummary` folds it into `current_price` via
/// `effectivePrice(p, true)` - so by the time it reaches an
/// `Allocation` it has the same "already effective" shape as a live
/// unmerged price. Keying on it applied the ratio twice for every
/// unmerged, live-priced, ratio'd lot - i.e. the single-CIT-lot case
/// that `docs/reference/config/portfolio-srf.md` documents as the
/// primary use of `price_ratio`.
///
/// Known limit, deliberately not handled: a MERGED group that mixes
/// manual-priced and live-priced components gets a blended
/// `current_price` (`total_mv / norm_shares`) that is neither raw nor
/// preadjusted. That is an expressiveness gap in `mergeAllocsBySymbol`
/// itself, not something a lot-row accessor can repair. Merged groups
/// whose components share one provenance - the common case, and the
/// only case a ratio'd alias produces - are exact: with every
/// component live off the same raw price `r`, `total_mv / norm_shares`
/// reduces to `r`.
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
for (allocations) |a| {
if (std.mem.eql(u8, a.symbol, symbol)) return a.current_price;
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
}
return 0;
}
/// Whether a lot row should render its own effective price in the Price
/// cell, which is otherwise blank because the position row above
/// already shows it.
///
/// True only for a ratio'd lot, where the lot's price genuinely differs
/// from its position's: the position row shows the base-ticker price
/// ($90.15 SPYM) while the lot trades at its institutional NAV ($775.17
/// at an 8.6x ratio). Showing it makes the ratio auditable at a glance
/// - and a blank cell is exactly why a 7-figure ratio bug in the
/// neighbouring Value and Gain/Loss cells went unnoticed.
///
/// Both the width pass and the two renderers gate on this, so they
/// cannot disagree about whether the cell is occupied.
pub fn hasOwnPrice(lot: Lot) bool {
return lot.price_ratio != 1.0;
}
/// Write `content` into a `width`-column field: right-justified (spaces
/// before) when `right`, else left-justified (spaces after).
/// Display-column aware. The streaming analogue of
@ -523,6 +684,12 @@ pub const CDs = struct {
// Tests
const testing = std.testing;
// Test-only, and the module rather than a type extraction: the
// lot-row/position-row reconciliation invariant has to go through the
// real `positionsAsOf` -> `portfolioSummary` -> `mergeAllocsBySymbol`
// pipeline, because the bug lived in the seam between them.
const portfolio_mod = @import("../models/portfolio.zig");
const valuation = @import("../analytics/valuation.zig");
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
const as_of = Date.fromYmd(2024, 6, 1);
@ -592,11 +759,464 @@ test "CDs.init: all matured yields empty active slice" {
try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
}
// effectivePriceFor / hasOwnPrice
// padRowCells
/// Widths with every column distinct, so a test can tell which column a
/// cell landed in purely from how wide it came back.
fn rcWidths() PositionsWidths {
return .{
.symbol_w = 10,
.shares_w = 9,
.price_w = 8,
.value_w = 14,
.gainloss_w = 12,
};
}
test "padRowCells: every cell lands in its own column at its own width" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const w = rcWidths();
const c = try padRowCells(a, w, .{
.symbol = "AAPL",
.shares = "10.0",
.cost = "$150.00",
.price = "$175.00",
.value = "$1,750.00",
.gainloss = "+$250.00",
});
try testing.expectEqual(@as(usize, w.symbol_w), fmt.displayCols(c.symbol));
try testing.expectEqual(@as(usize, w.shares_w), fmt.displayCols(c.shares));
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.cost));
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
try testing.expectEqual(@as(usize, w.value_w), fmt.displayCols(c.value));
try testing.expectEqual(@as(usize, w.gainloss_w), fmt.displayCols(c.gainloss));
// Symbol is the one left-justified column; the numerics are right.
try testing.expect(std.mem.startsWith(u8, c.symbol, "AAPL"));
try testing.expect(std.mem.endsWith(u8, c.shares, "10.0"));
try testing.expect(std.mem.endsWith(u8, c.cost, "$150.00"));
try testing.expect(std.mem.endsWith(u8, c.price, "$175.00"));
try testing.expect(std.mem.endsWith(u8, c.value, "$1,750.00"));
try testing.expect(std.mem.endsWith(u8, c.gainloss, "+$250.00"));
}
test "padRowCells: cost and price do not clobber each other" {
// These two share `price_w`. Three hand-rolled copies of this layout
// used to pad them into separate stack buffers; a shared or reused
// buffer would silently make the two columns equal, and no TUI test
// exists to notice. Pin it.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const c = try padRowCells(a, rcWidths(), .{
.symbol = "X",
.shares = "1",
.cost = "$1.00",
.price = "$9.00",
.value = "$9.00",
.gainloss = "+$8.00",
});
try testing.expect(std.mem.endsWith(u8, c.cost, "$1.00"));
try testing.expect(std.mem.endsWith(u8, c.price, "$9.00"));
try testing.expect(!std.mem.eql(u8, c.cost, c.price));
// Distinct backing memory, not two views of one buffer.
try testing.expect(c.cost.ptr != c.price.ptr);
}
test "padRowCells: the COST value stays in the cost column" {
// The regression this whole type exists for. The TUI lot row named its
// Avg Cost value `lot_price_str` and its Price value
// `lot_eff_price_str`, so "price" named a cost and the position and lot
// arms disagreed about the same two columns. Renaming toward consistency
// risked transposing them, and a TUI row needs a live `App` to render,
// so nothing would have caught it.
//
// Named fields make the mapping assertable: feed unmistakable values and
// check each comes back from the field it was handed to.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const c = try padRowCells(a, rcWidths(), .{
.symbol = "SYM",
.shares = "SHR",
.cost = "COST",
.price = "PRICE",
.value = "VALUE",
.gainloss = "GL",
});
try testing.expect(std.mem.indexOf(u8, c.cost, "COST") != null);
try testing.expect(std.mem.indexOf(u8, c.price, "PRICE") != null);
// ...and no leakage in either direction.
try testing.expect(std.mem.indexOf(u8, c.cost, "PRICE") == null);
try testing.expect(std.mem.indexOf(u8, c.price, "COST") == null);
try testing.expect(std.mem.indexOf(u8, c.value, "VALUE") != null);
try testing.expect(std.mem.indexOf(u8, c.gainloss, "GL") != null);
try testing.expect(std.mem.indexOf(u8, c.symbol, "SYM") != null);
try testing.expect(std.mem.indexOf(u8, c.shares, "SHR") != null);
}
test "padRowCells: a multibyte cell is padded by display columns" {
// The no-data sentinel is one display column in three bytes. Padding it
// by BYTES under-fills the cell by two columns and skews every column to
// its right - the exact defect that hit the compare table's sentinel
// rows. `padRowCells` must not reintroduce it.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const w = rcWidths();
const c = try padRowCells(a, w, .{
.symbol = "SYM",
.shares = "1",
.cost = fmt.no_data_sentinel,
.price = fmt.no_data_sentinel,
.value = "$1.00",
.gainloss = "+$0.00",
});
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.cost));
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
// Byte length exceeds the column width precisely because the glyph is
// multibyte - proof the padding counted columns, not bytes.
try testing.expect(c.cost.len > w.price_w);
}
test "padRowCells: an empty cell fills its column" {
// A lot row leaves the Price cell blank unless the lot is ratio'd. It
// still has to occupy the column or the row shears.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const w = rcWidths();
const c = try padRowCells(a, w, .{
.symbol = "open",
.shares = "100.0",
.cost = "$97.50",
.price = "",
.value = "$9,750.00",
.gainloss = "+$0.00",
});
try testing.expectEqual(@as(usize, w.price_w), fmt.displayCols(c.price));
try testing.expectEqualStrings(" ", c.price);
}
test "padRowCells: an over-wide cell is returned unchanged" {
// Over-wide is the safe direction: `computeWidths` sizes columns from
// the same data, and the renderer truncates at terminal width. Silently
// clipping here would corrupt a figure instead of just crowding it.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const huge = "$123,456,789.00";
const c = try padRowCells(a, rcWidths(), .{
.symbol = "SYM",
.shares = "1",
.cost = huge,
.price = "$1.00",
.value = "$1.00",
.gainloss = "+$0.00",
});
try testing.expectEqualStrings(huge, c.cost);
}
test "effectivePriceFor: live price gets the lot's ratio applied" {
// The bug this guards: `Allocation.current_price` is the RAW
// base-ticker price. A proxied sleeve quoted off a $90.15 base at an
// 8.6x institutional ratio prices at $775.17, not $90.15.
const allocs = [_]Allocation{mkAlloc("BENCH", 6097.4, 53.65, 90.15, 549779, 222651)};
const lot = Lot{
.symbol = "DI-IDX",
.ticker = "BENCH",
.price_ratio = 8.598685594945021,
.shares = 709.235272,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
}
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
// The sign of the failure matters: an under-1.0 ratio is what
// produced the +$3.39M phantom gain, because skipping the ratio
// INFLATES the price. 5075.077 shares at a raw $765.91 is $3.89M;
// the real effective price is 765.91 * 0.2387 = $182.82 -> $927,824.
const allocs = [_]Allocation{mkAlloc("BENCH", 1211.4, 408.47, 765.91, 927824, 433004)};
const lot = Lot{
.symbol = "AGG-LC",
.ticker = "BENCH",
.price_ratio = 0.2386960690140873,
.shares = 5075.077,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
};
const eff_price = effectivePriceFor(&allocs, lot);
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
// The whole point: value must land on the real figure, not the
// ratio-skipped one.
try testing.expectApproxEqRel(@as(f64, 927824.086), lot.effectiveShares() * eff_price, 1e-6);
try testing.expect(lot.effectiveShares() * eff_price < 1_000_000);
}
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
}
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
// THE REGRESSION. A single ratio'd lot whose ticker is shared with
// nobody produces an UNMERGED allocation: `positionsAsOf` groups by
// `(priceSymbol, price_ratio)`, so `Allocation.price_ratio` is the
// lot's own 5.0, and `portfolioSummary` already folded that ratio
// into `current_price`. Applying it again squares it.
//
// This is the documented primary use of `price_ratio` - a lone 401k
// CIT lot priced off its retail sibling (see
// docs/reference/config/portfolio-srf.md). Keying provenance on
// `is_manual_price` returned 144.04 * 5.0 = 720.20 here.
const allocs = [_]Allocation{mkAllocUnmerged("VTTHX", 1200, 106.99, 144.04, 5.0, 44_460)};
const lot = Lot{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = 5.0,
.shares = 1200,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
};
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
// And the lot row must reconcile with its own position row.
try testing.expectApproxEqRel(
allocs[0].market_value,
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
1e-9,
);
}
test "effectivePriceFor: unmerged manual price is also already effective" {
// Same unmerged shape, manual `price::` instead of a candle close.
// `buildFallbackPrices` stores the raw override and flags the
// allocation, then `portfolioSummary` folds it in via
// `effectivePrice(p, true)` - so by the time it reaches an
// Allocation it has the same "already effective" shape as the live
// case above. `price_ratio != 1.0` covers both; `is_manual_price` is
// not consulted.
var alloc = mkAllocUnmerged("ORCX", 100, 18.15, 19.01, 5.0, 86);
alloc.is_manual_price = true;
const allocs = [_]Allocation{alloc};
const lot = Lot{
.symbol = "ORCX",
.price_ratio = 5.0,
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 18.15,
};
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
}
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
// The counterpart, and why the discriminator can't just be "always
// skip". `mergeAllocsBySymbol` normalizes shares to base-ticker
// units, sets `current_price` to the raw base price, and resets
// `price_ratio` to 1.0 - so the lot's own ratio must be applied.
// `is_manual_price` is false in both this case and the unmerged live
// case above, which is exactly why it cannot discriminate them.
const merged = [_]Allocation{mkAlloc("BENCH", 1929.2, 426.05, 765.91, 1_477_603, 655_655)};
const lot = Lot{
.symbol = "DI-IDX",
.ticker = "BENCH",
.price_ratio = 1.0120921601549708,
.shares = 709.235272,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
}
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
// A closed lot's `close_price` is the price it actually closed at -
// already in the lot's own units. Matches how the contributions
// pipeline values closed lots (`effectivePrice(close_price, true)`).
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
const lot = Lot{
.symbol = "CUSIP1",
.ticker = "BENCH",
.price_ratio = 5.061982036579556,
.shares = 2412.601,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
.close_price = 150.39,
};
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
}
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
}
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
// A CUSIP lot must find its allocation under the ticker alias.
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
var aliased = by_cusip;
aliased.ticker = "BENCH";
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
}
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
const plain = Lot{ .symbol = "ABC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1 };
try testing.expect(!hasOwnPrice(plain));
var ratioed = plain;
ratioed.price_ratio = 8.6;
try testing.expect(hasOwnPrice(ratioed));
// Under 1.0 counts too - that direction is the one that inflated.
ratioed.price_ratio = 0.2387;
try testing.expect(hasOwnPrice(ratioed));
}
test "effectivePriceFor: lot rows sum to their merged position's market value" {
// The exact shape that shipped the bug. Two proxied sleeves alias
// one base ticker with DIFFERENT ratios, so `positionsAsOf` groups
// them into two Positions and `mergeAllocsBySymbol` collapses those
// into ONE Allocation whose `shares` are normalized into
// base-ticker-equivalent units and whose `current_price` is the RAW
// base-ticker price.
//
// A lot row that multiplied its own raw shares by that raw price
// rendered 5075.077 * $765.91 = $3.89M for a lot really worth
// $928K, and the two lot rows summed to three times the position
// row above them. This asserts they reconcile.
const base_price: f64 = 765.91;
var lots = [_]Lot{
.{
.symbol = "DI-IDX",
.ticker = "BENCH",
.price_ratio = 1.0120921601549708,
.shares = 709.235272,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Sample Trust",
},
.{
.symbol = "AGG-LC",
.ticker = "BENCH",
.price_ratio = 0.2386960690140873,
.shares = 5075.077,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
.account = "Sample 401(k)",
},
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = testing.allocator };
const as_of = Date.fromYmd(2026, 8, 26);
const positions = try pf.positionsAsOf(testing.allocator, as_of);
defer testing.allocator.free(positions);
// Same ticker, different ratios -> two Positions, not one.
try testing.expectEqual(@as(usize, 2), positions.len);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("BENCH", base_price);
var summary = try valuation.portfolioSummary(as_of, testing.allocator, pf, positions, prices, null);
defer summary.deinit(testing.allocator);
// ...which merge back into one Allocation.
try testing.expectEqual(@as(usize, 1), summary.allocations.len);
const a = summary.allocations[0];
try testing.expectEqualStrings("BENCH", a.symbol);
// Merged rows normalize to base units, so the ratio is spent.
try testing.expectEqual(@as(f64, 1.0), a.price_ratio);
try testing.expectApproxEqRel(base_price, a.current_price, 1e-9);
var lot_mv_total: f64 = 0;
var lot_gl_total: f64 = 0;
for (lots) |lot| {
const eff_price = effectivePriceFor(summary.allocations, lot);
lot_mv_total += lot.effectiveShares() * eff_price;
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
}
try testing.expectApproxEqRel(a.market_value, lot_mv_total, 1e-9);
try testing.expectApproxEqRel(a.unrealized_gain_loss, lot_gl_total, 1e-9);
// And pin the magnitude, so a future regression that merely
// reconciles two equally-wrong numbers still fails.
try testing.expectApproxEqRel(@as(f64, 1_477_603.06), lot_mv_total, 1e-6);
try testing.expectApproxEqRel(@as(f64, 655_655.23), lot_gl_total, 1e-6);
}
test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position" {
// The unmerged half of the same invariant, driven through the real
// pipeline. One ratio'd lot, ticker shared with nobody, so
// `mergeAllocsBySymbol` leaves it alone: `price_ratio` stays 5.0 and
// `current_price` is already effective.
//
// Under the `is_manual_price` discriminator this test failed with
// lot_mv = shares * raw * ratio * ratio - the ratio squared, a 5x
// overstatement of a real position. The merged test above passed
// throughout, which is how the bug shipped.
const raw_price: f64 = 28.808; // retail sibling; institutional NAV = 144.04
const ratio: f64 = 5.0;
var lots = [_]Lot{.{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = ratio,
.shares = 1200,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
.account = "Sample 401(k)",
}};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = testing.allocator };
const as_of = Date.fromYmd(2026, 8, 26);
const positions = try pf.positionsAsOf(testing.allocator, as_of);
defer testing.allocator.free(positions);
try testing.expectEqual(@as(usize, 1), positions.len);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("VTTHX", raw_price);
var summary = try valuation.portfolioSummary(as_of, testing.allocator, pf, positions, prices, null);
defer summary.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), summary.allocations.len);
const a = summary.allocations[0];
// Unmerged: the allocation KEEPS the lot's ratio, and current_price
// is the effective (institutional) price, not the raw retail one.
try testing.expectEqual(ratio, a.price_ratio);
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
try testing.expectApproxEqRel(@as(f64, 172_848.0), lots[0].effectiveShares() * eff_price, 1e-9);
}
// computeWidths
/// Build a minimal Allocation for width tests. `cost_basis` is derived
/// so `unrealized_gain_loss` is consistent (not that computeWidths
/// reads cost_basis, but it keeps the fixture honest).
/// Build a minimal Allocation for the width and effective-price tests.
/// `cost_basis` is derived so `unrealized_gain_loss` is consistent (not
/// that computeWidths reads cost_basis, but it keeps the fixture
/// honest).
///
/// `price_ratio` defaults to 1.0, which is the MERGED shape (or a plain
/// unratioed position): `current_price` is the raw base-ticker price.
/// For the unmerged shape use `mkAllocUnmerged` - the distinction is
/// load-bearing, see `effectivePriceFor`.
fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation {
return .{
.symbol = symbol,
@ -612,6 +1232,77 @@ fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, m
};
}
/// An UNMERGED allocation: one ratio'd position that shares its ticker
/// with nobody, so `mergeAllocsBySymbol` never touched it. It keeps its
/// lot's `price_ratio`, and `portfolioSummary` already folded that ratio
/// into `current_price` (`pos.effectivePrice(raw, is_manual)`), so
/// `current_price` here is the EFFECTIVE price, not the raw base price.
///
/// This is the shape every fixture was missing: `mkAlloc` leaves
/// `price_ratio` at 1.0, which is indistinguishable from a merged group,
/// so a whole suite of tests can pass while the unmerged path squares
/// the ratio.
fn mkAllocUnmerged(symbol: []const u8, shares: f64, avg_cost: f64, effective_price: f64, price_ratio: f64, gl: f64) Allocation {
const mv = shares * effective_price;
return .{
.symbol = symbol,
.display_symbol = symbol,
.shares = shares,
.avg_cost = avg_cost,
.current_price = effective_price,
.market_value = mv,
.cost_basis = mv - gl,
.weight = 1.0,
.unrealized_gain_loss = gl,
.unrealized_return = 0,
.price_ratio = price_ratio,
};
}
test "computeWidths: a ratio'd lot's effective price widens the Price column" {
// A ratio'd lot renders its own effective price in the
// otherwise-blank Price cell, and at an 8.6x institutional ratio
// that price dwarfs every allocation figure: base ticker $90.15 (6
// cols), avg cost $53.65 (6), lot open price $461.24 (7) - but the
// effective price is $775.17 (7)... so push the ratio higher to make
// it the strict maximum and prove the width pass observes it.
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 53.65, 90.15, 9015, 1000)};
const lots = [_]Lot{.{
.symbol = "DI-IDX",
.ticker = "BENCH",
.price_ratio = 150.0, // effective = $13,522.50 -> "$13,522.50" = 10 cols
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 100.0,
}};
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
try testing.expectEqual(@as(usize, 10), w.price_w);
}
test "computeWidths: the Price cell is measured only when the lot occupies it" {
// Needs an effective price that is NOT already measured from the
// allocations, or the gate is unfalsifiable: for an open ratio-1.0
// lot the effective price IS `alloc.current_price`, which is always
// measured. A closed lot's comes from its own `close_price` instead,
// so it is visible to the lot pass alone.
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
var lots = [_]Lot{.{
.symbol = "ABC",
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0,
.close_price = 99999.99, // "$99,999.99" = 10 cols
}};
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
// Ratio'd: the cell is occupied, so the same price now widens it.
lots[0].price_ratio = 2.0;
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
}
test "computeWidths: empty portfolio sits at the header-label floors" {
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);

View file

@ -831,8 +831,15 @@ fn buildContextFromParts(
// Symbols default to SPY/AGG; user can override via
// `type::config,benchmark_stock::SYMBOL` and
// `type::config,benchmark_bond::SYMBOL` in projections.srf.
const stock_sym = config.benchmark_stock;
const bond_sym = config.benchmark_bond;
//
// Read through the accessors, not the backing buffers: `config` is a
// value copy (this function's local, and it gets copied again into
// the returned `ProjectionContext`), and the accessors derive their
// slice from whichever copy you hold. This is the read that surfaced
// the old self-referential-slice bug - an override arrived here as
// NUL bytes and `getCandles` silently found no such symbol.
const stock_sym = config.benchmarkStock();
const bond_sym = config.benchmarkBond();
const spy_result = svc.getCandles(stock_sym, .{}) catch null;
defer if (spy_result) |r| r.deinit();
const spy_divs = svc.getCachedDividends(alloc, stock_sym);