allow determining portfolio positions for a specific date

This commit is contained in:
Emil Lerch 2026-04-23 00:36:22 -07:00
parent dac310e38e
commit 1ef8ffd10d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 237 additions and 9 deletions

View file

@ -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;

View file

@ -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);