change portfolio summary when live pricing is applied

This commit is contained in:
Emil Lerch 2026-06-19 15:44:05 -07:00
parent c11518d40f
commit bd6ba89ea7
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 70 additions and 5 deletions

View file

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

View file

@ -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() });
}