825 lines
34 KiB
Zig
825 lines
34 KiB
Zig
//! Portfolio file loading + per-portfolio data pipeline.
|
|
//!
|
|
//! Single home for "read N portfolio_*.srf files and merge them into
|
|
//! one Portfolio for both surfaces (CLI commands, TUI App)." Both
|
|
//! surfaces import this module directly; neither depends on the
|
|
//! other for portfolio loading. Pre-extraction, the same logic
|
|
//! lived in `commands/common.zig` and the TUI either called into
|
|
//! that file (which had a "TUI calls into commands/" code smell)
|
|
//! or — worse — rolled its own parallel single-file path that
|
|
//! drifted from the CLI's multi-file logic.
|
|
//!
|
|
//! The split is meaningful in only one direction: this module knows
|
|
//! about pattern resolution (via `commands/framework.resolvePatterns`)
|
|
//! and the `cache` deserializer. It does NOT know about RunCtx,
|
|
//! Globals, or any CLI-shape concerns. The CLI-specific
|
|
//! `loadPortfolio(ctx, as_of)` convenience wrapper that bridges
|
|
//! a `RunCtx` to `loadPortfolioFromConfig` lives in
|
|
//! `commands/common.zig` where it belongs.
|
|
//!
|
|
//! ## Surface
|
|
//!
|
|
//! - `LoadedPortfolio` — merged Portfolio + computed positions/syms
|
|
//! + the resolved path slice the lots came from. Carries an
|
|
//! `anchor()` accessor for sibling-file derivation
|
|
//! (`accounts.srf`, `metadata.srf`, history dir).
|
|
//!
|
|
//! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)`
|
|
//! — the workhorse. Resolves `-p` patterns through
|
|
//! `framework.resolvePatterns`, reads + deserializes + merges,
|
|
//! returns a fully-populated `LoadedPortfolio`. Used by the
|
|
//! CLI (via `commands.common.loadPortfolio` wrapping it with a
|
|
//! `RunCtx`) and directly by the TUI.
|
|
//!
|
|
//! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` — caller
|
|
//! has already resolved patterns; load the given files. Used by
|
|
//! the TUI's reload-button path (re-uses the original resolved
|
|
//! path slice without re-globbing).
|
|
//!
|
|
//! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` —
|
|
//! git-historical variant: read each file at `rev` via
|
|
//! `git.show` instead of from the working tree. Files that don't
|
|
//! exist at the rev are silently skipped (the union just doesn't
|
|
//! include those lots). Used by `zfin snapshot --as-of` to
|
|
//! capture point-in-time portfolio state across a multi-file
|
|
//! glob from git history.
|
|
//!
|
|
//! All three loaders share a single private `loadFromBytes` core
|
|
//! that does the deserialize + union-merge + position-compute +
|
|
//! symbol-extract pass. Tests target `loadFromBytes` directly with
|
|
//! synthetic byte literals to avoid filesystem I/O.
|
|
//!
|
|
//! - `PortfolioData` + `buildPortfolioData(...)` — second-stage
|
|
//! pipeline: turn a `LoadedPortfolio` (or its parts) plus a
|
|
//! `prices` map into a `PortfolioSummary` with allocations,
|
|
//! candle map, and historical snapshots.
|
|
|
|
const std = @import("std");
|
|
const zfin = @import("root.zig");
|
|
const framework = @import("commands/framework.zig");
|
|
const stderr = @import("stderr.zig");
|
|
const git = @import("git.zig");
|
|
|
|
// ── Portfolio loading ────────────────────────────────────────
|
|
|
|
/// Result of loading and parsing one or more portfolio files. The
|
|
/// returned `portfolio` holds the union of all lots across every
|
|
/// resolved file; `positions` and `syms` are computed against that
|
|
/// merged view. Caller must call deinit().
|
|
pub const LoadedPortfolio = struct {
|
|
/// Resolved paths the lots came from, sorted lexicographically
|
|
/// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor*
|
|
/// path used for sibling-file derivation (`accounts.srf`,
|
|
/// `metadata.srf`, `transaction_log.srf`, history dir).
|
|
/// Display labels typically render `paths[0]` plus
|
|
/// "(+N more)" when `paths.len > 1`. Owned.
|
|
paths: []const []const u8,
|
|
/// Optional `ResolvedPaths` handle for the same set of paths.
|
|
/// When the loader resolved patterns through `RunCtx`, the
|
|
/// `Config.ResolvedPaths` is captured here so `deinit()` can
|
|
/// release the owned path strings. When the loader was given
|
|
/// pre-resolved paths directly (test path, snapshot fallback),
|
|
/// this is null and the `paths` slice is shallow-copied bytes
|
|
/// the caller still owns.
|
|
resolved_paths: ?zfin.Config.ResolvedPaths,
|
|
/// Raw bytes of every file we read. One entry per portfolio
|
|
/// file. Owned.
|
|
file_datas: []const []const u8,
|
|
portfolio: zfin.Portfolio,
|
|
positions: []const zfin.Position,
|
|
syms: []const []const u8,
|
|
|
|
pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.syms);
|
|
allocator.free(self.positions);
|
|
self.portfolio.deinit();
|
|
for (self.file_datas) |d| allocator.free(d);
|
|
allocator.free(self.file_datas);
|
|
// Path-string ownership: `resolved_paths` (if present) owns
|
|
// the underlying path strings. The `paths` slice is the
|
|
// borrowed view; free only its outer storage.
|
|
allocator.free(self.paths);
|
|
if (self.resolved_paths) |rp| rp.deinit();
|
|
}
|
|
|
|
/// Convenience: returns `paths[0]`, the first / anchor path.
|
|
/// Sibling-file derivation (accounts.srf, metadata.srf, etc.)
|
|
/// hangs off this directory.
|
|
pub fn anchor(self: LoadedPortfolio) []const u8 {
|
|
return self.paths[0];
|
|
}
|
|
};
|
|
|
|
/// Resolve `patterns` against `config` (cwd → ZFIN_HOME), then load
|
|
/// the union of all matched portfolio files. The TUI uses this
|
|
/// directly (no `RunCtx`); CLI commands go through
|
|
/// `commands.common.loadPortfolio(ctx, ...)` which is a thin
|
|
/// wrapper.
|
|
///
|
|
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
|
|
/// (`&.{}`) for the default `portfolio*.srf` behavior.
|
|
///
|
|
/// Returns `null` on any error path (a stderr message has already
|
|
/// been printed). Caller must `deinit(allocator)` the returned
|
|
/// struct.
|
|
pub fn loadPortfolioFromConfig(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
config: zfin.Config,
|
|
patterns: []const []const u8,
|
|
as_of: zfin.Date,
|
|
) ?LoadedPortfolio {
|
|
var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
|
|
error.MixedPortfolioDirs => {
|
|
stderr.print(io, "Error: portfolio files resolved to multiple directories.\n");
|
|
stderr.print(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n");
|
|
stderr.print(io, " next to the portfolio, so all portfolio files must share a directory.\n");
|
|
return null;
|
|
},
|
|
else => {
|
|
stderr.print(io, "Error: failed to resolve portfolio path(s)\n");
|
|
return null;
|
|
},
|
|
};
|
|
if (resolved.paths.len == 0) {
|
|
resolved.deinit();
|
|
// The error message names the searched location explicitly
|
|
// so the user can verify it against their expectations.
|
|
// ZFIN_HOME is exclusive when set: we never look at cwd
|
|
// in that case, so the message would be misleading if it
|
|
// mentioned cwd as a possibility.
|
|
if (config.zfin_home) |home| {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: no portfolio file found in ZFIN_HOME ({s}). Looked for portfolio*.srf.\n", .{home}) catch "Error: no portfolio file found in ZFIN_HOME\n";
|
|
stderr.print(io, msg);
|
|
} else {
|
|
stderr.print(io, "Error: no portfolio file found in cwd. Looked for portfolio*.srf. (ZFIN_HOME is unset.)\n");
|
|
}
|
|
return null;
|
|
}
|
|
// Snapshot the path-string view as our own owned slice. Backing
|
|
// strings stay live as long as `resolved.inner` does — we
|
|
// hand `inner` off to LoadedPortfolio (it'll be freed by
|
|
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
|
|
// view slice is allocator-owned but redundant after the dupe;
|
|
// free it before discarding the wrapper.
|
|
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
|
|
resolved.deinit();
|
|
return null;
|
|
};
|
|
allocator.free(resolved.paths);
|
|
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
|
|
}
|
|
|
|
/// Lower-level loader: caller has already resolved the path list and
|
|
/// owns the path strings. Used by the TUI's manual reload (re-loads
|
|
/// the same files without re-globbing) and by tests.
|
|
///
|
|
/// Strings inside `paths` are NOT freed by `LoadedPortfolio.deinit`
|
|
/// — caller retains ownership of them. The slice `paths` itself IS
|
|
/// freed by deinit (the LoadedPortfolio takes ownership of just the
|
|
/// slice).
|
|
pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio {
|
|
if (paths.len == 0) {
|
|
stderr.print(io, "Error: No portfolio file found\n");
|
|
return null;
|
|
}
|
|
// Dupe the slice so deinit can free it without touching the
|
|
// caller's storage. Path strings remain caller-owned and are
|
|
// borrowed by the returned struct (resolved_paths = null
|
|
// signals "no Config.ResolvedPaths to deinit").
|
|
const paths_owned = allocator.dupe([]const u8, paths) catch return null;
|
|
return loadFromPaths(io, allocator, paths_owned, null, as_of);
|
|
}
|
|
|
|
/// Like `loadPortfolioFromPaths`, but reads each file from a git
|
|
/// revision instead of the working tree. The result is the union
|
|
/// of all files at that rev, deserialized + union-merged the same
|
|
/// way the live loader does.
|
|
///
|
|
/// Files that don't exist at the requested rev (e.g.
|
|
/// `portfolio_other.srf` was added later than `rev`) are silently
|
|
/// skipped — the union just doesn't include those lots. This is the
|
|
/// right behavior for `zfin snapshot --as-of <past-date>` against a
|
|
/// portfolio that's been split into multiple files over time.
|
|
///
|
|
/// All `paths` must live within the same git repository as the
|
|
/// first path. Repository discovery uses `git.findRepo` on the
|
|
/// first path; the rest derive their rel-paths by trimming the
|
|
/// repo-root prefix.
|
|
///
|
|
/// Returns null on:
|
|
/// - empty paths slice (no portfolio file to load);
|
|
/// - first path not in any git repo;
|
|
/// - any deserialize / merge / position-compute failure (same as
|
|
/// the working-tree loader).
|
|
///
|
|
/// `git.show` failures other than `PathMissingInRev` (e.g.
|
|
/// `UnknownRevision`) propagate as null; the function prints a
|
|
/// stderr message describing which file/rev failed.
|
|
pub fn loadPortfolioFromPathsAtRev(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
paths: []const []const u8,
|
|
rev: []const u8,
|
|
as_of: zfin.Date,
|
|
) ?LoadedPortfolio {
|
|
if (paths.len == 0) {
|
|
stderr.print(io, "Error: No portfolio file found\n");
|
|
return null;
|
|
}
|
|
|
|
// Discover the repo from the first path. All other paths are
|
|
// assumed to live in the same repo (typical for sibling
|
|
// portfolio files in the same directory); we derive their
|
|
// rel-paths below.
|
|
const info = git.findRepo(io, allocator, paths[0]) catch {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: portfolio path not in a git repo: {s}\n", .{paths[0]}) catch "Error: portfolio path not in a git repo\n";
|
|
stderr.print(io, msg);
|
|
return null;
|
|
};
|
|
defer allocator.free(info.root);
|
|
defer allocator.free(info.rel_path);
|
|
|
|
// Read each file at `rev`. On `PathMissingInRev` we substitute
|
|
// empty bytes; `loadFromBytes` knows to treat that as "file
|
|
// absent at this rev, skip without parsing." Any other git
|
|
// error is fatal.
|
|
var datas: std.ArrayList([]const u8) = .empty;
|
|
var datas_consumed = false;
|
|
defer if (!datas_consumed) {
|
|
for (datas.items) |d| allocator.free(d);
|
|
datas.deinit(allocator);
|
|
};
|
|
|
|
for (paths) |abs_path| {
|
|
// Derive rel_path from this file's absolute path. The first
|
|
// file's rel_path was computed by findRepo; for the rest we
|
|
// strip the repo root prefix manually. realpath ensures
|
|
// canonicalization (the user could pass a non-canonical
|
|
// path).
|
|
const real = std.Io.Dir.cwd().realPathFileAlloc(io, abs_path, allocator) catch {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: cannot resolve real path for: {s}\n", .{abs_path}) catch "Error: cannot resolve real path\n";
|
|
stderr.print(io, msg);
|
|
return null;
|
|
};
|
|
defer allocator.free(real);
|
|
|
|
const rel = if (std.mem.startsWith(u8, real, info.root) and real.len > info.root.len)
|
|
std.mem.trimStart(u8, real[info.root.len..], "/")
|
|
else
|
|
std.fs.path.basename(real);
|
|
|
|
const data = git.show(io, allocator, info.root, rev, rel) catch |err| switch (err) {
|
|
error.PathMissingInRev => empty_blk: {
|
|
// File didn't exist at this rev. Substitute empty
|
|
// bytes; loadFromBytes skips empty entries.
|
|
break :empty_blk allocator.dupe(u8, "") catch return null;
|
|
},
|
|
else => {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: git show {s}:{s}: {t}\n", .{ rev, rel, err }) catch "Error: git show failed\n";
|
|
stderr.print(io, msg);
|
|
return null;
|
|
},
|
|
};
|
|
datas.append(allocator, data) catch {
|
|
allocator.free(data);
|
|
return null;
|
|
};
|
|
}
|
|
|
|
const datas_owned = datas.toOwnedSlice(allocator) catch return null;
|
|
datas_consumed = true;
|
|
|
|
// Hand off ownership of the slice we just dupe'd from the
|
|
// caller. `loadFromBytes` frees both `paths_owned` and
|
|
// `datas_owned` on any failure.
|
|
const paths_owned = allocator.dupe([]const u8, paths) catch {
|
|
for (datas_owned) |d| allocator.free(d);
|
|
allocator.free(datas_owned);
|
|
return null;
|
|
};
|
|
return loadFromBytes(io, allocator, paths_owned, null, datas_owned, as_of);
|
|
}
|
|
|
|
/// Internal: load+merge given a pre-resolved paths slice. The slice
|
|
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
|
|
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
|
|
/// hand off ownership of the path strings to the returned struct;
|
|
/// when null, path strings are caller-owned.
|
|
fn loadFromPaths(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
paths_owned: []const []const u8,
|
|
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
|
as_of: zfin.Date,
|
|
) ?LoadedPortfolio {
|
|
// Read every file up front; bail on first error.
|
|
var file_datas: std.ArrayList([]const u8) = .empty;
|
|
for (paths_owned) |p| {
|
|
const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n";
|
|
stderr.print(io, msg);
|
|
for (file_datas.items) |d| allocator.free(d);
|
|
file_datas.deinit(allocator);
|
|
allocator.free(paths_owned);
|
|
if (resolved_paths_opt) |rp| rp.deinit();
|
|
return null;
|
|
};
|
|
file_datas.append(allocator, data) catch {
|
|
allocator.free(data);
|
|
for (file_datas.items) |d| allocator.free(d);
|
|
file_datas.deinit(allocator);
|
|
allocator.free(paths_owned);
|
|
if (resolved_paths_opt) |rp| rp.deinit();
|
|
return null;
|
|
};
|
|
}
|
|
|
|
const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
|
|
for (file_datas.items) |d| allocator.free(d);
|
|
file_datas.deinit(allocator);
|
|
allocator.free(paths_owned);
|
|
if (resolved_paths_opt) |rp| rp.deinit();
|
|
return null;
|
|
};
|
|
|
|
// Hand off to the bytes-only path. `loadFromBytes` takes
|
|
// ownership of all four slices and frees them on any error.
|
|
return loadFromBytes(io, allocator, paths_owned, resolved_paths_opt, file_datas_owned, as_of);
|
|
}
|
|
|
|
/// Bytes-only union-merge core. Takes ownership of `paths_owned`,
|
|
/// `resolved_paths_opt` (if non-null), and `file_datas_owned` (each
|
|
/// element must be `allocator`-owned). Frees them on any error.
|
|
///
|
|
/// Tests use this directly with synthetic byte literals; production
|
|
/// callers come through `loadFromPaths` (working-tree reads) or
|
|
/// `loadPortfolioFromPathsAtRev` (git-historical reads). All three
|
|
/// share this single deserialize-merge-positions-syms pass.
|
|
///
|
|
/// `paths_owned` and `file_datas_owned` must have the same length;
|
|
/// each `file_datas_owned[i]` is the bytes loaded from
|
|
/// `paths_owned[i]`. Path strings are used only for diagnostics
|
|
/// (which file failed to parse) and in the returned struct.
|
|
fn loadFromBytes(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
paths_owned: []const []const u8,
|
|
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
|
file_datas_owned: []const []const u8,
|
|
as_of: zfin.Date,
|
|
) ?LoadedPortfolio {
|
|
std.debug.assert(paths_owned.len == file_datas_owned.len);
|
|
|
|
// Cleanup is staged: lots live on `merged` until
|
|
// `toOwnedSlice`, then move into `combined` (which has its
|
|
// own deinit). The two cleanup paths are mutually exclusive,
|
|
// tracked by `lots_owner`. On success, neither fires.
|
|
var merged: std.ArrayList(zfin.Lot) = .empty;
|
|
var combined: zfin.Portfolio = .{ .lots = &.{}, .allocator = allocator };
|
|
const LotsOwner = enum { merged_list, combined_struct, none };
|
|
var lots_owner: LotsOwner = .merged_list;
|
|
var success = false;
|
|
|
|
defer if (!success) {
|
|
switch (lots_owner) {
|
|
.merged_list => {
|
|
for (merged.items) |lot| {
|
|
allocator.free(lot.symbol);
|
|
if (lot.note) |n| allocator.free(n);
|
|
if (lot.account) |a| allocator.free(a);
|
|
if (lot.ticker) |t| allocator.free(t);
|
|
if (lot.underlying) |u| allocator.free(u);
|
|
}
|
|
merged.deinit(allocator);
|
|
},
|
|
.combined_struct => combined.deinit(),
|
|
.none => {},
|
|
}
|
|
for (file_datas_owned) |d| allocator.free(d);
|
|
allocator.free(file_datas_owned);
|
|
allocator.free(paths_owned);
|
|
if (resolved_paths_opt) |rp| rp.deinit();
|
|
};
|
|
|
|
// Deserialize each into an owned Portfolio, then merge their
|
|
// lot slices into a single combined slice. We can't simply
|
|
// concat the underlying slices because each Portfolio expects
|
|
// to free its own lots in `deinit()`; instead, we steal each
|
|
// Portfolio's lots[] (string fields are already dupe'd into
|
|
// `allocator`) and free only the empty Portfolio struct.
|
|
for (file_datas_owned, 0..) |data, idx| {
|
|
// Empty bytes are a legitimate "file absent" signal —
|
|
// `loadPortfolioFromPathsAtRev` uses this to indicate a
|
|
// file that didn't exist at the requested git rev. Skip
|
|
// without trying to parse.
|
|
if (data.len == 0) continue;
|
|
|
|
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
|
var msg_buf: [512]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
|
|
stderr.print(io, msg);
|
|
return null;
|
|
};
|
|
for (portfolio.lots) |lot| {
|
|
merged.append(allocator, lot) catch {
|
|
portfolio.deinit();
|
|
return null;
|
|
};
|
|
}
|
|
// Free the now-empty Portfolio's lots slice without freeing
|
|
// the per-lot strings — they were transferred to `merged`.
|
|
allocator.free(portfolio.lots);
|
|
}
|
|
|
|
const merged_slice = merged.toOwnedSlice(allocator) catch return null;
|
|
combined = .{
|
|
.lots = merged_slice,
|
|
.allocator = allocator,
|
|
};
|
|
lots_owner = .combined_struct;
|
|
|
|
const positions = combined.positions(as_of, allocator) catch {
|
|
stderr.print(io, "Error: Cannot compute positions\n");
|
|
return null;
|
|
};
|
|
|
|
const syms = combined.stockSymbols(allocator) catch {
|
|
allocator.free(positions);
|
|
stderr.print(io, "Error: Cannot get stock symbols\n");
|
|
return null;
|
|
};
|
|
|
|
success = true;
|
|
return .{
|
|
.paths = paths_owned,
|
|
.resolved_paths = resolved_paths_opt,
|
|
.file_datas = file_datas_owned,
|
|
.portfolio = combined,
|
|
.positions = positions,
|
|
.syms = syms,
|
|
};
|
|
}
|
|
|
|
// ── Portfolio data pipeline ──────────────────────────────────
|
|
|
|
/// Result of the shared portfolio data pipeline. Caller must call deinit().
|
|
pub const PortfolioData = struct {
|
|
summary: zfin.valuation.PortfolioSummary,
|
|
candle_map: std.StringHashMap([]const zfin.Candle),
|
|
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
|
|
|
|
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
|
|
self.summary.deinit(allocator);
|
|
var it = self.candle_map.valueIterator();
|
|
while (it.next()) |v| allocator.free(v.*);
|
|
self.candle_map.deinit();
|
|
}
|
|
};
|
|
|
|
/// Build portfolio summary, candle map, and historical snapshots from
|
|
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
|
|
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
|
|
///
|
|
/// Callers are responsible for populating `prices` (via network fetch,
|
|
/// cache read, or pre-fetched map) before calling this.
|
|
///
|
|
/// Returns error.NoAllocations if the summary produces no positions
|
|
/// (e.g. no cached prices available).
|
|
pub fn buildPortfolioData(
|
|
allocator: std.mem.Allocator,
|
|
portfolio: zfin.Portfolio,
|
|
positions: []const zfin.Position,
|
|
syms: []const []const u8,
|
|
prices: *std.StringHashMap(f64),
|
|
svc: *zfin.DataService,
|
|
as_of: zfin.Date,
|
|
) !PortfolioData {
|
|
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
|
|
defer manual_price_set.deinit();
|
|
|
|
var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch
|
|
return error.SummaryFailed;
|
|
errdefer summary.deinit(allocator);
|
|
|
|
if (summary.allocations.len == 0) {
|
|
summary.deinit(allocator);
|
|
return error.NoAllocations;
|
|
}
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
|
errdefer {
|
|
var it = candle_map.valueIterator();
|
|
while (it.next()) |v| allocator.free(v.*);
|
|
candle_map.deinit();
|
|
}
|
|
for (syms) |sym| {
|
|
if (svc.getCachedCandles(sym)) |cs| {
|
|
// cs.data is owned by svc.allocator, which matches the
|
|
// caller's `allocator` in practice (they're wired to the
|
|
// same root). Store the raw slice; PortfolioData.deinit
|
|
// below frees via the caller's allocator.
|
|
try candle_map.put(sym, cs.data);
|
|
}
|
|
}
|
|
|
|
const snapshots = zfin.valuation.computeHistoricalSnapshots(
|
|
as_of,
|
|
positions,
|
|
prices.*,
|
|
candle_map,
|
|
);
|
|
|
|
return .{
|
|
.summary = summary,
|
|
.candle_map = candle_map,
|
|
.snapshots = snapshots,
|
|
};
|
|
}
|
|
|
|
// ── loadFromBytes tests (in-memory; no disk I/O) ─────────────
|
|
//
|
|
// All tests here construct synthetic SRF byte literals and route
|
|
// them through `loadFromBytes` directly. Both production callers
|
|
// (working-tree `loadFromPaths` and git-historical
|
|
// `loadPortfolioFromPathsAtRev`) share this core, so unit-testing
|
|
// it covers the union-merge / parse-error / empty-bytes behavior
|
|
// without spinning up temp filesystems or git repos.
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Test helper: dupe a slice of byte-string literals into the
|
|
/// allocator so `loadFromBytes` can free them on its own.
|
|
fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const []const u8 {
|
|
var owned = try allocator.alloc([]const u8, datas.len);
|
|
errdefer {
|
|
for (owned[0..]) |d| allocator.free(d);
|
|
allocator.free(owned);
|
|
}
|
|
var filled: usize = 0;
|
|
for (datas, 0..) |d, i| {
|
|
owned[i] = try allocator.dupe(u8, d);
|
|
filled = i + 1;
|
|
}
|
|
return owned;
|
|
}
|
|
|
|
test "loadFromBytes: union of two synthetic SRF files" {
|
|
const allocator = testing.allocator;
|
|
|
|
const file_a =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
|
\\
|
|
;
|
|
const file_b =
|
|
\\#!srfv1
|
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
|
\\
|
|
;
|
|
|
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
|
const datas = try dupeBytes(allocator, &.{ file_a, file_b });
|
|
|
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
|
// Lots are appended in file order.
|
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
|
try testing.expectEqualStrings("MSFT", loaded.portfolio.lots[1].symbol);
|
|
}
|
|
|
|
test "loadFromBytes: single file with valid contents" {
|
|
const allocator = testing.allocator;
|
|
|
|
const file_a =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
|
\\
|
|
;
|
|
|
|
const paths = try allocator.dupe([]const u8, &.{"a.srf"});
|
|
const datas = try dupeBytes(allocator, &.{file_a});
|
|
|
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
|
}
|
|
|
|
test "loadFromBytes: empty-bytes entry is treated as absent file" {
|
|
// Production use case: `loadPortfolioFromPathsAtRev` returns
|
|
// empty bytes for a path that didn't exist at the requested
|
|
// git rev. The union-merge silently skips it instead of
|
|
// failing — matches "snapshot at a date before this file
|
|
// was added" semantics.
|
|
const allocator = testing.allocator;
|
|
|
|
const file_a =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
|
\\
|
|
;
|
|
const file_b: []const u8 = "";
|
|
|
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
|
const datas = try dupeBytes(allocator, &.{ file_a, file_b });
|
|
|
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
|
}
|
|
|
|
test "loadFromBytes: all-empty bytes returns empty portfolio" {
|
|
// Production use case: snapshot at a rev that predates ALL
|
|
// tracked portfolio files. No lots; no error.
|
|
const allocator = testing.allocator;
|
|
|
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
|
const datas = try dupeBytes(allocator, &.{ "", "" });
|
|
|
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 0), loaded.portfolio.lots.len);
|
|
}
|
|
|
|
// ── loadPortfolioFromPathsAtRev tests (disk + git) ───────────
|
|
//
|
|
// These spin up a temp git repo so they exercise the full
|
|
// `git.show + skip-on-PathMissingInRev` contract end-to-end.
|
|
// Kept minimal (one happy-path, one missing-file case); the
|
|
// bulk of the logic is in `loadFromBytes`, covered above.
|
|
|
|
/// Run a one-shot git command in `cwd` for test setup. Panics on
|
|
/// failure — these tests can't proceed without a working repo.
|
|
fn gitInTestRepo(allocator: std.mem.Allocator, cwd: []const u8, argv: []const []const u8) !void {
|
|
const full_argv = try allocator.alloc([]const u8, argv.len + 3);
|
|
defer allocator.free(full_argv);
|
|
full_argv[0] = "git";
|
|
full_argv[1] = "-C";
|
|
full_argv[2] = cwd;
|
|
@memcpy(full_argv[3..], argv);
|
|
const result = try std.process.run(allocator, testing.io, .{
|
|
.argv = full_argv,
|
|
.stdout_limit = .limited(64 * 1024),
|
|
});
|
|
defer allocator.free(result.stdout);
|
|
defer allocator.free(result.stderr);
|
|
switch (result.term) {
|
|
.exited => |code| if (code != 0) {
|
|
std.debug.print("git command failed (code {d}): {s}\nstderr: {s}\n", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
|
return error.GitFailed;
|
|
},
|
|
else => return error.GitFailed,
|
|
}
|
|
}
|
|
|
|
test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" {
|
|
const allocator = testing.allocator;
|
|
|
|
// Skip if `git` isn't on PATH (CI sandbox without git).
|
|
{
|
|
const probe = std.process.run(allocator, testing.io, .{
|
|
.argv = &.{ "git", "--version" },
|
|
.stdout_limit = .limited(1024),
|
|
}) catch return;
|
|
defer allocator.free(probe.stdout);
|
|
defer allocator.free(probe.stderr);
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf);
|
|
const dir = path_buf[0..dir_len];
|
|
|
|
const file_a =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
|
\\
|
|
;
|
|
const file_b =
|
|
\\#!srfv1
|
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
|
\\
|
|
;
|
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });
|
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });
|
|
|
|
try gitInTestRepo(allocator, dir, &.{ "init", "-q" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" });
|
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf", "portfolio_other.srf" });
|
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" });
|
|
|
|
const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
defer allocator.free(p1);
|
|
const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
|
|
defer allocator.free(p2);
|
|
const paths = [_][]const u8{ p1, p2 };
|
|
|
|
var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, "HEAD", zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
|
}
|
|
|
|
test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earlier rev" {
|
|
const allocator = testing.allocator;
|
|
|
|
// Skip if `git` isn't on PATH.
|
|
{
|
|
const probe = std.process.run(allocator, testing.io, .{
|
|
.argv = &.{ "git", "--version" },
|
|
.stdout_limit = .limited(1024),
|
|
}) catch return;
|
|
defer allocator.free(probe.stdout);
|
|
defer allocator.free(probe.stderr);
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf);
|
|
const dir = path_buf[0..dir_len];
|
|
|
|
const file_a =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
|
\\
|
|
;
|
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });
|
|
|
|
try gitInTestRepo(allocator, dir, &.{ "init", "-q" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" });
|
|
try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" });
|
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf" });
|
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" });
|
|
|
|
// Capture commit 1 SHA before adding file_b.
|
|
const result = try std.process.run(allocator, testing.io, .{
|
|
.argv = &.{ "git", "-C", dir, "rev-parse", "HEAD" },
|
|
.stdout_limit = .limited(1024),
|
|
});
|
|
defer allocator.free(result.stdout);
|
|
defer allocator.free(result.stderr);
|
|
const sha = std.mem.trim(u8, result.stdout, " \t\r\n");
|
|
const sha_owned = try allocator.dupe(u8, sha);
|
|
defer allocator.free(sha_owned);
|
|
|
|
// Add and commit a second portfolio file.
|
|
const file_b =
|
|
\\#!srfv1
|
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
|
\\
|
|
;
|
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });
|
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio_other.srf" });
|
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "add second" });
|
|
|
|
const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
|
defer allocator.free(p1);
|
|
const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
|
|
defer allocator.free(p2);
|
|
const paths = [_][]const u8{ p1, p2 };
|
|
|
|
// Load at commit 1 — second file didn't exist yet. Expect
|
|
// only AAPL from portfolio.srf, no error.
|
|
var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, sha_owned, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
|
try testing.expect(false);
|
|
return;
|
|
};
|
|
defer loaded.deinit(allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
|
}
|