add new compare command (CLI only for now)

This commit is contained in:
Emil Lerch 2026-05-01 08:52:43 -07:00
parent 0d3dfc6a55
commit 6a2cc8e775
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 1999 additions and 0 deletions

View file

@ -175,12 +175,66 @@ Commands:
earnings <SYMBOL> Earnings history and upcoming events
etf <SYMBOL> ETF profile (expense ratio, holdings, sectors)
portfolio <FILE> Portfolio analysis from .srf file
snapshot [opts] Write a daily portfolio snapshot to history/
compare <DATE> [<DATE>]
Compare portfolio state across two dates
cache stats Show cached symbols
cache clear Delete all cached data
interactive, i Launch interactive TUI
help Show usage
```
### compare
Compare the portfolio at two points in time. Useful for answering
"how am I doing since X" without the noise of the full portfolio
display.
```
zfin compare <DATE> Compare snapshot at DATE vs current live portfolio
zfin compare <DATE1> <DATE2> Compare two historical snapshots
```
Arguments can be given in any order — the command always displays
older → newer. Dates are `YYYY-MM-DD`. Snapshots come from
`history/YYYY-MM-DD-portfolio.srf` files produced by
`zfin snapshot` (typically run via cron).
**Output:**
- **Liquid:** raw value change — includes any contributions or
withdrawals made between the two dates (adjusting for flows is
out of scope).
- **Per-symbol price change:** for symbols held on *both* dates.
Sorted by % change descending (biggest winners first). The dollar
column uses the shares-held-throughout floor (`min(shares_then,
shares_now)`) so newly-added shares don't inflate it and sold
shares don't deflate it.
- **Hidden count:** positions added or removed between the two dates
are counted but not rendered.
On a missing snapshot date, the command prints the nearest earlier
and later available dates to stderr and exits 1 — no silent
snapping.
Example output shape (values illustrative):
```
$ zfin compare 2024-01-15 2024-03-15
Portfolio comparison: 2024-01-15 → 2024-03-15 (60 days)
Liquid: $100,000.00 → $105,000.00 +$5,000.00 +5.00%
Per-symbol price change (5 held throughout)
FOO $40.00 → $44.00 +10.00% +$400.00
BAR $100.00 → $105.00 +5.00% +$250.00
...
BAZ $50.00 → $48.00 -4.00% -$80.00
(1 added, 1 removed since 2024-01-15 — hidden)
```
### Interactive TUI flags
```
@ -472,6 +526,8 @@ Commands:
etf <SYMBOL> ETF profile (expense ratio, holdings, sectors)
portfolio [FILE] Portfolio summary (default: portfolio.srf)
analysis [FILE] Portfolio analysis breakdowns (default: portfolio.srf)
snapshot [opts] Write a daily portfolio snapshot to history/
compare <D1> [<D2>] Compare portfolio state across two dates
enrich <FILE|SYMBOL> Generate metadata.srf from Alpha Vantage
lookup <CUSIP> CUSIP to ticker lookup via OpenFIGI
cache stats Show cached symbols

1465
src/commands/compare.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@ const usage =
\\ analysis Show portfolio analysis
\\ contributions Show money added since last commit (git-based diff)
\\ snapshot [opts] Write a daily portfolio snapshot to history/
\\ compare <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
@ -282,6 +283,7 @@ fn runCli() !u8 {
!std.mem.eql(u8, command, "portfolio") and
!std.mem.eql(u8, command, "projections") and
!std.mem.eql(u8, command, "snapshot") and
!std.mem.eql(u8, command, "compare") and
!std.mem.eql(u8, command, "version");
// Upper-case the first arg for symbol-taking commands, but skip when
// the arg is a flag (starts with '-'). This lets commands like
@ -443,6 +445,21 @@ fn runCli() !u8 {
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
else => return err,
};
} else if (std.mem.eql(u8, command, "compare")) {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.compare.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) {
// All user-level validation errors return 1 silently the
// command already printed a message to stderr.
error.UnexpectedArg,
error.MissingDateArg,
error.InvalidDate,
error.SameDate,
error.SnapshotNotFound,
error.PortfolioLoadFailed,
=> return 1,
else => return err,
};
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1;
@ -515,6 +532,7 @@ const commands = struct {
const enrich = @import("commands/enrich.zig");
const contributions = @import("commands/contributions.zig");
const snapshot = @import("commands/snapshot.zig");
const compare = @import("commands/compare.zig");
const version = @import("commands/version.zig");
const projections = @import("commands/projections.zig");
};

460
src/views/compare.zig Normal file
View file

