consolidate returns calculations

This commit is contained in:
Emil Lerch 2026-08-17 19:39:45 -07:00
parent 7163610b8e
commit 6674969001
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 396 additions and 240 deletions

View file

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

View file

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

View file

@ -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", .{});

View file

@ -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" {

View file

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

View file

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

View file

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

View file

@ -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" },