fix portfolio account totals/honor as_of date

This commit is contained in:
Emil Lerch 2026-04-23 02:43:04 -07:00
parent 58c20a4de9
commit 9364532c9b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 21 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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