explicit allocator parameters in cache store/service tier, store arena in TUI PortfolioData

This commit is contained in:
Emil Lerch 2026-06-09 15:54:13 -07:00
parent f597c0cbef
commit b6050bb653
Signed by: lobo
GPG key ID: A7B62D657EF764F8
12 changed files with 302 additions and 240 deletions

53
src/cache/store.zig vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -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");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {