From 123bd3eb4696eb35d5216e526f2e16d5a4bb8ea9 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 29 Jun 2026 19:35:41 -0700 Subject: [PATCH] computation of the overall portfolio summary in live mode --- src/PortfolioData.zig | 168 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index cdb7ed2..a443f1b 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -293,6 +293,29 @@ live_quotes_at_s: ?i64 = null, /// render). Allocated in pd's arena. watchlist_prices: ?std.StringHashMap(f64) = null, +// ── Re-value (live-streaming) state ─────────────────────────── +// +// Captured by `load` and reused by `revalue` for the cheap +// price-overlay re-value used during live streaming: apply streamed +// prices over the base candle-close prices and recompute only the +// summary - no file re-read, no worker respawn. Both fields live in +// the main `arena` (reaped on reload), so they are valid only between +// loads; `revalue` bails when they are empty/null. + +/// Parsed positions from the last load (shallow arena copy; symbol +/// slices point into `file`, which outlives them). Empty before the +/// first load. +revalue_positions: []const zfin.Position = &.{}, +/// Base (candle-close) prices from the last load, keyed by held symbol +/// 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, +/// 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 +/// on `deinit`. +revalue_arena: ArenaAllocator, + // ── Async data (access via methods) ────────────────────────── // // Each datum has a (future, data) pair. The future is what @@ -327,6 +350,7 @@ pub fn init(opts: InitOptions) PortfolioData { return .{ .arena = .init(opts.gpa), .candles_arena = .init(opts.gpa), + .revalue_arena = .init(opts.gpa), .io = opts.io, .svc = opts.svc, }; @@ -341,6 +365,7 @@ pub fn deinit(self: *PortfolioData) void { // slice values - single bulk free is enough; no need to // walk the map. self.candles_arena.deinit(); + self.revalue_arena.deinit(); self.arena.deinit(); self.* = undefined; } @@ -558,6 +583,11 @@ pub fn load( self.file = null; _ = self.arena.reset(.retain_capacity); self.paths = &.{}; + // The reset above reaped the previous load's re-value state (it + // lived in this arena). Null it now so a stray `revalue` between + // here and the recapture below can't read freed memory. + self.revalue_positions = &.{}; + self.revalue_base_prices = null; self.summary = null; self.latest_quote_date = null; self.live_prices_applied = false; @@ -716,6 +746,14 @@ pub fn load( } self.watchlist_prices = wp; + // Capture re-value state for live streaming BEFORE buildFallbackPrices + // mutates `prices` (so the base is the candle-close + any startup + // overlay, without the manual/avg-cost fallbacks that revalue + // re-derives each call). Keys are duped into the arena since the + // load_all-borrowed keys don't outlive this function. + self.revalue_positions = dupePositions(arena_alloc, positions); + self.revalue_base_prices = dupePricesArena(arena_alloc, &prices); + // ── Build summary ───────────────────────────────────────── var manual_price_set = zfin.valuation.buildFallbackPrices(arena_alloc, pf.lots, positions, &prices) catch return error.OutOfMemory; @@ -797,6 +835,56 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo return self.load(saved, today, opts); } +/// Cheap live re-value: apply a streamed-price `overlay` over the base +/// (candle-close) prices captured by the last `load`, then recompute +/// ONLY the summary. No file I/O, no worker respawn - safe to call on a +/// sub-second tick while streaming. +/// +/// `overlay` maps held symbol -> latest price; its keys must match the +/// summary's price keys (held symbols), and non-matching keys are +/// ignored. Symbols absent from the overlay keep their base price, so +/// non-streaming assets (mutual funds, CDs, cash) stay put. +/// +/// Returns true when the summary was recomputed; false (a no-op) before +/// the first successful load, or if the previous load captured no +/// re-value state. On any internal failure the previous summary is left +/// intact and false is returned. +pub fn revalue(self: *PortfolioData, today: Date, overlay: *const std.StringHashMap(f64)) bool { + const pf = self.file orelse return false; + const base = if (self.revalue_base_prices) |*b| b else return false; + const positions = self.revalue_positions; + if (positions.len == 0) return false; + + // Reset bounds memory across a long streaming session: each call + // reuses the same arena for its working price map and the new + // summary; the previous call's summary is reaped here. + _ = self.revalue_arena.reset(.retain_capacity); + const ra = self.revalue_arena.allocator(); + + // Working prices = base candle-close prices, with the streamed + // overlay applied over matching held symbols. + var prices = std.StringHashMap(f64).init(ra); + var overrides: usize = 0; + var bit = base.iterator(); + while (bit.next()) |e| { + const px = if (overlay.get(e.key_ptr.*)) |live_px| blk: { + overrides += 1; + break :blk live_px; + } else e.value_ptr.*; + prices.put(e.key_ptr.*, px) catch return false; + } + + const manual_price_set = zfin.valuation.buildFallbackPrices(ra, pf.lots, positions, &prices) catch return false; + const sum = zfin.valuation.portfolioSummary(today, ra, pf, positions, prices, manual_price_set) catch return false; + if (sum.allocations.len == 0) return false; + + self.summary = sum; + // Flip the "as of" wording when at least one held position was + // re-priced from the live overlay (mirrors the load-overlay path). + self.live_prices_applied = overrides > 0; + return true; +} + /// 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 @@ -929,6 +1017,20 @@ fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Posi return dup; } +/// Copy a price map into `arena` with arena-duped keys, so it outlives +/// the (load_all-borrowed) source keys. Used to capture the base prices +/// for `revalue`. Best-effort: a failed entry is skipped (a missing +/// base price just degrades that position to its fallback in revalue). +fn dupePricesArena(arena: Allocator, src: *std.StringHashMap(f64)) std.StringHashMap(f64) { + var out = std.StringHashMap(f64).init(arena); + var it = src.iterator(); + while (it.next()) |e| { + const k = arena.dupe(u8, e.key_ptr.*) catch continue; + out.put(k, e.value_ptr.*) catch continue; + } + return out; +} + /// Adapter that captures the first 8 failed symbols into pd's /// arena while still forwarding to the caller's progress callback. const FailCapture = struct { @@ -1378,3 +1480,69 @@ test "PortfolioData.candles_data: soft load preserves the candle map" { try testing.expect(pd.candles_data != null); try testing.expect(pd.candles_data.?.contains("VTI")); } + +test "PortfolioData.revalue: overlays streamed prices and recomputes the total" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc }); + defer { + // `file` below is an empty stack Portfolio, not heap-owned; clear + // it so deinit doesn't try to free it. + pd.file = null; + pd.deinit(); + } + + 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 }, + .{ .symbol = "SPY", .shares = 5, .avg_cost = 400.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, + }; + pd.file = .{ .lots = &.{}, .allocator = testing.allocator }; + pd.revalue_positions = &positions; + + var base = std.StringHashMap(f64).init(testing.allocator); + defer base.deinit(); + try base.put("AAPL", 200.0); + try base.put("SPY", 700.0); + pd.revalue_base_prices = base; + + const today = Date.fromYmd(2026, 5, 8); + + // No overlay: total = 10*200 + 5*700 = 5500; nothing overridden. + var no_overlay = std.StringHashMap(f64).init(testing.allocator); + defer no_overlay.deinit(); + try testing.expect(pd.revalue(today, &no_overlay)); + try testing.expectApproxEqAbs(@as(f64, 5500.0), pd.summary.?.total_value, 0.01); + try testing.expect(!pd.live_prices_applied); + + // Overlay AAPL -> 210: total = 10*210 + 5*700 = 5600. + var overlay = std.StringHashMap(f64).init(testing.allocator); + defer overlay.deinit(); + try overlay.put("AAPL", 210.0); + try testing.expect(pd.revalue(today, &overlay)); + try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01); + try testing.expect(pd.live_prices_applied); + + // A repeat call reuses the revalue arena (memory stays bounded) and + // yields the same result - exercises the per-call arena reset. + try testing.expect(pd.revalue(today, &overlay)); + try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01); +} + +test "PortfolioData.revalue: no-op before any load" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc }); + defer pd.deinit(); + + var overlay = std.StringHashMap(f64).init(testing.allocator); + defer overlay.deinit(); + // No positions / base prices captured -> false, and summary stays null. + try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay)); + try testing.expect(pd.summary == null); +}