explicit allocator parameters in cache store/service tier, store arena in TUI PortfolioData
This commit is contained in:
parent
f597c0cbef
commit
b6050bb653
12 changed files with 302 additions and 240 deletions
53
src/cache/store.zig
vendored
53
src/cache/store.zig
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
314
src/tui.zig
314
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 <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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue