1434 lines
61 KiB
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());
|
|
}
|