From bd6ba89ea7619f93ce0fa36d4a7bfd9a265f04d6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 19 Jun 2026 15:44:05 -0700 Subject: [PATCH] change portfolio summary when live pricing is applied --- src/PortfolioData.zig | 61 +++++++++++++++++++++++++++++++++++++-- src/tui/portfolio_tab.zig | 14 +++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index b273e94..e3a4598 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -269,6 +269,13 @@ summary: ?PortfolioSummary = null, /// Drives the "as of close on YYYY-MM-DD" line in the TUI. latest_quote_date: ?Date = null, +/// True when the most recent load overlaid live intraday quotes onto +/// at least one held position (i.e. the summary reflects today's +/// quotes, not the candle close). Set only on the TUI refresh path +/// (`LoadOptions.live_quotes`); false on startup / `R` file reloads. +/// Drives the portfolio "as of" label wording. +live_prices_applied: bool = false, + /// Cached prices for watchlist symbols (no live fetching during /// render). Allocated in pd's arena. watchlist_prices: ?std.StringHashMap(f64) = null, @@ -446,6 +453,11 @@ fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void { /// live map's key, which the caller guarantees outlives the summary /// build. Factored out of `load` so the overlay logic is unit-testable /// without the full load machinery. +/// +/// Returns the number of HELD positions whose price was overlaid with +/// a live quote. The caller uses a non-zero count as the signal that +/// the portfolio summary now reflects live intraday prices rather than +/// the candle close (drives the "as of" label). fn applyLiveQuoteOverlay( arena: Allocator, prices: *std.StringHashMap(f64), @@ -453,7 +465,8 @@ fn applyLiveQuoteOverlay( portfolio_set: *const std.StringHashMap(void), watchlist_set: *const std.StringHashMap(void), live: *const std.StringHashMap(f64), -) error{OutOfMemory}!void { +) error{OutOfMemory}!usize { + var held_overrides: usize = 0; var lit = live.iterator(); while (lit.next()) |e| { const sym = e.key_ptr.*; @@ -463,11 +476,13 @@ fn applyLiveQuoteOverlay( // (load_all-borrowed) key when present; on insert it // borrows the live map's key, which outlives this call. prices.put(sym, px) catch return error.OutOfMemory; + held_overrides += 1; } else if (watchlist_set.contains(sym)) { const owned = arena.dupe(u8, sym) catch continue; wp.put(owned, px) catch |err| log.warn("live watchlist price put({s}): {t}", .{ sym, err }); } } + return held_overrides; } /// Kick off a portfolio load. Synchronous: parses the portfolio @@ -500,6 +515,7 @@ pub fn load( self.paths = &.{}; self.summary = null; self.latest_quote_date = null; + self.live_prices_applied = false; self.watchlist_prices = null; self.snapshots_data = null; self.dividends_data = null; @@ -642,7 +658,11 @@ pub fn load( // absent from the overlay keep their candle close — the candle // layer (built above) is always the fallback. if (opts.live_quotes) |live| { - try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live); + const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live); + // At least one held position is now priced from a live quote, + // so the summary reflects today's intraday prices rather than + // the candle close. Drives the portfolio "as of" label. + self.live_prices_applied = held_overrides > 0; } self.watchlist_prices = wp; @@ -1133,8 +1153,12 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore try live.put("TSLA", 333.0); // watchlist override try live.put("NVDA", 999.0); // in neither set → ignored - try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); + const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); + // AAPL (override) + MSFT (insert) are held; TSLA is watchlist; + // NVDA is neither. Only held positions count toward the signal + // that the summary now reflects live intraday prices. + try testing.expectEqual(@as(usize, 2), held_overrides); try testing.expectEqual(@as(f64, 111.0), prices.get("AAPL").?); try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?); try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); @@ -1144,6 +1168,37 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore try testing.expect(!wp.contains("NVDA")); } +test "applyLiveQuoteOverlay: watchlist-only live quotes report zero held overrides" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var prices = std.StringHashMap(f64).init(testing.allocator); + defer prices.deinit(); + var wp = std.StringHashMap(f64).init(testing.allocator); + defer wp.deinit(); + + var portfolio_set = std.StringHashMap(void).init(testing.allocator); + defer portfolio_set.deinit(); + try portfolio_set.put("AAPL", {}); // held, but no live quote arrives + + var watchlist_set = std.StringHashMap(void).init(testing.allocator); + defer watchlist_set.deinit(); + try watchlist_set.put("TSLA", {}); + + var live = std.StringHashMap(f64).init(testing.allocator); + defer live.deinit(); + try live.put("TSLA", 333.0); // watchlist only + + const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); + + // No held position got a live price, so the summary still reflects + // the candle close → the "as of" label must stay date-based. + try testing.expectEqual(@as(usize, 0), held_overrides); + try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); + try testing.expect(!prices.contains("AAPL")); +} + test "PortfolioData.candles_data: deinit frees candles_arena cleanly" { var svc: DataService = .{ .allocator = testing.allocator, diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 14c8230..75f1e41 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1322,7 +1322,12 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = summary_text, .style = summary_style }); - if (app.portfolio.latest_quote_date) |d| { + if (app.portfolio.live_prices_applied) { + // Live overlay applied: values reflect today's intraday + // quotes, not the candle close. Static text for now; a + // precise "as of HH:MM ET" awaits real tz handling. + try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() }); + } 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() }); } @@ -1341,7 +1346,12 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va try lines.append(arena, .{ .text = summary_text, .style = summary_style }); // "as of" date indicator - if (app.portfolio.latest_quote_date) |d| { + if (app.portfolio.live_prices_applied) { + // Live overlay applied: values reflect today's intraday + // quotes, not the candle close. Static text for now; a + // precise "as of HH:MM ET" awaits real tz handling. + try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() }); + } 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() }); }