diff --git a/src/cache/store.zig b/src/cache/store.zig index 4a93ccf..8f083f8 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -315,8 +315,15 @@ pub const Store = struct { /// Read and deserialize cached data. With `.fresh_only`, returns null if stale. /// With `.any`, returns data regardless of freshness. + /// + /// `allocator` owns the returned `CacheResult.data`. It can be + /// the same as `self.allocator` (the historical default) or a + /// caller-supplied arena. Internal scratch (raw cache bytes, + /// SRF iterator state) still uses `self.allocator` because it + /// gets freed before this function returns. pub fn read( self: *Store, + allocator: std.mem.Allocator, comptime T: type, symbol: []const u8, comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void, @@ -362,16 +369,16 @@ pub const Store = struct { const timestamp = it.created orelse std.Io.Timestamp.now(self.io, .real).toSeconds(); if (T == EtfProfile) { - const profile = deserializeEtfProfile(self.allocator, &it) catch return null; + const profile = deserializeEtfProfile(allocator, &it) catch return null; return .{ .data = profile, .timestamp = timestamp }; } if (T == OptionsChain) { - const items = deserializeOptions(self.allocator, &it) catch return null; + const items = deserializeOptions(allocator, &it) catch return null; return .{ .data = items, .timestamp = timestamp }; } } - return readSlice(T, self.io, self.allocator, data, postProcess, freshness); + return readSlice(T, self.io, allocator, data, postProcess, freshness); } /// Serialize data and write to cache with the given TTL. @@ -499,7 +506,7 @@ pub const Store = struct { // below frees these duped strings after we're done with the // merged list. Keep the post-process logic in lockstep with // the deinit handling — they're a pair. - const existing_result = self.read(T, symbol, null, .any); + const existing_result = self.read(self.allocator, T, symbol, null, .any); const existing: []const T = if (existing_result) |r| r.data else &.{}; defer if (existing_result != null) { if (comptime @hasDecl(T, "deinit")) { @@ -743,7 +750,7 @@ pub const Store = struct { self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| { // Append failed (file missing?) — fall back to full load + rewrite log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) }); - if (self.read(Candle, symbol, null, .any)) |existing| { + if (self.read(self.allocator, Candle, symbol, null, .any)) |existing| { defer self.allocator.free(existing.data); const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return; defer self.allocator.free(merged); @@ -1731,7 +1738,7 @@ test "writeMerged Dividend: empty cache writes input sorted descending" { }; s.write(Dividend, "TEST", incoming[0..], .{ .seconds = Ttl.dividends }); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -1768,7 +1775,7 @@ test "writeMerged Dividend: existing entries preserved on key collision" { }; s.write(Dividend, "TEST", incoming[0..], .{ .seconds = Ttl.dividends }); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -1800,7 +1807,7 @@ test "writeMerged Dividend: union sorted desc, new entry added" { }; s.write(Dividend, "TEST", incoming[0..], .{ .seconds = Ttl.dividends }); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -1901,7 +1908,7 @@ test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon }; s.writeWithSource(Dividend, "TEST", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -1951,7 +1958,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" { s.writeWithSource(Dividend, "TEST", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon"); // Read back and verify the upgrade landed. - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -1995,7 +2002,7 @@ test "writeMerged Dividend: existing currency preserved on second write with dif defer for (second) |d| d.deinit(allocator); s.writeWithSource(Dividend, "TEST", second[0..], .{ .seconds = Ttl.dividends }, "polygon"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2029,7 +2036,7 @@ test "writeMerged Dividend: type unknown counts as null and gets upgraded" { }; s.writeWithSource(Dividend, "TEST", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2072,7 +2079,7 @@ test "writeMerged Dividend: non-null fields are not overwritten" { }; s.writeWithSource(Dividend, "TEST", second[0..], .{ .seconds = Ttl.dividends }, "polygon"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2118,7 +2125,7 @@ test "writeMerged Dividend: upgrade is no-op when both have same fields" { s.writeWithSource(Dividend, "TEST", repeat[0..], .{ .seconds = Ttl.dividends }, "polygon"); // The merged result is still just one record (no duplication). - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), result.data.len); @@ -2161,7 +2168,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en }; s.writeWithSource(Dividend, "FDRXX", tiingo_view[0..], .{ .seconds = Ttl.dividends }, "tiingo"); - const result = s.read(Dividend, "FDRXX", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "FDRXX", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2196,7 +2203,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound" }; s.writeWithSource(Dividend, "TEST", second[0..], .{ .seconds = Ttl.dividends }, "tiingo"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2229,7 +2236,7 @@ test "writeMerged Dividend: near-match dedup respects amount tolerance" { }; s.writeWithSource(Dividend, "TEST", second[0..], .{ .seconds = Ttl.dividends }, "tiingo"); - const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2267,7 +2274,7 @@ test "writeMerged Dividend: near-match dedup tolerates Tiingo amount rounding" { }; s.writeWithSource(Dividend, "FAGIX", tiingo_view[0..], .{ .seconds = Ttl.dividends }, "tiingo"); - const result = s.read(Dividend, "FAGIX", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Dividend, "FAGIX", null, .any) orelse return error.NoCache; defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); @@ -2304,7 +2311,7 @@ test "writeMerged Split: near-match dedup is a no-op (no amount field)" { }; s.writeWithSource(Split, "TEST", second[0..], .{ .seconds = Ttl.splits }, "tiingo"); - const result = s.read(Split, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); try std.testing.expectEqual(@as(usize, 2), result.data.len); @@ -2329,7 +2336,7 @@ test "writeMerged Split: SPYM-style supplementary entry added" { }; s.write(Split, "SPYM", tiingo_view[0..], .{ .seconds = Ttl.splits }); - const result = s.read(Split, "SPYM", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Split, "SPYM", null, .any) orelse return error.NoCache; defer allocator.free(result.data); try std.testing.expectEqual(@as(usize, 1), result.data.len); @@ -2362,7 +2369,7 @@ test "writeMerged Split: forward-looking Polygon entry preserved across Tiingo r }; s.write(Split, "TEST", tiingo_view[0..], .{ .seconds = Ttl.splits }); - const result = s.read(Split, "TEST", null, .any) orelse return error.NoCache; + const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); // Both entries must remain — Polygon's forward-looking entry survives. @@ -2769,7 +2776,7 @@ test "Store.read self-heals torn candles_daily and wipes the pair" { try store.writeRaw("FRDM", .candles_meta, intact_meta); // Reading candles MUST signal cache miss. - const result = store.read(Candle, "FRDM", null, .any); + const result = store.read(store.allocator, Candle, "FRDM", null, .any); try std.testing.expect(result == null); // Both files are wiped. @@ -2819,7 +2826,7 @@ test "Store.read does not self-heal an intact candles_daily" { try store.writeRaw("OK", .candles_meta, good_meta); // Reading should succeed, and the cache files must still be there. - const result = store.read(Candle, "OK", null, .any); + const result = store.read(store.allocator, Candle, "OK", null, .any); try std.testing.expect(result != null); if (result) |r| { defer testing.allocator.free(r.data); diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 5d5bd08..7ff0288 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -126,7 +126,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Load account tax type metadata (optional). Anchor-derived path // is correct: accounts.srf is one-per-portfolio-set. - var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(anchor_path); + var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, anchor_path); defer if (acct_map_opt) |*am| am.deinit(); var result = zfin.analysis.analyzePortfolio( diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 49e2db5..6df0c20 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1322,7 +1322,7 @@ fn runHygieneCheck( defer portfolio.deinit(); // Load accounts.srf - var account_map = svc.loadAccountMap(portfolio_path) orelse { + var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); return; }; @@ -1829,7 +1829,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const portfolio_path = loaded.anchor(); // Load accounts.srf - var account_map = svc.loadAccountMap(portfolio_path) orelse { + var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n"); return; }; diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 02b673b..e932c39 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -510,7 +510,7 @@ fn prepareReport( // reclassification fires at diff time. When missing or // unparseable, computeReport falls back to the default cash_delta // classification — no account gets the opt-in treatment. - var account_map_opt = svc.loadAccountMap(portfolio_path); + var account_map_opt = svc.loadAccountMap(allocator, portfolio_path); defer if (account_map_opt) |*am| am.deinit(); // Load transaction_log.srf from BOTH sides of the diff. The diff --git a/src/commands/import.zig b/src/commands/import.zig index 36f5a52..55274f4 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -371,7 +371,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // way every other zfin command does. We don't need the // service for anything else (no price fetching), but reusing // its helper keeps sibling-file resolution consistent. - var account_map = svc.loadAccountMap(target_path) orelse { + var account_map = svc.loadAccountMap(allocator, target_path) orelse { cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n"); cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n"); cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n"); diff --git a/src/commands/review.zig b/src/commands/review.zig index 0d594d3..a446500 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -188,7 +188,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; defer cm.deinit(); - var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(anchor_path); + var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, anchor_path); defer if (acct_map_opt) |*am| am.deinit(); // Per-symbol cached dividends so total-return windows include @@ -203,8 +203,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { dividend_map.deinit(); } for (pf_data.summary.allocations) |a| { - if (svc.getCachedDividends(a.symbol)) |divs| { - try dividend_map.put(a.symbol, divs); + if (svc.getCachedDividends(allocator, a.symbol)) |divs| { + try dividend_map.put(a.symbol, divs.data); } } diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index a8e4df4..3e849c3 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -274,7 +274,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // // Not applied in auto mode: auto mode's as_of already comes from // cache mode and is guaranteed to be a trading day. - if (as_of_override != null and !hasAnyTradingDayCandle(svc, syms, as_of)) { + if (as_of_override != null and !hasAnyTradingDayCandle(svc, allocator, syms, as_of)) { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, @@ -291,7 +291,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer symbol_prices.deinit(); for (syms) |sym| { - if (svc.getCachedCandles(sym)) |cs| { + if (svc.getCachedCandles(allocator, sym)) |cs| { defer cs.deinit(); if (zfin.valuation.candleCloseOnOrBefore(cs.data, as_of)) |cad| { try symbol_prices.put(sym, cad); @@ -559,12 +559,13 @@ pub fn probeFreshAsOfDate( /// signals a non-trading day for our purposes. pub fn hasAnyTradingDayCandle( svc: *zfin.DataService, + allocator: std.mem.Allocator, symbols: []const []const u8, date: Date, ) bool { for (symbols) |sym| { if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; - const cs = svc.getCachedCandles(sym) orelse continue; + const cs = svc.getCachedCandles(allocator, sym) orelse continue; defer cs.deinit(); // Linear scan from the end — recent dates are where `date` is // most likely to land for a backfill. @@ -593,7 +594,7 @@ pub fn collectQuoteDates( for (symbols, 0..) |sym, idx| { const is_mm = portfolio_mod.isMoneyMarketSymbol(sym); var last_date: ?Date = null; - if (svc.getCachedCandles(sym)) |cs| { + if (svc.getCachedCandles(allocator, sym)) |cs| { defer cs.deinit(); if (cs.data.len > 0) last_date = cs.data[cs.data.len - 1].date; } @@ -922,7 +923,7 @@ fn runAnalysis( var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch return error.BadMetadata; defer cm.deinit(); - var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(portfolio_path); + var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, portfolio_path); defer if (acct_map_opt) |*am| am.deinit(); return zfin.analysis.analyzePortfolio( diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index e782032..91d894a 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -519,11 +519,10 @@ pub fn buildPortfolioData( candle_map.deinit(); } for (syms) |sym| { - if (svc.getCachedCandles(sym)) |cs| { - // cs.data is owned by svc.allocator, which matches the - // caller's `allocator` in practice (they're wired to the - // same root). Store the raw slice; PortfolioData.deinit - // below frees via the caller's allocator. + if (svc.getCachedCandles(allocator, sym)) |cs| { + // cs.data is owned by the caller's `allocator`. Store + // the raw slice; PortfolioData.deinit (or the arena + // reset, in TUI) below frees via the same allocator. try candle_map.put(sym, cs.data); } } diff --git a/src/service.zig b/src/service.zig index 6908af0..f3f66d7 100644 --- a/src/service.zig +++ b/src/service.zig @@ -445,7 +445,7 @@ pub const DataService = struct { // through to provider fetch. Skip-network does the opposite: // returns cached even if stale, never touches the network. if (!opts.force_refresh) { - if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { + if (s.read(self.allocator, T, symbol, postProcess, .fresh_only)) |cached| { log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -454,7 +454,7 @@ pub const DataService = struct { if (opts.skip_network) { // Offline mode: return whatever's cached, even if stale. // Cache miss is FetchFailed (not a network error). - if (s.read(T, symbol, postProcess, .any)) |cached| { + if (s.read(self.allocator, T, symbol, postProcess, .any)) |cached| { log.info("{s}: {s} stale-cached returned (skip_network)", .{ symbol, @tagName(data_type) }); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -463,7 +463,7 @@ pub const DataService = struct { // Try server sync before hitting providers (skipped on force_refresh). if (!opts.force_refresh and self.syncFromServer(symbol, data_type)) { - if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { + if (s.read(self.allocator, T, symbol, postProcess, .fresh_only)) |cached| { log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -742,7 +742,7 @@ pub const DataService = struct { log.debug("{s}: skip_network and only TwelveData cached — treating as unavailable", .{symbol}); return DataError.FetchFailed; } - if (s.read(Candle, symbol, null, .any)) |r| { + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| { if (!s.isCandleMetaFresh(symbol)) { log.info("{s}: candles stale-cached returned (skip_network)", .{symbol}); } @@ -758,7 +758,7 @@ pub const DataService = struct { } else if (!opts.force_refresh and s.isCandleMetaFresh(symbol)) { // Fresh — deserialize candles and return log.debug("{s}: candles fresh in local cache", .{symbol}); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; } else { // Stale — try server sync before incremental fetch. @@ -767,7 +767,7 @@ pub const DataService = struct { if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh", .{symbol}); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol}); @@ -779,7 +779,7 @@ pub const DataService = struct { // If last cached date is today or later, just refresh the TTL (meta only) if (!fetch_from.lessThan(today)) { s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } else { // Incremental fetch from day after last cached candle @@ -794,13 +794,13 @@ pub const DataService = struct { // If degraded (fail_count >= 3), return stale data rather than failing if (new_fail_count >= 3) { log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count }); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; } return DataError.TransientError; } // Non-transient failure — return stale data if available - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; return DataError.FetchFailed; }; @@ -810,12 +810,12 @@ pub const DataService = struct { // No new candles (weekend/holiday) — refresh TTL, reset fail_count self.allocator.free(new_candles); s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } else { // Append new candles to existing file + update meta, reset fail_count s.appendCandles(symbol, new_candles, result.provider, 0); - if (s.read(Candle, symbol, null, .any)) |r| { + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| { self.allocator.free(new_candles); return .{ .data = r.data, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } @@ -835,7 +835,7 @@ pub const DataService = struct { if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol}); - if (s.read(Candle, symbol, null, .any)) |r| + if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol}); @@ -910,7 +910,7 @@ pub const DataService = struct { const today = fmt.todayDate(self.io); if (!opts.force_refresh) { - if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { + if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { // Check if any past/today earnings event is still missing actual results. // If so, the announcement likely just happened — force a refresh. // (Suppressed when opts.skip_network — offline mode never refetches.) @@ -929,7 +929,7 @@ pub const DataService = struct { if (opts.skip_network) { // Offline mode: fall back to any cached entry (even stale) before giving up. - if (s.read(EarningsEvent, symbol, earningsPostProcess, .any)) |cached| { + if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .any)) |cached| { log.info("{s}: earnings stale-cached returned (skip_network)", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -938,7 +938,7 @@ pub const DataService = struct { // Try server sync before hitting FMP (skipped on force_refresh). if (!opts.force_refresh and self.syncFromServer(symbol, .earnings)) { - if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { + if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { log.debug("{s}: earnings synced from server and fresh", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -1097,14 +1097,14 @@ pub const DataService = struct { var s = self.store(); if (!opts.force_refresh) { - if (s.read(Wikidata.ClassificationRecord, symbol, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Wikidata.ClassificationRecord, symbol, null, .fresh_only)) |cached| { log.debug("{s}: classification fresh in local cache", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } } if (opts.skip_network) { - if (s.read(Wikidata.ClassificationRecord, symbol, null, .any)) |cached| { + if (s.read(self.allocator, Wikidata.ClassificationRecord, symbol, null, .any)) |cached| { log.info("{s}: classification stale-cached returned (skip_network)", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -1113,7 +1113,7 @@ pub const DataService = struct { // Try server sync before hitting Wikidata. if (!opts.force_refresh and self.syncFromServer(symbol, .classification)) { - if (s.read(Wikidata.ClassificationRecord, symbol, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Wikidata.ClassificationRecord, symbol, null, .fresh_only)) |cached| { log.debug("{s}: classification synced from server", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -1417,14 +1417,14 @@ pub const DataService = struct { var s = self.store(); if (!opts.force_refresh) { - if (s.read(Edgar.EntityFactRecord, cik, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.EntityFactRecord, cik, null, .fresh_only)) |cached| { log.debug("CIK {s}: entity_facts fresh in local cache", .{cik}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } } if (opts.skip_network) { - if (s.read(Edgar.EntityFactRecord, cik, null, .any)) |cached| { + if (s.read(self.allocator, Edgar.EntityFactRecord, cik, null, .any)) |cached| { log.info("CIK {s}: entity_facts stale-cached returned (skip_network)", .{cik}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -1432,7 +1432,7 @@ pub const DataService = struct { } if (!opts.force_refresh and self.syncFromServer(cik, .entity_facts)) { - if (s.read(Edgar.EntityFactRecord, cik, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.EntityFactRecord, cik, null, .fresh_only)) |cached| { log.debug("CIK {s}: entity_facts synced from server", .{cik}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; } @@ -1491,7 +1491,7 @@ pub const DataService = struct { var s = self.store(); if (!opts.force_refresh) { - if (s.read(Edgar.EtfMetricRecord, symbol, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.EtfMetricRecord, symbol, null, .fresh_only)) |cached| { log.debug("{s}: etf_metrics fresh in local cache", .{symbol}); return .{ .data = cached.data, @@ -1503,7 +1503,7 @@ pub const DataService = struct { } if (opts.skip_network) { - if (s.read(Edgar.EtfMetricRecord, symbol, null, .any)) |cached| { + if (s.read(self.allocator, Edgar.EtfMetricRecord, symbol, null, .any)) |cached| { log.info("{s}: etf_metrics stale-cached returned (skip_network)", .{symbol}); return .{ .data = cached.data, @@ -1516,7 +1516,7 @@ pub const DataService = struct { } if (!opts.force_refresh and self.syncFromServer(symbol, .etf_metrics)) { - if (s.read(Edgar.EtfMetricRecord, symbol, null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.EtfMetricRecord, symbol, null, .fresh_only)) |cached| { log.debug("{s}: etf_metrics synced from server", .{symbol}); return .{ .data = cached.data, @@ -1618,7 +1618,7 @@ pub const DataService = struct { var s = self.store(); if (!opts.force_refresh) { - if (s.read(Edgar.MutualFundTickerEntry, "_edgar", null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.MutualFundTickerEntry, "_edgar", null, .fresh_only)) |cached| { if (cached.data.len > 0) { return Edgar.TickerMap(Edgar.MutualFundTickerEntry).fromEntries(self.allocator, cached.data); } @@ -1646,7 +1646,7 @@ pub const DataService = struct { var s = self.store(); if (!opts.force_refresh) { - if (s.read(Edgar.CompanyTickerEntry, "_edgar", null, .fresh_only)) |cached| { + if (s.read(self.allocator, Edgar.CompanyTickerEntry, "_edgar", null, .fresh_only)) |cached| { if (cached.data.len > 0) { return Edgar.TickerMap(Edgar.CompanyTickerEntry).fromEntries(self.allocator, cached.data); } @@ -1875,34 +1875,38 @@ pub const DataService = struct { /// Read candles from cache only (no network fetch). Used by TUI for display. /// Returns null if no cached data exists or if the entry is a negative cache (fetch_failed). /// - /// Returns a `FetchResult(Candle)` so the caller can `result.deinit()` - /// without needing to know the service's internal allocator. - pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?FetchResult(Candle) { + /// `allocator` owns the returned `FetchResult.data`. Pass an + /// arena for "lives until reload" use cases (TUI per-portfolio + /// data); pass a per-call arena for CLI batch commands. + pub fn getCachedCandles(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(Candle) { var s = self.store(); if (s.isNegative(symbol, .candles_daily)) return null; - const result = s.read(Candle, symbol, null, .any) orelse return null; - return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = self.allocator }; + const result = s.read(allocator, Candle, symbol, null, .any) orelse return null; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; } - /// Read dividends from cache only (no network fetch). - pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend { + /// Read dividends from cache only (no network fetch). See + /// `getCachedCandles` for the allocator contract. + pub fn getCachedDividends(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(Dividend) { var s = self.store(); - const result = s.read(Dividend, symbol, null, .any) orelse return null; - return result.data; + const result = s.read(allocator, Dividend, symbol, null, .any) orelse return null; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; } - /// Read earnings from cache only (no network fetch). - pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent { + /// Read earnings from cache only (no network fetch). See + /// `getCachedCandles` for the allocator contract. + pub fn getCachedEarnings(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(EarningsEvent) { var s = self.store(); - const result = s.read(EarningsEvent, symbol, earningsPostProcess, .any) orelse return null; - return result.data; + const result = s.read(allocator, EarningsEvent, symbol, earningsPostProcess, .any) orelse return null; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; } - /// Read options from cache only (no network fetch). - pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain { + /// Read options from cache only (no network fetch). See + /// `getCachedCandles` for the allocator contract. + pub fn getCachedOptions(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(OptionsChain) { var s = self.store(); - const result = s.read(OptionsChain, symbol, null, .any) orelse return null; - return result.data; + const result = s.read(allocator, OptionsChain, symbol, null, .any) orelse return null; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; } // ── Portfolio price loading ────────────────────────────────── @@ -2682,7 +2686,7 @@ pub const DataService = struct { /// Load and parse accounts.srf from the same directory as the given portfolio path. /// Returns null if the file doesn't exist or can't be parsed. /// Caller owns the returned AccountMap and must call deinit(). - pub fn loadAccountMap(self: *DataService, portfolio_path: []const u8) ?analysis.AccountMap { + pub fn loadAccountMap(self: *DataService, allocator: std.mem.Allocator, portfolio_path: []const u8) ?analysis.AccountMap { const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return null; defer self.allocator.free(acct_path); @@ -2690,7 +2694,7 @@ pub const DataService = struct { const data = std.Io.Dir.cwd().readFileAlloc(self.io, acct_path, self.allocator, .limited(1024 * 1024)) catch return null; defer self.allocator.free(data); - return analysis.parseAccountsFile(self.allocator, data) catch null; + return analysis.parseAccountsFile(allocator, data) catch null; } /// Load and parse `transaction_log.srf` from the same directory as diff --git a/src/tui.zig b/src/tui.zig index a7fa435..62b3815 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -426,129 +426,141 @@ pub const SymbolData = struct { /// single tab doesn't own this data — it's a shared cache scoped /// to "the current portfolio file." pub const PortfolioData = struct { + /// Backing arena for everything that lives until the next + /// portfolio reload. Owns: file, summary, historical_snapshots, + /// account_map, watchlist_prices, prefetched_prices, + /// candle_map, dividend_map, map_load_slots — and every + /// nested allocation those structures make. Reset on reload + /// via `reset()`; deallocated on App teardown via `deinit()`. + /// + /// Backed by the App's GPA (passed at `init`). The arena's + /// own backing pages are reaped when `deinit()` runs. + arena: std.heap.ArenaAllocator, + /// Parsed portfolio.srf (lots, watchlist, classifications). - /// The "portfolio" everyone refers to. Owned here; freed via - /// `deinit`. + /// The "portfolio" everyone refers to. Allocated against + /// `self.allocator()`. file: ?zfin.Portfolio = null, /// Computed summary (allocations, totals, gain/loss). Derived /// from `file` + per-symbol prices. Refreshed on price updates. + /// Allocated against `self.allocator()`. summary: ?zfin.valuation.PortfolioSummary = null, /// Whether the portfolio is loaded into `file`. Distinct from /// `file != null` because the load may have failed and we /// want to remember "we tried." loaded: bool = false, - /// Historical snapshot values (1W/1M/1Q/1Y/3Y/5Y/10Y) for the - /// portfolio's value-over-time view. Populated on portfolio - /// load; null until then. + /// Historical snapshot values for the portfolio's value-over- + /// time view. Populated on portfolio load; null until then. historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null, /// Account-tax-type metadata loaded from `accounts.srf` next /// to the portfolio. Used by analysis (tax-type breakdown) - /// and portfolio (per-account display). + /// and portfolio (per-account display). Allocated against + /// `self.allocator()`. /// /// **Cross-tab mutation note.** Analysis-tab refresh - /// (`tab_modules.analysis.tab.reload`) clears this field so the next - /// load re-reads `accounts.srf` from disk (the user may have - /// edited it). Portfolio-tab consumers re-read this field on - /// every render, so the clear-and-reload doesn't require a - /// notification today. If a future tab needs to react to the - /// clear (e.g. invalidate a cached aggregation), the right - /// escape valve is a new framework lifecycle hook - /// (e.g. `onAccountMapChange`) that tabs opt into via - /// `@hasDecl`. Not added speculatively. + /// (`tab_modules.analysis.tab.reload`) clears this field so + /// the next load re-reads `accounts.srf` from disk (the user + /// may have edited it). Portfolio-tab consumers re-read this + /// field on every render, so the clear-and-reload doesn't + /// require a notification today. account_map: ?zfin.analysis.AccountMap = null, /// Cached prices for watchlist symbols (no live fetching during /// render). Populated on portfolio load and refresh. watchlist_prices: ?std.StringHashMap(f64) = null, - /// Most recent quote date across the portfolio's held symbols - /// (max of each symbol's last cached candle date). Drives the - /// "as of close on YYYY-MM-DD" line under the portfolio totals. - /// Computed as a side effect of the portfolio-prices loop in - /// `App.ensurePortfolioDataLoaded`; null when no symbols have - /// cached candles. + /// Most recent quote date across the portfolio's held symbols. + /// Drives the "as of close on YYYY-MM-DD" line under the + /// portfolio totals. Null when no symbols have cached candles. latest_quote_date: ?zfin.Date = null, /// Prices fetched before the TUI started (with stderr /// progress). Consumed by the first /// `App.ensurePortfolioDataLoaded` call to skip redundant - /// network round-trips on startup. Owned here; freed after - /// first consumption. + /// network round-trips on startup. prefetched_prices: ?std.StringHashMap(f64) = null, /// Per-symbol cached candles. Populated synchronously in - /// `App.ensurePortfolioDataLoaded` — the candle map is a - /// byproduct of computing historical snapshots there, so we - /// transfer ownership to App rather than re-reading the cache - /// per-tab. Cleared on portfolio reload, freed once in - /// `deinit`. + /// `App.ensurePortfolioDataLoaded` as a byproduct of computing + /// historical snapshots; cross-tab read-only. candle_map: ?std.StringHashMap([]const zfin.Candle) = null, /// Per-symbol cached dividends, populated by an async /// background load started in `App.ensurePortfolioDataLoaded`. /// Tabs that need this map MUST call `App.waitMapsReady()` /// first; the load may still be in flight when a tab - /// activates. Each value slice is allocated by the cache - /// layer (`getCachedDividends` → `readSlice`) against the - /// App's allocator and is owned here — `PortfolioData.deinit` - /// frees both the slice and its inner currency strings via - /// `Dividend.freeSlice`. + /// activates. dividend_map: ?std.StringHashMap([]const zfin.Dividend) = null, /// `std.Io.Group` holding the per-symbol async tasks that - /// populate `dividend_map`. Per Zig 0.16 release notes, - /// `cancel(io)` is the canonical drain primitive — used on - /// portfolio reload (the in-flight load is now stale; throw - /// it away) and on App teardown. `await(io)` is what tabs - /// use through `App.waitMapsReady()` when they actually need - /// the maps. + /// populate `dividend_map`. `cancel(io)` is the canonical + /// drain primitive on reload + teardown. map_load_group: std.Io.Group = .init, /// Per-symbol result slots written by background workers. /// `App.waitMapsReady` folds these into `dividend_map` after - /// the Group's `await` succeeds. Tied to the Group's - /// lifetime: cleared after fold, freed on cancel. + /// the Group's `await` succeeds. map_load_slots: ?[]MapLoadSlot = null, - /// Coordination state for the in-flight map load. - /// `idle` = no load running, no maps. `loading` = workers - /// dispatched, slots populated, dividend_map not yet folded. - /// `ready` = `await` succeeded, dividend_map populated, slots - /// freed. `canceled` = an external cancel propagated through; - /// dividend_map stays null and tabs degrade gracefully. + /// Coordination state for the in-flight map load. See + /// `MapLoadPhase`. map_load_phase: MapLoadPhase = .idle, - /// Free `candle_map` and `dividend_map` and their value - /// slices. Sets both fields to null. Idempotent — safe to - /// call when one or both maps are already null. - /// - /// Used by both `deinit` (App teardown) and the portfolio - /// reload path (drop the stale maps before rebuilding). The - /// callers are responsible for canceling any in-flight - /// background load via `App.cancelMapLoad` BEFORE invoking - /// this — workers must not be holding references to slot - /// pointers when we free the maps. - pub fn freeMaps(self: *PortfolioData, allocator: std.mem.Allocator) void { - if (self.candle_map) |*m| { - var it = m.valueIterator(); - while (it.next()) |v| allocator.free(v.*); - m.deinit(); - self.candle_map = null; - } - if (self.dividend_map) |*m| { - // Each slice was allocated by the cache layer - // (getCachedDividends → readSlice) against this same - // allocator. We're the only owner; free them. - var it = m.valueIterator(); - while (it.next()) |v| zfin.Dividend.freeSlice(allocator, v.*); - m.deinit(); - self.dividend_map = null; - } + /// Construct an empty `PortfolioData`. `gpa` is the App-level + /// general-purpose allocator that backs the arena. The arena + /// itself owns its pages and releases them on `deinit()`. + pub fn init(gpa: std.mem.Allocator) PortfolioData { + return .{ .arena = .init(gpa) }; } - pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { - if (self.summary) |*s| s.deinit(allocator); - if (self.account_map) |*am| am.deinit(); - if (self.watchlist_prices) |*wp| wp.deinit(); - if (self.prefetched_prices) |*pp| pp.deinit(); + /// Per-portfolio-load allocator. Pass to anything producing + /// data with portfolio-load lifetime: cache reads, summary + /// computation, historical snapshots, account map parsing, + /// the parsed Portfolio file, the maps and their bucket + /// storage. + pub fn allocator(self: *PortfolioData) std.mem.Allocator { + return self.arena.allocator(); + } + + /// Drop all per-portfolio state. Resets the arena, freeing + /// every allocation made via `self.allocator()`. After this, + /// every optional field is null and the arena is empty + /// (capacity retained for the next load). + /// + /// MUST be preceded by `App.cancelMapLoad` so workers aren't + /// holding pointers into the arena. The function pair + /// composes: `cancelMapLoad` first, then `reset`. They are + /// not redundant — `cancelMapLoad` interrupts in-flight + /// async tasks; `reset` reaps memory. + /// + /// Does NOT touch `map_load_group` or `map_load_phase` — the + /// caller resets the Group via `= .init` after `cancelMapLoad`. + /// + /// Does NOT free `self.file`. The parsed Portfolio is GPA- + /// allocated (loaders use a single allocator for kept data + /// + temporaries; we don't want temporaries in the arena). + /// Caller must free it explicitly via `pf.deinit()` BEFORE + /// calling `reset()`. The reload path in `portfolio_tab.zig` + /// does this. + pub fn reset(self: *PortfolioData) void { + self.file = null; + self.summary = null; + self.loaded = false; + self.historical_snapshots = null; + self.account_map = null; + self.watchlist_prices = null; + self.latest_quote_date = null; + self.prefetched_prices = null; + self.candle_map = null; + self.dividend_map = null; + self.map_load_slots = null; + _ = self.arena.reset(.retain_capacity); + } + + /// Tear down. Releases the arena's backing pages back to the + /// GPA. Caller has already canceled any in-flight Group via + /// `App.cancelMapLoad`. + /// + /// Frees `self.file` (the parsed Portfolio) since it's GPA- + /// allocated, not arena-allocated. + pub fn deinit(self: *PortfolioData) void { if (self.file) |*pf| pf.deinit(); - self.freeMaps(allocator); - if (self.map_load_slots) |slots| allocator.free(slots); - self.* = .{}; + self.arena.deinit(); + self.* = undefined; } }; @@ -577,6 +589,10 @@ pub const MapLoadSlot = struct { /// from the cache. Runs as a `std.Io.Group.async` task; signature /// is `Cancelable!void` per the Group contract. /// +/// `allocator` should be the portfolio's arena allocator +/// (`app.portfolio.allocator()`). The dividend slice lands in +/// the arena and is reaped on the next portfolio reload. +/// /// Calls `io.checkCancel()` so a cancel-on-reload propagates /// promptly even when the slot list is large. The cache read /// itself is sync — fast enough for cancellation between symbols @@ -584,11 +600,12 @@ pub const MapLoadSlot = struct { fn loadDividendsForSlot( io: std.Io, svc: *zfin.DataService, + allocator: std.mem.Allocator, slot: *MapLoadSlot, ) std.Io.Cancelable!void { try io.checkCancel(); - if (svc.getCachedDividends(slot.symbol)) |divs| { - slot.dividends = divs; + if (svc.getCachedDividends(allocator, slot.symbol)) |fr| { + slot.dividends = fr.data; } } @@ -617,7 +634,9 @@ pub const App = struct { /// account map, watchlist prices, historical snapshots). See /// `PortfolioData` above. Reloaded by /// `tab_modules.portfolio.reloadPortfolioFile` on file changes. - portfolio: PortfolioData = .{}, + /// Constructed at App init via `PortfolioData.init(allocator)`; + /// no default because the arena needs a backing allocator. + portfolio: PortfolioData, /// Captured at App init and refreshed at tab change. Using a cached /// date (rather than calling the clock on every render) keeps render /// deterministic within a single frame and avoids threading `io` @@ -1112,7 +1131,7 @@ pub const App = struct { pub fn ensureAccountMap(self: *App) void { if (self.portfolio.account_map != null) return; const ppath = self.anchorPath() orelse return; - self.portfolio.account_map = self.svc.loadAccountMap(ppath); + self.portfolio.account_map = self.svc.loadAccountMap(self.portfolio.allocator(), ppath); } fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { @@ -1452,7 +1471,7 @@ pub const App = struct { // Extract watchlist prices if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.portfolio.allocator()); } var wp = &(self.portfolio.watchlist_prices.?); var pp_iter = pp.iterator(); @@ -1467,7 +1486,7 @@ pub const App = struct { } else { // Live fetch (refresh path) — fetch watchlist first, then stock prices if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.portfolio.allocator()); } var wp = &(self.portfolio.watchlist_prices.?); if (self.watchlist) |wl| { @@ -1530,8 +1549,11 @@ pub const App = struct { } self.portfolio.latest_quote_date = latest_date; - // Build portfolio summary, candle map, and historical snapshots - const pf_data = portfolio_loader.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { + // Build portfolio summary, candle map, and historical + // snapshots. Allocate against the portfolio's arena so + // every per-portfolio allocation lives until the next + // reload (which calls `app.portfolio.reset()`). + const pf_data = portfolio_loader.buildPortfolioData(self.portfolio.allocator(), pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { error.NoAllocations => { self.setStatus("No cached prices. Run: zfin perf first"); return; @@ -1545,19 +1567,13 @@ pub const App = struct { return; }, }; - // Transfer ownership: summary, candle_map, and snapshots - // all move onto App. The candle_map is a byproduct of the - // historical-snapshot computation that happened inside - // `buildPortfolioData`; reusing it across tabs avoids - // redundant SRF cache reads per tab activation. + // Transfer the byproducts onto App. The candle_map's + // value slices are arena-allocated so we don't have to + // free anything prior — the previous load's data was + // reaped by `app.portfolio.reset()` if a reload happened + // before this point, or never existed if first load. self.portfolio.summary = pf_data.summary; self.portfolio.historical_snapshots = pf_data.snapshots; - // Free any prior candle_map before overwriting. - if (self.portfolio.candle_map) |*m| { - var it = m.valueIterator(); - while (it.next()) |v| self.allocator.free(v.*); - m.deinit(); - } self.portfolio.candle_map = pf_data.candle_map; // Spawn the background dividend loader. Idempotent — if @@ -1615,7 +1631,9 @@ pub const App = struct { } pub fn freePortfolioSummary(self: *App) void { - if (self.portfolio.summary) |*s| s.deinit(self.allocator); + // Summary's nested allocations live in the portfolio + // arena; the next `app.portfolio.reset()` reaps them. + // Null the field to mark it invalid. self.portfolio.summary = null; } @@ -1631,7 +1649,8 @@ pub const App = struct { const summary = self.portfolio.summary orelse return; if (summary.allocations.len == 0) return; - const slots = self.allocator.alloc(MapLoadSlot, summary.allocations.len) catch return; + const arena = self.portfolio.allocator(); + const slots = arena.alloc(MapLoadSlot, summary.allocations.len) catch return; for (slots, summary.allocations) |*slot, alloc| { slot.* = .{ .symbol = alloc.symbol }; } @@ -1639,7 +1658,7 @@ pub const App = struct { self.portfolio.map_load_phase = .loading; for (slots) |*slot| { - self.portfolio.map_load_group.async(self.io, loadDividendsForSlot, .{ self.io, self.svc, slot }); + self.portfolio.map_load_group.async(self.io, loadDividendsForSlot, .{ self.io, self.svc, arena, slot }); } } @@ -1663,27 +1682,27 @@ pub const App = struct { self.portfolio.map_load_group.await(self.io) catch |err| switch (err) { error.Canceled => { self.portfolio.map_load_phase = .canceled; - if (self.portfolio.map_load_slots) |slots| { - self.allocator.free(slots); - self.portfolio.map_load_slots = null; - } + // Slots are arena-allocated; the next reset() + // reaps them. Just null the field. + self.portfolio.map_load_slots = null; return; }, }; - // Fold slots into dividend_map. + // Fold slots into dividend_map. Both the HashMap's bucket + // storage and the slot array live in the arena. const slots = self.portfolio.map_load_slots orelse { self.portfolio.map_load_phase = .ready; return; }; - var dividends = std.StringHashMap([]const zfin.Dividend).init(self.allocator); + var dividends = std.StringHashMap([]const zfin.Dividend).init(self.portfolio.allocator()); for (slots) |slot| { if (slot.dividends) |d| dividends.put(slot.symbol, d) catch |err| { std.log.scoped(.tui).warn("dividend_map.put({s}): {t}", .{ slot.symbol, err }); }; } self.portfolio.dividend_map = dividends; - self.allocator.free(slots); + // Slot array stays in arena; nulling the field is enough. self.portfolio.map_load_slots = null; self.portfolio.map_load_phase = .ready; } @@ -1699,12 +1718,12 @@ pub const App = struct { /// Workers see `error.Canceled` from any `io.checkCancel()` /// call; our worker has one between the slot index and the /// cache read so cancellation propagates promptly. + /// + /// The slot array is arena-allocated; we just null the field. + /// The next `app.portfolio.reset()` reaps everything. pub fn cancelMapLoad(self: *App) void { self.portfolio.map_load_group.cancel(self.io); - if (self.portfolio.map_load_slots) |slots| { - self.allocator.free(slots); - self.portfolio.map_load_slots = null; - } + self.portfolio.map_load_slots = null; self.portfolio.map_load_phase = .idle; } @@ -1815,7 +1834,7 @@ pub const App = struct { const state_ptr = &@field(self.states, field.name); Module.tab.deinit(state_ptr, self); } - self.portfolio.deinit(self.allocator); + self.portfolio.deinit(); if (self.portfolio_resolved) |rp| rp.deinit(); if (self.portfolio_paths.len > 0) self.allocator.free(self.portfolio_paths); } @@ -2544,6 +2563,7 @@ pub fn run( .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, .chart_config = chart_config, + .portfolio = PortfolioData.init(allocator), }; // History tab requires explicit init (allocator-backed hash map); // other tabs use field defaults. The corresponding deinit lives @@ -2981,15 +3001,16 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $ // ── PortfolioData lifecycle tests ──────────────────────────── test "PortfolioData.deinit: empty struct cleans up without leaks" { - var pd: PortfolioData = .{}; - pd.deinit(std.testing.allocator); + var pd: PortfolioData = .init(std.testing.allocator); + pd.deinit(); } -test "PortfolioData.deinit: with candle_map frees value slices" { +test "PortfolioData.deinit: with candle_map (arena-allocated) cleans up without leaks" { const Candle = zfin.Candle; - var pd: PortfolioData = .{}; - var cm = std.StringHashMap([]const Candle).init(std.testing.allocator); - const slice = try std.testing.allocator.alloc(Candle, 1); + var pd: PortfolioData = .init(std.testing.allocator); + const arena = pd.allocator(); + var cm = std.StringHashMap([]const Candle).init(arena); + const slice = try arena.alloc(Candle, 1); slice[0] = .{ .date = zfin.Date.fromYmd(2026, 1, 1), .open = 1.0, @@ -3001,18 +3022,19 @@ test "PortfolioData.deinit: with candle_map frees value slices" { }; try cm.put("VTI", slice); pd.candle_map = cm; - pd.deinit(std.testing.allocator); + // arena.deinit() inside pd.deinit() reaps everything. + pd.deinit(); } -test "PortfolioData.deinit: with dividend_map frees value slices and inner currency strings" { +test "PortfolioData.deinit: with dividend_map (arena-allocated) cleans up without leaks" { const Dividend = zfin.Dividend; - var pd: PortfolioData = .{}; - var dm = std.StringHashMap([]const Dividend).init(std.testing.allocator); - // Allocate a real slice with a Dividend that owns an inner - // string. PortfolioData.deinit must free both the slice and - // the string for testing.allocator to be satisfied. - const slice = try std.testing.allocator.alloc(Dividend, 1); - const owned_currency = try std.testing.allocator.dupe(u8, "USD"); + var pd: PortfolioData = .init(std.testing.allocator); + const arena = pd.allocator(); + var dm = std.StringHashMap([]const Dividend).init(arena); + // Both the slice AND the inner currency string allocate from + // the arena. arena.deinit() reaps them in one shot. + const slice = try arena.alloc(Dividend, 1); + const owned_currency = try arena.dupe(u8, "USD"); slice[0] = .{ .ex_date = zfin.Date.fromYmd(2026, 1, 1), .pay_date = zfin.Date.fromYmd(2026, 1, 15), @@ -3021,12 +3043,13 @@ test "PortfolioData.deinit: with dividend_map frees value slices and inner curre }; try dm.put("VTI", slice); pd.dividend_map = dm; - pd.deinit(std.testing.allocator); + pd.deinit(); } -test "PortfolioData.deinit: with map_load_slots frees the slot array" { - var pd: PortfolioData = .{}; - pd.map_load_slots = try std.testing.allocator.alloc(MapLoadSlot, 3); +test "PortfolioData.deinit: with map_load_slots (arena-allocated) cleans up without leaks" { + var pd: PortfolioData = .init(std.testing.allocator); + const arena = pd.allocator(); + pd.map_load_slots = try arena.alloc(MapLoadSlot, 3); for (pd.map_load_slots.?, 0..) |*slot, i| { slot.* = .{ .symbol = switch (i) { 0 => "A", @@ -3034,5 +3057,26 @@ test "PortfolioData.deinit: with map_load_slots frees the slot array" { else => "C", } }; } - pd.deinit(std.testing.allocator); + pd.deinit(); +} + +test "PortfolioData.reset: nulls fields and reaps arena allocations" { + var pd: PortfolioData = .init(std.testing.allocator); + defer pd.deinit(); + const arena = pd.allocator(); + + // Populate a few fields. + var cm = std.StringHashMap([]const zfin.Candle).init(arena); + try cm.put("VTI", &.{}); + pd.candle_map = cm; + pd.loaded = true; + pd.latest_quote_date = zfin.Date.fromYmd(2026, 1, 1); + + pd.reset(); + + try std.testing.expect(pd.candle_map == null); + try std.testing.expect(!pd.loaded); + try std.testing.expect(pd.latest_quote_date == null); + // Arena was reset; we can allocate again from a fresh state. + _ = try pd.allocator().alloc(u8, 16); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 96d7fa6..9111364 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1698,15 +1698,27 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { // but account_list entries borrow from the portfolio and will dangle. state.account_list.clearRetainingCapacity(); - // Re-read the portfolio file(s) - if (app.portfolio.file) |*pf| pf.deinit(); - app.portfolio.file = null; - if (app.portfolio_paths.len == 0) { app.setStatus("No portfolio file to reload"); return; } + // Cancel in-flight async work, then reset the portfolio's + // arena. After `reset()`: every arena-allocated field is + // null and the arena is empty. Re-init the Group for the + // next load cycle. + // + // The parsed `file` (Portfolio struct) is still allocated + // against the App-level GPA — `loadPortfolioFromPaths` uses + // a single allocator for both the kept Portfolio and its + // temporaries, so we keep using the GPA here. Free the + // prior file explicitly before reset so its lots/strings + // don't leak. + if (app.portfolio.file) |*pf| pf.deinit(); + app.cancelMapLoad(); + app.portfolio.reset(); + app.portfolio.map_load_group = .init; + if (portfolio_loader.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| { // Take the merged Portfolio; discard the auxiliary slices // we don't keep on App. Note we deliberately don't replace @@ -1735,16 +1747,6 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { app.watchlist = tui.loadWatchlist(app.io, app.allocator, path); } - // Recompute summary using cached prices (no network) - app.freePortfolioSummary(); - - // Cancel any in-flight background dividend load and free the - // shared candle/dividend maps before rebuilding. Re-init the - // Group for the next load cycle. - app.cancelMapLoad(); - app.portfolio.freeMaps(app.allocator); - app.portfolio.map_load_group = .init; - state.expanded = @splat(false); state.cash_expanded = false; state.illiquid_expanded = false; @@ -1771,8 +1773,9 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { var latest_date: ?zfin.Date = null; var missing: usize = 0; for (syms) |sym| { - // Cache only — no network - const candles_slice = app.svc.getCachedCandles(sym); + // Cache only — no network. Each result is deinit'd inside + // the loop; `app.allocator` (GPA) is fine for scratch. + const candles_slice = app.svc.getCachedCandles(app.allocator, sym); if (candles_slice) |cs| { defer cs.deinit(); if (cs.data.len > 0) { @@ -1786,8 +1789,12 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { } app.portfolio.latest_quote_date = latest_date; - // Build portfolio summary, candle map, and historical snapshots from cache - const pf_data = portfolio_loader.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { + // Build portfolio summary, candle map, and historical snapshots + // from cache. Allocate against the portfolio's arena so the + // candle_map's value slices live until the next reload (which + // calls `app.portfolio.reset()`). Mirrors + // `App.ensurePortfolioDataLoaded`. + const pf_data = portfolio_loader.buildPortfolioData(app.portfolio.allocator(), pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { error.NoAllocations => { app.setStatus("No cached prices available"); return; diff --git a/src/views/projections.zig b/src/views/projections.zig index c3b93e3..6c271e2 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -735,7 +735,7 @@ fn buildContextFromParts( var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { - const candles_res = svc.getCachedCandles(a.symbol) orelse continue; + const candles_res = svc.getCachedCandles(alloc, a.symbol) orelse continue; defer candles_res.deinit(); const candles = history.sliceCandlesAsOf(candles_res.data, as_of); if (candles.len > 0) {