zfin/src/commands/import.zig

1434 lines
61 KiB
Zig

//! `zfin import` — synthesize a portfolio file from a brokerage
//! holdings export.
//!
//! The first mutating CLI command in zfin. The use case is a managed
//! account whose lot-level history isn't worth maintaining by hand —
//! direct-indexing accounts that don't track an underlying ETF, my
//! mother's brokerage, etc. Each run replaces the target file's
//! contents with one synthetic lot per (account, symbol) drawn from
//! the brokerage's positions export. We trade lot-level fidelity for
//! "look at the brokerage CSV; rerun import; commit" simplicity.
//!
//! ## Synthetic lots
//!
//! Brokerage holdings exports give us "100 AAPL @ $150 avg cost" —
//! aggregate, no buy date. We synthesize one `Lot` per row:
//!
//! - `symbol::` from the export
//! - `shares::` from the export's quantity
//! - `open_date::` from the prior portfolio's matching lot (see
//! "Re-import merge" below) when present, else `1970-01-01`
//! (sentinel — we don't have a real signal for new positions)
//! - `open_price::` from the prior matching lot when present,
//! else `cost_basis / quantity` if both > 0, else
//! `current_value / quantity`, else 0
//! - `account::` resolved via `accounts.srf` from the export's
//! account_number; refuse to import if any number is unmapped
//! - `note::` from the prior matching lot when present (carries
//! the original first-seen import date), else
//! `"imported <broker> YYYY-MM-DD"` for newly-introduced
//! positions
//! - `security_type::cash` for cash-classified positions
//!
//! ## Re-import merge
//!
//! When the target portfolio file already exists, `import` reads
//! it, builds a `(symbol, account) → Lot` lookup, and uses that
//! to inherit per-position metadata that the brokerage CSV
//! doesn't carry. The merge rules:
//!
//! - **Held positions** (in both prior file and new export):
//! keep `open_date`, `open_price`, and `note` from the prior
//! lot; only `shares` and `security_type` come from the new
//! export. A re-import of an unchanged held position
//! produces byte-identical output, so `git diff` only
//! surfaces actual brokerage changes (lot-size drift,
//! real cost-basis adjustments).
//! - **New positions** (in new export, not in prior): treat
//! as a fresh lot — `open_date::1970-01-01` sentinel,
//! synthesized `open_price`, today-stamped note. The note
//! records "first-seen" rather than "every-time-seen", so
//! it doesn't churn on subsequent imports.
//! - **Closed positions** (in prior, not in new export):
//! silently dropped. `import` is a "replace, don't merge
//! transactions" tool; if you sold AAPL between imports,
//! the new file just stops including AAPL. The git history
//! is the audit trail.
//! - **Multiple lots for same `(symbol, account)`** in the
//! prior file: the EARLIEST `open_date` wins. That's the
//! longest-standing buy and the right anchor for trailing-
//! return math.
//!
//! ### What the merge does NOT preserve
//!
//! Hand-edited fields like `price::`, `price_ratio::`,
//! `ticker::`, or `drip::` on a prior lot get blown away on
//! re-import. If you've manually annotated a managed-account
//! portfolio with such fields, `import` is the wrong tool —
//! either edit by hand or rebuild the annotations after each
//! refresh.
//!
//! ### Why `1970-01-01` (Date.epoch) for new lots?
//!
//! Brokerage holdings CSVs don't carry per-lot buy dates, so any
//! synthesized `open_date` for a brand-new position is a guess.
//! Using `today` would be actively misleading because the next
//! import would rewrite it again. Using `1970-01-01` is honest —
//! "we don't know" — and is the merge anchor for the SECOND
//! import's prior-lookup, by which point the user has had a
//! chance to hand-edit the date if they care.
//!
//! This loses per-buy lot history, which is acceptable for managed
//! accounts where we don't track buys/sells anyway. Git serves as
//! the history record for the file itself: prior states are visible
//! in `git log` and recoverable with `git show`.
//!
//! ## Workflow
//!
//! ```
//! # 1. Download Fidelity positions CSV from the website.
//! # 2. Run import; review the diff in git.
//! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv
//! # 3. Inspect changes; commit when satisfied.
//! git diff portfolio_other.srf
//! git add portfolio_other.srf && git commit -m "Update managed portfolio"
//! ```
//!
//! ## Safety
//!
//! - `-p`/`--portfolio` is REQUIRED — we never guess which file to
//! overwrite. The pattern must resolve to a single concrete path
//! (no globs, no multi-match).
//! - If the target file exists, prompt on stderr: `Overwrite <path>?
//! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt
//! (apt-style).
//! - Atomic write via `atomic.writeFileAtomic` — a kill mid-write
//! leaves the prior file intact.
//! - No backup file. Git is the backup. If the file isn't tracked
//! by git, the user will see that in `git status` after the run.
//! - Hard fail (no fallback) when an export references an
//! account_number not present in `accounts.srf`. The user adds
//! the mapping and reruns; better that than silently writing
//! lots with broken account names.
const std = @import("std");
const builtin = @import("builtin");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const Date = @import("../Date.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const cache = @import("../cache/store.zig");
const atomic = @import("../atomic.zig");
const fidelity = @import("../brokerage/fidelity.zig");
const schwab = @import("../brokerage/schwab.zig");
const wells_fargo = @import("../brokerage/wells_fargo.zig");
const brokerage_types = @import("../brokerage/types.zig");
const analysis = @import("../analytics/analysis.zig");
const BrokeragePosition = brokerage_types.BrokeragePosition;
/// Source brokerage for the import. Fidelity and Schwab carry an
/// account number in the export rows themselves; Wells Fargo's
/// paste does not, so the WF variant carries an optional explicit
/// account-name override (`--account NAME`) that the resolver
/// uses when filename inference fails.
pub const Source = union(enum) {
fidelity: []const u8,
schwab: []const u8,
wells_fargo: WellsFargoArgs,
pub const WellsFargoArgs = struct {
path: []const u8,
/// `--account NAME` value, if the user supplied one.
/// Otherwise the resolver falls back to filename
/// inference and finally to a single-WF-entry lookup.
account_override: ?[]const u8,
};
pub fn label(self: Source) []const u8 {
return switch (self) {
.fidelity => "fidelity",
.schwab => "schwab",
.wells_fargo => "wells_fargo",
};
}
pub fn path(self: Source) []const u8 {
return switch (self) {
.fidelity => |p| p,
.schwab => |p| p,
.wells_fargo => |a| a.path,
};
}
};
pub const ParsedArgs = struct {
source: Source,
/// Skip the overwrite prompt (apt-style `-y` / `--yes`).
yes: bool = false,
};
pub const meta: framework.Meta = .{
.name = "import",
.group = .hygiene,
.synopsis = "Synthesize a portfolio file from a brokerage holdings export",
.help =
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y]
\\
\\Synthesize a portfolio file from a brokerage positions export.
\\Each run REPLACES the target portfolio file with synthetic lots
\\drawn from the export — one lot per (account, symbol).
\\
\\Designed for managed accounts (direct-indexing baskets, accounts
\\you don't track at lot granularity). Per-buy history is lost;
\\git serves as the file-level history.
\\
\\Re-import merge: when the target file already exists, lots that
\\are still in the new export inherit their prior `open_date`,
\\`open_price`, and `note::` — so trailing-return / ST/LT
\\classifications stay stable across re-imports and `git diff`
\\only flags genuine brokerage changes. Newly-introduced
\\positions get `open_date::1970-01-01` (a "we don't know"
\\sentinel; the next import will treat it as the prior anchor).
\\Lots that disappear from the export are silently dropped — if
\\you sold a position between imports, it just stops appearing.
\\Hand-edited fields (`price::`, `ticker::`, etc.) on prior
\\lots are NOT preserved.
\\
\\Required:
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
\\ concrete path, not a glob). REQUIRED.
\\ --fidelity <CSV> Fidelity positions CSV
\\ ("All accounts" → Positions tab → Download)
\\ --schwab <CSV> Schwab per-account positions CSV
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
\\ positions table from the WF portal
\\ and save to a file). Pass `-` to
\\ read from stdin.
\\
\\Options:
\\ --account <NAME> (Wells Fargo only) explicit account
\\ name to attribute the lots to. Must
\\ match an entry in accounts.srf. If
\\ omitted, the importer infers the
\\ account from the filename, then
\\ falls back to the single WF entry
\\ in accounts.srf.
\\ -y, --yes Don't prompt before overwriting an
\\ existing file.
\\
\\Account-name resolution requires `accounts.srf` next to the
\\target file with `institution::` + `account_number::` entries
\\matching the brokerage export. Import refuses to write when an
\\account_number from the export is unmapped.
\\
,
.uppercase_first_arg = false,
.user_errors = error{
UnexpectedArg,
MissingSource,
ConflictingSources,
MissingPortfolioPath,
AmbiguousPortfolioPath,
EmptyFile,
UnexpectedHeader,
CannotReadCsv,
CannotReadAccountsFile,
UnmappedAccount,
AmbiguousWellsFargoAccount,
UnknownAccount,
UserDeclined,
WriteFailed,
},
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var fidelity_path: ?[]const u8 = null;
var schwab_path: ?[]const u8 = null;
var wells_fargo_path: ?[]const u8 = null;
var account_override: ?[]const u8 = null;
var yes = false;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--fidelity")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
fidelity_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--schwab")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
schwab_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
return error.UnexpectedArg;
}
i += 1;
wells_fargo_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--account")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
return error.UnexpectedArg;
}
i += 1;
account_override = cmd_args[i];
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true;
} else {
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
// Mutual-exclusion check across the three source flags. Two
// of three set still counts as a conflict.
var source_count: u8 = 0;
if (fidelity_path != null) source_count += 1;
if (schwab_path != null) source_count += 1;
if (wells_fargo_path != null) source_count += 1;
if (source_count > 1) {
cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
return error.ConflictingSources;
}
// `--account` is a Wells-Fargo-only knob; the other sources
// carry per-row account_numbers in the export. Reject up
// front so the user notices early.
if (account_override != null and wells_fargo_path == null) {
cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
return error.UnexpectedArg;
}
const source: Source = if (fidelity_path) |p|
.{ .fidelity = p }
else if (schwab_path) |p|
.{ .schwab = p }
else if (wells_fargo_path) |p|
.{ .wells_fargo = .{ .path = p, .account_override = account_override } }
else {
cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
return error.MissingSource;
};
return .{ .source = source, .yes = yes };
}
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;
// ── Resolve the target portfolio path ─────────────────────
//
// -p is REQUIRED for import. We never guess which file to
// overwrite. We also reject globs and multi-match patterns
// here — the user must point us at exactly one file. If they
// genuinely mean to import for a portfolio that lives at
// multiple paths, they need to pick one explicitly.
const target_path = try resolveSingleTarget(ctx);
// ── Read the brokerage export ─────────────────────────────
//
// `-` selects stdin (for the paste-from-clipboard workflow);
// any other path reads from disk. Same convention regardless
// of source.
const csv_path = parsed.source.path();
const csv_data = try readSourceData(io, allocator, csv_path);
defer allocator.free(csv_data);
// ── Parse ─────────────────────────────────────────────────
const positions: []BrokeragePosition = switch (parsed.source) {
.fidelity => try fidelity.parseCsv(allocator, csv_data),
.schwab => blk: {
const r = try schwab.parseCsv(allocator, csv_data);
break :blk r.positions;
},
.wells_fargo => try wells_fargo.parsePaste(allocator, csv_data),
};
defer allocator.free(positions);
if (positions.len == 0) {
cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n");
return error.EmptyFile;
}
// ── Load accounts.srf for account-number → name mapping ───
//
// Sibling file derivation: `DataService.loadAccountMap` walks
// up from the portfolio path to find `accounts.srf`, the same
// way every other zfin command does. We don't need the
// service for anything else (no price fetching), but reusing
// its helper keeps sibling-file resolution consistent.
var account_map = svc.loadAccountMap(allocator, target_path) orelse {
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n");
cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n");
cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n");
return error.CannotReadAccountsFile;
};
defer account_map.deinit();
// ── WF: resolve and patch the per-row account_number ──────
//
// Wells Fargo pastes don't carry an account identifier, so
// every position came back with `account_number = ""`. Defer
// to `wells_fargo.applyAccountToPositions` to resolve
// (explicit `--account` → filename-inferred → single-WF-entry
// fallback) and rewrite every position's
// account_number/account_name accordingly. The downstream
// `synthesizeLots` lookup then works uniformly across
// brokerages.
if (parsed.source == .wells_fargo) {
const wf_args = parsed.source.wells_fargo;
try wells_fargo.applyAccountToPositions(io, account_map, csv_path, wf_args.account_override, positions);
}
// ── Read the existing target file (if any) for merge ──────
//
// When the target file already exists, we treat its lots as
// signal: a position that was in the file last week with
// `open_date::2026-04-15` should keep that date this week,
// even though brokerage CSVs don't carry per-buy dates and
// the synthesizer's default `open_date` is the
// `1970-01-01` sentinel. See TODO history and the module
// doc-block "Re-import merge" section.
//
// Parse failure aborts. A garbage existing file is a weird
// state; silently treating it as fresh would mask the
// problem. The user fixes or deletes the file and retries.
const prior_data = std.Io.Dir.cwd().readFileAlloc(io, target_path, allocator, .limited(10 * 1024 * 1024)) catch null;
defer if (prior_data) |d| allocator.free(d);
var prior_portfolio_opt: ?zfin.Portfolio = null;
defer if (prior_portfolio_opt) |*p| p.deinit();
if (prior_data) |data| {
prior_portfolio_opt = cache.deserializePortfolio(allocator, data) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse existing portfolio file: {s}\n", .{target_path}) catch "Error: Cannot parse existing portfolio file\n";
cli.stderrPrint(io, msg);
cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
return error.WriteFailed;
};
}
var prior_lookup_opt: ?PriorLotsLookup = null;
defer if (prior_lookup_opt) |*p| p.deinit();
if (prior_portfolio_opt) |pf| {
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
}
// ── Synthesize lots ───────────────────────────────────────
const lots = synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt) catch |err| switch (err) {
error.UnmappedAccount => {
// synthesizeLots already printed the offending account
// numbers to stderr; just propagate as a user-level error.
return err;
},
else => |e| return e,
};
defer freeLots(allocator, lots);
// ── Confirm overwrite when target exists ──────────────────
//
// Inline access() check: a single call site doesn't earn its
// own helper, and the catch-form here matches the pattern in
// snapshot.zig / history.zig.
const target_exists = blk: {
std.Io.Dir.cwd().access(io, target_path, .{}) catch break :blk false;
break :blk true;
};
if (target_exists and !parsed.yes) {
if (!try confirmOverwrite(io, target_path)) {
cli.stderrPrint(io, "Aborted; no changes written.\n");
return error.UserDeclined;
}
}
// ── Serialize + atomic write ──────────────────────────────
const serialized = try cache.serializePortfolio(allocator, lots);
defer allocator.free(serialized);
atomic.writeFileAtomic(io, allocator, target_path, serialized) catch |err| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Failed to write portfolio file ({s}): {s}\n", .{ target_path, @errorName(err) }) catch "Error: Failed to write portfolio file\n";
cli.stderrPrint(io, msg);
return error.WriteFailed;
};
// ── Summary ───────────────────────────────────────────────
var account_count: usize = 0;
{
// Count distinct accounts referenced by the synthesized
// lots. Cheap because lots[].account is already mapped to
// portfolio account names; we just dedupe.
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (lots) |lot| {
if (lot.account) |a| {
if (!seen.contains(a)) {
try seen.put(a, {});
account_count += 1;
}
}
}
}
try out.print(
"Wrote {d} lot{s} across {d} account{s} to {s} (source: {s}).\n",
.{
lots.len,
if (lots.len == 1) "" else "s",
account_count,
if (account_count == 1) "" else "s",
target_path,
parsed.source.label(),
},
);
try out.flush();
}
// ── Helpers ──────────────────────────────────────────────────
/// Resolve `-p`/`--portfolio` to exactly one concrete path. Refuses
/// glob patterns and refuses multi-match cases — import is a
/// destructive operation, "we'll write to the first match" is not
/// an answer.
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
const patterns = ctx.globals.portfolio_patterns;
if (patterns.len == 0) {
cli.stderrPrint(ctx.io, "Error: import requires `-p <FILE>` (the portfolio file to write).\n");
return error.MissingPortfolioPath;
}
if (patterns.len > 1) {
cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p <FILE>` (got multiple).\n");
return error.AmbiguousPortfolioPath;
}
const pat = patterns[0];
if (zfin.Config.isGlobPattern(pat)) {
cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n");
return error.AmbiguousPortfolioPath;
}
// Resolve via Config: ZFIN_HOME when set (exclusive), else
// cwd. If the file doesn't exist yet (first run for a new
// portfolio), fall back to the literal pattern so we write
// to ./<pattern> — that's the natural place for a freshly-
// created managed-account file before the user moves it
// anywhere canonical.
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
// Caller doesn't free this; returning the resolved path
// string puts the lifetime on the arena allocator.
return r.path;
}
return pat;
}
/// Read the source bytes from `path`. The literal path `-` reads
/// from stdin so users can paste directly into a heredoc-style
/// invocation; any other value reads from disk. Returns owned
/// bytes the caller must free.
fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
if (std.mem.eql(u8, path, "-")) {
var stdin_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch {
cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
return error.CannotReadCsv;
};
return data;
}
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n";
cli.stderrPrint(io, msg);
return error.CannotReadCsv;
};
}
/// Prompt for y/N confirmation on stderr, read one line from stdin.
/// Returns true only on a 'y' or 'Y' answer (with optional whitespace);
/// anything else (including EOF / empty) is "no".
fn confirmOverwrite(io: std.Io, path: []const u8) !bool {
var stderr_buf: [512]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr_writer.interface.print("Overwrite {s}? (y/N) ", .{path});
try stderr_writer.interface.flush();
var stdin_buf: [256]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
// Take up to one line; treat EOF as "no".
const line = stdin_reader.interface.takeDelimiterExclusive('\n') catch |err| switch (err) {
error.EndOfStream => return false,
else => return err,
};
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
return trimmed.len > 0 and (trimmed[0] == 'y' or trimmed[0] == 'Y');
}
/// Lookup table built from a previously-imported portfolio's lots,
/// keyed by `(symbol, account)`. Used by `synthesizeLots` to
/// preserve the prior `open_date` / `open_price` / `note::` for a
/// position that's still present in the new export.
///
/// When multiple lots share the same `(symbol, account)` (the
/// merge-aware design accepts this — a hand-edited file might
/// have several lots, or a prior version of import wrote
/// multiple), the EARLIEST `open_date` wins. That's the
/// longest-standing buy and the right anchor for trailing-return
/// math.
///
/// Backed by a `StringHashMap` keyed by `"<symbol>\x00<account>"`.
/// The composite-key string is owned by the lookup and freed in
/// `deinit`. Lot pointers are borrowed from the caller's
/// `Portfolio` and remain valid as long as the source portfolio
/// does.
const PriorLotsLookup = struct {
map: std.StringHashMap(*const portfolio_mod.Lot),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
errdefer {
var it = map.keyIterator();
while (it.next()) |k| allocator.free(k.*);
map.deinit();
}
for (lots) |*lot| {
// Skip closed lots: they shouldn't anchor a re-import's
// open_date for a position the brokerage shows as held.
// (Today's import doesn't write `close_date`/`close_price`
// anyway, so this is also defensive against hand-edited
// closed lots in the file.)
if (lot.close_date != null) continue;
// Cash lots have no symbol/account-meaningful identity
// for matching across imports — skip.
if (lot.security_type == .cash) continue;
const account = lot.account orelse continue;
const key = try makeKey(allocator, lot.symbol, account);
const gop = try map.getOrPut(key);
if (gop.found_existing) {
// Duplicate (symbol, account) — keep the EARLIEST
// open_date as the merge anchor. Free the freshly
// built key (the one already in the map stays).
allocator.free(key);
if (lot.open_date.lessThan(gop.value_ptr.*.open_date)) {
gop.value_ptr.* = lot;
}
} else {
gop.value_ptr.* = lot;
}
}
return .{ .map = map, .allocator = allocator };
}
fn deinit(self: *PriorLotsLookup) void {
var it = self.map.keyIterator();
while (it.next()) |k| self.allocator.free(k.*);
self.map.deinit();
}
/// Find the prior `Lot` for `(symbol, account)`, returning
/// null when no match exists. Caller must keep the source
/// portfolio alive while using the returned pointer.
fn find(self: PriorLotsLookup, symbol: []const u8, account: []const u8) !?*const portfolio_mod.Lot {
var key_buf: [256]u8 = undefined;
// Fast path: stack-allocate the key. Fall back to heap
// for unusually long symbols/accounts.
const total = symbol.len + 1 + account.len;
if (total <= key_buf.len) {
@memcpy(key_buf[0..symbol.len], symbol);
key_buf[symbol.len] = 0;
@memcpy(key_buf[symbol.len + 1 ..][0..account.len], account);
return self.map.get(key_buf[0..total]);
}
const key = try makeKey(self.allocator, symbol, account);
defer self.allocator.free(key);
return self.map.get(key);
}
fn makeKey(allocator: std.mem.Allocator, symbol: []const u8, account: []const u8) ![]u8 {
return std.fmt.allocPrint(allocator, "{s}\x00{s}", .{ symbol, account });
}
};
/// Synthesize a `Lot` per `BrokeragePosition`. Resolves each
/// brokerage account_number to a portfolio account name via
/// `account_map`; refuses (with a stderr listing of unmapped
/// numbers) if any can't be resolved.
///
/// `today` stamps the `note::` field on lots that don't have a
/// matching prior entry (`note::imported <broker> YYYY-MM-DD`).
/// Lots that DO match a prior entry inherit that prior lot's
/// note (which carries the original first-seen date), so a
/// re-import of an unchanged held position produces a
/// byte-identical line — the `git diff` only shows genuine
/// brokerage changes.
///
/// `prior_lookup` (if non-null) carries the lots from the
/// existing target file. For each synthesized lot, we look up
/// `(symbol, account)`:
/// - **Match:** preserve `open_date`, `open_price`, and `note`
/// from the prior lot. Brokerage shares/cost-basis still come
/// from the new export.
/// - **No match:** new position. Use `Date.epoch` for
/// `open_date` (no signal to do better) and stamp today's
/// date in the note.
///
/// Takes `io` so it can print the unmapped-account-number
/// enumeration directly to stderr — easier than threading the list
/// back to the caller, and keeps the test path simple (tests pass
/// `std.testing.io` and observe the error code).
///
/// Returned lots own their string fields against `allocator` so
/// `cache.serializePortfolio` and `freeLots` can be applied
/// uniformly. Caller must call `freeLots`.
fn synthesizeLots(
io: std.Io,
allocator: std.mem.Allocator,
positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
source: Source,
today: Date,
prior_lookup: ?PriorLotsLookup,
) ![]portfolio_mod.Lot {
const institution = source.label();
// First pass: collect any unmapped account numbers so we can
// report all of them at once instead of failing on the first
// and making the user re-run for each.
var unmapped: std.ArrayList([]const u8) = .empty;
defer unmapped.deinit(allocator);
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (positions) |pos| {
if (account_map.findByInstitutionAccount(institution, pos.account_number) == null) {
if (!seen.contains(pos.account_number)) {
try seen.put(pos.account_number, {});
try unmapped.append(allocator, pos.account_number);
}
}
}
}
if (unmapped.items.len > 0) {
// Skip the human-readable error message under tests to keep
// test output clean; the returned error is what the test
// assertions rely on.
if (!builtin.is_test) {
var stderr_buf: [4096]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr_writer.interface.print(
"Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n",
.{
unmapped.items.len,
if (unmapped.items.len == 1) "" else "s",
institution,
if (unmapped.items.len == 1) "is" else "are",
},
);
for (unmapped.items) |num| {
try stderr_writer.interface.print(" - {s}\n", .{num});
}
try stderr_writer.interface.print(
"\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++
"`account_number::` value, then rerun the import.\n",
.{institution},
);
try stderr_writer.interface.flush();
}
return error.UnmappedAccount;
}
var lots = std.ArrayList(portfolio_mod.Lot).empty;
errdefer {
for (lots.items) |lot| freeLot(allocator, lot);
lots.deinit(allocator);
}
// Stamp for newly-introduced lots (no prior match). Held
// positions inherit their prior note instead, so the
// `git diff` between two imports of an unchanged held
// position stays empty.
var fresh_note_buf: [64]u8 = undefined;
const fresh_note = try std.fmt.bufPrint(&fresh_note_buf, "imported {s} {f}", .{ institution, today });
for (positions) |pos| {
const acct_name = account_map.findByInstitutionAccount(institution, pos.account_number).?;
const shares = pos.quantity orelse blk: {
// Cash positions have null quantity; the convention
// elsewhere in zfin (audit, snapshot's cash row) is
// shares=current_value, open_price=$1. Mirror that.
if (pos.is_cash) break :blk pos.current_value orelse 0;
// Non-cash without quantity is malformed export data.
// Treat as 0 shares so the lot is harmless; the user
// will see it in `git diff` and either fix the export
// or hand-edit the lot.
break :blk 0;
};
const synthesized_open_price: f64 = if (pos.is_cash)
1.0
else if (pos.cost_basis) |cb|
if (shares > 0) cb / shares else 0
else if (pos.current_value) |cv|
if (shares > 0) cv / shares else 0
else
0;
const security_type: portfolio_mod.LotType = if (pos.is_cash) .cash else .stock;
// Merge the prior lot's open_date / open_price / note if
// we have one. Cash lots are excluded from `prior_lookup`
// (they have no account-meaningful identity) and always
// get the synthesized values.
const prior: ?*const portfolio_mod.Lot = if (prior_lookup) |pl|
(if (security_type == .cash) null else try pl.find(pos.symbol, acct_name))
else
null;
const open_date: Date = if (prior) |p| p.open_date else Date.epoch;
const open_price: f64 = if (prior) |p| p.open_price else synthesized_open_price;
const note_text: []const u8 = if (prior) |p|
(if (p.note) |n| n else fresh_note)
else
fresh_note;
try lots.append(allocator, .{
.symbol = try allocator.dupe(u8, pos.symbol),
.shares = shares,
.open_date = open_date,
.open_price = open_price,
.account = try allocator.dupe(u8, acct_name),
.security_type = security_type,
.note = try allocator.dupe(u8, note_text),
});
}
return lots.toOwnedSlice(allocator);
}
/// Free per-lot allocator-owned strings + the slice. Mirror of the
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
/// except we don't construct a Portfolio here — `serializePortfolio`
/// takes a bare `[]const Lot`).
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void {
for (lots) |lot| freeLot(allocator, lot);
allocator.free(lots);
}
fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void {
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);
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.AccountTaxEntry) !analysis.AccountMap {
var owned = try allocator.alloc(analysis.AccountTaxEntry, entries.len);
for (entries, 0..) |e, i| {
owned[i] = .{
.account = try allocator.dupe(u8, e.account),
.tax_type = e.tax_type,
.institution = if (e.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (e.account_number) |s| try allocator.dupe(u8, s) else null,
};
}
return .{ .entries = owned, .allocator = allocator };
}
test "synthesizeLots: stock positions get open_price = cost_basis / quantity" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{
.account_number = "Z123",
.account_name = "Individual",
.symbol = "AAPL",
.description = "APPLE INC",
.quantity = 100,
.current_value = 17500,
.cost_basis = 12000,
.is_cash = false,
},
};
const today = Date.fromYmd(2026, 5, 21);
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, today, null);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expectEqualStrings("AAPL", lots[0].symbol);
try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01);
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100
try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?);
// No prior portfolio (`prior_lookup = null`) → new-lot path:
// `open_date` is the sentinel and the note carries the
// import date so the user can tell when it was first seen.
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
try testing.expectEqual(portfolio_mod.LotType.stock, lots[0].security_type);
try testing.expect(lots[0].note != null);
try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "imported fidelity") != null);
try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "2026-05-21") != null);
}
test "synthesizeLots: missing cost_basis falls back to current_value" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{
.account_number = "1234",
.account_name = "Sample Trust",
.symbol = "MSFT",
.description = "MICROSOFT",
.quantity = 10,
.current_value = 4000,
.cost_basis = null,
.is_cash = false,
},
};
const today = Date.fromYmd(2026, 5, 21);
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .schwab = "" }, today, null);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expectApproxEqAbs(@as(f64, 400.0), lots[0].open_price, 0.01); // 4000 / 10
}
test "synthesizeLots: cash positions become security_type=cash with shares=value, price=1" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{
.account_number = "Z123",
.account_name = "Individual",
.symbol = "FZFXX",
.description = "MM FUND",
.quantity = null,
.current_value = 5000,
.cost_basis = null,
.is_cash = true,
},
};
const today = Date.fromYmd(2026, 5, 21);
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, today, null);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expectEqual(portfolio_mod.LotType.cash, lots[0].security_type);
try testing.expectApproxEqAbs(@as(f64, 5000), lots[0].shares, 0.01);
try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].open_price, 0.01);
}
test "synthesizeLots: lots are byte-identical across imports when prior_lookup matches" {
// Pins the diff-cleanliness property of the merge path:
// when the existing portfolio file already carries a lot for
// a `(symbol, account)` pair, a re-import on any future date
// reuses that lot's `open_date` / `open_price` / `note`,
// producing byte-identical serialized output. Without this,
// held positions would show up as modified in `git diff` on
// every import — exactly what the merge layer was added to
// prevent.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 1, .current_value = 100, .cost_basis = 100, .is_cash = false },
};
// Build a one-lot prior portfolio that matches the position
// by (symbol, account). The merge path should preserve every
// field of this lot (open_date, open_price, note) regardless
// of the import date.
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 1,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 95.0,
.account = "Sample Brokerage",
.security_type = .stock,
.note = "imported fidelity 2024-01-15",
},
};
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior_lookup.deinit();
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
defer freeLots(allocator, lots_a);
const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup);
defer freeLots(allocator, lots_b);
// Prior open_date is preserved — NOT today's date and NOT
// the sentinel.
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_a[0].open_date.days);
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_b[0].open_date.days);
// Prior open_price preserved — even though the brokerage
// export shows cost_basis=100 → would-synthesize $100/share.
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_a[0].open_price, 0.01);
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_b[0].open_price, 0.01);
// Prior note preserved (carries the original 2024-01-15
// import date, not today's).
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_a[0].note.?);
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_b[0].note.?);
// The serialized bytes match — what `git diff` would
// actually see. This is the property the merge layer adds.
const bytes_a = try cache.serializePortfolio(allocator, lots_a);
defer allocator.free(bytes_a);
const bytes_b = try cache.serializePortfolio(allocator, lots_b);
defer allocator.free(bytes_b);
try testing.expectEqualStrings(bytes_a, bytes_b);
}
test "synthesizeLots: unmapped account_number fails with UnmappedAccount" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{
.account_number = "UNKNOWN",
.account_name = "?",
.symbol = "AAPL",
.description = "",
.quantity = 1,
.current_value = 100,
.cost_basis = 100,
.is_cash = false,
},
};
try testing.expectError(error.UnmappedAccount, synthesizeLots(
testing.io,
allocator,
&positions,
account_map,
.{ .fidelity = "" },
Date.fromYmd(2026, 5, 21),
null,
));
}
test "synthesizeLots: institution mismatch counts as unmapped" {
// Account number 1234 is registered for schwab; an entry coming
// in as fidelity must NOT match it. Catches the case where the
// user has the same trailing digits at two brokerages.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{
.account_number = "1234",
.account_name = "Some Fidelity Account",
.symbol = "AAPL",
.description = "",
.quantity = 1,
.current_value = 100,
.cost_basis = 100,
.is_cash = false,
},
};
try testing.expectError(error.UnmappedAccount, synthesizeLots(
testing.io,
allocator,
&positions,
account_map,
.{ .fidelity = "" },
Date.fromYmd(2026, 5, 21),
null,
));
}
test "synthesizeLots: multi-account export fans out per-account" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "Z111" },
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z222" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z111", .account_name = "Roth", .symbol = "VTI", .description = "", .quantity = 50, .current_value = 11000, .cost_basis = 10000, .is_cash = false },
.{ .account_number = "Z222", .account_name = "Brokerage", .symbol = "VTI", .description = "", .quantity = 100, .current_value = 22000, .cost_basis = 18000, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), null);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 2), lots.len);
try testing.expectEqualStrings("Sample Roth", lots[0].account.?);
try testing.expectEqualStrings("Sample Brokerage", lots[1].account.?);
try testing.expectApproxEqAbs(@as(f64, 200.0), lots[0].open_price, 0.01); // 10000 / 50
try testing.expectApproxEqAbs(@as(f64, 180.0), lots[1].open_price, 0.01); // 18000 / 100
}
// ── Merge-aware re-import tests ──────────────────────────────
test "synthesizeLots: prior lot for (symbol, account) preserves open_date and open_price" {
// Held position case: a (symbol, account) that's in both
// the prior portfolio and the new export inherits the
// prior lot's open_date and open_price. Brokerage shares
// come from the new export (positions might have grown).
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 90.0,
.account = "Sample Brokerage",
.security_type = .stock,
.note = "imported fidelity 2024-06-01",
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
// Export shows 120 shares now (user bought more) at avg
// cost $100 — but the merge keeps the prior open_price.
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
// shares from new export
try testing.expectApproxEqAbs(@as(f64, 120), lots[0].shares, 0.01);
// open_date preserved from prior
try testing.expectEqual(Date.fromYmd(2024, 6, 1).days, lots[0].open_date.days);
// open_price preserved from prior (NOT 12000/120 = 100)
try testing.expectApproxEqAbs(@as(f64, 90.0), lots[0].open_price, 0.01);
// note preserved from prior
try testing.expectEqualStrings("imported fidelity 2024-06-01", lots[0].note.?);
}
test "synthesizeLots: new position with no prior match gets sentinel + today's note" {
// A (symbol, account) that doesn't appear in the prior
// portfolio is treated as a brand-new position. open_date
// is the sentinel (we don't know when the user bought),
// open_price is computed from the export's cost basis,
// note carries today's date.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
// Prior has only AAPL; new export has both AAPL and a new
// GOOG.
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 90.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 100, .current_value = 15000, .cost_basis = 9000, .is_cash = false },
.{ .account_number = "Z123", .account_name = "I", .symbol = "GOOG", .description = "", .quantity = 25, .current_value = 4000, .cost_basis = 3500, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 2), lots.len);
// AAPL: prior path
try testing.expectEqualStrings("AAPL", lots[0].symbol);
try testing.expectEqual(Date.fromYmd(2024, 6, 1).days, lots[0].open_date.days);
// GOOG: new-lot path. Sentinel open_date, computed
// open_price ($3500 / 25 = $140), today-stamped note.
try testing.expectEqualStrings("GOOG", lots[1].symbol);
try testing.expectEqual(Date.epoch.days, lots[1].open_date.days);
try testing.expectApproxEqAbs(@as(f64, 140.0), lots[1].open_price, 0.01);
try testing.expect(std.mem.indexOf(u8, lots[1].note.?, "2026-05-21") != null);
}
test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" {
// Hand-edited or legacy file might carry multiple lots
// for the same (symbol, account). The merge should anchor
// on the EARLIEST open_date — that's the longest-standing
// buy and the right basis for trailing-return math.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 50,
.open_date = Date.fromYmd(2025, 8, 15),
.open_price = 220.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
.{
.symbol = "AAPL",
.shares = 50,
.open_date = Date.fromYmd(2022, 3, 10), // earlier — should win
.open_price = 150.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
.{
.symbol = "AAPL",
.shares = 50,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 180.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 150, .current_value = 30000, .cost_basis = 27500, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
// Earliest open_date (2022-03-10) wins, and so does its
// open_price ($150).
try testing.expectEqual(Date.fromYmd(2022, 3, 10).days, lots[0].open_date.days);
try testing.expectApproxEqAbs(@as(f64, 150.0), lots[0].open_price, 0.01);
}
test "synthesizeLots: prior closed lot does NOT anchor a held position" {
// If a prior lot has `close_date` set, treat it as gone
// — don't let it leak into the merge anchor. The new
// export shows the position is back; we treat it as a
// new lot.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 90.0,
.close_date = Date.fromYmd(2025, 6, 1),
.close_price = 200.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 50, .current_value = 7500, .cost_basis = 6000, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
// Closed prior lot was skipped; new lot got the new-lot
// path (sentinel open_date, computed open_price).
try testing.expectEqualStrings("AAPL", lots[0].symbol);
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 6000 / 50
}
test "synthesizeLots: positions dropped from new export are excluded (closed-lot drop)" {
// The merge isn't reciprocal: lots in the prior file
// that don't appear in the new export are silently
// dropped. This is the "import is a replace, not a
// merge of transactions" design. Documented as a
// limitation in the module doc-block.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 90.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
.{
.symbol = "GOOG",
.shares = 50,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100.0,
.account = "Sample Brokerage",
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
// Export only shows AAPL (user sold all GOOG).
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 100, .current_value = 15000, .cost_basis = 9000, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
// Only the AAPL lot survives; GOOG is silently dropped.
try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expectEqualStrings("AAPL", lots[0].symbol);
}
test "PriorLotsLookup: cash lots are excluded from the lookup" {
// Cash lots have synthetic symbols (often "CASH" or a
// money-market ticker) and aren't matched across imports
// — the brokerage's cash balance is the source of truth
// every time. Pin that they don't enter the lookup so we
// don't accidentally inherit a stale cash open_price/note.
const allocator = testing.allocator;
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "FZFXX",
.shares = 1000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.account = "Sample Brokerage",
.security_type = .cash,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);
}
test "parseArgs: --fidelity captures path" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--fidelity", "/path/to/fid.csv" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqualStrings("/path/to/fid.csv", parsed.source.fidelity);
try testing.expect(!parsed.yes);
}
test "parseArgs: --schwab captures path" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--schwab", "/path/to/schwab.csv" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqualStrings("/path/to/schwab.csv", parsed.source.schwab);
}
test "parseArgs: -y sets yes flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--fidelity", "f.csv", "-y" };
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.yes);
}
test "parseArgs: --yes long-form sets yes flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--yes", "--schwab", "s.csv" };
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.yes);
}
test "parseArgs: missing source errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{};
try testing.expectError(error.MissingSource, parseArgs(&ctx, &args));
}
test "parseArgs: --fidelity + --schwab errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--fidelity", "f.csv", "--schwab", "s.csv" };
try testing.expectError(error.ConflictingSources, parseArgs(&ctx, &args));
}
test "parseArgs: unknown flag errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{"--bogus"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --fidelity without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{"--fidelity"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "Source.label: returns broker name" {
try testing.expectEqualStrings("fidelity", (Source{ .fidelity = "" }).label());
try testing.expectEqualStrings("schwab", (Source{ .schwab = "" }).label());
try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = .{ .path = "", .account_override = null } }).label());
}
test "Source.path: returns the CSV path" {
try testing.expectEqualStrings("/x.csv", (Source{ .fidelity = "/x.csv" }).path());
try testing.expectEqualStrings("/y.csv", (Source{ .schwab = "/y.csv" }).path());
try testing.expectEqualStrings("/z.txt", (Source{ .wells_fargo = .{ .path = "/z.txt", .account_override = null } }).path());
}