899 lines
33 KiB
Zig
899 lines
33 KiB
Zig
//! `zfin snapshot` — write a daily portfolio snapshot to `history/`.
|
|
//!
|
|
//! Flow:
|
|
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
|
//! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`.
|
|
//! 3. Load portfolio + prices (via `cli.loadPortfolioPrices`, TTL-driven).
|
|
//! 4. Compute `as_of_date` from cached candle dates of held non-MM
|
|
//! stock symbols.
|
|
//! 5. If `history/<as_of_date>-portfolio.srf` already exists and
|
|
//! --force wasn't passed, skip (exit 0, stderr message).
|
|
//! 6. Build the snapshot records and write them atomically.
|
|
//!
|
|
//! The output format is discriminated SRF: every record starts with
|
|
//! `kind::<meta|total|tax_type|account|lot>`. Readers demux on that
|
|
//! field. See finance/README.md for the full schema.
|
|
|
|
const std = @import("std");
|
|
const srf = @import("srf");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const atomic = @import("../atomic.zig");
|
|
const version = @import("../version.zig");
|
|
const portfolio_mod = @import("../models/portfolio.zig");
|
|
const Date = @import("../models/date.zig").Date;
|
|
|
|
pub const SnapshotError = error{
|
|
PortfolioEmpty,
|
|
WriteFailed,
|
|
};
|
|
|
|
// ── Entry point ──────────────────────────────────────────────
|
|
|
|
/// Run the snapshot command.
|
|
///
|
|
/// `args` is the slice after `zfin snapshot`. Accepted flags:
|
|
/// --force overwrite existing snapshot for as_of_date
|
|
/// --out <path> override output path (skips the default derivation)
|
|
/// --dry-run compute + print, do not write
|
|
///
|
|
/// Exit semantics:
|
|
/// 0 on success (including duplicate-skip)
|
|
/// non-zero on any error
|
|
pub fn run(
|
|
allocator: std.mem.Allocator,
|
|
svc: *zfin.DataService,
|
|
portfolio_path: []const u8,
|
|
args: []const []const u8,
|
|
color: bool,
|
|
out: *std.Io.Writer,
|
|
) !void {
|
|
// Parse flags.
|
|
var force = false;
|
|
var dry_run = false;
|
|
var out_override: ?[]const u8 = null;
|
|
var i: usize = 0;
|
|
while (i < args.len) : (i += 1) {
|
|
const a = args[i];
|
|
if (std.mem.eql(u8, a, "--force")) {
|
|
force = true;
|
|
} else if (std.mem.eql(u8, a, "--dry-run")) {
|
|
dry_run = true;
|
|
} else if (std.mem.eql(u8, a, "--out")) {
|
|
i += 1;
|
|
if (i >= args.len) {
|
|
try cli.stderrPrint("Error: --out requires a path argument\n");
|
|
return error.UnexpectedArg;
|
|
}
|
|
out_override = args[i];
|
|
} else {
|
|
try cli.stderrPrint("Error: unknown argument to 'snapshot': ");
|
|
try cli.stderrPrint(a);
|
|
try cli.stderrPrint("\n");
|
|
return error.UnexpectedArg;
|
|
}
|
|
}
|
|
|
|
// Load portfolio.
|
|
const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| {
|
|
try cli.stderrPrint("Error reading portfolio file: ");
|
|
try cli.stderrPrint(@errorName(err));
|
|
try cli.stderrPrint("\n");
|
|
return err;
|
|
};
|
|
defer allocator.free(pf_data);
|
|
|
|
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
|
try cli.stderrPrint("Error parsing portfolio file.\n");
|
|
return error.WriteFailed;
|
|
};
|
|
defer portfolio.deinit();
|
|
|
|
if (portfolio.lots.len == 0) {
|
|
try cli.stderrPrint("Portfolio is empty; nothing to snapshot.\n");
|
|
return SnapshotError.PortfolioEmpty;
|
|
}
|
|
|
|
// Fetch prices via the shared TTL-driven loader.
|
|
const syms = try portfolio.stockSymbols(allocator);
|
|
defer allocator.free(syms);
|
|
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
|
|
if (syms.len > 0) {
|
|
var load_result = cli.loadPortfolioPrices(svc, syms, &.{}, false, color);
|
|
defer load_result.deinit();
|
|
var it = load_result.prices.iterator();
|
|
while (it.next()) |entry| {
|
|
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
|
}
|
|
}
|
|
|
|
// Manual `price::` overrides from portfolio.srf still win.
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.price) |p| {
|
|
if (!prices.contains(lot.priceSymbol())) {
|
|
try prices.put(lot.priceSymbol(), p * lot.price_ratio);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute as_of_date from the cached candle dates of held non-MM
|
|
// stock symbols. MM symbols are excluded because their quote dates
|
|
// are often weeks stale (dollar impact is nil, but they'd pollute
|
|
// the mode calculation).
|
|
const qdates = try collectQuoteDates(allocator, svc, syms);
|
|
defer allocator.free(qdates.dates);
|
|
const as_of = computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp());
|
|
|
|
// Derive output path.
|
|
var as_of_buf: [10]u8 = undefined;
|
|
const as_of_str = as_of.format(&as_of_buf);
|
|
|
|
const derived_path = if (out_override) |p|
|
|
try allocator.dupe(u8, p)
|
|
else
|
|
try deriveSnapshotPath(allocator, portfolio_path, as_of_str);
|
|
defer allocator.free(derived_path);
|
|
|
|
// Duplicate-skip check.
|
|
if (!force and !dry_run) {
|
|
if (std.fs.cwd().access(derived_path, .{})) |_| {
|
|
var msg_buf: [256]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "snapshot for {s} already exists: {s} (use --force to overwrite)\n", .{ as_of_str, derived_path }) catch "snapshot already exists\n";
|
|
try cli.stderrPrint(msg);
|
|
return;
|
|
} else |_| {}
|
|
}
|
|
|
|
// Build and render the snapshot.
|
|
var snap = try buildSnapshot(allocator, &portfolio, portfolio_path, svc, prices, syms, as_of, qdates);
|
|
defer snap.deinit(allocator);
|
|
|
|
const rendered = try renderSnapshot(allocator, snap);
|
|
defer allocator.free(rendered);
|
|
|
|
if (dry_run) {
|
|
try out.writeAll(rendered);
|
|
return;
|
|
}
|
|
|
|
// Ensure history/ directory exists.
|
|
if (std.fs.path.dirname(derived_path)) |dir| {
|
|
std.fs.cwd().makePath(dir) catch |err| switch (err) {
|
|
error.PathAlreadyExists => {},
|
|
else => {
|
|
try cli.stderrPrint("Error creating history directory: ");
|
|
try cli.stderrPrint(@errorName(err));
|
|
try cli.stderrPrint("\n");
|
|
return err;
|
|
},
|
|
};
|
|
}
|
|
|
|
atomic.writeFileAtomic(allocator, derived_path, rendered) catch |err| {
|
|
try cli.stderrPrint("Error writing snapshot: ");
|
|
try cli.stderrPrint(@errorName(err));
|
|
try cli.stderrPrint("\n");
|
|
return err;
|
|
};
|
|
|
|
try out.print("snapshot written: {s}\n", .{derived_path});
|
|
}
|
|
|
|
// ── Path helpers ─────────────────────────────────────────────
|
|
|
|
/// Derive `<dir(portfolio_path)>/history/<as_of>-portfolio.srf`.
|
|
/// Caller owns returned memory.
|
|
pub fn deriveSnapshotPath(
|
|
allocator: std.mem.Allocator,
|
|
portfolio_path: []const u8,
|
|
as_of_str: []const u8,
|
|
) ![]const u8 {
|
|
const dir = std.fs.path.dirname(portfolio_path) orelse ".";
|
|
const filename = try std.fmt.allocPrint(allocator, "{s}-portfolio.srf", .{as_of_str});
|
|
defer allocator.free(filename);
|
|
return std.fs.path.join(allocator, &.{ dir, "history", filename });
|
|
}
|
|
|
|
// ── Quote-date / as_of_date helpers ──────────────────────────
|
|
|
|
/// Per-symbol quote-date info gathered from the candle cache.
|
|
pub const QuoteInfo = struct {
|
|
symbol: []const u8, // borrowed from caller
|
|
/// Most recent candle date in cache, if any candles exist.
|
|
last_date: ?Date,
|
|
/// True when the symbol is classified money-market (excluded from
|
|
/// as_of_date computation because MM candles are often stale but
|
|
/// dollar-impact is nil).
|
|
is_money_market: bool,
|
|
};
|
|
|
|
pub const QuoteDates = struct {
|
|
/// Per-symbol info (same order as input `symbols`).
|
|
dates: []QuoteInfo,
|
|
};
|
|
|
|
/// Gather quote-date info for each symbol from the cache. Does not
|
|
/// fetch; relies on whatever the cache has. Symbols with no candles at
|
|
/// all get `last_date = null`.
|
|
pub fn collectQuoteDates(
|
|
allocator: std.mem.Allocator,
|
|
svc: *zfin.DataService,
|
|
symbols: []const []const u8,
|
|
) !QuoteDates {
|
|
var list = try allocator.alloc(QuoteInfo, symbols.len);
|
|
errdefer allocator.free(list);
|
|
|
|
for (symbols, 0..) |sym, idx| {
|
|
const is_mm = portfolio_mod.isMoneyMarketSymbol(sym);
|
|
var last_date: ?Date = null;
|
|
if (svc.getCachedCandles(sym)) |cs| {
|
|
defer allocator.free(cs);
|
|
if (cs.len > 0) last_date = cs[cs.len - 1].date;
|
|
}
|
|
list[idx] = .{ .symbol = sym, .last_date = last_date, .is_money_market = is_mm };
|
|
}
|
|
|
|
return .{ .dates = list };
|
|
}
|
|
|
|
/// Compute the snapshot's `as_of_date` from per-symbol quote info.
|
|
///
|
|
/// Rule: take the **mode** of `last_date` across non-MM symbols with a
|
|
/// known date; break ties by picking the maximum. If no non-MM symbol
|
|
/// has a date (portfolio is all cash/MM, or totally uncached), return
|
|
/// null and the caller falls back to the capture date.
|
|
pub fn computeAsOfDate(infos: []const QuoteInfo) ?Date {
|
|
// Two-pass mode: count occurrences, then pick max-count/max-date.
|
|
// With typical portfolios (~30 symbols) O(n²) is fine.
|
|
var best: ?Date = null;
|
|
var best_count: usize = 0;
|
|
|
|
for (infos) |a| {
|
|
if (a.is_money_market) continue;
|
|
const a_date = a.last_date orelse continue;
|
|
|
|
var count: usize = 0;
|
|
for (infos) |b| {
|
|
if (b.is_money_market) continue;
|
|
const b_date = b.last_date orelse continue;
|
|
if (a_date.eql(b_date)) count += 1;
|
|
}
|
|
|
|
if (count > best_count or
|
|
(count == best_count and best != null and best.?.lessThan(a_date)))
|
|
{
|
|
best = a_date;
|
|
best_count = count;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/// Return (min, max) of non-MM symbol quote dates, for metadata.
|
|
/// Returns null if no non-MM symbol has a known date.
|
|
pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date } {
|
|
var min_d: ?Date = null;
|
|
var max_d: ?Date = null;
|
|
for (infos) |info| {
|
|
if (info.is_money_market) continue;
|
|
const d = info.last_date orelse continue;
|
|
if (min_d == null or d.lessThan(min_d.?)) min_d = d;
|
|
if (max_d == null or max_d.?.lessThan(d)) max_d = d;
|
|
}
|
|
if (min_d == null) return null;
|
|
return .{ .min = min_d.?, .max = max_d.? };
|
|
}
|
|
|
|
// ── Snapshot records ─────────────────────────────────────────
|
|
//
|
|
// Each record kind below is a plain struct suitable for `srf.fmtFrom`.
|
|
// Field order in the struct declaration IS the on-disk order — srf's
|
|
// `Record.from` iterates `inline for (info.fields)`. The leading `kind`
|
|
// field is the discriminator readers demux on.
|
|
//
|
|
// IMPORTANT: `kind` does NOT have a default value. SRF elides fields
|
|
// whose value matches the declared default (see setField in srf.zig),
|
|
// so a `kind: []const u8 = "meta"` would vanish from the output. Each
|
|
// construction site supplies the tag explicitly.
|
|
//
|
|
// Optional fields default to `null` so they're elided on null values —
|
|
// that's the behavior we want for `price`, `quote_date`, etc.
|
|
|
|
pub const MetaRow = struct {
|
|
kind: []const u8,
|
|
snapshot_version: u32,
|
|
as_of_date: Date,
|
|
captured_at: i64,
|
|
zfin_version: []const u8,
|
|
quote_date_min: ?Date = null,
|
|
quote_date_max: ?Date = null,
|
|
stale_count: usize,
|
|
};
|
|
|
|
pub const TotalRow = struct {
|
|
kind: []const u8,
|
|
scope: []const u8,
|
|
value: f64,
|
|
};
|
|
|
|
pub const TaxTypeRow = struct {
|
|
kind: []const u8,
|
|
label: []const u8,
|
|
value: f64,
|
|
};
|
|
|
|
pub const AccountRow = struct {
|
|
kind: []const u8,
|
|
name: []const u8,
|
|
value: f64,
|
|
};
|
|
|
|
pub const LotRow = struct {
|
|
kind: []const u8,
|
|
symbol: []const u8,
|
|
lot_symbol: []const u8,
|
|
account: []const u8,
|
|
security_type: []const u8,
|
|
shares: f64,
|
|
open_price: f64,
|
|
cost_basis: f64,
|
|
value: f64,
|
|
/// Null for non-stock lots (cash/CD/illiquid have no per-share price).
|
|
price: ?f64 = null,
|
|
/// Null for non-stock lots.
|
|
quote_date: ?Date = null,
|
|
/// Emitted only when true (default is false, which srf skips).
|
|
quote_stale: bool = false,
|
|
};
|
|
|
|
pub const Snapshot = struct {
|
|
meta: MetaRow,
|
|
totals: []TotalRow,
|
|
tax_types: []TaxTypeRow,
|
|
accounts: []AccountRow,
|
|
lots: []LotRow,
|
|
|
|
pub fn deinit(self: *Snapshot, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.totals);
|
|
allocator.free(self.tax_types);
|
|
allocator.free(self.accounts);
|
|
allocator.free(self.lots);
|
|
}
|
|
};
|
|
|
|
/// Build the full snapshot in memory. Does not touch disk.
|
|
fn buildSnapshot(
|
|
allocator: std.mem.Allocator,
|
|
portfolio: *zfin.Portfolio,
|
|
portfolio_path: []const u8,
|
|
svc: *zfin.DataService,
|
|
prices: std.StringHashMap(f64),
|
|
syms: []const []const u8,
|
|
as_of: Date,
|
|
qdates: QuoteDates,
|
|
) !Snapshot {
|
|
// Totals
|
|
const positions = try portfolio.positions(allocator);
|
|
defer allocator.free(positions);
|
|
|
|
var manual_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, @constCast(&prices));
|
|
defer manual_set.deinit();
|
|
|
|
var summary = try zfin.valuation.portfolioSummary(allocator, portfolio.*, positions, prices, manual_set);
|
|
defer summary.deinit(allocator);
|
|
|
|
const illiquid = portfolio.totalIlliquid();
|
|
const net_worth = zfin.valuation.netWorth(portfolio.*, summary);
|
|
|
|
var totals = try allocator.alloc(TotalRow, 3);
|
|
totals[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };
|
|
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
|
|
// sections rather than failing the whole capture.
|
|
var tax_types: []TaxTypeRow = &.{};
|
|
var accounts: []AccountRow = &.{};
|
|
|
|
if (runAnalysis(allocator, portfolio, portfolio_path, svc, summary)) |result| {
|
|
var a = result;
|
|
defer a.deinit(allocator);
|
|
|
|
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 };
|
|
}
|
|
errdefer allocator.free(tax_types);
|
|
|
|
accounts = try allocator.alloc(AccountRow, a.account.len);
|
|
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 +
|
|
// stale flag; non-stock lots get face value.
|
|
var lots_list = std.ArrayList(LotRow).empty;
|
|
errdefer lots_list.deinit(allocator);
|
|
|
|
var stale_count: usize = 0;
|
|
_ = syms;
|
|
|
|
for (portfolio.lots) |lot| {
|
|
if (!lot.isOpen()) continue;
|
|
|
|
const sec_label = lot.security_type.label();
|
|
const lot_sym = lot.symbol;
|
|
const price_sym = lot.priceSymbol();
|
|
const acct = lot.account orelse "";
|
|
|
|
switch (lot.security_type) {
|
|
.stock => {
|
|
const raw_price = prices.get(price_sym) orelse lot.open_price;
|
|
const is_manual = manual_set.contains(price_sym);
|
|
const effective_price = if (is_manual) raw_price else raw_price * lot.price_ratio;
|
|
const value = lot.shares * effective_price;
|
|
|
|
var quote_date: ?Date = null;
|
|
for (qdates.dates) |qi| {
|
|
if (std.mem.eql(u8, qi.symbol, price_sym)) {
|
|
quote_date = qi.last_date;
|
|
break;
|
|
}
|
|
}
|
|
const stale = if (quote_date) |qd| !qd.eql(as_of) else false;
|
|
if (stale and !portfolio_mod.isMoneyMarketSymbol(price_sym)) stale_count += 1;
|
|
|
|
try lots_list.append(allocator, .{
|
|
.kind = "lot",
|
|
.symbol = price_sym,
|
|
.lot_symbol = lot_sym,
|
|
.account = acct,
|
|
.security_type = sec_label,
|
|
.shares = lot.shares,
|
|
.open_price = lot.open_price,
|
|
.cost_basis = lot.costBasis(),
|
|
.price = effective_price,
|
|
.value = value,
|
|
.quote_date = quote_date,
|
|
.quote_stale = stale,
|
|
});
|
|
},
|
|
.cash, .cd, .illiquid => {
|
|
// `shares` is the face/dollar value for these types.
|
|
try lots_list.append(allocator, .{
|
|
.kind = "lot",
|
|
.symbol = lot_sym,
|
|
.lot_symbol = lot_sym,
|
|
.account = acct,
|
|
.security_type = sec_label,
|
|
.shares = lot.shares,
|
|
.open_price = 0,
|
|
.cost_basis = 0,
|
|
.value = lot.shares,
|
|
});
|
|
},
|
|
.option => {
|
|
const opt_value = @abs(lot.shares) * lot.open_price * lot.multiplier;
|
|
try lots_list.append(allocator, .{
|
|
.kind = "lot",
|
|
.symbol = lot_sym,
|
|
.lot_symbol = lot_sym,
|
|
.account = acct,
|
|
.security_type = sec_label,
|
|
.shares = lot.shares,
|
|
.open_price = lot.open_price,
|
|
.cost_basis = opt_value,
|
|
.value = opt_value,
|
|
});
|
|
},
|
|
.watch => {
|
|
// Watchlist lots aren't positions — skip.
|
|
},
|
|
}
|
|
}
|
|
|
|
const range = quoteDateRange(qdates.dates);
|
|
|
|
return .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = as_of,
|
|
.captured_at = std.time.timestamp(),
|
|
.zfin_version = version.version_string,
|
|
.quote_date_min = if (range) |r| r.min else null,
|
|
.quote_date_max = if (range) |r| r.max else null,
|
|
.stale_count = stale_count,
|
|
},
|
|
.totals = totals,
|
|
.tax_types = tax_types,
|
|
.accounts = accounts,
|
|
.lots = try lots_list.toOwnedSlice(allocator),
|
|
};
|
|
}
|
|
|
|
fn runAnalysis(
|
|
allocator: std.mem.Allocator,
|
|
portfolio: *zfin.Portfolio,
|
|
portfolio_path: []const u8,
|
|
svc: *zfin.DataService,
|
|
summary: zfin.valuation.PortfolioSummary,
|
|
) !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]});
|
|
defer allocator.free(meta_path);
|
|
|
|
const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch return error.NoMetadata;
|
|
defer allocator.free(meta_data);
|
|
|
|
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);
|
|
defer if (acct_map_opt) |*am| am.deinit();
|
|
|
|
return zfin.analysis.analyzePortfolio(
|
|
allocator,
|
|
summary.allocations,
|
|
cm,
|
|
portfolio.*,
|
|
summary.total_value,
|
|
acct_map_opt,
|
|
);
|
|
}
|
|
|
|
// ── SRF rendering ────────────────────────────────────────────
|
|
|
|
/// Render a snapshot to SRF bytes. Caller owns result.
|
|
///
|
|
/// Each section is emitted as a homogeneous record slice via
|
|
/// `srf.fmtFrom`. The first section (meta) carries `emit_directives =
|
|
/// true` so the `#!srfv1` header and `#!created=...` line are written
|
|
/// once at the top; subsequent sections set `emit_directives = false`
|
|
/// to suppress a duplicate header.
|
|
pub fn renderSnapshot(allocator: std.mem.Allocator, snap: Snapshot) ![]const u8 {
|
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
errdefer aw.deinit();
|
|
const w = &aw.writer;
|
|
|
|
// Single-element slice so we can route the meta row through the
|
|
// same `fmtFrom` pipeline as the rest of the sections. This also
|
|
// puts the `#!created=...` header at the top of the file.
|
|
const meta_rows: [1]MetaRow = .{snap.meta};
|
|
try w.print("{f}", .{srf.fmtFrom(MetaRow, allocator, &meta_rows, .{
|
|
.emit_directives = true,
|
|
.created = snap.meta.captured_at,
|
|
})});
|
|
|
|
// Subsequent sections: records only (no header).
|
|
const tail_opts: srf.FormatOptions = .{ .emit_directives = false };
|
|
try w.print("{f}", .{srf.fmtFrom(TotalRow, allocator, snap.totals, tail_opts)});
|
|
try w.print("{f}", .{srf.fmtFrom(TaxTypeRow, allocator, snap.tax_types, tail_opts)});
|
|
try w.print("{f}", .{srf.fmtFrom(AccountRow, allocator, snap.accounts, tail_opts)});
|
|
try w.print("{f}", .{srf.fmtFrom(LotRow, allocator, snap.lots, tail_opts)});
|
|
|
|
return aw.toOwnedSlice();
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "deriveSnapshotPath: standard layout" {
|
|
// Build the input portfolio path and expected output from
|
|
// path-joined components so the test runs on both POSIX and Windows.
|
|
const portfolio_path = try std.fs.path.join(
|
|
testing.allocator,
|
|
&.{ "home", "lobo", "finance", "portfolio.srf" },
|
|
);
|
|
defer testing.allocator.free(portfolio_path);
|
|
|
|
const expected = try std.fs.path.join(
|
|
testing.allocator,
|
|
&.{ "home", "lobo", "finance", "history", "2026-04-20-portfolio.srf" },
|
|
);
|
|
defer testing.allocator.free(expected);
|
|
|
|
const p = try deriveSnapshotPath(testing.allocator, portfolio_path, "2026-04-20");
|
|
defer testing.allocator.free(p);
|
|
try testing.expectEqualStrings(expected, p);
|
|
}
|
|
|
|
test "deriveSnapshotPath: bare filename (no dir) falls back to cwd" {
|
|
const expected = try std.fs.path.join(
|
|
testing.allocator,
|
|
&.{ ".", "history", "2026-04-20-portfolio.srf" },
|
|
);
|
|
defer testing.allocator.free(expected);
|
|
|
|
const p = try deriveSnapshotPath(testing.allocator, "portfolio.srf", "2026-04-20");
|
|
defer testing.allocator.free(p);
|
|
try testing.expectEqualStrings(expected, p);
|
|
}
|
|
|
|
test "computeAsOfDate: mode of non-MM dates, ties broken by max" {
|
|
const d1 = Date.fromYmd(2026, 4, 17);
|
|
const d2 = Date.fromYmd(2026, 4, 20);
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "VTI", .last_date = d2, .is_money_market = false },
|
|
.{ .symbol = "AAPL", .last_date = d2, .is_money_market = false },
|
|
.{ .symbol = "MSFT", .last_date = d1, .is_money_market = false },
|
|
// Money-market with stale date — must not win the mode.
|
|
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true },
|
|
};
|
|
const result = computeAsOfDate(&infos);
|
|
try testing.expect(result != null);
|
|
try testing.expect(result.?.eql(d2));
|
|
}
|
|
|
|
test "computeAsOfDate: ties break toward max date" {
|
|
const d1 = Date.fromYmd(2026, 4, 17);
|
|
const d2 = Date.fromYmd(2026, 4, 20);
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "A", .last_date = d1, .is_money_market = false },
|
|
.{ .symbol = "B", .last_date = d2, .is_money_market = false },
|
|
};
|
|
const result = computeAsOfDate(&infos).?;
|
|
try testing.expect(result.eql(d2));
|
|
}
|
|
|
|
test "computeAsOfDate: all MM returns null" {
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true },
|
|
.{ .symbol = "VMFXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true },
|
|
};
|
|
try testing.expect(computeAsOfDate(&infos) == null);
|
|
}
|
|
|
|
test "computeAsOfDate: symbols with no cached date are ignored" {
|
|
const d = Date.fromYmd(2026, 4, 20);
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "VTI", .last_date = d, .is_money_market = false },
|
|
.{ .symbol = "UNCACHED", .last_date = null, .is_money_market = false },
|
|
};
|
|
const result = computeAsOfDate(&infos).?;
|
|
try testing.expect(result.eql(d));
|
|
}
|
|
|
|
test "computeAsOfDate: empty input returns null" {
|
|
try testing.expect(computeAsOfDate(&.{}) == null);
|
|
}
|
|
|
|
test "quoteDateRange: min and max skip MM symbols" {
|
|
const d_old = Date.fromYmd(2026, 4, 17);
|
|
const d_new = Date.fromYmd(2026, 4, 20);
|
|
const d_ancient = Date.fromYmd(2025, 1, 1);
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "A", .last_date = d_new, .is_money_market = false },
|
|
.{ .symbol = "B", .last_date = d_old, .is_money_market = false },
|
|
// MM way older — must be excluded from the range.
|
|
.{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true },
|
|
};
|
|
const r = quoteDateRange(&infos).?;
|
|
try testing.expect(r.min.eql(d_old));
|
|
try testing.expect(r.max.eql(d_new));
|
|
}
|
|
|
|
test "quoteDateRange: returns null when no non-MM data" {
|
|
const infos = [_]QuoteInfo{
|
|
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true },
|
|
};
|
|
try testing.expect(quoteDateRange(&infos) == null);
|
|
}
|
|
|
|
test "renderSnapshot: minimal snapshot shape" {
|
|
const totals = [_]TotalRow{
|
|
.{ .kind = "total", .scope = "net_worth", .value = 1000.0 },
|
|
.{ .kind = "total", .scope = "liquid", .value = 800.0 },
|
|
.{ .kind = "total", .scope = "illiquid", .value = 200.0 },
|
|
};
|
|
const snap: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 1_745_222_400,
|
|
.zfin_version = "testver",
|
|
.stale_count = 0,
|
|
},
|
|
.totals = @constCast(&totals),
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = &.{},
|
|
};
|
|
|
|
const rendered = try renderSnapshot(testing.allocator, snap);
|
|
defer testing.allocator.free(rendered);
|
|
|
|
// Header + front-matter from the first fmtFrom call.
|
|
try testing.expect(std.mem.startsWith(u8, rendered, "#!srfv1\n"));
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "#!created=1745222400") != null);
|
|
|
|
// Meta record fields (discriminator, version, date, captured_at).
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::meta") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "as_of_date::2026-04-20") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "zfin_version::testver") != null);
|
|
|
|
// Totals records use kind::total plus scope+value.
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::net_worth") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::liquid") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::illiquid") != null);
|
|
}
|
|
|
|
test "renderSnapshot: includes quote_date_min/max when present, elided when null" {
|
|
const snap_with: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 0,
|
|
.zfin_version = "x",
|
|
.quote_date_min = Date.fromYmd(2026, 4, 17),
|
|
.quote_date_max = Date.fromYmd(2026, 4, 20),
|
|
.stale_count = 2,
|
|
},
|
|
.totals = &.{},
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = &.{},
|
|
};
|
|
const rendered_with = try renderSnapshot(testing.allocator, snap_with);
|
|
defer testing.allocator.free(rendered_with);
|
|
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_min::2026-04-17") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != null);
|
|
|
|
// Same structure with nulls — srf elides optional fields matching
|
|
// their `null` default, so those keys must NOT appear.
|
|
const snap_without: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 0,
|
|
.zfin_version = "x",
|
|
.stale_count = 0,
|
|
},
|
|
.totals = &.{},
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = &.{},
|
|
};
|
|
const rendered_without = try renderSnapshot(testing.allocator, snap_without);
|
|
defer testing.allocator.free(rendered_without);
|
|
try testing.expect(std.mem.indexOf(u8, rendered_without, "quote_date_min") == null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered_without, "quote_date_max") == null);
|
|
}
|
|
|
|
test "renderSnapshot: lot rendering elides price/quote_date/stale when default" {
|
|
const lots = [_]LotRow{
|
|
// Stock lot — all three optional fields populated.
|
|
.{
|
|
.kind = "lot",
|
|
.symbol = "VTI",
|
|
.lot_symbol = "VTI",
|
|
.account = "Emil Roth",
|
|
.security_type = "Stock",
|
|
.shares = 100,
|
|
.open_price = 200.0,
|
|
.cost_basis = 20000.0,
|
|
.value = 31002.0,
|
|
.price = 310.02,
|
|
.quote_date = Date.fromYmd(2026, 4, 17),
|
|
.quote_stale = true,
|
|
},
|
|
// Cash lot — optionals left at default (null / false), so srf
|
|
// elides them.
|
|
.{
|
|
.kind = "lot",
|
|
.symbol = "Savings",
|
|
.lot_symbol = "Savings",
|
|
.account = "Emil Roth",
|
|
.security_type = "Cash",
|
|
.shares = 50000,
|
|
.open_price = 0,
|
|
.cost_basis = 0,
|
|
.value = 50000,
|
|
},
|
|
};
|
|
const snap: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 0,
|
|
.zfin_version = "x",
|
|
.stale_count = 1,
|
|
},
|
|
.totals = &.{},
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = @constCast(&lots),
|
|
};
|
|
const rendered = try renderSnapshot(testing.allocator, snap);
|
|
defer testing.allocator.free(rendered);
|
|
|
|
// Stock lot line: extract it so we can check in isolation.
|
|
const vti_start = std.mem.indexOf(u8, rendered, "kind::lot,symbol::VTI").?;
|
|
const vti_end = std.mem.indexOfScalarPos(u8, rendered, vti_start, '\n').?;
|
|
const vti_line = rendered[vti_start..vti_end];
|
|
// All three optional-on-stock fields present.
|
|
try testing.expect(std.mem.indexOf(u8, vti_line, ",price:num:") != null);
|
|
try testing.expect(std.mem.indexOf(u8, vti_line, "quote_date::2026-04-17") != null);
|
|
try testing.expect(std.mem.indexOf(u8, vti_line, "quote_stale") != null);
|
|
|
|
// Cash lot line: the three optional fields must be elided because
|
|
// they match their declared defaults (null, null, false).
|
|
const cash_start = std.mem.indexOf(u8, rendered, "kind::lot,symbol::Savings").?;
|
|
const cash_end = std.mem.indexOfScalarPos(u8, rendered, cash_start, '\n').?;
|
|
const cash_line = rendered[cash_start..cash_end];
|
|
try testing.expect(std.mem.indexOf(u8, cash_line, ",price:num:") == null);
|
|
try testing.expect(std.mem.indexOf(u8, cash_line, "quote_date") == null);
|
|
try testing.expect(std.mem.indexOf(u8, cash_line, "quote_stale") == null);
|
|
}
|
|
|
|
test "renderSnapshot: tax_type and account rows carry kind discriminator" {
|
|
const tax = [_]TaxTypeRow{
|
|
.{ .kind = "tax_type", .label = "Taxable", .value = 5000 },
|
|
.{ .kind = "tax_type", .label = "Roth (Post-Tax)", .value = 3000 },
|
|
};
|
|
const accts = [_]AccountRow{
|
|
.{ .kind = "account", .name = "Emil Roth", .value = 2500 },
|
|
};
|
|
const snap: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 0,
|
|
.zfin_version = "x",
|
|
.stale_count = 0,
|
|
},
|
|
.totals = &.{},
|
|
.tax_types = @constCast(&tax),
|
|
.accounts = @constCast(&accts),
|
|
.lots = &.{},
|
|
};
|
|
const rendered = try renderSnapshot(testing.allocator, snap);
|
|
defer testing.allocator.free(rendered);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Taxable") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Roth (Post-Tax)") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rendered, "kind::account,name::Emil Roth") != null);
|
|
}
|
|
|
|
test "renderSnapshot: front-matter emitted exactly once" {
|
|
// All four tail sections use emit_directives=false; only the meta
|
|
// call produces the #!srfv1 + #!created lines. Make sure we don't
|
|
// regress into duplicate headers.
|
|
const totals = [_]TotalRow{
|
|
.{ .kind = "total", .scope = "net_worth", .value = 1000 },
|
|
};
|
|
const snap: Snapshot = .{
|
|
.meta = .{
|
|
.kind = "meta",
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.captured_at = 1,
|
|
.zfin_version = "x",
|
|
.stale_count = 0,
|
|
},
|
|
.totals = @constCast(&totals),
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = &.{},
|
|
};
|
|
const rendered = try renderSnapshot(testing.allocator, snap);
|
|
defer testing.allocator.free(rendered);
|
|
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!srfv1"));
|
|
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!created="));
|
|
}
|