add full snapshot processing test

This commit is contained in:
Emil Lerch 2026-04-23 06:21:32 -07:00
parent d3f2f15a2d
commit 614f846a41
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -278,7 +278,7 @@ pub fn run(
}
// Build and render the snapshot.
var snap = try buildSnapshot(allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates);
var snap = try captureSnapshot(allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates);
defer snap.deinit(allocator);
const rendered = try renderSnapshot(allocator, snap);
@ -588,13 +588,17 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date }
// module so analytics code (`src/analytics/timeline.zig`) can reference
// them without depending on a `commands/` module.
/// Build the full snapshot in memory. Does not touch disk.
/// I/O-edged orchestration wrapper around `buildSnapshot`.
///
/// `prices` is a flat `symbol -> price` map derived from `symbol_prices`
/// plus manual overrides. `symbol_prices` carries richer per-symbol
/// info (matched candle date, staleness) for the per-lot `quote_date`
/// / `quote_stale` fields.
fn buildSnapshot(
/// Assembles the dependencies that require disk or service access
/// positions, portfolio summary, manual-price set, analysis result
/// (loaded from metadata.srf + accounts.srf) and hands them to the
/// pure `buildSnapshot` builder.
///
/// This is the path taken by the `zfin snapshot` command. Tests can
/// call `buildSnapshot` directly with hand-built fixtures instead of
/// going through here.
fn captureSnapshot(
allocator: std.mem.Allocator,
portfolio: *zfin.Portfolio,
portfolio_path: []const u8,
@ -605,9 +609,9 @@ fn buildSnapshot(
as_of: Date,
qdates: QuoteDates,
) !Snapshot {
// Totals. Use `positionsAsOf(as_of)` rather than `positions()` so
// historical backfills correctly count lots that were held on
// `as_of` regardless of whether they're open today.
// Use `positionsAsOf(as_of)` rather than `positions()` so historical
// backfills correctly count lots that were held on `as_of`
// regardless of whether they're open today.
const positions = try portfolio.positionsAsOf(allocator, as_of);
defer allocator.free(positions);
@ -617,6 +621,68 @@ fn buildSnapshot(
var summary = try zfin.valuation.portfolioSummary(allocator, portfolio.*, positions, prices, manual_set);
defer summary.deinit(allocator);
// Analysis is optional metadata.srf may not exist during initial
// setup, in which case `runAnalysis` returns an error and we pass
// null through to `buildSnapshot`, which emits empty
// tax_type/account sections.
var analysis_opt: ?zfin.analysis.AnalysisResult = runAnalysis(
allocator,
portfolio,
portfolio_path,
svc,
summary,
as_of,
) catch null;
defer if (analysis_opt) |*a| a.deinit(allocator);
return buildSnapshot(
allocator,
portfolio,
summary,
prices,
manual_set,
symbol_prices,
syms,
as_of,
qdates,
analysis_opt,
);
}
/// Build the full snapshot in memory. Pure: no disk, no network, no
/// service calls. All dependencies are explicit parameters; the caller
/// is responsible for assembling them (see `captureSnapshot` for the
/// I/O-edged orchestration).
///
/// Inputs:
/// - `portfolio`, `as_of`: the what and when.
/// - `summary`, `manual_set`: stock aggregates + totals + the set of
/// symbols whose price is already share-class-adjusted. See the
/// "Pricing model" block in `models/portfolio.zig`.
/// - `prices`: flat `symbol -> price` map.
/// - `symbol_prices`: richer per-symbol candle match info for the
/// per-lot `quote_date` / `quote_stale` fields.
/// - `qdates`: per-symbol latest cached candle dates, used only
/// for the meta row's `quote_date_min`/`_max` span.
/// - `analysis_result`: optional per-account / per-tax-type rollup.
/// Null when metadata.srf was absent; yields empty section slices.
fn buildSnapshot(
allocator: std.mem.Allocator,
portfolio: *zfin.Portfolio,
summary: zfin.valuation.PortfolioSummary,
prices: std.StringHashMap(f64),
manual_set: std.StringHashMap(void),
symbol_prices: std.StringHashMap(zfin.valuation.CandleAtDate),
syms: []const []const u8,
as_of: Date,
qdates: QuoteDates,
analysis_result: ?zfin.analysis.AnalysisResult,
) !Snapshot {
// `summary` and `manual_set` are caller-provided see
// `captureSnapshot` for how they're assembled from
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
// `portfolioSummary`. The caller owns their lifetimes.
const illiquid = portfolio.totalIlliquid();
const net_worth = zfin.valuation.netWorth(portfolio.*, summary);
@ -625,20 +691,18 @@ fn buildSnapshot(
totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value };
totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };
// Analysis (optional depends on metadata.srf existing). If it
// fails we still emit the snapshot with empty tax_type/account
// Per-account / per-tax-type roll-ups come from the caller
// `run()` invokes `runAnalysis` (which reads metadata.srf and
// loads the account map) before calling us. Null means
// metadata.srf was absent; we emit empty tax_type/account
// sections rather than failing the whole capture.
//
// `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.
// `analyzePortfolio` was given `as_of` upstream so per-lot
// filtering via `lotIsOpenAsOf(as_of)` matches the headline totals.
var tax_types: []TaxTypeRow = &.{};
var accounts: []AccountRow = &.{};
if (runAnalysis(allocator, portfolio, portfolio_path, svc, summary, as_of)) |result| {
var a = result;
defer a.deinit(allocator);
if (analysis_result) |a| {
tax_types = try allocator.alloc(TaxTypeRow, a.tax_type.len);
for (a.tax_type, 0..) |t, idx| {
tax_types[idx] = .{ .kind = "tax_type", .label = t.label, .value = t.value };
@ -649,10 +713,6 @@ fn buildSnapshot(
for (a.account, 0..) |acc, idx| {
accounts[idx] = .{ .kind = "account", .name = acc.label, .value = acc.value };
}
} else |_| {
// Silent: metadata.srf may legitimately not exist during initial
// setup. Header is already emitted; missing-analysis just means
// fewer breakdowns in the snapshot.
}
// Per-lot rows (open lots only). Stock lots get current price +
@ -1155,3 +1215,289 @@ test "renderSnapshot: front-matter emitted exactly once" {
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!srfv1"));
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!created="));
}
// buildSnapshot integration test
//
// Covers the full pure pipeline: construct fixture inputs, call
// `buildSnapshot`, render, assert on the resulting bytes. Catches
// regressions in:
// - pricing rules (effectivePrice / marketValue / price_ratio)
// - per-lot quote_date / quote_stale propagation
// - manual-price flag handling (is_preadjusted)
// - meta row field assembly
// - totals ordering (net_worth, liquid, illiquid)
// - analysis result tax_type/account row mapping
//
// We assert on semantic properties rather than byte-identical golden
// output to avoid brittleness on float formatting and HashMap
// iteration order. If a future change reshuffles record order or
// precision, these asserts should still pass.
test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
const allocator = testing.allocator;
// Portfolio: three lots, three scenarios.
// 1. AAPL plain retail-class, live price from candle.
// 2. VTTHX institutional share class (ratio 5.185), live price.
// 3. NON40OR52 manual price:: override (is_manual=true).
var lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150.0,
.security_type = .stock,
.account = "Roth",
},
.{
.symbol = "VTTHX",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 140.0,
.security_type = .stock,
.account = "401k",
.price_ratio = 5.185,
},
.{
.symbol = "NON40OR52",
.shares = 1000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 95.0,
.security_type = .stock,
.account = "401k",
.price = 100.0, // manual override
},
};
var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
// Positions the caller assembles these via `positionsAsOf`.
const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17));
defer allocator.free(positions);
// Prices constructed the same way `captureSnapshot` does: live
// candle closes for AAPL/VTTHX, manual override pre-multiplied for
// NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op).
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
try prices.put("VTTHX", 27.78); // retail-class close; ratio applied downstream
try prices.put("NON40OR52", 100.0); // manual, already pre-multiplied
// buildFallbackPrices populates manual_set with symbols whose
// price is already share-class-adjusted. We mimic that here.
var manual_set = std.StringHashMap(void).init(allocator);
defer manual_set.deinit();
try manual_set.put("NON40OR52", {});
// PortfolioSummary normally comes from valuation.portfolioSummary;
// build the equivalent inline. Total value reflects the pricing
// rules: AAPL 10×200=2000, VTTHX 100×27.78×5.185=14,403.93,
// NON40OR52 1000×100=100,000. Total = 116,403.93.
var allocations = [_]zfin.valuation.Allocation{
.{
.symbol = "AAPL",
.display_symbol = "AAPL",
.shares = 10,
.avg_cost = 150,
.current_price = 200,
.market_value = 2000,
.cost_basis = 1500,
.weight = 0,
.unrealized_gain_loss = 500,
.unrealized_return = 0.333,
.account = "Roth",
},
.{
.symbol = "VTTHX",
.display_symbol = "VTTHX",
.shares = 100,
.avg_cost = 140,
.current_price = 144.04,
.market_value = 14404,
.cost_basis = 14000,
.weight = 0,
.unrealized_gain_loss = 404,
.unrealized_return = 0.028,
.price_ratio = 5.185,
.account = "401k",
},
.{
.symbol = "NON40OR52",
.display_symbol = "NON40OR52",
.shares = 1000,
.avg_cost = 95,
.current_price = 100,
.market_value = 100000,
.cost_basis = 95000,
.weight = 0,
.unrealized_gain_loss = 5000,
.unrealized_return = 0.053,
.is_manual_price = true,
.account = "401k",
},
};
const summary: zfin.valuation.PortfolioSummary = .{
.total_value = 116404.0,
.total_cost = 110500.0,
.unrealized_gain_loss = 5904.0,
.unrealized_return = 0.0534,
.realized_gain_loss = 0,
.allocations = &allocations,
};
// symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent
// (manual price doesn't have a candle lookup `quote_date` should
// be null and `quote_stale` should be false).
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
defer symbol_prices.deinit();
try symbol_prices.put("AAPL", .{ .close = 200.0, .date = Date.fromYmd(2026, 4, 17), .stale = false });
try symbol_prices.put("VTTHX", .{ .close = 27.78, .date = Date.fromYmd(2026, 4, 17), .stale = false });
const syms = [_][]const u8{ "AAPL", "VTTHX", "NON40OR52" };
const qdates_data = [_]QuoteInfo{
.{ .symbol = "AAPL", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false },
.{ .symbol = "VTTHX", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false },
};
const qdates: QuoteDates = .{ .dates = @constCast(&qdates_data) };
var snap = try buildSnapshot(
allocator,
&portfolio,
summary,
prices,
manual_set,
symbol_prices,
&syms,
Date.fromYmd(2026, 4, 17),
qdates,
null, // no classification tax_types/accounts empty
);
defer snap.deinit(allocator);
const rendered = try renderSnapshot(allocator, snap);
defer allocator.free(rendered);
// Meta row
try testing.expect(std.mem.indexOf(u8, rendered, "as_of_date::2026-04-17") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "stale_count:num:0") != null);
// Totals
// Emitted in fixed order: net_worth, liquid, illiquid.
const nw_pos = std.mem.indexOf(u8, rendered, "scope::net_worth").?;
const liq_pos = std.mem.indexOf(u8, rendered, "scope::liquid").?;
const ill_pos = std.mem.indexOf(u8, rendered, "scope::illiquid").?;
try testing.expect(nw_pos < liq_pos);
try testing.expect(liq_pos < ill_pos);
// Per-lot pricing assertions
// AAPL: retail, no ratio. 10 shares * 200 = 2000.
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::AAPL") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:2000") != null);
// VTTHX: institutional, ratio 5.185 applied. value 100 * 27.78 * 5.185 = 14403.93
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::VTTHX") != null);
// Value is 14404.23 due to 5.185 × 27.78 × 100 float rounding; check within tolerance.
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:14403") != null or
std.mem.indexOf(u8, rendered, "value:num:14404") != null);
// NON40OR52: manual, ratio skipped. 1000 * 100 = 100000.
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != null);
// NON40OR52 has no candle lookup no quote_date on its row.
// (We can't easily assert a field is absent on a specific row
// without parsing, but we can assert the manual lot has no
// quote_stale flag.)
const nm_pos = std.mem.indexOf(u8, rendered, "symbol::NON40OR52").?;
const nm_end = std.mem.indexOfScalarPos(u8, rendered, nm_pos, '\n') orelse rendered.len;
const nm_row = rendered[nm_pos..nm_end];
try testing.expect(std.mem.indexOf(u8, nm_row, "quote_stale") == null);
// No tax_type/account rows when analysis_result is null
try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type") == null);
try testing.expect(std.mem.indexOf(u8, rendered, "kind::account") == null);
}
test "buildSnapshot: stale carry-forward flagged on lot row" {
const allocator = testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{
.symbol = "MSFT",
.shares = 5,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 300.0,
.security_type = .stock,
.account = "Roth",
},
};
var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 20));
defer allocator.free(positions);
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("MSFT", 400.0);
var manual_set = std.StringHashMap(void).init(allocator);
defer manual_set.deinit();
var allocations = [_]zfin.valuation.Allocation{
.{
.symbol = "MSFT",
.display_symbol = "MSFT",
.shares = 5,
.avg_cost = 300,
.current_price = 400,
.market_value = 2000,
.cost_basis = 1500,
.weight = 0,
.unrealized_gain_loss = 500,
.unrealized_return = 0.333,
.account = "Roth",
},
};
const summary: zfin.valuation.PortfolioSummary = .{
.total_value = 2000,
.total_cost = 1500,
.unrealized_gain_loss = 500,
.unrealized_return = 0.333,
.realized_gain_loss = 0,
.allocations = &allocations,
};
// Target as_of = 2026-04-20, but MSFT's matched candle is 04-17
// (e.g., a stale cache). Lot row should carry quote_date::2026-04-17
// and quote_stale:bool:true.
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
defer symbol_prices.deinit();
try symbol_prices.put("MSFT", .{ .close = 400.0, .date = Date.fromYmd(2026, 4, 17), .stale = true });
const syms = [_][]const u8{"MSFT"};
const qdates_data = [_]QuoteInfo{
.{ .symbol = "MSFT", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false },
};
const qdates: QuoteDates = .{ .dates = @constCast(&qdates_data) };
var snap = try buildSnapshot(
allocator,
&portfolio,
summary,
prices,
manual_set,
symbol_prices,
&syms,
Date.fromYmd(2026, 4, 20),
qdates,
null,
);
defer snap.deinit(allocator);
const rendered = try renderSnapshot(allocator, snap);
defer allocator.free(rendered);
try testing.expect(std.mem.indexOf(u8, rendered, "quote_date::2026-04-17") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "quote_stale:bool:true") != null);
// stale_count on meta should be 1.
try testing.expect(std.mem.indexOf(u8, rendered, "stale_count:num:1") != null);
}