add new compare command (CLI only for now)
This commit is contained in:
parent
0d3dfc6a55
commit
6a2cc8e775
4 changed files with 1999 additions and 0 deletions
56
README.md
56
README.md
|
|
@ -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
1465
src/commands/compare.zig
Normal file
File diff suppressed because it is too large
Load diff
18
src/main.zig
18
src/main.zig
|
|
@ -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
460
src/views/compare.zig
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue