show daily change, including intraday in tui

This commit is contained in:
Emil Lerch 2026-08-27 13:08:03 -07:00
parent cf76598659
commit 2f87927bcb
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 1137 additions and 29 deletions

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;
@ -1066,8 +1139,21 @@ fn snapshotsWorker(self: *PortfolioData, as_of: Date, positions: []const zfin.Po
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.
@ -1242,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

@ -670,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,
@ -709,21 +925,26 @@ 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 -> **RAW base-ticker price**, on the same
/// footing as the candle closes this reads for the historical side. Pass the
/// candle-close price map (`PortfolioData.revalue_base_prices`, or
/// `portfolio_loader`'s `prices`) - NEVER
/// `summary.allocations[].current_price`, which for an unmerged allocation
/// `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;
@ -740,11 +961,13 @@ pub fn computeHistoricalSnapshots(
const candles = candle_map.get(pos.symbol) orelse continue;
const hist_price = findPriceAtDate(candles, target) orelse continue;
// Both sides are RAW base-ticker prices - the historical one from
// candle history, the current one per this function's contract -
// so the share-class ratio applies to both: `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;
}
@ -767,6 +990,390 @@ 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 "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
@ -807,7 +1414,7 @@ test "computeHistoricalSnapshots: current_prices must be RAW, ratio applied once
defer raw_prices.deinit();
try raw_prices.put("VTTHX", raw_now);
const snaps = computeHistoricalSnapshots(as_of, &positions, raw_prices, candle_map);
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.
@ -821,7 +1428,7 @@ test "computeHistoricalSnapshots: current_prices must be RAW, ratio applied once
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, candle_map)[0];
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%
}
@ -852,7 +1459,7 @@ test "computeHistoricalSnapshots: an unratioed position is unaffected either way
defer prices.deinit();
try prices.put("ABC", 110);
const m1 = computeHistoricalSnapshots(as_of, &positions, prices, candle_map)[0];
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);

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;
@ -737,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);

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

@ -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

@ -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| {
@ -2737,6 +2840,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().?);