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);
|
defer allocator.free(qdates.dates);
|
||||||
const as_of = as_of_override orelse (computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp()));
|
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
|
// Per-symbol candle close lookup keyed on `as_of`. Owns no string
|
||||||
// memory (keys borrow from the caller's `syms`).
|
// memory (keys borrow from the caller's `syms`).
|
||||||
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
||||||
|
|
@ -452,6 +473,37 @@ pub fn probeFreshAsOfDate(
|
||||||
return computeAsOfDate(infos);
|
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
|
/// Gather quote-date info for each symbol from the cache. Does not
|
||||||
/// fetch; relies on whatever the cache has. Symbols with no candles at
|
/// fetch; relies on whatever the cache has. Symbols with no candles at
|
||||||
/// all get `last_date = null`.
|
/// all get `last_date = null`.
|
||||||
|
|
@ -549,8 +601,10 @@ fn buildSnapshot(
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
qdates: QuoteDates,
|
qdates: QuoteDates,
|
||||||
) !Snapshot {
|
) !Snapshot {
|
||||||
// Totals
|
// Totals. Use `positionsAsOf(as_of)` rather than `positions()` so
|
||||||
const positions = try portfolio.positions(allocator);
|
// 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);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
var manual_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, @constCast(&prices));
|
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
|
// Analysis (optional — depends on metadata.srf existing). If it
|
||||||
// fails we still emit the snapshot with empty tax_type/account
|
// fails we still emit the snapshot with empty tax_type/account
|
||||||
// sections rather than failing the whole capture.
|
// 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 tax_types: []TaxTypeRow = &.{};
|
||||||
var accounts: []AccountRow = &.{};
|
var accounts: []AccountRow = &.{};
|
||||||
|
|
||||||
|
|
@ -602,7 +665,11 @@ fn buildSnapshot(
|
||||||
_ = syms;
|
_ = syms;
|
||||||
|
|
||||||
for (portfolio.lots) |lot| {
|
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 sec_label = lot.security_type.label();
|
||||||
const lot_sym = lot.symbol;
|
const lot_sym = lot.symbol;
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,31 @@ pub const Lot = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn isOpen(self: Lot) bool {
|
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| {
|
if (self.maturity_date) |mat| {
|
||||||
const today = Date.fromEpoch(std.time.timestamp());
|
if (!as_of.lessThan(mat)) return false;
|
||||||
if (!today.lessThan(mat)) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +326,20 @@ pub const Portfolio = struct {
|
||||||
|
|
||||||
/// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash).
|
/// 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.
|
/// 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 {
|
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);
|
var map = std.StringHashMap(Position).init(allocator);
|
||||||
defer map.deinit();
|
defer map.deinit();
|
||||||
|
|
||||||
|
|
@ -339,13 +373,20 @@ pub const Portfolio = struct {
|
||||||
entry.value_ptr.price_ratio = lot.price_ratio;
|
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.shares += lot.shares;
|
||||||
entry.value_ptr.total_cost += lot.costBasis();
|
entry.value_ptr.total_cost += lot.costBasis();
|
||||||
entry.value_ptr.open_lots += 1;
|
entry.value_ptr.open_lots += 1;
|
||||||
} else {
|
} else {
|
||||||
entry.value_ptr.closed_lots += 1;
|
// Closed-as-of: contributes realized gain IF the close
|
||||||
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
// 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());
|
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" {
|
test "nonStockValueForAccount" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const future = Date.fromYmd(2099, 12, 31);
|
const future = Date.fromYmd(2099, 12, 31);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue