From 9364532c9b33696fe9f2f2d98224b2e56284b61e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 23 Apr 2026 02:43:04 -0700 Subject: [PATCH] fix portfolio account totals/honor as_of date --- src/analytics/analysis.zig | 15 +++++++++++++-- src/commands/analysis.zig | 1 + src/commands/snapshot.zig | 15 ++++++--------- src/tui/analysis_tab.zig | 1 + 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 0101ff2..b4ac84e 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -9,6 +9,7 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; +const Date = @import("../models/date.zig").Date; /// A single slice of a breakdown (e.g., "Technology" -> 25.3%) pub const BreakdownItem = struct { @@ -157,6 +158,11 @@ pub const AnalysisResult = struct { /// `classifications` is the metadata file data. /// `portfolio` is the full portfolio (for cash/CD/illiquid totals). /// `account_map` is optional account tax type metadata. +/// `as_of` is the date against which lot open/closed status is +/// evaluated. Pass `null` to use wall-clock today (the default for +/// interactive commands); historical snapshot backfill passes the +/// target date so lots opened/closed/matured between `as_of` and today +/// are counted correctly. pub fn analyzePortfolio( allocator: std.mem.Allocator, allocations: []const Allocation, @@ -164,6 +170,7 @@ pub fn analyzePortfolio( portfolio: Portfolio, total_portfolio_value: f64, account_map: ?AccountMap, + as_of: ?Date, ) !AnalysisResult { // Accumulators: label -> dollar amount var ac_map = std.StringHashMap(f64).init(allocator); @@ -222,9 +229,13 @@ pub fn analyzePortfolio( price_lookup.put(alloc.symbol, alloc.current_price) catch {}; } - // Account breakdown from individual lots (avoids "Multiple" aggregation issue) + // Account breakdown from individual lots (avoids "Multiple" aggregation issue). + // Use `lotIsOpenAsOf(as_of)` when provided so backfilled snapshots + // correctly include/exclude lots based on the target date rather + // than wall-clock today. `isOpen()` = `lotIsOpenAsOf(today)`. + const reference_date = as_of orelse Date.fromEpoch(std.time.timestamp()); for (portfolio.lots) |lot| { - if (!lot.isOpen()) continue; + if (!lot.lotIsOpenAsOf(reference_date)) continue; const acct = lot.account orelse continue; const value: f64 = switch (lot.security_type) { .stock => blk: { diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index c608da9..67c6222 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -75,6 +75,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co portfolio, pf_data.summary.total_value, acct_map_opt, + null, // null => use wall-clock today (interactive, not backfill) ) catch { try cli.stderrPrint("Error computing analysis.\n"); return; diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index f8dc589..30a8dbd 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -625,18 +625,13 @@ fn buildSnapshot( // fails we still emit the snapshot with empty tax_type/account // sections rather than failing the whole capture. // - // Known limitation: `runAnalysis` uses `isOpen()` (wall-clock today) - // for its per-account/tax-type roll-ups rather than - // `lotIsOpenAsOf(as_of)`. For backfill dates where lots have - // matured/closed/opened between `as_of` and today, the per-account - // totals can be off by the affected lots. The headline - // net_worth/liquid/illiquid totals are NOT affected — those flow - // through `positionsAsOf(as_of)` which honors the target date. - // Fixing analysis to be as_of-aware is tracked separately. + // `analyzePortfolio` is passed `as_of` so per-account/tax-type + // roll-ups filter lots via `lotIsOpenAsOf(as_of)` — matches the + // backfill semantics used for the headline totals. var tax_types: []TaxTypeRow = &.{}; var accounts: []AccountRow = &.{}; - if (runAnalysis(allocator, portfolio, portfolio_path, svc, summary)) |result| { + if (runAnalysis(allocator, portfolio, portfolio_path, svc, summary, as_of)) |result| { var a = result; defer a.deinit(allocator); @@ -774,6 +769,7 @@ fn runAnalysis( portfolio_path: []const u8, svc: *zfin.DataService, summary: zfin.valuation.PortfolioSummary, + as_of: Date, ) !zfin.analysis.AnalysisResult { const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = try std.fmt.allocPrint(allocator, "{s}metadata.srf", .{portfolio_path[0..dir_end]}); @@ -795,6 +791,7 @@ fn runAnalysis( portfolio.*, summary.total_value, acct_map_opt, + as_of, ); } diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 7c035ca..36c23c6 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -61,6 +61,7 @@ fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.Portfol pf, summary.total_value, app.account_map, + null, // null => use wall-clock today (interactive, not backfill) ) catch { app.setStatus("Error computing analysis"); return;