1646 lines
66 KiB
Zig
1646 lines
66 KiB
Zig
//! `zfin snapshot` — write a daily portfolio snapshot to `history/`.
|
||
//!
|
||
//! Flow:
|
||
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
||
//! The first resolved file's directory determines the
|
||
//! `history/` location (sibling-of-first convention).
|
||
//! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`.
|
||
//! 3. Load portfolio composition as the **union of all matched
|
||
//! portfolio files** (multi-file glob support). Normally from
|
||
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
|
||
//! from git history at the repo-wide latest sha ≤ the
|
||
//! requested date via `loadPortfolioFromPathsAtRev`. Files
|
||
//! that didn't exist at that sha are silently skipped — the
|
||
//! union just doesn't include those lots. Falls back to
|
||
//! working copy if git is unavailable.
|
||
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
|
||
//! (skipped under `--as-of` — past candles don't change).
|
||
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
|
||
//! of cached candle dates of held non-MM stock symbols.
|
||
//! 6. For each symbol, look up the close price ≤ `as_of_date` from
|
||
//! its candle cache. Carry-forward gets flagged as
|
||
//! `quote_stale = true`.
|
||
//! 7. If `history/<as_of_date>-portfolio.srf` already exists and
|
||
//! `--force` wasn't passed, skip (exit 0, stderr message).
|
||
//! 8. Build the snapshot records and write them atomically.
|
||
//!
|
||
//! The snapshot file itself is a SINGLE union'd record set —
|
||
//! consumers (compare, projections --vs, TUI history tab) read one
|
||
//! `<date>-portfolio.srf` per date and don't need to know how many
|
||
//! source files contributed to it.
|
||
//!
|
||
//! Flags:
|
||
//! --force overwrite existing snapshot for as_of_date
|
||
//! --dry-run compute + print to stdout, do not write
|
||
//! --out <path> override output path
|
||
//! --as-of <YYYY-MM-DD> write a snapshot for a historical date
|
||
//! (uses git to recover portfolio state and
|
||
//! candle cache for pricing)
|
||
//!
|
||
//! 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 framework = @import("framework.zig");
|
||
const atomic = @import("../atomic.zig");
|
||
const version = @import("../version.zig");
|
||
const portfolio_mod = @import("../models/portfolio.zig");
|
||
const Date = @import("../Date.zig");
|
||
const model = @import("../models/snapshot.zig");
|
||
const git = @import("../git.zig");
|
||
const portfolio_loader = @import("../portfolio_loader.zig");
|
||
|
||
// Re-export record types so callers that reach `commands/snapshot.zig`
|
||
// (tests, mostly) still see the familiar names. New code should prefer
|
||
// `@import("models/snapshot.zig")` directly.
|
||
pub const MetaRow = model.MetaRow;
|
||
pub const TotalRow = model.TotalRow;
|
||
pub const TaxTypeRow = model.TaxTypeRow;
|
||
pub const AccountRow = model.AccountRow;
|
||
pub const LotRow = model.LotRow;
|
||
pub const Snapshot = model.Snapshot;
|
||
|
||
pub const SnapshotError = error{
|
||
PortfolioEmpty,
|
||
WriteFailed,
|
||
};
|
||
|
||
pub const ParsedArgs = struct {
|
||
force: bool = false,
|
||
dry_run: bool = false,
|
||
out_override: ?[]const u8 = null,
|
||
as_of_override: ?Date = null,
|
||
};
|
||
|
||
pub const meta: framework.Meta = .{
|
||
.name = "snapshot",
|
||
.group = .timeseries,
|
||
.synopsis = "Write a daily portfolio snapshot to history/",
|
||
.help =
|
||
\\Usage: zfin snapshot [opts]
|
||
\\
|
||
\\Compute a portfolio snapshot for today (or a historical date with
|
||
\\`--as-of`) and write it as a discriminated SRF file under
|
||
\\`history/<as_of_date>-portfolio.srf`. The output records start with
|
||
\\`kind::<meta|total|tax_type|account|lot>` so readers can demux on
|
||
\\that field.
|
||
\\
|
||
\\Default mode: refresh the candle cache for held symbols, derive
|
||
\\`as_of_date` from the mode of cached candle dates, look up
|
||
\\close-on-or-before for each lot, and write atomically.
|
||
\\
|
||
\\Options:
|
||
\\ --force overwrite existing snapshot for as_of_date
|
||
\\ --dry-run compute + print to stdout, do not write
|
||
\\ --out <path> override output path
|
||
\\ --as-of <DATE> write a snapshot for a historical date
|
||
\\ (uses git to recover portfolio state and
|
||
\\ candle cache for pricing). DATE accepts
|
||
\\ YYYY-MM-DD or relative shortcuts
|
||
\\ (1W/1M/1Q/1Y).
|
||
\\
|
||
\\If `history/<as_of_date>-portfolio.srf` already exists and `--force`
|
||
\\wasn't passed, the run skips with a stderr message.
|
||
\\
|
||
,
|
||
.uppercase_first_arg = false,
|
||
.user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed },
|
||
};
|
||
|
||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||
var parsed: ParsedArgs = .{};
|
||
var i: usize = 0;
|
||
while (i < cmd_args.len) : (i += 1) {
|
||
const a = cmd_args[i];
|
||
if (std.mem.eql(u8, a, "--force")) {
|
||
parsed.force = true;
|
||
} else if (std.mem.eql(u8, a, "--dry-run")) {
|
||
parsed.dry_run = true;
|
||
} else if (std.mem.eql(u8, a, "--out")) {
|
||
i += 1;
|
||
if (i >= cmd_args.len) {
|
||
cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
|
||
return error.UnexpectedArg;
|
||
}
|
||
parsed.out_override = cmd_args[i];
|
||
} else if (std.mem.eql(u8, a, "--as-of")) {
|
||
i += 1;
|
||
if (i >= cmd_args.len) {
|
||
cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
|
||
return error.UnexpectedArg;
|
||
}
|
||
// Reference date for resolving relative forms in `--as-of`
|
||
// (e.g. "1W" → 7 days before this anchor).
|
||
const flag_anchor = Date.fromEpoch(ctx.now_s);
|
||
parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
|
||
error.InvalidDate => return error.UnexpectedArg,
|
||
};
|
||
} else {
|
||
cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
|
||
cli.stderrPrint(ctx.io, a);
|
||
cli.stderrPrint(ctx.io, "\n");
|
||
return error.UnexpectedArg;
|
||
}
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
// ── Entry point ──────────────────────────────────────────────
|
||
|
||
/// Run the snapshot command.
|
||
///
|
||
/// Exit semantics:
|
||
/// 0 on success (including duplicate-skip)
|
||
/// non-zero on any error
|
||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||
const svc = ctx.svc orelse return error.MissingDataService;
|
||
const io = ctx.io;
|
||
const allocator = ctx.allocator;
|
||
const out = ctx.out;
|
||
const color = ctx.color;
|
||
const now_s = ctx.now_s;
|
||
const today = ctx.today;
|
||
|
||
const force = parsed.force;
|
||
const dry_run = parsed.dry_run;
|
||
const out_override = parsed.out_override;
|
||
const as_of_override = parsed.as_of_override;
|
||
|
||
const pf = ctx.resolvePortfolioPath();
|
||
defer pf.deinit(allocator);
|
||
const portfolio_path = pf.path;
|
||
|
||
// Load portfolio. In normal (no --as-of) mode this is the
|
||
// current working-copy union of all matched portfolio files.
|
||
// With --as-of, we first try to retrieve the portfolio state
|
||
// from git history at or before the target date — that gives
|
||
// accurate composition for past snapshots. If git lookup fails
|
||
// (portfolio not tracked, no commits before the date, git
|
||
// unavailable), we warn and fall back to the working-copy
|
||
// union, which at least approximates "positions the user
|
||
// currently holds" and is better than erroring out.
|
||
//
|
||
// `portfolio_path` (singular, first-resolved) is used only for
|
||
// sibling-derivation (history dir, snapshot file path); the
|
||
// portfolio CONTENT comes from the multi-file glob.
|
||
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
|
||
defer loaded.deinit(allocator);
|
||
var portfolio = loaded.portfolio;
|
||
// We don't deinit `portfolio` separately — `loaded.deinit`
|
||
// handles it.
|
||
|
||
if (portfolio.lots.len == 0) {
|
||
cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
|
||
return SnapshotError.PortfolioEmpty;
|
||
}
|
||
|
||
const syms = try portfolio.stockSymbols(allocator);
|
||
defer allocator.free(syms);
|
||
|
||
// Early duplicate-skip: if the cache is fully fresh, we can compute
|
||
// as_of_date without touching the network or doing a full price load,
|
||
// then short-circuit when today's snapshot already exists. Critically,
|
||
// this only applies when ALL non-MM symbols have fresh metadata — a
|
||
// single stale symbol means a refresh might bring forward a newer
|
||
// `last_date`, which would change as_of_date and make the existing
|
||
// snapshot file no longer a duplicate.
|
||
//
|
||
// Only runs when as_of is inferred from the cache (the common path).
|
||
// With --as-of, the target date is explicit and might not even
|
||
// correspond to "latest cache state," so we skip the fast path and
|
||
// let the normal flow handle the duplicate-skip check against the
|
||
// explicit date below.
|
||
if (!force and out_override == null and as_of_override == null) {
|
||
if (try probeFreshAsOfDate(allocator, svc, syms)) |candidate| {
|
||
var cand_buf: [10]u8 = undefined;
|
||
const cand_str = std.fmt.bufPrint(&cand_buf, "{f}", .{candidate}) catch "????-??-??";
|
||
const candidate_path = try deriveSnapshotPath(allocator, portfolio_path, cand_str);
|
||
defer allocator.free(candidate_path);
|
||
if (std.Io.Dir.cwd().access(io, candidate_path, .{})) |_| {
|
||
var msg_buf: [256]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(
|
||
&msg_buf,
|
||
"snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n",
|
||
.{ cand_str, candidate_path },
|
||
) catch "snapshot already exists\n";
|
||
cli.stderrPrint(io, msg);
|
||
if (!dry_run) return;
|
||
// --dry-run falls through: the user probably wants to see
|
||
// what would be written.
|
||
} else |_| {}
|
||
}
|
||
}
|
||
|
||
// Candle-native pricing:
|
||
//
|
||
// 1. Run the shared price loader to refresh the candle cache. We
|
||
// ignore its `prices` output (which is "last candle close per
|
||
// symbol") because we want a specific-date lookup instead.
|
||
// Skipped under --as-of: we're backfilling a historical date,
|
||
// so refreshing won't add data for the past.
|
||
// 2. Compute `as_of_date` from the refreshed cache (or the
|
||
// user-provided --as-of).
|
||
// 3. For every stock symbol, look up the close-on-or-before
|
||
// `as_of_date` in its candles. This is semantically stronger
|
||
// than "latest close": it means every lot's price reflects
|
||
// market state at the same instant, and backfill to a past
|
||
// date is just a matter of passing a different `as_of`.
|
||
//
|
||
// The duplicate-skip fast path above already handled the common
|
||
// "cache is fresh, snapshot exists" case without any of this work.
|
||
if (syms.len > 0 and as_of_override == null) {
|
||
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
|
||
load_result.deinit();
|
||
}
|
||
|
||
// Compute as_of_date. Explicit --as-of wins; otherwise derive 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 = as_of_override orelse (computeAsOfDate(qdates.dates) orelse Date.fromEpoch(now_s));
|
||
|
||
// Under --as-of, skip days with no market activity (weekends, US
|
||
// market holidays). Detection is cache-based: if NO non-MM symbol
|
||
// has a candle dated exactly `as_of`, no market data was published
|
||
// for that date. Emitting a snapshot would just carry Friday's
|
||
// close forward with every row flagged stale — useless and
|
||
// polluting to the timeline.
|
||
//
|
||
// Not applied in auto mode: auto mode's as_of already comes from
|
||
// cache mode and is guaranteed to be a trading day.
|
||
if (as_of_override != null and !hasAnyTradingDayCandle(svc, syms, as_of)) {
|
||
var msg_buf: [256]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(
|
||
&msg_buf,
|
||
"skipping {f}: no market data (weekend or holiday)\n",
|
||
.{as_of},
|
||
) catch "skipping non-trading day\n";
|
||
cli.stderrPrint(io, msg);
|
||
return;
|
||
}
|
||
|
||
// Per-symbol candle close lookup keyed on `as_of`. Owns no string
|
||
// memory (keys borrow from the caller's `syms`).
|
||
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
||
defer symbol_prices.deinit();
|
||
|
||
for (syms) |sym| {
|
||
if (svc.getCachedCandles(sym)) |cs| {
|
||
defer cs.deinit();
|
||
if (zfin.valuation.candleCloseOnOrBefore(cs.data, as_of)) |cad| {
|
||
try symbol_prices.put(sym, cad);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the flat-price map that `portfolioSummary` expects, plus
|
||
// apply manual `price::` overrides from portfolio.srf (which win
|
||
// over the candle lookup).
|
||
var prices = std.StringHashMap(f64).init(allocator);
|
||
defer prices.deinit();
|
||
var sp_it = symbol_prices.iterator();
|
||
while (sp_it.next()) |entry| {
|
||
try prices.put(entry.key_ptr.*, entry.value_ptr.close);
|
||
}
|
||
for (portfolio.lots) |lot| {
|
||
if (lot.price) |p| {
|
||
if (!prices.contains(lot.priceSymbol())) {
|
||
// Pre-multiply manual overrides so the shared `prices`
|
||
// map holds share-class-correct values — see the
|
||
// "Pricing model / caching pre-multiply pattern" note
|
||
// in models/portfolio.zig.
|
||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Derive output path.
|
||
var as_of_buf: [10]u8 = undefined;
|
||
const as_of_str = std.fmt.bufPrint(&as_of_buf, "{f}", .{as_of}) catch "????-??-??";
|
||
|
||
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.Io.Dir.cwd().access(io, 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";
|
||
cli.stderrPrint(io, msg);
|
||
return;
|
||
} else |_| {}
|
||
}
|
||
|
||
// Build and render the snapshot.
|
||
var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s);
|
||
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.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) {
|
||
error.PathAlreadyExists => {},
|
||
else => {
|
||
cli.stderrPrint(io, "Error creating history directory: ");
|
||
cli.stderrPrint(io, @errorName(err));
|
||
cli.stderrPrint(io, "\n");
|
||
return err;
|
||
},
|
||
};
|
||
}
|
||
|
||
atomic.writeFileAtomic(io, allocator, derived_path, rendered) catch |err| {
|
||
cli.stderrPrint(io, "Error writing snapshot: ");
|
||
cli.stderrPrint(io, @errorName(err));
|
||
cli.stderrPrint(io, "\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 });
|
||
}
|
||
|
||
// ── Portfolio-at-date loader ─────────────────────────────────
|
||
|
||
/// Load portfolio bytes for the given `as_of` date. When `as_of` is
|
||
/// null, simply reads the current working-copy of `portfolio_path`.
|
||
///
|
||
/// When `as_of` is provided, attempts to find the latest git commit
|
||
/// touching the portfolio file at or before the end of `as_of`, then
|
||
/// `git show`s that revision's content. Falls back to the working copy
|
||
/// (with a stderr warning) on:
|
||
/// - git not available
|
||
/// - portfolio.srf not tracked in git
|
||
/// - no commit exists at or before as_of (pre-git dates)
|
||
///
|
||
/// The working-copy fallback is intentional: the user's note is that
|
||
/// pre-git dates "need either mtime fallback or get skipped (not
|
||
/// errored)," and mtime-fallback is equivalent to "use the current
|
||
/// file" — the file's current state IS its mtime state. A clean exit
|
||
/// lets bulk-backfill loops keep moving.
|
||
///
|
||
/// Caller owns the returned `LoadedPortfolio`.
|
||
///
|
||
/// In normal (no `--as-of`) mode this loads the working-copy union
|
||
/// of all matched portfolio files via `cli.loadPortfolio`. With
|
||
/// `--as-of`, it discovers the repo-wide latest sha at or before
|
||
/// the target date (`git.shaAtOrBefore`) and reads each file in
|
||
/// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`).
|
||
/// Files that didn't exist at that sha are silently skipped — the
|
||
/// union just doesn't include those lots, which is correct for
|
||
/// "snapshot of state on this date."
|
||
///
|
||
/// On git lookup failure (not in a repo, no commit before the
|
||
/// target, etc.), warns and falls back to the working-copy union
|
||
/// — better to capture today's state than fail entirely.
|
||
fn loadPortfolioForSnapshot(
|
||
ctx: *framework.RunCtx,
|
||
today: Date,
|
||
as_of: ?Date,
|
||
) !portfolio_loader.LoadedPortfolio {
|
||
const io = ctx.io;
|
||
const allocator = ctx.allocator;
|
||
|
||
const target = as_of orelse {
|
||
// Normal mode — load working-copy union via the shared
|
||
// multi-file loader. `today` is used as the as_of for
|
||
// position computation.
|
||
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
|
||
};
|
||
|
||
// --as-of mode: find the repo-wide sha at or before target.
|
||
// Need a single concrete path to discover the repo from; use
|
||
// the first resolved portfolio path.
|
||
const pf = ctx.resolvePortfolioPath();
|
||
defer pf.deinit(allocator);
|
||
const portfolio_path = pf.path;
|
||
|
||
const info = git.findRepo(io, allocator, portfolio_path) catch |err| switch (err) {
|
||
error.NotInGitRepo, error.GitUnavailable => {
|
||
warnGitFallback(io, target, "no git repo");
|
||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||
},
|
||
else => return err,
|
||
};
|
||
defer allocator.free(info.root);
|
||
defer allocator.free(info.rel_path);
|
||
|
||
var date_buf: [10]u8 = undefined;
|
||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch "????-??-??";
|
||
const sha_opt = git.shaAtOrBefore(io, allocator, info.root, date_str) catch |err| switch (err) {
|
||
error.GitUnavailable, error.GitLogFailed => {
|
||
warnGitFallback(io, target, "git lookup failed");
|
||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||
},
|
||
else => return err,
|
||
};
|
||
const sha = sha_opt orelse {
|
||
warnGitFallback(io, target, "no commit at or before target date");
|
||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||
};
|
||
defer allocator.free(sha);
|
||
|
||
// Resolve the full glob and load each file at `sha`.
|
||
var resolved = ctx.resolvePortfolioPaths() catch {
|
||
warnGitFallback(io, target, "could not resolve portfolio glob");
|
||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||
};
|
||
defer resolved.deinit();
|
||
|
||
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, resolved.paths, sha, target) orelse {
|
||
warnGitFallback(io, target, "could not load portfolio at rev");
|
||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||
};
|
||
}
|
||
|
||
fn warnGitFallback(io: std.Io, target: Date, reason: []const u8) void {
|
||
var buf: [256]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(
|
||
&buf,
|
||
"warning: {s} for portfolio at {f}; using working copy as approximation\n",
|
||
.{ reason, target },
|
||
) catch "warning: git history unavailable; using working copy as approximation\n";
|
||
cli.stderrPrint(io, msg);
|
||
}
|
||
|
||
// ── 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,
|
||
};
|
||
|
||
/// Probe the cache to see if we can safely compute `as_of_date` without
|
||
/// doing a full price load. Returns the candidate date only if EVERY
|
||
/// non-MM held symbol has fresh cache metadata — a single stale symbol
|
||
/// means a refresh could bring forward a newer `last_date` and change
|
||
/// the answer, so we must do the full load in that case.
|
||
///
|
||
/// This exists purely as a fast path for the duplicate-skip check:
|
||
/// callers that get a non-null result may safely consult
|
||
/// `history/<date>-portfolio.srf` for an existing file without spending
|
||
/// the ~15s network round-trip of `loadPortfolioPrices`.
|
||
///
|
||
/// MM symbols are allowed to be stale — their `last_date` is excluded
|
||
/// from the mode calculation anyway.
|
||
pub fn probeFreshAsOfDate(
|
||
allocator: std.mem.Allocator,
|
||
svc: *zfin.DataService,
|
||
symbols: []const []const u8,
|
||
) !?Date {
|
||
if (symbols.len == 0) return null;
|
||
|
||
var infos = try allocator.alloc(QuoteInfo, symbols.len);
|
||
defer allocator.free(infos);
|
||
|
||
for (symbols, 0..) |sym, idx| {
|
||
const is_mm = portfolio_mod.isMoneyMarketSymbol(sym);
|
||
// MM symbols are excluded from as_of_date computation regardless
|
||
// of freshness, so their TTL state doesn't matter here.
|
||
if (!is_mm and !svc.isCandleCacheFresh(sym)) return null;
|
||
infos[idx] = .{
|
||
.symbol = sym,
|
||
.last_date = svc.getCachedLastDate(sym),
|
||
.is_money_market = is_mm,
|
||
};
|
||
}
|
||
|
||
return computeAsOfDate(infos);
|
||
}
|
||
|
||
/// Check whether any non-MM held symbol has a candle dated exactly
|
||
/// `date` in the cache. Used to detect non-trading days (weekends,
|
||
/// holidays) so `--as-of` backfill can skip them.
|
||
///
|
||
/// "Any" rather than "all" because US market holidays may still have
|
||
/// international or money-market trading that'd show up on a few
|
||
/// symbols. The absence of US equity candles across the board is what
|
||
/// signals a non-trading day for our purposes.
|
||
pub fn hasAnyTradingDayCandle(
|
||
svc: *zfin.DataService,
|
||
symbols: []const []const u8,
|
||
date: Date,
|
||
) bool {
|
||
for (symbols) |sym| {
|
||
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
|
||
const cs = svc.getCachedCandles(sym) orelse continue;
|
||
defer cs.deinit();
|
||
// Linear scan from the end — recent dates are where `date` is
|
||
// most likely to land for a backfill.
|
||
var i: usize = cs.data.len;
|
||
while (i > 0) {
|
||
i -= 1;
|
||
if (cs.data[i].date.eql(date)) return true;
|
||
// Candles are sorted ascending; bail early once we're past.
|
||
if (cs.data[i].date.lessThan(date)) break;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// 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 cs.deinit();
|
||
if (cs.data.len > 0) last_date = cs.data[cs.data.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 ─────────────────────────────────────────
|
||
//
|
||
// Record structs live in `src/models/snapshot.zig` — see the re-exports
|
||
// near the top of this file. The types are separated from this command
|
||
// module so analytics code (`src/analytics/timeline.zig`) can reference
|
||
// them without depending on a `commands/` module.
|
||
|
||
/// I/O-edged orchestration wrapper around `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(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
portfolio: *zfin.Portfolio,
|
||
portfolio_path: []const u8,
|
||
svc: *zfin.DataService,
|
||
prices: std.StringHashMap(f64),
|
||
symbol_prices: std.StringHashMap(zfin.valuation.CandleAtDate),
|
||
syms: []const []const u8,
|
||
as_of: Date,
|
||
qdates: QuoteDates,
|
||
now_s: i64,
|
||
) !Snapshot {
|
||
// 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);
|
||
|
||
var manual_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, @constCast(&prices));
|
||
defer manual_set.deinit();
|
||
|
||
var summary = try zfin.valuation.portfolioSummary(as_of, 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(
|
||
io,
|
||
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,
|
||
now_s,
|
||
);
|
||
}
|
||
|
||
/// 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,
|
||
now_s: i64,
|
||
) !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.totalIlliquidAsOf(as_of);
|
||
const net_worth = zfin.valuation.netWorthAsOf(portfolio.*, summary, as_of);
|
||
|
||
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 };
|
||
|
||
// 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` 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 (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 };
|
||
}
|
||
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 };
|
||
}
|
||
}
|
||
|
||
// 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| {
|
||
// Use as_of (not wall-clock today) so backfill snapshots
|
||
// correctly include lots that had opened by then and exclude
|
||
// ones that had already closed or matured. See
|
||
// `Lot.lotIsOpenAsOf` for semantics.
|
||
if (!lot.lotIsOpenAsOf(as_of)) 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 = lot.effectivePrice(raw_price, is_manual);
|
||
const value = lot.marketValue(raw_price, is_manual);
|
||
|
||
// Candle-native quote_date/quote_stale: `symbol_prices`
|
||
// already ran `candleCloseOnOrBefore(as_of)` per symbol,
|
||
// which records both the matched candle's actual date
|
||
// and whether it was a carry-forward. Manual price
|
||
// overrides don't appear in symbol_prices and have no
|
||
// quote_date (explicitly nil, not stale).
|
||
var quote_date: ?Date = null;
|
||
var stale = false;
|
||
if (!is_manual) {
|
||
if (symbol_prices.get(price_sym)) |cad| {
|
||
quote_date = cad.date;
|
||
stale = cad.stale;
|
||
}
|
||
}
|
||
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 = now_s,
|
||
.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(
|
||
io: std.Io,
|
||
allocator: std.mem.Allocator,
|
||
portfolio: *zfin.Portfolio,
|
||
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]});
|
||
defer allocator.free(meta_path);
|
||
|
||
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(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,
|
||
as_of,
|
||
);
|
||
}
|
||
|
||
// ── 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 "parseArgs: defaults" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{};
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(!parsed.force);
|
||
try std.testing.expect(!parsed.dry_run);
|
||
try std.testing.expect(parsed.out_override == null);
|
||
try std.testing.expect(parsed.as_of_override == null);
|
||
}
|
||
|
||
test "parseArgs: --force --dry-run" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{ "--force", "--dry-run" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(parsed.force);
|
||
try std.testing.expect(parsed.dry_run);
|
||
}
|
||
|
||
test "parseArgs: --out captures path" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{ "--out", "/tmp/snap.srf" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expectEqualStrings("/tmp/snap.srf", parsed.out_override.?);
|
||
}
|
||
|
||
test "parseArgs: --out without value errors" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{"--out"};
|
||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||
}
|
||
|
||
test "parseArgs: --as-of with explicit date" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{ "--as-of", "2026-01-15" };
|
||
const parsed = try parseArgs(&ctx, &args);
|
||
try std.testing.expect(parsed.as_of_override.?.eql(Date.fromYmd(2026, 1, 15)));
|
||
}
|
||
|
||
test "parseArgs: --as-of without value errors" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{"--as-of"};
|
||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||
}
|
||
|
||
test "parseArgs: unknown arg errors" {
|
||
var ctx: framework.RunCtx = undefined;
|
||
ctx.io = std.testing.io;
|
||
ctx.now_s = 0;
|
||
const args = [_][]const u8{"--bogus"};
|
||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||
}
|
||
|
||
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 "probeFreshAsOfDate: empty symbol list returns null without touching the service" {
|
||
// No symbols means no refresh is needed and no date to compute. A
|
||
// null service pointer here would be dereferenced if the function
|
||
// touched it, so this also proves the early-return path.
|
||
var svc: zfin.DataService = undefined;
|
||
// Pointer not dereferenced because the function returns before the
|
||
// loop. Using @constCast to produce a pointer of the right type
|
||
// without zero-initializing DataService internals.
|
||
try testing.expect((try probeFreshAsOfDate(testing.allocator, &svc, &.{})) == 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 = "Sample 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 = "Sample 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 = "Sample 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::Sample 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="));
|
||
}
|
||
|
||
// ── 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
|
||
1_745_222_400,
|
||
);
|
||
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,
|
||
1_745_222_400,
|
||
);
|
||
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);
|
||
}
|