build portfolio risk from a total-return index
All checks were successful
Generic zig build / build (push) Successful in 5m10s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 17s

This commit is contained in:
Emil Lerch 2026-08-17 20:01:06 -07:00
parent 337d346421
commit e3ce8f32a1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 370 additions and 9 deletions

View file

@ -36,6 +36,8 @@ const std = @import("std");
const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
const risk = @import("risk.zig");
const Dividend = @import("../models/dividend.zig").Dividend;
const Split = @import("../models/split.zig").Split;
/// Per-window flags marking metrics that required holding-dropout
/// renormalization. Set when at least one position lacked candle coverage
@ -87,8 +89,36 @@ pub const PositionCandles = struct {
/// Position weight in the portfolio (market_value / total_value).
/// Must be non-negative; zero-weight positions are skipped entirely.
weight: f64,
/// Cash distributions, **newest first** - the order
/// `cache.Store.read` returns them in, since `writeMerged` sorts
/// descending by date.
///
/// When this and `splits` are both empty, the resample falls back to
/// the provider's `adj_close`, preserving the behaviour callers had
/// before these fields existed. Supply them to get a total-return
/// index built from raw `close` instead - see `synthesizeWindow`.
dividends: []const Dividend = &.{},
/// Split events, **newest first**, same convention as `dividends`.
///
/// Required for correctness whenever `dividends` is supplied: raw
/// `close` is not split-adjusted, so an index built without these
/// would read a 2:1 split as a -50% month.
splits: []const Split = &.{},
};
/// Debug-only guard on the newest-first ordering the resample relies on.
/// A mis-ordered slice would silently mis-time reinvestment rather than
/// fail, so catch it where tests will see it.
fn assertDescending(comptime T: type, items: []const T, comptime dateField: []const u8) void {
if (!std.debug.runtime_safety) return;
if (items.len < 2) return;
for (items[1..], 0..) |item, i| {
const newer = @field(items[i], dateField);
const older = @field(item, dateField);
std.debug.assert(!newer.lessThan(older));
}
}
/// Compute true portfolio-level risk metrics for the standard windows.
/// Iterates `positions`, derives per-position monthly return series,
/// builds a weighted synthetic series per window with dropout-and-
@ -314,20 +344,73 @@ fn synthesizeWindow(
for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| {
const cand = positions[orig_idx].candles;
// Walk candles, recording the LAST close in each month bucket
const divs = positions[orig_idx].dividends;
const spls = positions[orig_idx].splits;
assertDescending(Dividend, divs, "ex_date");
assertDescending(Split, spls, "date");
// Which price series to resample.
//
// With corporate actions supplied, build a total-return index in
// units of shares held: start at one share, multiply on a split,
// and buy more with each distribution at that day's close.
//
// value(t) = shares(t) * close(t)
//
// Both events pass through that expression continuously - a split
// multiplies shares while dividing close, and a distribution buys
// exactly the shares its per-share drop paid for - so the series
// has no artificial cliffs and month-over-month ratios are true
// total returns.
//
// Why not `adj_close`: it is only as current as the last full
// candle fetch (see `CandleMeta.adj_basis`), and Yahoo's parser
// leaves it at 0 for a JSON `null`, which the `prev_p <= 0` guard
// below then silently drops - losing a whole month from the
// series rather than merely mis-stating it.
//
// Without corporate actions there is nothing to build from, so
// fall back to `adj_close` (via `chartClose`, which handles the
// zero case). That keeps callers predating these fields on
// exactly their old behaviour.
const use_index = divs.len > 0 or spls.len > 0;
// Walk candles, recording the LAST value in each month bucket
// that falls within the window. We use yearMonth() for the
// month-boundary comparison (cheap integer compare) and
// Date.monthsBetween for the slot index into `prices`.
var prev_ym: u32 = 0;
var prev_date: Date = window_start;
var last_close: f64 = 0;
var last_value: f64 = 0;
var have_any = false;
// Corporate-action cursors. Both slices are newest-first, so walk
// them from the tail to consume events chronologically. Pre-window
// bars are walked too, which is what makes `shares` correct on
// entry to the window.
var shares: f64 = 1.0;
var di: usize = divs.len;
var si: usize = spls.len;
for (cand) |c| {
if (use_index) {
// Splits first: a distribution's per-share amount is
// quoted against the share count in force on its ex-date.
while (si > 0 and !c.date.lessThan(spls[si - 1].date)) {
shares *= spls[si - 1].ratio();
si -= 1;
}
while (di > 0 and !c.date.lessThan(divs[di - 1].ex_date)) {
if (c.close > 0) shares += divs[di - 1].amount * shares / c.close;
di -= 1;
}
}
const value = if (use_index) shares * c.close else c.chartClose();
if (c.date.days < window_start.days) {
// Before window: still track the running last-close,
// Before window: still track the running last value,
// but only emit it once we cross into the window.
last_close = c.adj_close;
last_value = value;
prev_ym = c.date.yearMonth();
prev_date = c.date;
have_any = true;
@ -336,27 +419,27 @@ fn synthesizeWindow(
if (c.date.days > window_end.days) break;
const ym = c.date.yearMonth();
if (have_any and ym != prev_ym) {
// Month boundary: stash prev month's last close.
// Month boundary: stash prev month's last value.
const m_idx_signed = Date.monthsBetween(window_start, prev_date);
if (m_idx_signed >= 0) {
const m_idx: usize = @intCast(m_idx_signed);
if (m_idx < n_months_total) {
prices[p_idx * n_months_total + m_idx] = last_close;
prices[p_idx * n_months_total + m_idx] = last_value;
}
}
}
last_close = c.adj_close;
last_value = value;
prev_ym = ym;
prev_date = c.date;
have_any = true;
}
// Final partial month: stash whatever the latest close was.
// Final partial month: stash whatever the latest value was.
if (have_any) {
const m_idx_signed = Date.monthsBetween(window_start, prev_date);
if (m_idx_signed >= 0) {
const m_idx: usize = @intCast(m_idx_signed);
if (m_idx < n_months_total) {
prices[p_idx * n_months_total + m_idx] = last_close;
prices[p_idx * n_months_total + m_idx] = last_value;
}
}
}
@ -702,3 +785,173 @@ test "syntheticPortfolioRisk: return_3y annualizes correctly" {
try testing.expect(r.return_3y.? > 0.10);
try testing.expect(r.return_3y.? < 0.16);
}
// Total-return index (dividends + splits)
//
// The resample used to read `adj_close` directly. That inherits any
// staleness in the cached adjustment basis, and Yahoo's parser leaves
// `adj_close` at 0 for a JSON `null`, which the `prev_p <= 0` guard
// drops - losing the whole month. Supplying dividends and splits
// switches to an index built from raw `close`, in units of shares held.
/// Build a candle slice with an explicit close per month, so tests can
/// place a corporate action against a known price.
fn monthlyCloses(allocator: std.mem.Allocator, start_year: i16, closes: []const f64) ![]Candle {
var out = std.ArrayList(Candle).empty;
errdefer out.deinit(allocator);
for (closes, 0..) |px, i| {
const year_offset: i16 = @intCast(i / 12);
const m_in_year: u8 = @intCast((i % 12) + 1);
try out.append(allocator, makeCandle(Date.fromYmd(start_year + year_offset, m_in_year, 15), px));
try out.append(allocator, makeCandle(Date.fromYmd(start_year + year_offset, m_in_year, 28), px));
}
return out.toOwnedSlice(allocator);
}
test "synthesizeWindow index: a split is not a -50% month" {
// The correctness trap in building an index from raw `close`.
// `close` is not split-adjusted, so a 2:1 split halves it; without
// multiplying the share count the series reads a catastrophic month
// and inflates vol. 40 flat months with a split in the middle must
// produce zero volatility.
const a = testing.allocator;
var closes: [40]f64 = undefined;
for (&closes, 0..) |*c, i| c.* = if (i < 20) 200.0 else 100.0;
const cand = try monthlyCloses(a, 2022, &closes);
defer a.free(cand);
// Split lands on the first bar of the halved run.
const splits = [_]Split{.{ .date = Date.fromYmd(2023, 9, 15), .numerator = 2, .denominator = 1 }};
const positions = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .splits = &splits },
};
const r = try syntheticPortfolioRisk(a, &positions, Date.fromYmd(2025, 5, 1));
try testing.expect(r.vol_3y != null);
// A flat total-return series has no volatility. Without the share
// multiplication this would be enormous.
try testing.expect(r.vol_3y.? < 0.01);
try testing.expect(r.return_3y != null);
try testing.expectApproxEqAbs(@as(f64, 0), r.return_3y.?, 0.01);
}
test "synthesizeWindow index: distributions raise the total return" {
// Flat price, so every bit of return comes from the distributions.
const a = testing.allocator;
var closes: [40]f64 = undefined;
for (&closes) |*c| c.* = 100.0;
const cand = try monthlyCloses(a, 2022, &closes);
defer a.free(cand);
// Newest-first, matching the cache's on-disk order.
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 9, 15), .amount = 1.0 },
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 },
.{ .ex_date = Date.fromYmd(2023, 9, 15), .amount = 1.0 },
};
const bare = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0 },
};
const paying = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs },
};
const without = try syntheticPortfolioRisk(a, &bare, Date.fromYmd(2025, 5, 1));
const with = try syntheticPortfolioRisk(a, &paying, Date.fromYmd(2025, 5, 1));
// Flat price and no distributions: zero return.
try testing.expectApproxEqAbs(@as(f64, 0), without.return_3y.?, 1e-6);
// Three 1% reinvestments inside the window compound to ~3.03%,
// annualized over 3 years to ~1%.
try testing.expect(with.return_3y.? > 0.005);
try testing.expect(with.return_3y.? < 0.015);
}
test "synthesizeWindow index: unusable adj_close no longer drops the month" {
// Yahoo yields adj_close == 0 for a JSON null. The `prev_p <= 0`
// guard then skips both month transitions touching it, silently
// shortening the series. With an index built from `close`, the
// zeroed field is never consulted.
const a = testing.allocator;
var closes: [40]f64 = undefined;
for (&closes, 0..) |*c, i| c.* = 100.0 + @as(f64, @floatFromInt(i));
const cand = try monthlyCloses(a, 2022, &closes);
defer a.free(cand);
// Zero out adj_close on a mid-window month, as a null would.
for (cand) |*c| {
if (c.date.yearMonth() == Date.fromYmd(2024, 1, 15).yearMonth()) c.adj_close = 0;
}
const divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2023, 6, 15), .amount = 0.5 }};
const bare = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0 },
};
const indexed = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs },
};
const as_of = Date.fromYmd(2025, 5, 1);
const without = try syntheticPortfolioRisk(a, &bare, as_of);
const with = try syntheticPortfolioRisk(a, &indexed, as_of);
// Both still produce numbers - `chartClose`'s zero fallback keeps
// the bare path alive rather than dropping the month outright.
try testing.expect(without.return_3y != null);
try testing.expect(with.return_3y != null);
// And the indexed path is strictly better: it also counts the
// distribution the bare path cannot see.
try testing.expect(with.return_3y.? > without.return_3y.?);
}
test "synthesizeWindow index: no corporate actions preserves adj_close behaviour" {
// The defaulted fields must be a no-op, or every caller predating
// them silently changes numbers.
const a = testing.allocator;
const cand = try buildMonthlyCandles(a, 2022, 45, &linearGrowth);
defer a.free(cand);
const positions = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0 },
};
const as_of = Date.fromYmd(2025, 11, 1);
const baseline = try syntheticPortfolioRisk(a, &positions, as_of);
// Same input, explicitly-empty corporate actions.
const explicit = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &.{}, .splits = &.{} },
};
const same = try syntheticPortfolioRisk(a, &explicit, as_of);
try testing.expectEqual(baseline.return_3y, same.return_3y);
try testing.expectEqual(baseline.vol_3y, same.vol_3y);
try testing.expectEqual(baseline.sharpe_3y, same.sharpe_3y);
}
test "synthesizeWindow index: split and dividend on the same date" {
// Order matters: a distribution's per-share amount is quoted
// against the share count in force on its ex-date, so the split
// must apply first. Getting it backwards misprices the
// reinvestment by the split ratio.
const a = testing.allocator;
var closes: [40]f64 = undefined;
for (&closes, 0..) |*c, i| c.* = if (i < 20) 200.0 else 100.0;
const cand = try monthlyCloses(a, 2022, &closes);
defer a.free(cand);
const shared = Date.fromYmd(2023, 9, 15);
const splits = [_]Split{.{ .date = shared, .numerator = 2, .denominator = 1 }};
const divs = [_]Dividend{.{ .ex_date = shared, .amount = 1.0 }};
const positions = [_]PositionCandles{
.{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs, .splits = &splits },
};
const r = try syntheticPortfolioRisk(a, &positions, Date.fromYmd(2025, 5, 1));
// Flat total-return apart from a single 1% reinvestment: small
// positive return, and still essentially no volatility.
try testing.expect(r.return_3y != null);
try testing.expect(r.return_3y.? > 0);
try testing.expect(r.return_3y.? < 0.01);
try testing.expect(r.vol_3y.? < 0.02);
}

