From 6493a3745b156589357cda6096be967cddd249fa Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 20 Apr 2026 05:17:41 -0700 Subject: [PATCH] add contributions command --- src/commands/audit.zig | 22 +- src/commands/contributions.zig | 1201 ++++++++++++++++++++++++++++++++ src/main.zig | 402 ++++++++--- src/tui.zig | 27 +- 4 files changed, 1501 insertions(+), 151 deletions(-) create mode 100644 src/commands/contributions.zig diff --git a/src/commands/audit.zig b/src/commands/audit.zig index a69dba7..d3d88a6 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1168,12 +1168,10 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. // ── CLI entry point ───────────────────────────────────────── -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer) !void { var fidelity_csv: ?[]const u8 = null; var schwab_csv: ?[]const u8 = null; var schwab_summary = false; - var portfolio_path: []const u8 = "portfolio.srf"; - var explicit_portfolio = false; var i: usize = 0; while (i < args.len) : (i += 1) { @@ -1185,32 +1183,16 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [ schwab_csv = args[i]; } else if (std.mem.eql(u8, args[i], "--schwab-summary")) { schwab_summary = true; - } else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) { - i += 1; - portfolio_path = args[i]; - explicit_portfolio = true; - } else if (std.mem.eql(u8, args[i], "--no-color")) { - // handled globally - } - } - - var resolved_pf: ?zfin.Config.ResolvedPath = null; - defer if (resolved_pf) |r| r.deinit(allocator); - if (!explicit_portfolio) { - if (svc.config.resolveUserFile(allocator, portfolio_path)) |r| { - resolved_pf = r; - portfolio_path = r.path; } } if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { try cli.stderrPrint( - \\Usage: zfin audit [options] [-p ] + \\Usage: zfin [-p ] audit [options] \\ \\ --fidelity Fidelity positions CSV export \\ --schwab Schwab per-account positions CSV export \\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end) - \\ -p, --portfolio Portfolio file (default: portfolio.srf) \\ ); return; diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig new file mode 100644 index 0000000..d7899b6 --- /dev/null +++ b/src/commands/contributions.zig @@ -0,0 +1,1201 @@ +//! `zfin contributions` — show money added to the portfolio since the +//! last recorded state in git. +//! +//! Compares two snapshots of portfolio.srf: +//! - dirty working tree: HEAD vs working copy (default case) +//! - clean working tree: HEAD~1 vs HEAD (review last commit) +//! +//! Classifies each lot-level change as: +//! - New contribution (new lot, or cash increase for a fresh cash line) +//! - DRIP / reinvestment (same (symbol, account, open_date, open_price), Δshares) +//! - CD matured (CD removed with maturity_date <= today) +//! - CD removed early (CD removed with maturity_date > today — flagged) +//! - Cash delta (cash shares changed on existing line) +//! - Price-only update (manual price:: field changed, no share change) +//! - Flagged (open_price, maturity, account or other edits) +//! +//! Relies on: portfolio.srf being tracked in a git repo, and the `git` +//! executable existing on PATH. We never rely on comments; maturity is +//! decided from the Lot.maturity_date field, not from the file's form. + +const std = @import("std"); +const zfin = @import("../root.zig"); +const cli = @import("common.zig"); +const fmt = cli.fmt; +const Date = zfin.Date; +const Lot = zfin.Lot; +const LotType = @import("../models/portfolio.zig").LotType; + +// ── Public entry point ─────────────────────────────────────── + +pub fn run( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + color: bool, + out: *std.Io.Writer, +) !void { + // Arena for all transient allocations: git subprocess buffers, duped path + // strings, the snapshot blobs, the symbol set, price map, and the Report + // itself. Portfolio objects use the base allocator (they own their own + // deinit). One defer cleans everything up at once. + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // 1. Figure out the git repo and the portfolio's path inside it. + const repo = findGitRepo(arena, portfolio_path) catch |err| { + switch (err) { + error.NotInGitRepo => try cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n"), + error.GitUnavailable => try cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n"), + else => try cli.stderrPrint("Error locating git repo.\n"), + } + return; + }; + + // 2. Decide which snapshots to compare. + const status = try pathStatus(arena, repo.root, repo.rel_path); + if (status == .untracked) { + try cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); + return; + } + const dirty = status == .modified; + + // 3. Pull both snapshots. + const before = if (dirty) + gitShow(arena, repo.root, "HEAD", repo.rel_path) catch |err| { + switch (err) { + error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD.\n"), + else => try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"), + } + return; + } + else + gitShow(arena, repo.root, "HEAD~1", repo.rel_path) catch |err| { + switch (err) { + error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD~1.\n"), + error.UnknownRevision => try cli.stderrPrint("Error: no prior commit to compare against (HEAD~1 does not exist).\n"), + else => try cli.stderrPrint("Error reading HEAD~1:portfolio.srf from git.\n"), + } + return; + }; + + const after = if (dirty) + std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { + try cli.stderrPrint("Error reading working-copy portfolio file.\n"); + return; + } + else + gitShow(arena, repo.root, "HEAD", repo.rel_path) catch { + try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"); + return; + }; + + // 4. Parse both. Portfolio uses the base allocator; its own deinit frees + // its internals independently of the arena. + var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { + try cli.stderrPrint("Error parsing before-snapshot portfolio.\n"); + return; + }; + defer before_pf.deinit(); + + var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { + try cli.stderrPrint("Error parsing after-snapshot portfolio.\n"); + return; + }; + defer after_pf.deinit(); + + // 5. Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation. + var prices = std.StringHashMap(f64).init(arena); + // Union of stock symbols from both snapshots. + var sym_set = std.StringHashMap(void).init(arena); + for (before_pf.lots) |l| { + if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { + try sym_set.put(l.priceSymbol(), {}); + } + } + for (after_pf.lots) |l| { + if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { + try sym_set.put(l.priceSymbol(), {}); + } + } + var syms: std.ArrayList([]const u8) = .empty; + var sit = sym_set.keyIterator(); + while (sit.next()) |k| try syms.append(arena, k.*); + + if (syms.items.len > 0) { + var load_result = cli.loadPortfolioPrices(svc, syms.items, &.{}, false, color); + defer load_result.deinit(); + var pit = load_result.prices.iterator(); + while (pit.next()) |entry| { + try prices.put(entry.key_ptr.*, entry.value_ptr.*); + } + } + + // 6. Compute the diff and print the report. The Report's backing memory + // lives in the arena; no explicit deinit needed. + const report = try computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()); + + try printReport(out, &report, dirty, color); + try out.flush(); +} + +// ── Git discovery / invocation ─────────────────────────────── + +const RepoInfo = struct { + /// Absolute path to the repo root. + root: []const u8, + /// Relative path from root to the portfolio file (using '/'-style separators). + rel_path: []const u8, +}; + +fn findGitRepo(allocator: std.mem.Allocator, portfolio_path: []const u8) !RepoInfo { + // Resolve the portfolio file's directory. + const abs_path = try std.fs.cwd().realpathAlloc(allocator, portfolio_path); + const dir = std.fs.path.dirname(abs_path) orelse "/"; + + // git -C rev-parse --show-toplevel + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" }, + .max_output_bytes = 64 * 1024, + }) catch { + return error.GitUnavailable; + }; + + switch (result.term) { + .Exited => |code| if (code != 0) return error.NotInGitRepo, + else => return error.NotInGitRepo, + } + + const root_raw = std.mem.trim(u8, result.stdout, " \t\r\n"); + const root = try allocator.dupe(u8, root_raw); + + // Relative path from root to the portfolio file. + const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len) + std.mem.trimLeft(u8, abs_path[root.len..], "/") + else + std.fs.path.basename(abs_path); + const rel = try allocator.dupe(u8, rel_raw); + + return .{ .root = root, .rel_path = rel }; +} + +/// Inspect the portfolio file's git status. Only the portfolio file is +/// considered (via pathspec); untracked files elsewhere in the repo are +/// ignored. Returns one of: +/// - `.modified`: tracked and has unstaged and/or staged changes +/// - `.clean`: tracked and matches HEAD (no uncommitted changes) +/// - `.untracked`: not yet added to the repo (no HEAD version to diff against) +const PathStatus = enum { modified, clean, untracked }; + +fn pathStatus(allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8) !PathStatus { + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path }, + .max_output_bytes = 64 * 1024, + }) catch return error.GitUnavailable; + + switch (result.term) { + .Exited => |code| if (code != 0) return error.GitStatusFailed, + else => return error.GitStatusFailed, + } + + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return .clean; + // Porcelain format: "XY " where X is index status, Y is worktree status. + // "??" means untracked. Anything else with at least one non-space is a modification. + if (trimmed.len >= 2 and trimmed[0] == '?' and trimmed[1] == '?') return .untracked; + return .modified; +} + +/// Run `git show :` and return the stdout bytes. +fn gitShow(allocator: std.mem.Allocator, root: []const u8, rev: []const u8, rel_path: []const u8) ![]const u8 { + // Build ":". + const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path }); + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "git", "-C", root, "show", spec }, + .max_output_bytes = 32 * 1024 * 1024, + }) catch return error.GitUnavailable; + + switch (result.term) { + .Exited => |code| { + if (code != 0) { + // Distinguish "no such revision" from "path missing". + if (std.mem.indexOf(u8, result.stderr, "unknown revision") != null or + std.mem.indexOf(u8, result.stderr, "bad revision") != null or + std.mem.indexOf(u8, result.stderr, "ambiguous argument") != null) + { + return error.UnknownRevision; + } + if (std.mem.indexOf(u8, result.stderr, "does not exist") != null or + std.mem.indexOf(u8, result.stderr, "exists on disk, but not in") != null) + { + return error.PathMissingInRev; + } + return error.GitShowFailed; + } + }, + else => return error.GitShowFailed, + } + + return result.stdout; +} + +// ── Diff algorithm ─────────────────────────────────────────── + +/// Categorized change on a single lot-key. +const ChangeKind = enum { + new_stock, // lot appeared: stock purchase (drip::false) + new_drip_lot, // lot appeared: stock with drip::true (confirmed DRIP reinvestment) + new_cash, // lot appeared: cash added + new_cd, // lot appeared: CD opened + new_option, // lot appeared: option opened + drip_confirmed, // same key on a drip::true stock lot, Δshares > 0 + rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish) + drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot — unusual) + cash_delta, // same key, cash, Δshares + cd_matured, // lot disappeared: CD with maturity_date <= today + cd_removed_early, // lot disappeared: CD with maturity_date > today + lot_removed, // lot disappeared: stock/cash/option + price_only, // same key, price:: field changed, no share change + flagged, // any other shape of edit +}; + +const Change = struct { + kind: ChangeKind, + symbol: []const u8, + account: []const u8, + security_type: LotType, + /// Δshares (after - before). Zero for price_only and lot_removed. + delta_shares: f64 = 0, + /// Price used to value delta_shares (open_price, current price, or manual price). + unit_value: f64 = 0, + /// For cd_matured / cd_removed_early / lot_removed: the before-lot's shares. + face_value: f64 = 0, + /// For cd_matured / cd_removed_early: maturity_date. + maturity_date: ?Date = null, + /// For price_only: old and new values. + old_price: f64 = 0, + new_price: f64 = 0, + /// Free-form detail for flagged changes. + detail: ?[]const u8 = null, + + pub fn value(self: Change) f64 { + return self.delta_shares * self.unit_value; + } +}; + +/// Summary aggregated for the report. All string fields and backing memory +/// live in the caller-supplied arena; there is no explicit deinit. +const Report = struct { + changes: []Change, + /// Per-account rollups for the summary section. + account_totals: std.StringHashMap(AccountTotal), + + const AccountTotal = struct { + new_money: f64 = 0, // stock+cd+cash new lots (drip::false) + drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased) + rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous) + cd_interest: f64 = 0, // implied interest from matured CDs + cash_delta: f64 = 0, // unclassified cash balance changes + }; +}; + +/// Build a canonical lookup key for matching lots between snapshots. +/// Key: (security_type, symbol, account, open_date, open_price). +fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { + var open_date_buf: [10]u8 = undefined; + return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{s}|{d:.6}", .{ + @tagName(lot.security_type), + lot.symbol, + lot.account orelse "", + lot.open_date.format(&open_date_buf), + lot.open_price, + }); +} + +/// Aggregate duplicate-key lots by summing shares. (Rare in practice but +/// possible.) Returns map key -> (shares, representative Lot). +const LotAgg = struct { + shares: f64, + lot: Lot, +}; + +fn aggregateByKey( + allocator: std.mem.Allocator, + lots: []const Lot, +) !std.StringHashMap(LotAgg) { + var map = std.StringHashMap(LotAgg).init(allocator); + + for (lots) |lot| { + const k = try lotKey(allocator, lot); + const gop = try map.getOrPut(k); + if (gop.found_existing) { + gop.value_ptr.shares += lot.shares; + } else { + gop.value_ptr.* = .{ .shares = lot.shares, .lot = lot }; + } + } + return map; +} + +fn computeReport( + allocator: std.mem.Allocator, + before: []const Lot, + after: []const Lot, + prices: *const std.StringHashMap(f64), + today: Date, +) !Report { + var changes: std.ArrayList(Change) = .empty; + + var before_map = try aggregateByKey(allocator, before); + var after_map = try aggregateByKey(allocator, after); + + // Helper for duping strings into the arena so Change fields have + // predictable lifetimes even if caller-supplied lot strings go away. + const Dup = struct { + a: std.mem.Allocator, + fn of(self: @This(), s: []const u8) ![]const u8 { + return self.a.dupe(u8, s); + } + }; + const sdup = Dup{ .a = allocator }; + + // Pass 1: keys in after. Classify as new, shares-changed, or (matched + // with price-only or other-metadata changes) edited. + var ait = after_map.iterator(); + while (ait.next()) |entry| { + const after_agg = entry.value_ptr.*; + if (before_map.get(entry.key_ptr.*)) |before_agg| { + // Key present in both. Compare shares and other fields. + const delta = after_agg.shares - before_agg.shares; + const lot = after_agg.lot; + const acct = try sdup.of(lot.account orelse ""); + const sym = try sdup.of(lot.symbol); + if (@abs(delta) > 0.000001) { + const before_lot = before_agg.lot; + const is_drip = lot.drip or before_lot.drip; + const kind: ChangeKind = switch (lot.security_type) { + .stock => if (delta > 0) + (if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta) + else + ChangeKind.drip_negative, + .cash => .cash_delta, + .cd => .flagged, // CD face value shouldn't change on the same key + .option => .flagged, + else => .flagged, + }; + // Determine unit_value for stocks: prefer current cached price; + // fall back to manual price::; fall back to open_price. + const unit_value: f64 = blk: { + if (lot.security_type == .stock) { + if (prices.get(lot.priceSymbol())) |p| break :blk p * lot.price_ratio; + if (lot.price) |p| break :blk p * lot.price_ratio; + break :blk lot.open_price * lot.price_ratio; + } + // cash/cd: 1:1 with shares + break :blk 1.0; + }; + try changes.append(allocator, .{ + .kind = kind, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .delta_shares = delta, + .unit_value = unit_value, + }); + } else { + // Same shares. Check for price:: field or other metadata changes. + const before_lot = before_agg.lot; + const a_price = lot.price; + const b_price = before_lot.price; + if ((a_price != null) != (b_price != null) or + (a_price != null and b_price != null and @abs(a_price.? - b_price.?) > 0.000001)) + { + try changes.append(allocator, .{ + .kind = .price_only, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .old_price = b_price orelse 0, + .new_price = a_price orelse 0, + }); + } else if ((lot.maturity_date == null) != (before_lot.maturity_date == null) or + (lot.maturity_date != null and before_lot.maturity_date != null and + !lot.maturity_date.?.eql(before_lot.maturity_date.?))) + { + var old_buf: [10]u8 = undefined; + var new_buf: [10]u8 = undefined; + const old_str = if (before_lot.maturity_date) |d| d.format(&old_buf) else "(none)"; + const new_str = if (lot.maturity_date) |d| d.format(&new_buf) else "(none)"; + const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str }); + try changes.append(allocator, .{ + .kind = .flagged, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .detail = detail, + }); + } + // Other edits (note, rate, etc.) are intentionally ignored to + // keep noise low. + } + } else { + // Key only in after → new lot. + const lot = after_agg.lot; + const acct = try sdup.of(lot.account orelse ""); + const sym = try sdup.of(lot.symbol); + const kind: ChangeKind = switch (lot.security_type) { + .stock => if (lot.drip) ChangeKind.new_drip_lot else ChangeKind.new_stock, + .cash => .new_cash, + .cd => .new_cd, + .option => .new_option, + else => .flagged, + }; + // For fresh stock lots: value at open_price (that's literally the + // money that went in). For cash: shares == dollars. For CDs: + // face = shares × open_price. + const unit_value: f64 = switch (lot.security_type) { + .stock => lot.open_price * lot.price_ratio, + .cash => 1.0, + .cd => lot.open_price, + .option => lot.open_price * lot.multiplier, + else => lot.open_price, + }; + try changes.append(allocator, .{ + .kind = kind, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .delta_shares = after_agg.shares, + .unit_value = unit_value, + }); + } + } + + // Pass 2: keys in before but not in after → lot disappeared. + var bit = before_map.iterator(); + while (bit.next()) |entry| { + if (after_map.contains(entry.key_ptr.*)) continue; + const before_agg = entry.value_ptr.*; + const lot = before_agg.lot; + const acct = try sdup.of(lot.account orelse ""); + const sym = try sdup.of(lot.symbol); + + var kind: ChangeKind = .lot_removed; + if (lot.security_type == .cd) { + if (lot.maturity_date) |mat| { + // "matured" if maturity_date <= today (i.e. NOT today.lessThan(mat)) + if (!today.lessThan(mat)) { + kind = .cd_matured; + } else { + kind = .cd_removed_early; + } + } else { + kind = .cd_removed_early; // no maturity — treat as flagged-ish + } + } + + try changes.append(allocator, .{ + .kind = kind, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .face_value = before_agg.shares * (if (lot.security_type == .cd) lot.open_price else 1.0), + .maturity_date = lot.maturity_date, + .delta_shares = -before_agg.shares, + }); + } + + // Build per-account totals. + var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator); + + for (changes.items) |c| { + const gop = try acct_totals.getOrPut(c.account); + if (!gop.found_existing) gop.value_ptr.* = .{}; + switch (c.kind) { + .new_stock, .new_cash, .new_cd, .new_option => { + gop.value_ptr.new_money += c.value(); + }, + .new_drip_lot, .drip_confirmed => { + gop.value_ptr.drip_confirmed += c.value(); + }, + .rollup_delta => { + gop.value_ptr.rollup += c.value(); + }, + .cash_delta => { + gop.value_ptr.cash_delta += c.value(); + }, + .cd_matured => { + // Interest is computed lazily against cash_delta in print(); + // we don't add face value to new_money (that's not new money). + }, + else => {}, + } + } + + return .{ + .changes = try changes.toOwnedSlice(allocator), + .account_totals = acct_totals, + }; +} + +// ── Output ─────────────────────────────────────────────────── + +fn printReport(out: *std.Io.Writer, report: *const Report, dirty: bool, color: bool) !void { + const h_color = cli.CLR_HEADER; + const pos_color = cli.CLR_POSITIVE; + const mut_color = cli.CLR_MUTED; + const warn_color = cli.CLR_WARNING; + + // Header + try cli.setBold(out, color); + try cli.setFg(out, color, h_color); + try out.writeAll("Portfolio contributions report\n"); + try cli.reset(out, color); + try cli.setFg(out, color, mut_color); + if (dirty) { + try out.writeAll(" Comparing working copy against HEAD\n\n"); + } else { + try out.writeAll(" Working tree clean — comparing HEAD~1 against HEAD\n\n"); + } + try cli.reset(out, color); + + // If nothing changed at all, say so explicitly and return. + if (report.changes.len == 0) { + try cli.setFg(out, color, mut_color); + try out.writeAll(" No changes detected.\n"); + try cli.reset(out, color); + return; + } + + // ── Section: New contributions / purchases ── + try printSection(out, "New contributions / purchases", color, h_color); + var any = false; + var new_total: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .new_stock, .new_cash, .new_cd, .new_option => { + any = true; + new_total += c.value(); + try printChangeLine(out, c, color, pos_color); + }, + else => {}, + }; + if (!any) try printNone(out, color, mut_color); + if (any) try printTotalLine(out, "Total", new_total, color, h_color); + try out.writeAll("\n"); + + // ── Section: DRIP (confirmed) ── + try printSection(out, "DRIP (confirmed — lots tagged drip::true)", color, h_color); + any = false; + var drip_total: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .new_drip_lot, .drip_confirmed => { + any = true; + drip_total += c.value(); + try printChangeLine(out, c, color, pos_color); + }, + else => {}, + }; + if (!any) try printNone(out, color, mut_color); + if (any) try printTotalLine(out, "Total", drip_total, color, h_color); + try out.writeAll("\n"); + + // ── Section: Rollup share deltas (ambiguous) ── + try printSection(out, "Rollup share deltas (DRIP or contribution)", color, h_color); + any = false; + var rollup_total: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .rollup_delta => { + any = true; + rollup_total += c.value(); + try printChangeLine(out, c, color, pos_color); + }, + else => {}, + }; + if (!any) try printNone(out, color, mut_color); + if (any) try printTotalLine(out, "Total", rollup_total, color, h_color); + try out.writeAll("\n"); + + // ── Section: CD events ── + try printSection(out, "CD events", color, h_color); + any = false; + var cd_interest_total: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .cd_matured, .cd_removed_early => { + any = true; + // Find same-account cash_delta to compute implied interest. + var matched_cash_delta: ?f64 = null; + for (report.changes) |c2| { + if (c2.kind == .cash_delta and std.mem.eql(u8, c2.account, c.account)) { + matched_cash_delta = c2.value(); + break; + } + } + const interest: ?f64 = if (c.kind == .cd_matured and matched_cash_delta != null) + matched_cash_delta.? - c.face_value + else + null; + if (interest) |i| if (i > 0) { + cd_interest_total += i; + }; + try printCdLine(out, c, interest, color); + }, + else => {}, + }; + if (!any) try printNone(out, color, mut_color); + if (any and cd_interest_total > 0) try printTotalLine(out, "Implied interest captured", cd_interest_total, color, h_color); + try out.writeAll("\n"); + + // ── Section: Cash deltas ── + try printSection(out, "Cash deltas (raw balance changes)", color, h_color); + any = false; + for (report.changes) |c| switch (c.kind) { + .cash_delta => { + any = true; + try printCashDeltaLine(out, c, report, color); + }, + else => {}, + }; + if (!any) try printNone(out, color, mut_color); + try out.writeAll("\n"); + + // ── Section: Price-only updates ── + var any_price = false; + for (report.changes) |c| if (c.kind == .price_only) { + any_price = true; + break; + }; + if (any_price) { + try printSection(out, "Price-only updates (informational)", color, h_color); + for (report.changes) |c| switch (c.kind) { + .price_only => { + try printPriceOnlyLine(out, c, color, mut_color); + }, + else => {}, + }; + try out.writeAll("\n"); + } + + // ── Section: Flagged ── + var any_flag = false; + for (report.changes) |c| switch (c.kind) { + .flagged, .lot_removed, .drip_negative => any_flag = true, + else => {}, + }; + if (any_flag) { + try printSection(out, "Flagged for review", color, h_color); + for (report.changes) |c| switch (c.kind) { + .flagged, .lot_removed, .drip_negative => { + try printFlaggedLine(out, c, color, warn_color); + }, + else => {}, + }; + try out.writeAll("\n"); + } + + // ── Summary ── + try printSection(out, "Summary by account", color, h_color); + var ait = report.account_totals.iterator(); + var total_new: f64 = 0; + var total_drip: f64 = 0; + var total_rollup: f64 = 0; + var total_cd_int: f64 = 0; + // Print per-account rows, recomputing CD interest as we go. + while (ait.next()) |entry| { + const acct = entry.key_ptr.*; + const t = entry.value_ptr.*; + // Recompute this account's CD interest from change list. + var cd_int: f64 = 0; + var face: f64 = 0; + for (report.changes) |c| { + if (!std.mem.eql(u8, c.account, acct)) continue; + if (c.kind == .cd_matured) face += c.face_value; + } + if (face > 0 and t.cash_delta > 0) { + const i = t.cash_delta - face; + if (i > 0) cd_int = i; + } + total_new += t.new_money; + total_drip += t.drip_confirmed; + total_rollup += t.rollup; + total_cd_int += cd_int; + + const acct_label = if (acct.len == 0) "(no account)" else acct; + try out.print(" {s:<28}", .{acct_label}); + try printSummaryCell(out, " new", t.new_money, color); + try printSummaryCell(out, " drip", t.drip_confirmed, color); + try printSummaryCell(out, " rollup", t.rollup, color); + try printSummaryCell(out, " cd-int", cd_int, color); + try out.writeAll("\n"); + } + try out.writeAll("\n"); + + // Grand totals + try cli.setBold(out, color); + try cli.setFg(out, color, h_color); + try out.writeAll("Totals\n"); + try cli.reset(out, color); + var buf1: [32]u8 = undefined; + var buf2: [32]u8 = undefined; + var buf3: [32]u8 = undefined; + var buf4: [32]u8 = undefined; + try out.print(" New contributions / purchases: {s}\n", .{fmt.fmtMoneyAbs(&buf1, total_new)}); + try out.print(" DRIP (confirmed): {s}\n", .{fmt.fmtMoneyAbs(&buf2, total_drip)}); + try out.print(" Rollup share deltas: {s} (DRIP or contribution; can't distinguish)\n", .{fmt.fmtMoneyAbs(&buf3, total_rollup)}); + if (total_cd_int > 0) { + try out.print(" CD interest captured: {s}\n", .{fmt.fmtMoneyAbs(&buf4, total_cd_int)}); + } +} + +fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { + try cli.setBold(out, color); + try cli.setFg(out, color, hdr); + try out.writeAll("== "); + try out.writeAll(title); + try out.writeAll(" ==\n"); + try cli.reset(out, color); +} + +fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void { + try cli.setFg(out, color, muted); + try out.writeAll(" (none)\n"); + try cli.reset(out, color); +} + +fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { + var buf: [32]u8 = undefined; + try cli.setFg(out, color, hdr); + try out.print(" {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) }); + try cli.reset(out, color); +} + +fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void { + var share_buf: [32]u8 = undefined; + var price_buf: [32]u8 = undefined; + var val_buf: [32]u8 = undefined; + const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?"; + const price_str = fmt.fmtMoneyAbs(&price_buf, c.unit_value); + const val_str = fmt.fmtMoneyAbs(&val_buf, c.value()); + + const acct = if (c.account.len == 0) "(no account)" else c.account; + try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); + if (c.security_type == .cash) { + try cli.setFg(out, color, pos); + try out.print(" {s}", .{val_str}); + try cli.reset(out, color); + } else { + try out.print(" {s} shares × {s} = ", .{ share_str, price_str }); + try cli.setFg(out, color, pos); + try out.print("{s}", .{val_str}); + try cli.reset(out, color); + } + try out.writeAll("\n"); +} + +fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void { + var face_buf: [32]u8 = undefined; + var mat_buf: [10]u8 = undefined; + const mat_str = if (c.maturity_date) |d| d.format(&mat_buf) else "(no maturity)"; + const acct = if (c.account.len == 0) "(no account)" else c.account; + const verb = switch (c.kind) { + .cd_matured => "matured", + .cd_removed_early => "removed EARLY", + else => "removed", + }; + try out.print(" {s:<14}{s:<24} {s:<16} face {s} maturity {s}\n", .{ + c.symbol, + acct, + verb, + fmt.fmtMoneyAbs(&face_buf, c.face_value), + mat_str, + }); + if (implied_interest) |i| { + var int_buf: [32]u8 = undefined; + try cli.setFg(out, color, cli.CLR_POSITIVE); + try out.print(" {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); + try cli.reset(out, color); + } +} + +fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void { + var val_buf: [32]u8 = undefined; + const v = c.value(); + const acct = if (c.account.len == 0) "(no account)" else c.account; + const sign = if (v >= 0) "+" else "-"; + try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); + try cli.setGainLoss(out, color, v); + try out.print("{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) }); + try cli.reset(out, color); + + // Hint if a CD matured in the same account. + for (report.changes) |o| { + if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) { + var face_buf: [32]u8 = undefined; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); + try cli.reset(out, color); + break; + } + } + try out.writeAll("\n"); +} + +fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { + var old_buf: [32]u8 = undefined; + var new_buf: [32]u8 = undefined; + const acct = if (c.account.len == 0) "(no account)" else c.account; + try cli.setFg(out, color, muted); + try out.print(" {s:<14}{s:<24} price {s} → {s}\n", .{ + c.symbol, + acct, + fmt.fmtMoneyAbs(&old_buf, c.old_price), + fmt.fmtMoneyAbs(&new_buf, c.new_price), + }); + try cli.reset(out, color); +} + +fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void { + const acct = if (c.account.len == 0) "(no account)" else c.account; + try cli.setFg(out, color, warn); + switch (c.kind) { + .flagged => { + try out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" }); + }, + .lot_removed => { + var face_buf: [32]u8 = undefined; + try out.print(" {s:<14}{s:<24} {s} lot removed (face {s})", .{ + c.symbol, acct, @tagName(c.security_type), fmt.fmtMoneyAbs(&face_buf, c.face_value), + }); + }, + .drip_negative => { + var val_buf: [32]u8 = undefined; + try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({s})", .{ + c.symbol, acct, fmt.fmtMoneyAbs(&val_buf, @abs(c.value())), + }); + }, + else => {}, + } + try cli.reset(out, color); + try out.writeAll("\n"); +} + +fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void { + var buf: [32]u8 = undefined; + try out.print("{s} ", .{label}); + if (v == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>12}", .{"-"}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_POSITIVE); + try out.print("{s:>12}", .{fmt.fmtMoneyAbs(&buf, v)}); + try cli.reset(out, color); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "computeReport: fresh stock purchase counts as new contribution" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("AAPL", 200.0); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 180, .account = "Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 1800.0), report.changes[0].value(), 0.01); +} + +test "computeReport: rollup_delta when shares increase on untagged lot" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VBTLX", 9.79); + + const before = [_]Lot{ + .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" }, + }; + const after = [_]Lot{ + .{ .symbol = "VBTLX", .shares = 1010, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + // drip::false on both sides → rollup_delta (ambiguous: DRIP or contribution) + try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01); +} + +test "computeReport: matured CD with maturity_date <= today" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Emil IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, + }; + const after = [_]Lot{}; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.cd_matured, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 58000.0), report.changes[0].face_value, 0.01); +} + +test "computeReport: CD removed before maturity flagged as early" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "CD2", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1) }, + }; + const after = [_]Lot{}; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.cd_removed_early, report.changes[0].kind); +} + +test "computeReport: price-only update not classified as cash flow" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9), .account = "401k" }, + }; + const after = [_]Lot{ + .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18), .account = "401k" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.price_only, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 161.71), report.changes[0].old_price, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 169.07), report.changes[0].new_price, 0.01); +} + +test "computeReport: CD matured + cash increase -> implied interest available" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + // Before: CD $58k + cash $3k. After: no CD, cash $62.5k. + const before = [_]Lot{ + .{ .symbol = "CDX", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, + .{ .symbol = "CASH", .shares = 3024.66, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, + }; + const after = [_]Lot{ + .{ .symbol = "CASH", .shares = 62510.95, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + // Expect: 1 cd_matured, 1 cash_delta + var n_matured: usize = 0; + var n_cash: usize = 0; + var cash_delta: f64 = 0; + var face: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .cd_matured => { + n_matured += 1; + face += c.face_value; + }, + .cash_delta => { + n_cash += 1; + cash_delta += c.value(); + }, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_matured); + try std.testing.expectEqual(@as(usize, 1), n_cash); + try std.testing.expectApproxEqAbs(@as(f64, 58000.0), face, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 62510.95 - 3024.66), cash_delta, 0.01); + // Implied interest: cash_delta - face = 59486.29 - 58000 = 1486.29 + try std.testing.expectApproxEqAbs(@as(f64, 1486.29), cash_delta - face, 0.01); +} + +test "computeReport: unit_value prefers current price over open_price for rollup delta" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 461.24); + + const before = [_]Lot{ + .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, + }; + const after = [_]Lot{ + .{ .symbol = "SPY", .shares = 718.4848, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); + // 1.1448 * 461.24 ≈ 528.07 + try std.testing.expectApproxEqAbs(@as(f64, 528.07), report.changes[0].value(), 0.1); +} + +test "computeReport: manual-priced lot (price:: no ticker) uses manual price" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + // No price in the map — should fall back to manual price::. + + const before = [_]Lot{ + .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" }, + }; + const after = [_]Lot{ + .{ .symbol = "NON40OR52", .shares = 5075.077, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); + // (5075.077 - 5070.866) = 4.211 shares × 169.07 = 711.96 + try std.testing.expectApproxEqAbs(@as(f64, 711.96), report.changes[0].value(), 0.5); +} + +test "computeReport: maturity_date change on same CD is flagged" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 7, 15) }, + }; + const after = [_]Lot{ + .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 7, 15) }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.flagged, report.changes[0].kind); +} + +test "computeReport: new lot with drip::true classified as new_drip_lot" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "FAGIX", .shares = 10.0, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01); +} + +test "computeReport: new stock lot without drip flag is new_stock" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); +} + +test "computeReport: drip::true existing lot with shares increase is drip_confirmed" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("FAGIX", 11.00); + + const before = [_]Lot{ + .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, + }; + const after = [_]Lot{ + .{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind); + try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01); +} + +test "computeReport: per-account totals separate drip_confirmed from rollup" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("FAGIX", 11.00); + try prices.put("VBTLX", 9.79); + + const before = [_]Lot{ + .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, + .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, + }; + const after = [_]Lot{ + .{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, + .{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); + + const t = report.account_totals.get("AcctA") orelse { + try std.testing.expect(false); + return; + }; + // drip_confirmed: 10 * 11 = 110 + try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01); + // rollup: 20 * 9.79 = 195.8 + try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01); +} diff --git a/src/main.zig b/src/main.zig index 7904397..f16e9ac 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,7 +4,7 @@ const tui = @import("tui.zig"); const cli = @import("commands/common.zig"); const usage = - \\Usage: zfin [options] + \\Usage: zfin [global options] [command options] \\ \\Commands: \\ interactive [opts] Launch interactive TUI @@ -16,43 +16,44 @@ const usage = \\ options Show options chain (all expirations) \\ earnings Show earnings history and upcoming \\ etf Show ETF profile (holdings, sectors, expense ratio) - \\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf) - \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) + \\ portfolio Load and analyze the portfolio + \\ analysis Show portfolio analysis + \\ contributions Show money added since last commit (git-based diff) \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ audit [opts] Reconcile portfolio against brokerage export \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ - \\Global options: - \\ --no-color Disable colored output - \\ - \\Interactive mode options: - \\ -p, --portfolio Portfolio file (.srf) + \\Global options (must appear before the subcommand): + \\ --no-color Disable colored output + \\ -p, --portfolio Portfolio file (default: portfolio.srf; cwd then ZFIN_HOME) + \\ metadata.srf and accounts.srf are loaded from the + \\ same directory as the resolved portfolio. \\ -w, --watchlist Watchlist file (default: watchlist.srf) + \\ + \\Interactive command options: \\ -s, --symbol Initial symbol (default: VTI) \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ \\Options command options: - \\ --ntm Show +/- N strikes near the money (default: 8) + \\ --ntm Show +/- N strikes near the money (default: 8) \\ \\Portfolio command options: - \\ If no file is given, searches current directory then ZFIN_HOME. - \\ -w, --watchlist Watchlist file \\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ \\Audit command options: \\ --fidelity Fidelity positions CSV export (download from "All accounts" positions tab) \\ --schwab Schwab per-account positions CSV export \\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin) - \\ -p, --portfolio Portfolio file (default: portfolio.srf) \\ - \\Analysis command: - \\ Reads metadata.srf (classification) and accounts.srf (tax types) - \\ from the same directory as the portfolio file. - \\ If no file is given, searches current directory then ZFIN_HOME. + \\Analysis & Contributions commands: + \\ Both operate on the globally-specified portfolio. They also read + \\ metadata.srf and accounts.srf from the same directory. + \\ Contributions additionally requires the portfolio file to be tracked + \\ in a git repo; `git` must be on PATH. \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) @@ -66,6 +67,81 @@ const usage = \\ ; +/// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME). +const Globals = struct { + no_color: bool = false, + /// Explicit portfolio path from -p/--portfolio (raw, null if not set). + portfolio_path: ?[]const u8 = null, + /// Explicit watchlist path from -w/--watchlist (raw, null if not set). + watchlist_path: ?[]const u8 = null, + /// Index into args of the first post-global token (the subcommand). + cursor: usize, +}; + +const GlobalParseError = error{ + MissingValue, + UnknownGlobalFlag, +}; + +/// Parse global flags from args[1..] up to the first non-flag (subcommand) +/// token. Errors if a pre-subcommand token starts with '-' but isn't a +/// recognized global, or if a value-taking flag is missing its value. +fn parseGlobals(args: []const []const u8) GlobalParseError!Globals { + var g: Globals = .{ .cursor = 1 }; + var i: usize = 1; + while (i < args.len) { + const a = args[i]; + if (a.len == 0 or a[0] != '-') break; + + if (std.mem.eql(u8, a, "--no-color")) { + g.no_color = true; + i += 1; + continue; + } + if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) { + if (i + 1 >= args.len) return error.MissingValue; + g.portfolio_path = args[i + 1]; + i += 2; + continue; + } + if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) { + if (i + 1 >= args.len) return error.MissingValue; + g.watchlist_path = args[i + 1]; + i += 2; + continue; + } + // Help flags are subcommand-like tokens, stop scanning. + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break; + + return error.UnknownGlobalFlag; + } + g.cursor = i; + return g; +} + +/// Resolve a portfolio-like path. If `explicit` is non-null the user supplied +/// it explicitly; we still run resolveUserFile to allow bare filenames to +/// resolve through cwd → ZFIN_HOME. If null, use the given default filename +/// and run through resolveUserFile. +fn resolveUserPath( + allocator: std.mem.Allocator, + config: zfin.Config, + explicit: ?[]const u8, + default_name: []const u8, +) struct { path: []const u8, resolved: ?zfin.Config.ResolvedPath } { + if (explicit) |p| { + // Try resolveUserFile so bare names like "foo.srf" fall back to ZFIN_HOME. + if (config.resolveUserFile(allocator, p)) |r| { + return .{ .path = r.path, .resolved = r }; + } + return .{ .path = p, .resolved = null }; + } + if (config.resolveUserFile(allocator, default_name)) |r| { + return .{ .path = r.path, .resolved = r }; + } + return .{ .path = default_name, .resolved = null }; +} + pub fn main() !u8 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -84,182 +160,181 @@ pub fn main() !u8 { return 1; } - // Scan for global --no-color flag - var no_color_flag = false; - for (args[1..]) |arg| { - if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true; - } - const color = @import("format.zig").shouldUseColor(no_color_flag); - - var config = zfin.Config.fromEnv(allocator); - defer config.deinit(); - const command = args[1]; - - if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) { + // Early help handling (before global parsing so `zfin --help` works). + if (std.mem.eql(u8, args[1], "help") or + std.mem.eql(u8, args[1], "--help") or + std.mem.eql(u8, args[1], "-h")) + { try out.writeAll(usage); try out.flush(); return 0; } - // Interactive TUI -- delegates to the TUI module (owns its own DataService) + // Parse global flags. + const globals = parseGlobals(args) catch |err| { + switch (err) { + error.MissingValue => try cli.stderrPrint("Error: global flag is missing its value\n"), + error.UnknownGlobalFlag => { + try cli.stderrPrint("Error: unknown global flag: "); + if (globalOffender(args)) |bad| { + try cli.stderrPrint(bad); + } + try cli.stderrPrint("\nRun 'zfin help' for usage.\n"); + }, + } + return 1; + }; + + if (globals.cursor >= args.len) { + try cli.stderrPrint("Error: missing command.\nRun 'zfin help' for usage.\n"); + return 1; + } + + const color = @import("format.zig").shouldUseColor(globals.no_color); + + var config = zfin.Config.fromEnv(allocator); + defer config.deinit(); + + const command = args[globals.cursor]; + const cmd_args = args[globals.cursor + 1 ..]; + + // Interactive TUI -- delegates to the TUI module (owns its own DataService). if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) { try out.flush(); - try tui.run(allocator, config, args); + try tui.run(allocator, config, globals.portfolio_path, globals.watchlist_path, cmd_args); return 0; } var svc = zfin.DataService.init(allocator, config); defer svc.deinit(); - // Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol. - // Skip normalization for commands where args[2] is a subcommand or file path. - if (args.len >= 3 and + // Normalize symbol argument (cmd_args[0]) to uppercase for commands + // that take a symbol. Skip for commands whose first arg is a subcommand + // or operand of a different kind. + const symbol_cmd = !std.mem.eql(u8, command, "cache") and !std.mem.eql(u8, command, "enrich") and !std.mem.eql(u8, command, "audit") and !std.mem.eql(u8, command, "analysis") and - !std.mem.eql(u8, command, "portfolio")) - { - for (args[2]) |*c| c.* = std.ascii.toUpper(c.*); + !std.mem.eql(u8, command, "contributions") and + !std.mem.eql(u8, command, "portfolio"); + if (symbol_cmd and cmd_args.len >= 1) { + for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); } if (std.mem.eql(u8, command, "perf")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'perf' requires a symbol argument\n"); return 1; } - try commands.perf.run(allocator, &svc, args[2], color, out); + try commands.perf.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "quote")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'quote' requires a symbol argument\n"); return 1; } - try commands.quote.run(allocator, &svc, args[2], color, out); + try commands.quote.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "history")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'history' requires a symbol argument\n"); return 1; } - try commands.history.run(allocator, &svc, args[2], color, out); + try commands.history.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "divs")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); return 1; } - try commands.divs.run(allocator, &svc, args[2], color, out); + try commands.divs.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "splits")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); return 1; } - try commands.splits.run(allocator, &svc, args[2], color, out); + try commands.splits.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "options")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'options' requires a symbol argument\n"); return 1; } - // Parse --ntm flag + // Parse --ntm flag. var ntm: usize = 8; - var ai: usize = 3; - while (ai < args.len) : (ai += 1) { - if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) { + var ai: usize = 1; + while (ai < cmd_args.len) : (ai += 1) { + if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) { ai += 1; - ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; + ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8; } } - try commands.options.run(allocator, &svc, args[2], ntm, color, out); + try commands.options.run(allocator, &svc, cmd_args[0], ntm, color, out); } else if (std.mem.eql(u8, command, "earnings")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); return 1; } - try commands.earnings.run(allocator, &svc, args[2], color, out); + try commands.earnings.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "etf")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); return 1; } - try commands.etf.run(allocator, &svc, args[2], color, out); + try commands.etf.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "portfolio")) { - // Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf) - var watchlist_path: ?[]const u8 = null; - var explicit_watchlist = false; + // Parse --refresh flag; reject any other token (including old + // positional FILE, which is now a global -p). var force_refresh = false; - var file_path: []const u8 = "portfolio.srf"; - var explicit_file = false; - var pi: usize = 2; - while (pi < args.len) : (pi += 1) { - if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) { - pi += 1; - watchlist_path = args[pi]; - explicit_watchlist = true; - } else if (std.mem.eql(u8, args[pi], "--refresh")) { + for (cmd_args) |a| { + if (std.mem.eql(u8, a, "--refresh")) { force_refresh = true; - } else if (std.mem.eql(u8, args[pi], "--no-color")) { - // already handled globally } else { - file_path = args[pi]; - explicit_file = true; + try reportUnexpectedArg("portfolio", a); + return 1; } } - // Resolve default file paths via ZFIN_HOME when not explicitly provided - var resolved_pf: ?zfin.Config.ResolvedPath = null; - defer if (resolved_pf) |r| r.deinit(allocator); - if (!explicit_file) { - if (config.resolveUserFile(allocator, file_path)) |r| { - resolved_pf = r; - file_path = r.path; - } - } - var resolved_wl: ?zfin.Config.ResolvedPath = null; - defer if (resolved_wl) |r| r.deinit(allocator); - if (!explicit_watchlist and watchlist_path == null) { - if (config.resolveUserFile(allocator, "watchlist.srf")) |r| { - resolved_wl = r; - watchlist_path = r.path; - } - } - try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out); + const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf"); + defer if (pf.resolved) |r| r.deinit(allocator); + const wl = resolveUserPath(allocator, config, globals.watchlist_path, "watchlist.srf"); + defer if (wl.resolved) |r| r.deinit(allocator); + const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null; + try commands.portfolio.run(allocator, &svc, pf.path, wl_path, force_refresh, color, out); } else if (std.mem.eql(u8, command, "lookup")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); return 1; } - try commands.lookup.run(allocator, &svc, args[2], color, out); + try commands.lookup.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "cache")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n"); return 1; } - try commands.cache.run(allocator, config, args[2], out); + try commands.cache.run(allocator, config, cmd_args[0], out); } else if (std.mem.eql(u8, command, "enrich")) { - if (args.len < 3) { + if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n"); return 1; } - try commands.enrich.run(allocator, &svc, args[2], out); + try commands.enrich.run(allocator, &svc, cmd_args[0], out); } else if (std.mem.eql(u8, command, "audit")) { - try commands.audit.run(allocator, &svc, args[2..], color, out); + const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf"); + defer if (pf.resolved) |r| r.deinit(allocator); + try commands.audit.run(allocator, &svc, pf.path, cmd_args, color, out); } else if (std.mem.eql(u8, command, "analysis")) { - // File path is first non-flag arg (default: portfolio.srf) - var analysis_file: []const u8 = "portfolio.srf"; - var explicit_analysis = false; - for (args[2..]) |arg| { - if (!std.mem.startsWith(u8, arg, "--")) { - analysis_file = arg; - explicit_analysis = true; - break; - } + for (cmd_args) |a| { + try reportUnexpectedArg("analysis", a); + return 1; } - var resolved_af: ?zfin.Config.ResolvedPath = null; - defer if (resolved_af) |r| r.deinit(allocator); - if (!explicit_analysis) { - if (config.resolveUserFile(allocator, analysis_file)) |r| { - resolved_af = r; - analysis_file = r.path; - } + const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf"); + defer if (pf.resolved) |r| r.deinit(allocator); + try commands.analysis.run(allocator, &svc, pf.path, color, out); + } else if (std.mem.eql(u8, command, "contributions")) { + for (cmd_args) |a| { + try reportUnexpectedArg("contributions", a); + return 1; } - try commands.analysis.run(allocator, &svc, analysis_file, color, out); + const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf"); + defer if (pf.resolved) |r| r.deinit(allocator); + try commands.contributions.run(allocator, &svc, pf.path, color, out); } else { try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); return 1; @@ -270,6 +345,50 @@ pub fn main() !u8 { return 0; } +/// Emit a consistent "unexpected argument" error, with a hint pointing at +/// the global-flag migration. Called when a command finds an arg it doesn't +/// understand (typically a stale positional file path or a misplaced global +/// flag like `--no-color` after the subcommand). +fn reportUnexpectedArg(command: []const u8, arg: []const u8) !void { + try cli.stderrPrint("Error: unexpected argument to '"); + try cli.stderrPrint(command); + try cli.stderrPrint("': "); + try cli.stderrPrint(arg); + try cli.stderrPrint("\n"); + if (std.mem.eql(u8, arg, "--no-color") or + std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--portfolio") or + std.mem.eql(u8, arg, "-w") or std.mem.eql(u8, arg, "--watchlist")) + { + try cli.stderrPrint("Hint: global flags must appear before the subcommand.\n"); + } else { + try cli.stderrPrint("Hint: the portfolio file is now a global option; use `zfin -p "); + try cli.stderrPrint(command); + try cli.stderrPrint("`.\n"); + } +} + +/// Return the first args[1..] token that looks like a flag we didn't handle. +/// Used only to craft an error message; best-effort. +fn globalOffender(args: []const []const u8) ?[]const u8 { + var i: usize = 1; + while (i < args.len) { + const a = args[i]; + if (a.len == 0 or a[0] != '-') return null; + if (std.mem.eql(u8, a, "--no-color")) { + i += 1; + continue; + } + if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio") or + std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) + { + i += 2; + continue; + } + return a; + } + return null; +} + // ── Command modules ────────────────────────────────────────── const commands = struct { const perf = @import("commands/perf.zig"); @@ -286,8 +405,61 @@ const commands = struct { const analysis = @import("commands/analysis.zig"); const audit = @import("commands/audit.zig"); const enrich = @import("commands/enrich.zig"); + const contributions = @import("commands/contributions.zig"); }; +// ── Tests ──────────────────────────────────────────────────── + +test "parseGlobals: no flags, subcommand only" { + const argv = [_][]const u8{ "zfin", "portfolio" }; + const g = try parseGlobals(&argv); + try std.testing.expectEqual(@as(usize, 1), g.cursor); + try std.testing.expectEqual(false, g.no_color); + try std.testing.expect(g.portfolio_path == null); + try std.testing.expect(g.watchlist_path == null); +} + +test "parseGlobals: --no-color, -p, -w then subcommand" { + const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" }; + const g = try parseGlobals(&argv); + try std.testing.expectEqual(@as(usize, 6), g.cursor); + try std.testing.expectEqual(true, g.no_color); + try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?); + try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?); +} + +test "parseGlobals: long forms" { + const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" }; + const g = try parseGlobals(&argv); + try std.testing.expectEqual(@as(usize, 5), g.cursor); + try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?); + try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?); +} + +test "parseGlobals: unknown flag errors" { + const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" }; + try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv)); +} + +test "parseGlobals: flag missing value errors" { + const argv = [_][]const u8{ "zfin", "-p" }; + try std.testing.expectError(error.MissingValue, parseGlobals(&argv)); +} + +test "parseGlobals: --help stops scanning" { + const argv = [_][]const u8{ "zfin", "--help" }; + const g = try parseGlobals(&argv); + try std.testing.expectEqual(@as(usize, 1), g.cursor); +} + +test "parseGlobals: subcommand-local flag NOT consumed as global" { + // `--refresh` is a portfolio-command flag; should stop global scanning + // when it appears before the subcommand (even though that's not the + // intended usage, make sure the error is "unknown global"). + const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" }; + try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv)); +} + // Single test binary: all source is in one module (file imports, no module // boundaries), so refAllDeclsRecursive discovers every test in the tree. test { diff --git a/src/tui.zig b/src/tui.zig index f31bee5..1282549 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1991,15 +1991,22 @@ comptime { } /// Entry point for the interactive TUI. -pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void { - var portfolio_path: ?[]const u8 = null; - var watchlist_path: ?[]const u8 = null; +/// `args` contains only command-local tokens (everything after `interactive`). +pub fn run( + allocator: std.mem.Allocator, + config: zfin.Config, + global_portfolio_path: ?[]const u8, + global_watchlist_path: ?[]const u8, + args: []const []const u8, +) !void { + var portfolio_path: ?[]const u8 = global_portfolio_path; + const watchlist_path: ?[]const u8 = global_watchlist_path; var symbol: []const u8 = ""; var symbol_upper_buf: [32]u8 = undefined; var has_explicit_symbol = false; var skip_watchlist = false; var chart_config: chart_mod.ChartConfig = .{}; - var i: usize = 2; + var i: usize = 0; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--default-keys")) { try keybinds.printDefaults(); @@ -2007,18 +2014,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co } else if (std.mem.eql(u8, args[i], "--default-theme")) { try theme_mod.printDefaults(); return; - } else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) { - if (i + 1 < args.len) { - i += 1; - portfolio_path = args[i]; - } - } else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) { - if (i + 1 < args.len) { - i += 1; - watchlist_path = args[i]; - } else { - watchlist_path = "watchlist.srf"; - } } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) { if (i + 1 < args.len) { i += 1;