@ -0,0 +1,460 @@
//! `src/views/compare.zig` view model for the portfolio comparison UX.
//!
//! Used by the CLI `compare` command and (eventually) a TUI compare-mode
//! overlay on the history tab. Sits alongside `views/portfolio_sections.zig`
//! and `views/history.zig` in the views layer renderer-agnostic display
//! data, no ANSI, no writer, no vaxis.
//!
//! ## Semantics
//!
//! Two snapshots "then" and "now" each described as a map of
//! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be
//! either another historical snapshot or the live portfolio; the view
//! doesn't care which.
//!
//! ### Liquid totals
//!
//! Raw delta: `now - then`. This **includes contributions and withdrawals**.
//! The per-symbol section below is the pure investment signal total-level
//! returns are deliberately not adjusted for flows, because reconstructing
//! contribution history is out of scope.
//!
//! ### Per-symbol price change
//!
//! Only symbols held on **both** dates appear. Added/removed positions are
//! counted but not rendered that matches the "I don't care about added/
//! removed" constraint and keeps the output scannable.
//!
//! Two per-symbol numbers:
//! - `pct_change` = `price_now / price_then - 1` (price-only, share-count
//! changes between the two dates don't affect it)
//! - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)`
//! the "held throughout" dollar impact. Uses the share floor to
//! isolate continuously-held exposure; shares added between the dates
//! don't contribute (matching the "don't count adds" intent),
//! shares sold don't either.
//!
//! Sorted by `pct_change` descending biggest winners first.
//!
//! ## Contract
//!
//! No IO, no fetching, no rendering. The CLI command is responsible for
//! loading snapshots, aggregating lot rows by symbol, and producing the
//! two `HoldingMap`s. This module does the math and returns a sorted,
//! styled view.
const std = @import("std");
const fmt = @import("../format.zig");
const Date = @import("../models/date.zig").Date;
pub const StyleIntent = fmt.StyleIntent;
// Data types
/// A single per-symbol comparison row, pre-computed.
///
/// The `symbol` string is borrowed from the caller's `HoldingMap` the
/// caller must keep that map (and its backing buffers) alive as long as
/// the view.
pub const SymbolChange = struct {
symbol: []const u8,
price_then: f64,
price_now: f64,
/// `min(shares_then, shares_now)` the continuously-held floor.
/// Drives `dollar_change`.
shares_held_throughout: f64,
/// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by
/// 100 at format time via `fmtSignedPercentBuf` or similar.
pct_change: f64,
/// `shares_held_throughout * (price_now - price_then)`. Signed.
dollar_change: f64,
/// `.positive` when pct_change > 0, `.negative` when < 0,
/// `.muted` when exactly zero.
style: StyleIntent,
};
/// The liquid-total row for the totals section. Raw delta (includes flows).
pub const TotalsRow = struct {
then: f64,
now: f64,
/// `now - then`.
delta: f64,
/// Ratio. `0.05` means +5%. Zero when `then` is zero (avoids NaN).
pct: f64,
/// `.positive`/`.negative`/`.muted` by `delta` sign.
style: StyleIntent,
};
/// Complete compare view. `symbols` is caller-owned; call `deinit()`.
pub const CompareView = struct {
then_date: Date,
now_date: Date,
/// `now_date.days - then_date.days`. Can be zero (same-day compare
/// is a no-op but won't error) or negative if the caller didn't
/// normalize order, but the CLI always swaps so it's 0 in
/// practice.
days_between: i32,
/// True when the "now" side is the live portfolio (not another
/// snapshot). Renderers can use this to distinguish "compared to
/// today" from "compared to another snapshot date".
now_is_live: bool,
liquid: TotalsRow,
/// Sorted by `pct_change` descending. Owned by the view.
symbols: []SymbolChange,
/// Count of held-throughout symbols always `== symbols.len`;
/// surfaced here for convenience in rendering the "(N held
/// throughout)" subtitle.
held_count: usize,
/// Symbols present in "now" but not "then" position opened
/// between the two dates. Never rendered as rows; shown as a count.
added_count: usize,
/// Symbols present in "then" but not "now" position closed
/// between the two dates. Never rendered as rows; shown as a count.
removed_count: usize,
pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
allocator.free(self.symbols);
}
};
/// One entry in a holdings snapshot total shares held of `symbol` and
/// the per-share price at that moment. Caller-populated; the view model
/// doesn't know or care where the numbers came from.
pub const Holding = struct {
shares: f64,
price: f64,
};
/// Symbol Holding. String keys are caller-owned; keep them alive as
/// long as the resulting `CompareView`.
pub const HoldingMap = std.StringHashMap(Holding);
// Pure builders
/// Compute a single per-symbol change from the raw inputs.
///
/// The pct-change denominator is `price_then`. If `price_then` is zero
/// (shouldn't happen for stocks but guards against bad data), the
/// pct_change is reported as 0 rather than a NaN/Inf leaking into the
/// sort comparator.
pub fn buildSymbolChange(
symbol: []const u8,
shares_then: f64,
price_then: f64,
shares_now: f64,
price_now: f64,
) SymbolChange {
const held = @min(shares_then, shares_now);
const pct = if (price_then != 0) (price_now / price_then - 1.0) else 0.0;
const dollar = held * (price_now - price_then);
const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted;
return .{
.symbol = symbol,
.price_then = price_then,
.price_now = price_now,
.shares_held_throughout = held,
.pct_change = pct,
.dollar_change = dollar,
.style = style,
};
}
/// Compute the liquid totals row. Safe when `then == 0` (pct 0 rather
/// than NaN).
pub fn buildTotalsRow(then: f64, now: f64) TotalsRow {
const delta = now - then;
const pct = if (then != 0) delta / then else 0.0;
const style: StyleIntent = if (delta > 0) .positive else if (delta < 0) .negative else .muted;
return .{
.then = then,
.now = now,
.delta = delta,
.pct = pct,
.style = style,
};
}
/// Primary view builder. Intersects `then_map` with `now_map`, computes
/// per-symbol changes for held-throughout symbols, counts added/removed,
/// and sorts descending by `pct_change`.
///
/// Symbol strings in the returned view borrow from `then_map`'s keys
/// (since we iterate `then_map` to build the intersection) the caller
/// must keep `then_map` alive at least as long as the view. Alternatively,
/// if the view needs to outlive both maps, the caller can dupe the
/// strings before passing them in.
pub fn buildCompareView(
allocator: std.mem.Allocator,
then_date: Date,
now_date: Date,
now_is_live: bool,
liquid_then: f64,
liquid_now: f64,
then_map: *const HoldingMap,
now_map: *const HoldingMap,
) !CompareView {
var changes: std.ArrayList(SymbolChange) = .empty;
errdefer changes.deinit(allocator);
var added: usize = 0;
var removed: usize = 0;
// Count added: in now, not in then.
var now_it = now_map.iterator();
while (now_it.next()) |e| {
if (!then_map.contains(e.key_ptr.*)) added += 1;
}
// Walk "then" to build the intersection and count removed.
var then_it = then_map.iterator();
while (then_it.next()) |e| {
const sym = e.key_ptr.*;
const then_h = e.value_ptr.*;
if (now_map.get(sym)) |now_h| {
try changes.append(allocator, buildSymbolChange(
sym,
then_h.shares,
then_h.price,
now_h.shares,
now_h.price,
));
} else {
removed += 1;
}
}
// Sort by pct_change descending. Stable is fine; stability isn't
// semantically relevant here but is cheaper in the not-all-unique case.
std.mem.sort(SymbolChange, changes.items, {}, struct {
fn lt(_: void, a: SymbolChange, b: SymbolChange) bool {
return a.pct_change > b.pct_change;
}
}.lt);
const items = try changes.toOwnedSlice(allocator);
return .{
.then_date = then_date,
.now_date = now_date,
.days_between = now_date.days - then_date.days,
.now_is_live = now_is_live,
.liquid = buildTotalsRow(liquid_then, liquid_now),
.symbols = items,
.held_count = items.len,
.added_count = added,
.removed_count = removed,
};
}
// Tests
const testing = std.testing;
test "buildSymbolChange: positive price move, shares stable" {
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
try testing.expectEqualStrings("AAPL", c.symbol);
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9);
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
try testing.expectEqual(StyleIntent.positive, c.style);
}
test "buildSymbolChange: negative move" {
const c = buildSymbolChange("NFLX", 10, 500.0, 10, 400.0);
try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.negative, c.style);
}
test "buildSymbolChange: zero price move → muted style, zero dollar" {
const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0);
try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.muted, c.style);
}
test "buildSymbolChange: shares_held is min (added shares between dates)" {
// Started with 100, added 50, now at 150. Held-throughout floor = 100.
const c = buildSymbolChange("MSFT", 100, 400.0, 150, 420.0);
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
// dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000
try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: shares_held is min (sold shares between dates)" {
// Started with 200, sold down to 50. Held-throughout floor = 50.
const c = buildSymbolChange("GOOG", 200, 160.0, 50, 180.0);
try testing.expectEqual(@as(f64, 50), c.shares_held_throughout);
// dollar = 50 * (180-160) = 1000
try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: zero price_then doesn't NaN" {
const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0);
try testing.expectEqual(@as(f64, 0.0), c.pct_change);
try testing.expectEqual(StyleIntent.muted, c.style);
// dollar_change is still 10 * 50 = 500 that's the true held-throughout
// price delta even though % is undefined.
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
}
test "buildTotalsRow: positive delta" {
const t = buildTotalsRow(1_000_000.0, 1_050_000.0);
try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6);
try testing.expectApproxEqAbs(@as(f64, 0.05), t.pct, 1e-9);
try testing.expectEqual(StyleIntent.positive, t.style);
}
test "buildTotalsRow: negative delta" {
const t = buildTotalsRow(1_000_000.0, 950_000.0);
try testing.expectApproxEqAbs(@as(f64, -50_000.0), t.delta, 1e-6);
try testing.expectApproxEqAbs(@as(f64, -0.05), t.pct, 1e-9);
try testing.expectEqual(StyleIntent.negative, t.style);
}
test "buildTotalsRow: zero delta" {
const t = buildTotalsRow(100.0, 100.0);
try testing.expectEqual(@as(f64, 0.0), t.delta);
try testing.expectEqual(StyleIntent.muted, t.style);
}
test "buildTotalsRow: zero then doesn't NaN" {
const t = buildTotalsRow(0.0, 100.0);
try testing.expectEqual(@as(f64, 0.0), t.pct);
try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive
}
test "buildCompareView: intersection with added and removed" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
// Held in both (will appear)
try then_map.put("AAPL", .{ .shares = 100, .price = 150.0 });
try now_map.put("AAPL", .{ .shares = 100, .price = 165.0 });
try then_map.put("MSFT", .{ .shares = 50, .price = 400.0 });
try now_map.put("MSFT", .{ .shares = 50, .price = 395.0 });
// Removed (in then, not in now)
try then_map.put("NFLX", .{ .shares = 10, .price = 500.0 });
// Added (in now, not in then)
try now_map.put("TSLA", .{ .shares = 20, .price = 250.0 });
try now_map.put("NVDA", .{ .shares = 15, .price = 140.0 });
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 4, 20),
Date.fromYmd(2026, 4, 30),
true, // live
1_000_000.0,
1_050_000.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), view.held_count);
try testing.expectEqual(@as(usize, 2), view.added_count);
try testing.expectEqual(@as(usize, 1), view.removed_count);
try testing.expectEqual(@as(i32, 10), view.days_between);
try testing.expectEqual(true, view.now_is_live);
// Sort order: AAPL (+10%) before MSFT (-1.25%)
try testing.expectEqualStrings("AAPL", view.symbols[0].symbol);
try testing.expectEqualStrings("MSFT", view.symbols[1].symbol);
try testing.expect(view.symbols[0].pct_change > view.symbols[1].pct_change);
// Totals row
try testing.expectApproxEqAbs(@as(f64, 0.05), view.liquid.pct, 1e-9);
try testing.expectEqual(StyleIntent.positive, view.liquid.style);
}
test "buildCompareView: empty intersection" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
try then_map.put("OLD", .{ .shares = 10, .price = 100.0 });
try now_map.put("NEW", .{ .shares = 5, .price = 200.0 });
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 2, 1),
false,
1000.0,
2000.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), view.held_count);
try testing.expectEqual(@as(usize, 1), view.added_count);
try testing.expectEqual(@as(usize, 1), view.removed_count);
try testing.expectEqual(false, view.now_is_live);
}
test "buildCompareView: both empty" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 1, 1),
true,
0.0,
0.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), view.held_count);
try testing.expectEqual(@as(usize, 0), view.added_count);
try testing.expectEqual(@as(usize, 0), view.removed_count);
try testing.expectEqual(@as(i32, 0), view.days_between);
try testing.expectEqual(StyleIntent.muted, view.liquid.style);
}
test "buildCompareView: sort strictly descending across many symbols" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
// Seed 5 held-throughout symbols with distinct returns
try then_map.put("A", .{ .shares = 10, .price = 100.0 });
try now_map.put("A", .{ .shares = 10, .price = 110.0 }); // +10%
try then_map.put("B", .{ .shares = 10, .price = 100.0 });
try now_map.put("B", .{ .shares = 10, .price = 80.0 }); // -20%
try then_map.put("C", .{ .shares = 10, .price = 100.0 });
try now_map.put("C", .{ .shares = 10, .price = 130.0 }); // +30%
try then_map.put("D", .{ .shares = 10, .price = 100.0 });
try now_map.put("D", .{ .shares = 10, .price = 95.0 }); // -5%
try then_map.put("E", .{ .shares = 10, .price = 100.0 });
try now_map.put("E", .{ .shares = 10, .price = 105.0 }); // +5%
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 2, 1),
false,
5000.0,
5200.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 5), view.held_count);
// Expected order by pct desc: C (+30), A (+10), E (+5), D (-5), B (-20)
try testing.expectEqualStrings("C", view.symbols[0].symbol);
try testing.expectEqualStrings("A", view.symbols[1].symbol);
try testing.expectEqualStrings("E", view.symbols[2].symbol);
try testing.expectEqualStrings("D", view.symbols[3].symbol);
try testing.expectEqualStrings("B", view.symbols[4].symbol);
}