diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index cee772a..d95d0ce 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -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, diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index ba7fd12..b185b5d 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -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); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index cbf6435..ebbae91 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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); diff --git a/src/format.zig b/src/format.zig index 7d5b8ab..21af11e 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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 "?"; } } diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index 3aa19c6..14f7160 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -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; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index ac420ef..69dba6d 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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().?);