add snapshot command
This commit is contained in:
parent
8af5c5f696
commit
6ed2ff1f20
3 changed files with 1045 additions and 0 deletions
136
src/atomic.zig
Normal file
136
src/atomic.zig
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Atomic filesystem writes.
|
||||
//!
|
||||
//! `writeFileAtomic` writes to `<path>.tmp`, fsyncs, and renames to
|
||||
//! `<path>`. Crash-safe replacement for `createFile + writeAll + close`:
|
||||
//! if the process dies mid-write, the destination file is left at its
|
||||
//! prior contents (or absent) rather than truncated or half-written.
|
||||
//!
|
||||
//! Used by the snapshot writer so a ctrl-C or kernel panic mid-run
|
||||
//! can't produce a corrupt `history/<date>-portfolio.srf`.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Suffix appended to the temp file during atomic writes. Exposed so
|
||||
/// callers that want to sweep orphan temp files (e.g. from a previous
|
||||
/// crash) know what to look for.
|
||||
pub const tmp_suffix = ".tmp";
|
||||
|
||||
/// Write `bytes` to `path` atomically.
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1. Write to `<path>.tmp` (truncating any previous tmp file).
|
||||
/// 2. `fsync` the tmp file so the data is durable before we rename.
|
||||
/// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same
|
||||
/// filesystem, which is guaranteed here because both are the literal
|
||||
/// path plus `.tmp`).
|
||||
///
|
||||
/// On any error the tmp file is best-effort removed so we don't leave
|
||||
/// clutter behind. The caller's `path` is unchanged unless the final
|
||||
/// rename succeeds.
|
||||
///
|
||||
/// The allocator is used for a short-lived temp-path buffer
|
||||
/// (`path.len + tmp_suffix.len` bytes) and freed before return.
|
||||
pub fn writeFileAtomic(
|
||||
allocator: std.mem.Allocator,
|
||||
path: []const u8,
|
||||
bytes: []const u8,
|
||||
) !void {
|
||||
const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix });
|
||||
defer allocator.free(tmp_path);
|
||||
|
||||
{
|
||||
var tmp_file = try std.fs.cwd().createFile(tmp_path, .{
|
||||
.truncate = true,
|
||||
.exclusive = false,
|
||||
});
|
||||
errdefer {
|
||||
tmp_file.close();
|
||||
std.fs.cwd().deleteFile(tmp_path) catch {};
|
||||
}
|
||||
|
||||
try tmp_file.writeAll(bytes);
|
||||
// fsync so the kernel flushes data to disk before the rename
|
||||
// appears. Without this, a crash between rename() and the data
|
||||
// hitting disk could leave an empty-but-present file at `path`.
|
||||
try tmp_file.sync();
|
||||
tmp_file.close();
|
||||
}
|
||||
|
||||
std.fs.cwd().rename(tmp_path, path) catch |err| {
|
||||
std.fs.cwd().deleteFile(tmp_path) catch {};
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "writeFileAtomic creates new file" {
|
||||
var tmp_dir = std.testing.tmpDir(.{});
|
||||
defer tmp_dir.cleanup();
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
|
||||
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" });
|
||||
defer std.testing.allocator.free(file_path);
|
||||
|
||||
try writeFileAtomic(std.testing.allocator, file_path, "hello world\n");
|
||||
|
||||
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
|
||||
defer std.testing.allocator.free(contents);
|
||||
try std.testing.expectEqualStrings("hello world\n", contents);
|
||||
|
||||
// Tmp file should have been consumed by rename.
|
||||
const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix });
|
||||
defer std.testing.allocator.free(tmp_path);
|
||||
try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(tmp_path, .{}));
|
||||
|
||||
// Clean up for the next test run.
|
||||
std.fs.cwd().deleteFile(file_path) catch {};
|
||||
}
|
||||
|
||||
test "writeFileAtomic overwrites existing file" {
|
||||
var tmp_dir = std.testing.tmpDir(.{});
|
||||
defer tmp_dir.cleanup();
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
|
||||
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" });
|
||||
defer std.testing.allocator.free(file_path);
|
||||
|
||||
// Seed with old content.
|
||||
{
|
||||
var f = try std.fs.cwd().createFile(file_path, .{});
|
||||
try f.writeAll("old contents");
|
||||
f.close();
|
||||
}
|
||||
|
||||
try writeFileAtomic(std.testing.allocator, file_path, "new contents");
|
||||
|
||||
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
|
||||
defer std.testing.allocator.free(contents);
|
||||
try std.testing.expectEqualStrings("new contents", contents);
|
||||
|
||||
std.fs.cwd().deleteFile(file_path) catch {};
|
||||
}
|
||||
|
||||
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
|
||||
// Point at a path whose parent directory doesn't exist. The tmp dir
|
||||
// itself exists (so the filesystem is fine), but the "missing"
|
||||
// subdirectory does not — createFile on the .tmp file must fail
|
||||
// with FileNotFound regardless of platform.
|
||||
var tmp_dir = std.testing.tmpDir(.{});
|
||||
defer tmp_dir.cleanup();
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
|
||||
const bad_path = try std.fs.path.join(
|
||||
std.testing.allocator,
|
||||
&.{ dir_path, "missing", "file.txt" },
|
||||
);
|
||||
defer std.testing.allocator.free(bad_path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.FileNotFound,
|
||||
writeFileAtomic(std.testing.allocator, bad_path, "x"),
|
||||
);
|
||||
}
|
||||
899
src/commands/snapshot.zig
Normal file
899
src/commands/snapshot.zig
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
//! `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="));
|
||||
}
|
||||
10
src/main.zig
10
src/main.zig
|
|
@ -19,6 +19,7 @@ const usage =
|
|||
\\ portfolio Load and analyze the portfolio
|
||||
\\ analysis Show portfolio analysis
|
||||
\\ contributions Show money added since last commit (git-based diff)
|
||||
\\ snapshot [opts] Write a daily portfolio snapshot to history/
|
||||
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
||||
|
|
@ -229,6 +230,7 @@ pub fn main() !u8 {
|
|||
!std.mem.eql(u8, command, "analysis") and
|
||||
!std.mem.eql(u8, command, "contributions") and
|
||||
!std.mem.eql(u8, command, "portfolio") and
|
||||
!std.mem.eql(u8, command, "snapshot") and
|
||||
!std.mem.eql(u8, command, "version");
|
||||
if (symbol_cmd and cmd_args.len >= 1) {
|
||||
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
|
||||
|
|
@ -347,6 +349,13 @@ pub fn main() !u8 {
|
|||
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||
try commands.contributions.run(allocator, &svc, pf.path, color, out);
|
||||
} else if (std.mem.eql(u8, command, "snapshot")) {
|
||||
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||
commands.snapshot.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) {
|
||||
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
|
||||
else => return err,
|
||||
};
|
||||
} else {
|
||||
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
|
||||
return 1;
|
||||
|
|
@ -418,6 +427,7 @@ const commands = struct {
|
|||
const audit = @import("commands/audit.zig");
|
||||
const enrich = @import("commands/enrich.zig");
|
||||
const contributions = @import("commands/contributions.zig");
|
||||
const snapshot = @import("commands/snapshot.zig");
|
||||
const version = @import("commands/version.zig");
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue