From 66749690010d0c144717bd33d5aedae72679dfd4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 17 Aug 2026 19:39:45 -0700 Subject: [PATCH] consolidate returns calculations --- src/analytics/benchmark.zig | 6 +- src/analytics/performance.zig | 231 +++++++++++++++++++++++++++++++++- src/commands/perf.zig | 5 +- src/history.zig | 107 ---------------- src/models/candle.zig | 111 +++++++++++++++- src/service.zig | 46 +++---- src/views/projections.zig | 52 +++++--- src/views/review.zig | 78 +----------- 8 files changed, 396 insertions(+), 240 deletions(-) diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index 201cd3d..b57e0a9 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -56,7 +56,7 @@ pub const BenchmarkComparison = struct { /// Per-position data needed for weighted return calculations. /// `returns.week` carries the 1-week return, populated automatically -/// by `performance.trailingReturns`. +/// by `performance.totalReturns`. pub const PositionReturn = struct { symbol: []const u8, weight: f64, @@ -338,7 +338,7 @@ pub fn buildComparison( ) BenchmarkComparison { // `stock_trailing.week` and `bond_trailing.week` propagate // through `toReturnsByPeriod` automatically - see - // `performance.trailingReturns`, which populates the field + // `performance.totalReturns`, which populates the field // alongside the longer trailing periods. const stock_r = toReturnsByPeriod(stock_trailing); const bond_r = toReturnsByPeriod(bond_trailing); @@ -621,7 +621,7 @@ test "conservativeWeightedReturn single position single period" { test "buildComparison with week returns" { // Week returns now flow through `TrailingReturns.week` rather - // than separate parameters - `performance.trailingReturns` + // than separate parameters - `performance.totalReturns` // populates the field automatically. const stock_tr = TrailingReturns{ .one_year = makePR(0.20, 0.20), diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index fe3ee3a..1324ce3 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; +const sliceCandlesAsOf = @import("../models/candle.zig").sliceCandlesAsOf; const Dividend = @import("../models/dividend.zig").Dividend; const Split = @import("../models/split.zig").Split; const cumulativeSplitRatio = @import("../models/split.zig").cumulativeSplitRatio; @@ -161,7 +162,12 @@ pub const TrailingReturns = struct { /// - When dividend data is complete: reinvestment >= adj_close (compounding) /// - When dividend data is incomplete: adj_close > reinvestment (missing dividends) /// So the higher value is always the more correct one. -pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: TrailingReturns) TrailingReturns { +/// +/// Private: reachable only through `totalReturns` / +/// `totalReturnsMonthEnd`, which is the point - the merge is not +/// optional, and a caller able to skip it can produce a total return +/// that is quietly low whenever the cached adjustment basis is stale. +fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: TrailingReturns) TrailingReturns { return .{ .one_year = bestResult(div_returns.one_year, adj_close_returns.one_year), .three_year = bestResult(div_returns.three_year, adj_close_returns.three_year), @@ -183,9 +189,77 @@ fn bestResult(a: ?PerformanceResult, b: ?PerformanceResult) ?PerformanceResult { return if (a_ann >= b_ann) a else b; } +// ── Total return (the public entry points) ─────────────────── +// +// There are two ways to compute a total return from cached data and +// neither is reliable alone: +// +// - The `adj_close` ratio inherits whatever adjustment basis the +// cache holds. When a distribution has gone ex since the last full +// fetch, the bars behind it were never marked down, so the ratio +// reads low by roughly the missed yield (see `CandleMeta.adj_basis`). +// - Simulated reinvestment over raw `close` is exact, but only if the +// dividend record is complete. A missing dividend silently drops +// from the compounding. +// +// The failure modes are opposite and both are downward, so taking the +// higher of the two is correct in both directions. That merge is the +// only sanctioned way to get a total return, which is why these are the +// only public entry points and the four single-engine wrappers below +// are private: a caller reaching for the adj_close series alone gets a +// number that is quietly wrong exactly when the cache is stale. +// +// Both take `as_of` and truncate internally. Previously the as-of +// behaviour depended on whether the caller remembered to pre-slice its +// candles, which two of three call sites did and one did not. + +/// Total return over 1/3/5/10-year windows ending at the newest bar on +/// or before `as_of`. +/// +/// Pass `dividends = null` when no dividend record is available; the +/// result then falls back to the adj_close series alone, which is the +/// best available answer but is exposed to adjustment-basis staleness. +pub fn totalReturns( + candles: []const Candle, + dividends: ?[]const Dividend, + as_of: Date, +) TrailingReturns { + const window = sliceCandlesAsOf(candles, as_of); + if (window.len == 0) return .{}; + + const adj = trailingReturns(window); + const divs = dividends orelse return adj; + return withDividendFallback(trailingReturnsWithDividends(window, divs), adj); +} + +/// Morningstar-style total return using month-end reference dates: the +/// window ends on the last trading day of the month before `as_of`. +/// +/// Same dividend-vs-adj_close merge as `totalReturns`. Note the start +/// dates snap *backward* here (Morningstar's convention) where +/// `totalReturns` snaps forward, and `.week` is left null because a +/// month-end reference makes a 7-day window meaningless. +pub fn totalReturnsMonthEnd( + candles: []const Candle, + dividends: ?[]const Dividend, + as_of: Date, +) TrailingReturns { + const window = sliceCandlesAsOf(candles, as_of); + if (window.len == 0) return .{}; + + const adj = trailingReturnsMonthEnd(window, as_of); + const divs = dividends orelse return adj; + return withDividendFallback(trailingReturnsMonthEndWithDividends(window, divs, as_of), adj); +} + /// Trailing returns from exact calendar date N years ago to latest candle date. /// Start dates snap forward to the next trading day (e.g., weekend -> Monday). -pub fn trailingReturns(candles: []const Candle) TrailingReturns { +/// +/// Private: the adj_close-only series understates total return whenever the +/// cached adjustment basis predates a distribution, so nothing outside this +/// module should consume it directly. Use `totalReturns`, which pairs it with +/// the dividend-reinvestment series. See that function's doc comment. +fn trailingReturns(candles: []const Candle) TrailingReturns { if (candles.len == 0) return .{}; const end_date = candles[candles.len - 1].date; @@ -200,7 +274,10 @@ pub fn trailingReturns(candles: []const Candle) TrailingReturns { } /// Same as trailingReturns but with dividend reinvestment. -pub fn trailingReturnsWithDividends( +/// +/// Private: use `totalReturns`, which merges this with the adj_close +/// series so an incomplete dividend record cannot understate the result. +fn trailingReturnsWithDividends( candles: []const Candle, dividends: []const Dividend, ) TrailingReturns { @@ -221,7 +298,9 @@ pub fn trailingReturnsWithDividends( /// End date = last calendar day of prior month. Start date = that month-end minus N years. /// Both dates snap backward to the last trading day on or before, matching /// Morningstar's "last business day of the month" convention. -pub fn trailingReturnsMonthEnd(candles: []const Candle, as_of: Date) TrailingReturns { +/// +/// Private: use `totalReturnsMonthEnd`. See `totalReturns`. +fn trailingReturnsMonthEnd(candles: []const Candle, as_of: Date) TrailingReturns { if (candles.len == 0) return .{}; // End reference = last day of the prior month (snaps backward to last trading day) @@ -236,7 +315,9 @@ pub fn trailingReturnsMonthEnd(candles: []const Candle, as_of: Date) TrailingRet } /// Same as trailingReturnsMonthEnd but with dividend reinvestment. -pub fn trailingReturnsMonthEndWithDividends( +/// +/// Private: use `totalReturnsMonthEnd`. See `totalReturns`. +fn trailingReturnsMonthEndWithDividends( candles: []const Candle, dividends: []const Dividend, as_of: Date, @@ -1002,3 +1083,143 @@ test "trailingReturnsPriceOnly -- price-only diverges from total return on divid // above demonstrates.) try std.testing.expectApproxEqAbs(@as(f64, 0.0), total.one_year.?.total_return, 0.01); } + +// ── totalReturns / totalReturnsMonthEnd (the assemblers) ───── +// +// These replaced three divergent call-site assemblies: `service.zig` +// merged adj_close with dividends for both as-of and month-end, +// `views/review.zig` did the same for month-end only, and +// `views/projections.zig` did no merge at all - it called the +// adj_close-only series directly, so a stale adjustment basis flowed +// straight into the benchmark comparison and the Monte Carlo inputs. + +/// 500 consecutive daily bars at a flat price, so a price return of +/// exactly zero isolates the dividend contribution. +fn flatSeries(buf: []Candle, first: Date, price: f64) []Candle { + for (buf, 0..) |*c, i| c.* = makeCandle(first.addDays(@intCast(i)), price); + return buf; +} + +test "totalReturns: empty candles yields an empty struct" { + const tr = totalReturns(&.{}, null, Date.fromYmd(2026, 6, 1)); + try std.testing.expect(tr.one_year == null); + try std.testing.expect(tr.three_year == null); + try std.testing.expect(tr.five_year == null); + try std.testing.expect(tr.ten_year == null); + try std.testing.expect(tr.week == null); +} + +test "totalReturns: null dividends degrades to the adj_close series" { + // The no-dividend-record case must still produce a number rather + // than null - it is the best available answer, just exposed to + // adjustment-basis staleness. + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + + const tr = totalReturns(candles, null, Date.fromYmd(2026, 8, 14)); + try std.testing.expect(tr.one_year != null); + // Flat price, no dividends: zero total return. + try std.testing.expectApproxEqAbs(@as(f64, 0), tr.one_year.?.total_return, 1e-9); +} + +test "totalReturns: recovers a distribution the adjustment basis missed" { + // The regression this whole change exists for. A series whose + // adj_close was never restated has adj_close == close throughout, so + // the adj_close ratio equals the *price* return and silently omits + // the distribution. The dividend-reinvestment series still sees it, + // and the merge must prefer that. + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + const as_of = Date.fromYmd(2026, 8, 14); + + // $1.00 on a $100 close = a 1% yield, inside the 1Y window. + const divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2026, 1, 15), .amount = 1.0 }}; + + const stale = totalReturns(candles, null, as_of); + const merged = totalReturns(candles, &divs, as_of); + + // Uncorrected, the distribution is invisible. + try std.testing.expectApproxEqAbs(@as(f64, 0), stale.one_year.?.total_return, 1e-9); + // Merged, it is worth exactly its yield. + try std.testing.expectApproxEqAbs(@as(f64, 0.01), merged.one_year.?.total_return, 1e-9); + try std.testing.expect(merged.one_year.?.total_return > stale.one_year.?.total_return); +} + +test "totalReturns: a complete dividend record is never worse than adj_close" { + // `withDividendFallback` takes the higher of the two per period, so + // adding dividends can only raise a total return, never lower it. + // That directionality is what makes the merge safe: both failure + // modes (stale basis, incomplete dividends) bias downward. + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + const as_of = Date.fromYmd(2026, 8, 14); + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2025, 9, 15), .amount = 0.5 }, + .{ .ex_date = Date.fromYmd(2026, 3, 15), .amount = 0.5 }, + }; + + const without = totalReturns(candles, null, as_of); + const with = totalReturns(candles, &divs, as_of); + try std.testing.expect(with.one_year.?.total_return >= without.one_year.?.total_return); +} + +test "totalReturns: as_of truncates the window instead of using the newest bar" { + // Previously the as-of behaviour depended on whether the caller + // remembered to pre-slice - `views/projections.zig` did, + // `service.zig` did not. Now it is the function's job. + var buf: [500]Candle = undefined; + // Rising 1/day from 100, so the endpoint is identifiable by value. + for (&buf, 0..) |*c, i| { + c.* = makeCandle(Date.fromYmd(2025, 6, 1).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i))); + } + const candles = buf[0..]; + + const late = totalReturns(candles, null, Date.fromYmd(2026, 8, 14)); + const early = totalReturns(candles, null, Date.fromYmd(2026, 6, 14)); + + // Both resolve a 1Y window... + try std.testing.expect(late.one_year != null); + try std.testing.expect(early.one_year != null); + // ...ending on their own as_of, not on the last bar in the slice. + try std.testing.expect(late.one_year.?.to.eql(Date.fromYmd(2026, 8, 14))); + try std.testing.expect(early.one_year.?.to.eql(Date.fromYmd(2026, 6, 14))); +} + +test "totalReturns: as_of before all candles yields an empty struct" { + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + const tr = totalReturns(candles, null, Date.fromYmd(2020, 1, 1)); + try std.testing.expect(tr.one_year == null); + try std.testing.expect(tr.week == null); +} + +test "totalReturnsMonthEnd: window ends on the last bar of the prior month" { + // Morningstar convention, and the reason this variant exists + // separately: the endpoint is a month boundary, not `as_of` itself. + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + + const tr = totalReturnsMonthEnd(candles, null, Date.fromYmd(2026, 8, 14)); + try std.testing.expect(tr.one_year != null); + try std.testing.expect(tr.one_year.?.to.eql(Date.fromYmd(2026, 7, 31))); + // A month-end reference makes a 7-day window meaningless. + try std.testing.expect(tr.week == null); +} + +test "totalReturnsMonthEnd: recovers a missed distribution too" { + var buf: [500]Candle = undefined; + const candles = flatSeries(&buf, Date.fromYmd(2025, 6, 1), 100); + const as_of = Date.fromYmd(2026, 8, 14); + const divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2026, 1, 15), .amount = 1.0 }}; + + const stale = totalReturnsMonthEnd(candles, null, as_of); + const merged = totalReturnsMonthEnd(candles, &divs, as_of); + try std.testing.expectApproxEqAbs(@as(f64, 0), stale.one_year.?.total_return, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 0.01), merged.one_year.?.total_return, 1e-9); +} + +test "totalReturnsMonthEnd: empty candles yields an empty struct" { + const tr = totalReturnsMonthEnd(&.{}, null, Date.fromYmd(2026, 6, 1)); + try std.testing.expect(tr.one_year == null); + try std.testing.expect(tr.ten_year == null); +} diff --git a/src/commands/perf.zig b/src/commands/perf.zig index c88d42e..14e61b2 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -88,15 +88,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // into), but we surface a hint that explicit dividend data is // missing. const has_explicit_divs = result.dividends != null; - const has_total = result.asof_total != null; // -- As-of-date returns -- try cli.printBold(out, color, "\nAs-of {f}:\n", .{end_date}); - try printReturnsTable(out, result.asof_price, if (has_total) result.asof_total else null, color); + try printReturnsTable(out, result.asof_price, result.asof_total, color); // -- Month-end returns -- try cli.printBold(out, color, "\nMonth-end ({f}):\n", .{month_end}); - try printReturnsTable(out, result.me_price, if (has_total) result.me_total else null, color); + try printReturnsTable(out, result.me_price, result.me_total, color); if (!has_explicit_divs) { try cli.printFg(out, color, cli.CLR_MUTED, "\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); diff --git a/src/history.zig b/src/history.zig index 47a0cb9..e3a161e 100644 --- a/src/history.zig +++ b/src/history.zig @@ -34,7 +34,6 @@ const builtin = @import("builtin"); const srf = @import("srf"); const snapshot = @import("models/snapshot.zig"); const Date = @import("Date.zig"); -const Candle = @import("models/candle.zig").Candle; const timeline = @import("analytics/timeline.zig"); const valuation = @import("analytics/valuation.zig"); const imported_values = @import("data/imported_values.zig"); @@ -564,35 +563,6 @@ pub fn resolveSnapshotDate( // ── Pure-domain aggregation ─────────────────────────────────── -/// Return the prefix of `candles` whose dates are `<= as_of`. -/// -/// When `as_of` is null, returns the full slice unchanged (live mode -/// pass-through). When set, binary-searches for the first index -/// strictly after `as_of` and slices up to it. Zero-length slice -/// when `as_of` precedes all cached candles. -/// -/// Candles are assumed sorted by date ascending. Used to truncate -/// benchmark and per-symbol price history for historical projections - -/// `performance.trailingReturns` uses the last candle's date as the -/// endpoint, so trimming the tail is equivalent to "compute as of -/// that date". -pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle { - const d = as_of orelse return candles; - if (candles.len == 0) return candles; - var lo: usize = 0; - var hi: usize = candles.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - const cd = candles[mid].date; - if (cd.lessThan(d) or cd.eql(d)) { - lo = mid + 1; - } else { - hi = mid; - } - } - return candles[0..lo]; -} - /// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not /// present (old snapshots from before the liquid/illiquid split - /// shouldn't happen in practice). @@ -1344,83 +1314,6 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01); } -// ── sliceCandlesAsOf tests ──────────────────────────────────── - -fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle { - return .{ - .date = Date.fromYmd(y, m, d), - .open = close, - .high = close, - .low = close, - .close = close, - .adj_close = close, - .volume = 0, - }; -} - -test "sliceCandlesAsOf: null as_of returns everything" { - const candles = [_]Candle{ - makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 2, 101), - }; - const sliced = sliceCandlesAsOf(&candles, null); - try testing.expectEqual(@as(usize, 2), sliced.len); -} - -test "sliceCandlesAsOf: empty input" { - const candles = [_]Candle{}; - const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 1)); - try testing.expectEqual(@as(usize, 0), sliced.len); -} - -test "sliceCandlesAsOf: empty input with null as_of" { - const candles = [_]Candle{}; - const sliced = sliceCandlesAsOf(&candles, null); - try testing.expectEqual(@as(usize, 0), sliced.len); -} - -test "sliceCandlesAsOf: exact date match included" { - const candles = [_]Candle{ - makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 2, 101), - makeTestCandle(2024, 1, 3, 102), - makeTestCandle(2024, 1, 4, 103), - }; - const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); - try testing.expectEqual(@as(usize, 2), sliced.len); - try testing.expectApproxEqAbs(@as(f64, 101), sliced[sliced.len - 1].close, 0.001); -} - -test "sliceCandlesAsOf: no exact match snaps to earlier" { - const candles = [_]Candle{ - makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 3, 102), // gap - no candle on the 2nd - makeTestCandle(2024, 1, 4, 103), - }; - // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2). - const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); - try testing.expectEqual(@as(usize, 1), sliced.len); - try testing.expectApproxEqAbs(@as(f64, 100), sliced[0].close, 0.001); -} - -test "sliceCandlesAsOf: as_of before all candles returns empty" { - const candles = [_]Candle{ - makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 2, 101), - }; - const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2023, 12, 31)); - try testing.expectEqual(@as(usize, 0), sliced.len); -} - -test "sliceCandlesAsOf: as_of after all candles returns everything" { - const candles = [_]Candle{ - makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 2, 101), - }; - const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2026, 1, 1)); - try testing.expectEqual(@as(usize, 2), sliced.len); -} - // ── resolveSnapshotDate tests ───────────────────────────────── test "resolveSnapshotDate: exact match returns exact=true" { diff --git a/src/models/candle.zig b/src/models/candle.zig index 9f346a7..6262fdf 100644 --- a/src/models/candle.zig +++ b/src/models/candle.zig @@ -1,3 +1,5 @@ +const std = @import("std"); +const testing = std.testing; const Date = @import("../Date.zig"); /// A single OHLCV bar, normalized from any provider. @@ -26,8 +28,36 @@ pub const Candle = struct { } }; +/// Return the prefix of `candles` whose dates are `<= as_of`. +/// +/// When `as_of` is null, returns the full slice unchanged (live mode +/// pass-through). When set, binary-searches for the first index +/// strictly after `as_of` and slices up to it. Zero-length slice +/// when `as_of` precedes all cached candles. +/// +/// Candles are assumed sorted by date ascending. Truncating the tail is +/// how "compute as of date D" is expressed throughout the analytics +/// layer: every window that derives its endpoint from the newest bar +/// (trailing returns, week change, month-end snaps) then lands on D +/// without needing its own as-of parameter. +pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle { + const d = as_of orelse return candles; + if (candles.len == 0) return candles; + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + const cd = candles[mid].date; + if (cd.lessThan(d) or cd.eql(d)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return candles[0..lo]; +} + test "chartClose prefers adj_close when populated" { - const std = @import("std"); const c = Candle{ .date = Date.fromYmd(2024, 3, 7), .open = 700.0, @@ -41,7 +71,6 @@ test "chartClose prefers adj_close when populated" { } test "chartClose falls back to close when adj_close is zero" { - const std = @import("std"); const c = Candle{ .date = Date.fromYmd(2024, 1, 1), .open = 100.0, @@ -55,7 +84,6 @@ test "chartClose falls back to close when adj_close is zero" { } test "chartClose synthetic split has continuous chart values" { - const std = @import("std"); // Pre-split: close=300, adj_close=100 (after a 3:1 split) // Post-split: close=100, adj_close=100 // Chart should see [100, 100] not [300, 100]. @@ -79,3 +107,80 @@ test "chartClose synthetic split has continuous chart values" { }; try std.testing.expectEqual(pre.chartClose(), post.chartClose()); } + +// ── sliceCandlesAsOf tests ──────────────────────────────────── + +fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle { + return .{ + .date = Date.fromYmd(y, m, d), + .open = close, + .high = close, + .low = close, + .close = close, + .adj_close = close, + .volume = 0, + }; +} + +test "sliceCandlesAsOf: null as_of returns everything" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, null); + try testing.expectEqual(@as(usize, 2), sliced.len); +} + +test "sliceCandlesAsOf: empty input" { + const candles = [_]Candle{}; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 1)); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: empty input with null as_of" { + const candles = [_]Candle{}; + const sliced = sliceCandlesAsOf(&candles, null); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: exact date match included" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + makeTestCandle(2024, 1, 3, 102), + makeTestCandle(2024, 1, 4, 103), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); + try testing.expectEqual(@as(usize, 2), sliced.len); + try testing.expectApproxEqAbs(@as(f64, 101), sliced[sliced.len - 1].close, 0.001); +} + +test "sliceCandlesAsOf: no exact match snaps to earlier" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 3, 102), // gap - no candle on the 2nd + makeTestCandle(2024, 1, 4, 103), + }; + // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2). + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); + try testing.expectEqual(@as(usize, 1), sliced.len); + try testing.expectApproxEqAbs(@as(f64, 100), sliced[0].close, 0.001); +} + +test "sliceCandlesAsOf: as_of before all candles returns empty" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2023, 12, 31)); + try testing.expectEqual(@as(usize, 0), sliced.len); +} + +test "sliceCandlesAsOf: as_of after all candles returns everything" { + const candles = [_]Candle{ + makeTestCandle(2024, 1, 1, 100), + makeTestCandle(2024, 1, 2, 101), + }; + const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2026, 1, 1)); + try testing.expectEqual(@as(usize, 2), sliced.len); +} diff --git a/src/service.zig b/src/service.zig index 04abe3d..f1547ee 100644 --- a/src/service.zig +++ b/src/service.zig @@ -2241,9 +2241,18 @@ pub const DataService = struct { /// See `tmp/multi-ticker-audit.md` for the cross-validation evidence. pub fn getTrailingReturns(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!struct { asof_price: performance.TrailingReturns, - asof_total: ?performance.TrailingReturns, + /// Total return as of the newest bar. Non-optional: it is always + /// computable once candles exist, because `performance.totalReturns` + /// degrades to the adj_close series internally when no dividend + /// record is available. It was `?TrailingReturns`, and the + /// optionality invited consumers to write + /// `asof_total orelse asof_price` - silently publishing a + /// price-only return labelled as total return, understated by the + /// full dividend yield. + asof_total: performance.TrailingReturns, me_price: performance.TrailingReturns, - me_total: ?performance.TrailingReturns, + /// Month-end total return. Non-optional for the same reason. + me_total: performance.TrailingReturns, candles: []Candle, dividends: ?[]Dividend, source: Source, @@ -2272,34 +2281,17 @@ pub const DataService = struct { // Month-end (end = last business day of prior month) const me_price = performance.trailingReturnsPriceOnlyMonthEnd(c, splits, today); - // Total return: dividend-reinvested when dividends are - // available; otherwise fall back to adj_close-based total - // return (which captures dividends for providers like Tiingo - // that bake dividends into adj_close). + // Total return. `performance.totalReturns` owns the + // dividend-reinvestment vs adj_close merge, including the + // no-dividend-record fallback, so there is one code path here + // rather than a branch per availability case. var divs: ?[]Dividend = null; - var asof_total: ?performance.TrailingReturns = null; - var me_total: ?performance.TrailingReturns = null; - - // adj_close-based total return is the fallback. With Tiingo - // (the default provider) adj_close is already dividend- - // adjusted, so this gives a reasonable total-return estimate - // even when explicit dividend records are missing. - const asof_adj = performance.trailingReturns(c); - const me_adj = performance.trailingReturnsMonthEnd(c, today); - if (self.getDividends(symbol, opts)) |div_result| { divs = div_result.data; - const asof_div = performance.trailingReturnsWithDividends(c, div_result.data); - const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); - asof_total = performance.withDividendFallback(asof_div, asof_adj); - me_total = performance.withDividendFallback(me_div, me_adj); - } else |_| { - // No dividend data: still surface the adj_close-based - // total return rather than null, since Tiingo's - // adj_close already includes dividend adjustment. - asof_total = asof_adj; - me_total = me_adj; - } + } else |_| {} + + const asof_total = performance.totalReturns(c, divs, today); + const me_total = performance.totalReturnsMonthEnd(c, divs, today); return .{ .asof_price = asof_price, diff --git a/src/views/projections.zig b/src/views/projections.zig index b4ff30e..f054927 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -12,6 +12,7 @@ const forecast = @import("../analytics/forecast_evaluation.zig"); const timeline = @import("../analytics/timeline.zig"); const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); +const sliceCandlesAsOf = @import("../models/candle.zig").sliceCandlesAsOf; const snapshot_model = @import("../models/snapshot.zig"); const history = @import("../history.zig"); const Date = @import("../Date.zig"); @@ -823,10 +824,9 @@ fn buildContextFromParts( cd_value, ); - // Fetch benchmark candles (checks cache first). In historical - // mode we slice to `<= as_of` - `performance.trailingReturns` - // anchors on the last candle's date, so trimming the tail gives - // returns "as of" that date for free. + // Fetch benchmark candles (checks cache first). `totalReturns` + // truncates to `<= as_of` internally, so historical mode needs no + // pre-slicing here. // // Symbols default to SPY/AGG; user can override via // `type::config,benchmark_stock::SYMBOL` and @@ -835,36 +835,50 @@ fn buildContextFromParts( const bond_sym = config.benchmark_bond; const spy_result = svc.getCandles(stock_sym, .{}) catch null; defer if (spy_result) |r| r.deinit(); - const spy_candles = history.sliceCandlesAsOf( - if (spy_result) |r| r.data else &.{}, - as_of, - ); + const spy_divs = svc.getCachedDividends(alloc, stock_sym); + defer if (spy_divs) |d| d.deinit(); const agg_result = svc.getCandles(bond_sym, .{}) catch null; defer if (agg_result) |r| r.deinit(); - const agg_candles = history.sliceCandlesAsOf( + const agg_divs = svc.getCachedDividends(alloc, bond_sym); + defer if (agg_divs) |d| d.deinit(); + + // Total return, not the bare adj_close ratio. This path used to + // call `performance.trailingReturns` directly, which is the + // adj_close series alone - so a stale adjustment basis understated + // the benchmark, the portfolio-weighted return, and + // `conservative_return`, which drives the Monte Carlo projections. + const spy_trailing = performance.totalReturns( + if (spy_result) |r| r.data else &.{}, + if (spy_divs) |d| d.data else null, + as_of, + ); + const agg_trailing = performance.totalReturns( if (agg_result) |r| r.data else &.{}, + if (agg_divs) |d| d.data else null, as_of, ); - const spy_trailing = performance.trailingReturns(spy_candles); - const agg_trailing = performance.trailingReturns(agg_candles); - - // Build per-position trailing returns from cached candles, each - // optionally truncated to the as-of date. `trailingReturns` - // populates `.week` per position; `portfolioWeightedReturns` - // aggregates the week the same way as the longer periods. + // Build per-position trailing returns from cached candles. + // `totalReturns` populates `.week` per position; + // `portfolioWeightedReturns` aggregates the week the same way as + // the longer periods. var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { const candles_res = svc.getCachedCandles(alloc, a.symbol) orelse continue; defer candles_res.deinit(); - const candles = history.sliceCandlesAsOf(candles_res.data, as_of); - if (candles.len > 0) { + const pos_divs = svc.getCachedDividends(alloc, a.symbol); + defer if (pos_divs) |d| d.deinit(); + if (sliceCandlesAsOf(candles_res.data, as_of).len > 0) { try pos_returns.append(alloc, .{ .symbol = a.symbol, .weight = a.weight, - .returns = performance.trailingReturns(candles), + .returns = performance.totalReturns( + candles_res.data, + if (pos_divs) |d| d.data else null, + as_of, + ), }); } } diff --git a/src/views/review.zig b/src/views/review.zig index bcc8a5e..ae6c23f 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -186,8 +186,10 @@ pub const ReviewView = struct { /// - `dividend_map` (optional): symbol -> dividend slices, used to /// compute total-return numbers. Pass `null` to fall back to /// adj_close-derived returns. (Most providers bake dividends into -/// adj_close, so the fallback is usually fine - `withDividendFallback` -/// picks whichever number is higher per period.) +/// adj_close, so the fallback is usually fine, but it inherits any +/// staleness in the cached adjustment basis - see +/// `CandleMeta.adj_basis`. `performance.totalReturnsMonthEnd` picks +/// whichever number is higher per period.) /// - `classifications`: parsed `metadata.srf` /// - `account_map` (optional): parsed `accounts.srf`. When null, /// Tax% is reported as null on every row. @@ -233,7 +235,7 @@ pub fn buildReview( const bucket = bucketForSymbol(a.symbol, classifications); const tax_pct = computeTaxPct(a.symbol, portfolio, account_map, as_of); - const tr_returns = computeTrailingReturns(candles, dividends, as_of); + const tr_returns = performance.totalReturnsMonthEnd(candles, dividends, as_of); const tr_risk = if (candles.len > 0) risk.trailingRisk(candles) else risk.TrailingRisk{}; try rows.append(allocator, .{ @@ -583,24 +585,6 @@ fn accountIsKnown(am: analysis.AccountMap, account: []const u8) bool { return false; } -/// Compute month-end total-return trailing returns. Falls back to -/// adj_close-only when no dividend data is available, picking the -/// higher value per period via `withDividendFallback`. -fn computeTrailingReturns( - candles: []const zfin.Candle, - dividends: ?[]const zfin.Dividend, - as_of: Date, -) performance.TrailingReturns { - if (candles.len == 0) return .{}; - - const adj = performance.trailingReturnsMonthEnd(candles, as_of); - if (dividends) |divs| { - const div = performance.trailingReturnsMonthEndWithDividends(candles, divs, as_of); - return performance.withDividendFallback(div, adj); - } - return adj; -} - /// Pull the annualized return out of a `PerformanceResult`. For 1Y, /// the period is right at the threshold - `annualizedReturn` returns /// null when the actual span < 0.95 years. We accept that null and @@ -844,58 +828,6 @@ test "sortRows: every numeric SortField variant is reachable via extractFloat" { } } -test "computeTrailingReturns: empty candles returns empty struct" { - const tr = computeTrailingReturns(&.{}, null, Date.fromYmd(2026, 6, 1)); - try testing.expect(tr.one_year == null); - try testing.expect(tr.three_year == null); - try testing.expect(tr.five_year == null); - try testing.expect(tr.ten_year == null); -} - -test "computeTrailingReturns: candles with no dividends uses adj_close path" { - // 36 months of 1% monthly growth -> ~12.7% annualized. Without - // dividends, this exercises the adj-close-only branch. Even when - // the synthetic data isn't enough to land any specific trailing - // window (depends on calendar arithmetic against the as_of), the - // function shouldn't crash and should return a struct. - var candles: [36]zfin.Candle = undefined; - var d = Date.fromYmd(2023, 1, 31); - for (0..36) |i| { - const price: f64 = 100.0 * std.math.pow(f64, 1.01, @as(f64, @floatFromInt(i))); - candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; - d = d.addDays(30); - } - const tr = computeTrailingReturns(&candles, null, Date.fromYmd(2026, 1, 31)); - // We don't assert on which windows populated - that depends on - // calendar arithmetic against `as_of` and the candle density. - // Coverage of the no-dividends branch (the `else` arm at the - // end of `computeTrailingReturns`) is what we want. - _ = tr; -} - -test "computeTrailingReturns: candles with dividends prefers higher fallback" { - // Same shape, but include a dividends slice. The fallback logic - // takes whichever produces a higher annualized return per period; - // for adj_close already including dividends and an explicit - // dividends slice, the helper picks whichever is higher. Either - // way the path through `withDividendFallback` is exercised. - var candles: [36]zfin.Candle = undefined; - var d = Date.fromYmd(2023, 1, 31); - for (0..36) |i| { - const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); - candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; - d = d.addDays(30); - } - const divs = [_]zfin.Dividend{ - .{ .ex_date = Date.fromYmd(2023, 6, 15), .pay_date = Date.fromYmd(2023, 7, 1), .amount = 0.5 }, - .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 0.5 }, - }; - const tr = computeTrailingReturns(&candles, &divs, Date.fromYmd(2026, 1, 31)); - // We don't need to assert exact values - coverage of the - // dividend-fallback branch is the goal. - _ = tr; -} - test "computeTaxPct: returns null when account_map is null" { var lots = [_]zfin.Lot{ .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" },