View file

@ -220,12 +220,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
}
// Per-symbol splits. Fetched alongside dividends because the totals
// row's total-return index is built from raw `close`, which is not
// split-adjusted - dividends without splits would read a 2:1 split
// as a -50% month. Splits ride along in the same Tiingo candle
// response the dividend warm above already paid for.
var split_map = std.StringHashMap([]const zfin.Split).init(allocator);
defer {
var it = split_map.iterator();
while (it.next()) |entry| {
allocator.free(@constCast(entry.value_ptr.*));
}
split_map.deinit();
}
for (pf_data.summary.allocations) |a| {
if (svc.getCachedSplits(allocator, a.symbol)) |spl| {
try split_map.put(a.symbol, spl.data);
}
}
var view = try review_view.buildReview(
allocator,
io,
pf_data.summary,
&pf_data.candle_map,
&dividend_map,
&split_map,
portfolio,
cm,
acct_map_opt,

View file

@ -23,6 +23,12 @@ pub const Candle = struct {
/// older cached candles or providers that don't populate
/// `adj_close` leave it at 0; in that case the raw close is
/// the best we can do.
///
/// The zero fallback matters beyond charts: Yahoo's parser yields
/// 0 for a JSON `null` element, and a risk series that divides by
/// it drops the month entirely. `analytics/portfolio_risk.zig`
/// reuses this for that reason - "adjusted close, or raw close
/// when the provider left it unusable" is the same question there.
pub fn chartClose(self: Candle) f64 {
return if (self.adj_close != 0) self.adj_close else self.close;
}

View file

@ -2383,6 +2383,18 @@ pub const DataService = struct {
return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator };
}
/// Cache-only split read, mirroring `getCachedDividends`.
///
/// Splits travel with dividends wherever raw `close` is being
/// adjusted: `close` is not split-adjusted, so a total-return series
/// built from dividends alone reads a 2:1 split as a -50% month.
/// Callers that fetch one should fetch both.
pub fn getCachedSplits(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(Split) {
var s = self.store();
const result = s.read(allocator, Split, symbol, null, .any) orelse return null;
return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator };
}
// Portfolio price loading
/// Status emitted for each symbol during price loading.
@ -4142,6 +4154,38 @@ test "loadAllDividends: honors skip_network for every symbol, and one miss does
try std.testing.expect(svc.getCachedDividends(allocator, "TSTA") == null);
}
test "getCachedSplits: reads cache only and reports absence" {
// Splits travel with dividends wherever raw `close` is adjusted, so
// this mirrors `getCachedDividends` exactly - including that an
// absent symbol returns null rather than an empty slice, which is a
// different fact (nothing cached vs. cached-and-never-split).
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
const config = Config{ .cache_dir = dir_path };
var svc = DataService.init(io, allocator, config);
defer svc.deinit();
var splits = [_]Split{
.{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 },
};
var store = svc.store();
store.write(Split, "TSTB", splits[0..], cache.DataType.splits.ttl());
svc.panic_on_network_attempt = true;
const b = svc.getCachedSplits(allocator, "TSTB") orelse return error.TestUnexpectedResult;
defer b.deinit();
try std.testing.expectEqual(@as(usize, 1), b.data.len);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), b.data[0].ratio(), 1e-9);
try std.testing.expect(svc.getCachedSplits(allocator, "TSTA") == null);
}
test "loadAllDividends: empty symbol list is a no-op" {
const allocator = std.testing.allocator;
const io = std.testing.io;

View file

@ -1031,12 +1031,36 @@ fn loadData(state: *State, app: *App) void {
};
if (state.view) |*v| v.deinit(app.allocator);
// Splits for the totals row's total-return index. Read straight
// from the cache rather than through a worker: the dividends
// worker above has already warmed the same Tiingo responses that
// carry splits, so this is a local file read. The slices only need
// to outlive `buildReview`, which reduces them to scalars.
var split_map = std.StringHashMap([]const zfin.Split).init(app.allocator);
defer {
var it = split_map.iterator();
while (it.next()) |entry| {
app.allocator.free(@constCast(entry.value_ptr.*));
}
split_map.deinit();
}
for (summary_ptr.allocations) |a| {
if (app.svc.getCachedSplits(app.allocator, a.symbol)) |spl| {
split_map.put(a.symbol, spl.data) catch {
app.allocator.free(spl.data);
break;
};
}
}
state.view = review_view.buildReview(
app.allocator,
app.io,
summary_ptr.*,
candle_map,
dividend_map,
&split_map,
pf,
cm_ptr.*,
acct_map_opt,
@ -2426,6 +2450,7 @@ test "deinitState: cleans up view (leak check)" {
summary,
&candle_map,
null,
null,
portfolio,
cm,
null,

View file

@ -201,6 +201,11 @@ pub fn buildReview(
summary: valuation.PortfolioSummary,
candle_map: *const std.StringHashMap([]const zfin.Candle),
dividend_map: ?*const std.StringHashMap([]const zfin.Dividend),
/// Per-symbol splits, newest-first. Travels with `dividend_map`:
/// the totals row builds a total-return index from raw `close`,
/// which is not split-adjusted, so dividends without splits would
/// read a 2:1 split as a -50% month.
split_map: ?*const std.StringHashMap([]const zfin.Split),
portfolio: zfin.Portfolio,
classifications: classification.ClassificationMap,
account_map: ?analysis.AccountMap,
@ -259,6 +264,11 @@ pub fn buildReview(
.symbol = a.symbol,
.candles = candles,
.weight = a.weight,
// Supplying both switches the synthetic series from
// `adj_close` to a total-return index; supplying neither
// leaves it on `adj_close`. Never supply just one.
.dividends = dividends orelse &.{},
.splits = if (split_map) |sm| (sm.get(a.symbol) orelse &.{}) else &.{},
});
}
@ -1206,6 +1216,7 @@ test "buildReview: dividends_missing separates a failed fetch from a holding tha
summary,
&candle_map,
&dividend_map,
null,
portfolio,
cm,
null,
@ -1235,6 +1246,7 @@ test "buildReview: dividends_missing separates a failed fetch from a holding tha
summary,
&candle_map,
null,
null,
portfolio,
cm,
null,
@ -1334,6 +1346,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" {
summary,
&candle_map,
null, // no dividend map
null, // no split map
portfolio,
cm,
am,