From 1ef8ffd10d150e071b7fc1abc4e89e024eaa2e76 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 23 Apr 2026 00:36:22 -0700 Subject: [PATCH] allow determining portfolio positions for a specific date --- src/commands/snapshot.zig | 73 +++++++++++++++- src/models/portfolio.zig | 173 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 237 insertions(+), 9 deletions(-) diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index a7734d4..f8dc589 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -201,6 +201,27 @@ pub fn run( defer allocator.free(qdates.dates); const as_of = as_of_override orelse (computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp())); + // Under --as-of, skip days with no market activity (weekends, US + // market holidays). Detection is cache-based: if NO non-MM symbol + // has a candle dated exactly `as_of`, no market data was published + // for that date. Emitting a snapshot would just carry Friday's + // close forward with every row flagged stale — useless and + // polluting to the timeline. + // + // Not applied in auto mode: auto mode's as_of already comes from + // cache mode and is guaranteed to be a trading day. + if (as_of_override != null and !hasAnyTradingDayCandle(allocator, svc, syms, as_of)) { + var date_buf: [10]u8 = undefined; + var msg_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint( + &msg_buf, + "skipping {s}: no market data (weekend or holiday)\n", + .{as_of.format(&date_buf)}, + ) catch "skipping non-trading day\n"; + try cli.stderrPrint(msg); + return; + } + // Per-symbol candle close lookup keyed on `as_of`. Owns no string // memory (keys borrow from the caller's `syms`). var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); @@ -452,6 +473,37 @@ pub fn probeFreshAsOfDate( return computeAsOfDate(infos); } +/// Check whether any non-MM held symbol has a candle dated exactly +/// `date` in the cache. Used to detect non-trading days (weekends, +/// holidays) so `--as-of` backfill can skip them. +/// +/// "Any" rather than "all" because US market holidays may still have +/// international or money-market trading that'd show up on a few +/// symbols. The absence of US equity candles across the board is what +/// signals a non-trading day for our purposes. +pub fn hasAnyTradingDayCandle( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + symbols: []const []const u8, + date: Date, +) bool { + for (symbols) |sym| { + if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; + const cs = svc.getCachedCandles(sym) orelse continue; + defer allocator.free(cs); + // Linear scan from the end — recent dates are where `date` is + // most likely to land for a backfill. + var i: usize = cs.len; + while (i > 0) { + i -= 1; + if (cs[i].date.eql(date)) return true; + // Candles are sorted ascending; bail early once we're past. + if (cs[i].date.lessThan(date)) break; + } + } + return false; +} + /// Gather quote-date info for each symbol from the cache. Does not /// fetch; relies on whatever the cache has. Symbols with no candles at /// all get `last_date = null`. @@ -549,8 +601,10 @@ fn buildSnapshot( as_of: Date, qdates: QuoteDates, ) !Snapshot { - // Totals - const positions = try portfolio.positions(allocator); + // Totals. Use `positionsAsOf(as_of)` rather than `positions()` so + // historical backfills correctly count lots that were held on + // `as_of` regardless of whether they're open today. + const positions = try portfolio.positionsAsOf(allocator, as_of); defer allocator.free(positions); var manual_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, @constCast(&prices)); @@ -570,6 +624,15 @@ fn buildSnapshot( // Analysis (optional — depends on metadata.srf existing). If it // fails we still emit the snapshot with empty tax_type/account // sections rather than failing the whole capture. + // + // Known limitation: `runAnalysis` uses `isOpen()` (wall-clock today) + // for its per-account/tax-type roll-ups rather than + // `lotIsOpenAsOf(as_of)`. For backfill dates where lots have + // matured/closed/opened between `as_of` and today, the per-account + // totals can be off by the affected lots. The headline + // net_worth/liquid/illiquid totals are NOT affected — those flow + // through `positionsAsOf(as_of)` which honors the target date. + // Fixing analysis to be as_of-aware is tracked separately. var tax_types: []TaxTypeRow = &.{}; var accounts: []AccountRow = &.{}; @@ -602,7 +665,11 @@ fn buildSnapshot( _ = syms; for (portfolio.lots) |lot| { - if (!lot.isOpen()) continue; + // Use as_of (not wall-clock today) so backfill snapshots + // correctly include lots that had opened by then and exclude + // ones that had already closed or matured. See + // `Lot.lotIsOpenAsOf` for semantics. + if (!lot.lotIsOpenAsOf(as_of)) continue; const sec_label = lot.security_type.label(); const lot_sym = lot.symbol; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index ee22773..42e6003 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -149,10 +149,31 @@ pub const Lot = struct { } pub fn isOpen(self: Lot) bool { - if (self.close_date != null) return false; + return self.lotIsOpenAsOf(Date.fromEpoch(std.time.timestamp())); + } + + /// Was the lot held at end-of-day on `as_of`? + /// + /// Used by historical snapshot backfill (`zfin snapshot --as-of`) + /// where "open" must be evaluated against the target date rather + /// than wall-clock today. `isOpen()` delegates to this with + /// today as `as_of`. + /// + /// End-of-day semantics (see tests): + /// - `open_date > as_of` → not yet bought → false + /// - `close_date` on/before as_of → sold that day or earlier → false + /// - `maturity_date` on/before as_of → matured that day or earlier → false + /// - otherwise → true + pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool { + // Not yet bought on `as_of`. + if (as_of.lessThan(self.open_date)) return false; + // Sold on or before `as_of`. + if (self.close_date) |cd| { + if (!as_of.lessThan(cd)) return false; + } + // Matured on or before `as_of` (options, CDs). if (self.maturity_date) |mat| { - const today = Date.fromEpoch(std.time.timestamp()); - if (!today.lessThan(mat)) return false; + if (!as_of.lessThan(mat)) return false; } return true; } @@ -305,7 +326,20 @@ pub const Portfolio = struct { /// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash). /// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker. + /// + /// Uses wall-clock today for the open/closed determination. For + /// historical snapshot backfill where "today" is not the right + /// reference, use `positionsAsOf(allocator, as_of)`. pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { + return self.positionsAsOf(allocator, Date.fromEpoch(std.time.timestamp())); + } + + /// Like `positions` but evaluates lot open/closed against `as_of` + /// rather than wall-clock today. See `Lot.lotIsOpenAsOf` for + /// semantics. Used by historical snapshot backfill so a lot closed + /// after `as_of` still contributes its shares on that date, and + /// a lot opened after `as_of` does not. + pub fn positionsAsOf(self: Portfolio, allocator: std.mem.Allocator, as_of: Date) ![]Position { var map = std.StringHashMap(Position).init(allocator); defer map.deinit(); @@ -339,13 +373,20 @@ pub const Portfolio = struct { entry.value_ptr.price_ratio = lot.price_ratio; } } - if (lot.isOpen()) { + if (lot.lotIsOpenAsOf(as_of)) { entry.value_ptr.shares += lot.shares; entry.value_ptr.total_cost += lot.costBasis(); entry.value_ptr.open_lots += 1; } else { - entry.value_ptr.closed_lots += 1; - entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; + // Closed-as-of: contributes realized gain IF the close + // happened on/before as_of. Lots not yet opened as of + // the target date shouldn't contribute anything — they + // didn't exist. + const not_yet_opened = as_of.lessThan(lot.open_date); + if (!not_yet_opened) { + entry.value_ptr.closed_lots += 1; + entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; + } } } @@ -812,6 +853,126 @@ test "isOpen respects maturity_date" { try std.testing.expect(stock.isOpen()); } +// ── lotIsOpenAsOf ──────────────────────────────────────────── +// +// `isOpen()` asks "is this lot held right now (wall-clock today)?" +// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on +// `as_of`?" — needed for historical snapshot backfill where wall-clock +// `today` is not the relevant reference date. +// +// Rules (end-of-day semantics): +// - open_date > as_of → not yet bought → CLOSED +// - close_date set and <= as_of → sold on/before → CLOSED +// - maturity_date set and <= as_of → matured on/before → CLOSED +// - otherwise → open +// +// "Closed on D excluded from D snapshot" is deliberate (end-of-day +// semantics: a lot sold on D is not held at day-end). Symmetric: "opened +// on D included in D snapshot" — you bought it that day, you hold it at +// day-end. + +test "lotIsOpenAsOf: open_date after as_of excludes" { + const lot = Lot{ + .symbol = "X", + .shares = 10, + .open_date = Date.fromYmd(2026, 4, 9), + .open_price = 100.0, + }; + try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); + try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); // opened that day + try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 10))); +} + +test "lotIsOpenAsOf: close_date on or before as_of excludes" { + const lot = Lot{ + .symbol = "X", + .shares = 10, + .open_date = Date.fromYmd(2026, 1, 1), + .open_price = 100.0, + .close_date = Date.fromYmd(2026, 4, 6), + .close_price = 110.0, + }; + try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 5))); // still open + try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); // sold that day + try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 7))); +} + +test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" { + // Option opened 03-16, matured 04-17. Asking about 04-06 should + // return true — open, maturity hasn't happened yet on 04-06. + // This was the real bug: isOpen() used wall-clock today, so + // backfilling any date before today but after maturity wrongly + // excluded the lot. + const opt = Lot{ + .symbol = "NVDA 04/17/2026 200 C", + .shares = -5, + .open_date = Date.fromYmd(2026, 3, 16), + .open_price = 2.79, + .security_type = .option, + .maturity_date = Date.fromYmd(2026, 4, 17), + }; + try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); + try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 16))); + try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 17))); // matured that day + try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 18))); +} + +test "lotIsOpenAsOf: close wins over maturity (closed early)" { + // Option opened 03-16, closed early 04-09, nominal maturity 04-17. + // On 04-06 (before both): open. + // On 04-09 (closed that day): not open. + // On 04-15 (between close and maturity): not open (already closed). + const opt = Lot{ + .symbol = "NVDA 04/17/2026 200 C", + .shares = -5, + .open_date = Date.fromYmd(2026, 3, 16), + .open_price = 2.79, + .security_type = .option, + .close_date = Date.fromYmd(2026, 4, 9), + .close_price = 0.09, + .maturity_date = Date.fromYmd(2026, 4, 17), + }; + try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); + try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 8))); + try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); + try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 15))); +} + +test "lotIsOpenAsOf: plain stock with no close, no maturity" { + const lot = Lot{ + .symbol = "AAPL", + .shares = 100, + .open_date = Date.fromYmd(2024, 1, 1), + .open_price = 150.0, + }; + try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2023, 12, 31))); + try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2024, 1, 1))); + try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2100, 1, 1))); +} + +test "lotIsOpenAsOf: isOpen() stays compatible via today" { + // Regression guard: isOpen() should still behave as before — + // equivalent to lotIsOpenAsOf(today). Test with a lot whose + // status doesn't depend on date to keep this deterministic. + const stock = Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 15), + .open_price = 150.0, + }; + try std.testing.expectEqual(stock.isOpen(), stock.lotIsOpenAsOf(Date.fromEpoch(std.time.timestamp()))); + + const closed = Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 15), + .open_price = 150.0, + .close_date = Date.fromYmd(2024, 6, 15), + .close_price = 200.0, + }; + try std.testing.expectEqual(closed.isOpen(), closed.lotIsOpenAsOf(Date.fromEpoch(std.time.timestamp()))); +} + test "nonStockValueForAccount" { const allocator = std.testing.allocator; const future = Date.fromYmd(2099, 12, 31);