change portfolio summary when live pricing is applied
This commit is contained in:
parent
c11518d40f
commit
bd6ba89ea7
2 changed files with 70 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue