allow determining portfolio positions for a specific date
This commit is contained in:
parent
dac310e38e
commit
1ef8ffd10d
2 changed files with 237 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue