add full snapshot processing test
This commit is contained in:
parent
d3f2f15a2d
commit
614f846a41
1 changed files with 369 additions and 23 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue