consolidate returns calculations
This commit is contained in:
parent
7163610b8e
commit
6674969001
8 changed files with 396 additions and 240 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
|
|
|
|||
107
src/history.zig
107
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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue