From fbe8320399d9473bcdc998c685465a7949a3d51c Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 21 Apr 2026 12:34:10 -0700 Subject: [PATCH] clean up performance.zig --- src/analytics/performance.zig | 20 +++++++++++--------- src/models/portfolio.zig | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index 574ef30..5f8458e 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Date = @import("../models/date.zig").Date; const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; +const portfolio = @import("../models/portfolio.zig"); /// Minimum holding period (in years) before annualizing returns. /// Set below 1.0 to handle trading-day snap (e.g. a "1-year" lookback @@ -89,11 +90,14 @@ fn totalReturnWithDividendsSnap( to: Date, start_dir: SearchDirection, ) ?PerformanceResult { + // When `from` predates all cached candles, the only safe shortcut is + // for stable-NAV funds: if the earliest candle we have is at $1, we + // can extrapolate backward and synthesize a $1 candle at `from`. + // Reuse `stableNavCandle` so the synthesized candle has the same + // shape as the one used elsewhere. const start = findNearestCandle(candles, from, start_dir) orelse - // Stable-NAV fund (e.g. money market): synthesize start candle at $1 - // when candle history doesn't reach back far enough. if (candles.len > 0 and from.lessThan(candles[0].date) and candles[0].close == 1.0) - stableNavCandle(from) + portfolio.stableNavCandle(from) else return null; const end = findNearestCandle(candles, to, .backward) orelse return null; @@ -111,7 +115,7 @@ fn totalReturnWithDividendsSnap( // For stable-NAV funds, dividends before candle history use $1. const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse if (start.close == 1.0) - stableNavCandle(div.ex_date) + portfolio.stableNavCandle(div.ex_date) else continue; if (price_candle.close > 0) { @@ -131,11 +135,9 @@ fn totalReturnWithDividendsSnap( }; } -/// Synthesize a candle with stable $1 NAV for a given date. -/// Used for money market funds whose NAV is always $1. -fn stableNavCandle(date: Date) Candle { - return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 }; -} +// Stable-NAV candle synthesis lives in src/models/portfolio.zig +// (`portfolio.stableNavCandle`) so every caller agrees on the shape +// of a synthesized $1 candle. Performance-only heuristics stay here. /// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close. /// Uses the last available date as the endpoint. diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index c2f734c..18be790 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -1,5 +1,14 @@ const std = @import("std"); const Date = @import("date.zig").Date; +const Candle = @import("candle.zig").Candle; + +/// Synthesize a stable-NAV (= $1) candle for a given date. Used when +/// historical price data for a money-market fund doesn't reach back as +/// far as the period under analysis — the close is known to be $1 by +/// construction, so we can extrapolate backward without inventing data. +pub fn stableNavCandle(date: Date) Candle { + return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 }; +} /// Type of holding in a portfolio lot. pub const LotType = enum { @@ -806,3 +815,13 @@ test "totalForAccount" { const total = portfolio.totalForAccount(allocator, "IRA", prices); try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01); } + +test "stableNavCandle: fills all fields at $1" { + const c = stableNavCandle(Date.fromYmd(2026, 4, 1)); + try std.testing.expectEqual(@as(f64, 1), c.close); + try std.testing.expectEqual(@as(f64, 1), c.open); + try std.testing.expectEqual(@as(f64, 1), c.high); + try std.testing.expectEqual(@as(f64, 1), c.low); + try std.testing.expectEqual(@as(f64, 1), c.adj_close); + try std.testing.expectEqual(@as(u64, 0), c.volume); +}