zfin/src/analytics/portfolio_risk.zig
Emil Lerch e3ce8f32a1
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
build portfolio risk from a total-return index
2026-08-17 20:01:06 -07:00

957 lines
41 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! True correlation-aware portfolio risk via synthetic-series construction.
//!
//! `analytics/risk.zig` computes per-symbol risk metrics: vol, Sharpe, max
//! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's
//! the right shape for individual holdings, but the weighted average of
//! per-symbol vols is NOT the same as the portfolio's true vol - the
//! diversification benefit (correlation < 1 between holdings) means the
//! portfolio-level number is typically 20-40% lower than the weighted
//! average for a real diversified portfolio.
//!
//! This module builds the correct number. For each window:
//!
//! 1. Resample each holding's daily candles to month-end closes.
//! 2. Identify the set of months covered by ALL participating holdings.
//! 3. For each covered month, compute portfolio_return_t =
//! Σᵢ wᵢ · position_return_i_t with weights renormalized over
//! participating holdings.
//! 4. Run the same volatility / Sharpe / max-drawdown math on the
//! synthetic monthly-return series.
//!
//! ## Reweight policy (per-window dynamic)
//!
//! A 2024-IPO position drops out of the 10Y window but participates fully
//! in the 3Y window. We renormalize weights independently per window so
//! every window gets the most-honest representation possible. When that
//! happens, the corresponding `reweight_flags` field is set so the renderer
//! can mark the totals-row cell with a reweight asterisk.
//!
//! ## Single window: 5Y for max drawdown
//!
//! The `review` view shows MaxDD at 5Y only (captures the 2022 bear market
//! without the 2020 COVID drawdown flooding every row), so this module
//! exposes `maxdd_5y` rather than max-drawdown at all windows.
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
/// for that window. Renderers append a `*` to the corresponding cell and
/// emit a footnote.
///
/// Plain (not packed) struct so individual fields can be addressed by
/// pointer where it's ergonomic; the wire size of nine bools isn't
/// performance-sensitive.
pub const ReweightFlags = struct {
vol_3y: bool = false,
vol_10y: bool = false,
sharpe_3y: bool = false,
sharpe_10y: bool = false,
maxdd_5y: bool = false,
return_1y: bool = false,
return_3y: bool = false,
return_5y: bool = false,
return_10y: bool = false,
};
/// True portfolio-level risk metrics from synthetic series construction.
/// Each `?f64` is null when no synthetic series could be constructed (no
/// participating holdings, or fewer than 12 monthly returns).
pub const SyntheticRisk = struct {
vol_3y: ?f64 = null,
vol_10y: ?f64 = null,
sharpe_3y: ?f64 = null,
sharpe_10y: ?f64 = null,
maxdd_5y: ?f64 = null,
/// Synthesized portfolio annualized return over the trailing window
/// (CAGR derived from the geometric compound of monthly returns).
/// The 1Y figure is the cumulative return (no annualization needed
/// for a 1-year window); 3Y/5Y/10Y are annualized.
return_1y: ?f64 = null,
return_3y: ?f64 = null,
return_5y: ?f64 = null,
return_10y: ?f64 = null,
reweight_flags: ReweightFlags = .{},
};
/// One position's contribution to the synthetic series. `candles` are
/// borrowed; the caller retains ownership for the duration of the call.
pub const PositionCandles = struct {
symbol: []const u8,
/// Sorted daily candles. Empty slice = the position has no candle
/// data at all (drops out of every window).
candles: []const Candle,
/// 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-
/// renormalize, and runs the same monthly-returns math `risk.zig` uses
/// per-symbol. `as_of` is the reference date (typically today) - windows
/// extend backward from there using calendar-year math.
pub fn syntheticPortfolioRisk(
allocator: std.mem.Allocator,
positions: []const PositionCandles,
as_of: Date,
) !SyntheticRisk {
if (positions.len == 0) return .{};
// Months count from the synthesized series. We need 12+ for vol/Sharpe;
// for a clean 10Y window plus a few months of slack we cap at 130.
const max_months = 130;
var result: SyntheticRisk = .{};
// Each window is computed independently - different windows include
// different holdings (newer positions drop out of longer windows).
const window_specs = [_]struct {
years: u16,
out_vol: ?*?f64,
out_sharpe: ?*?f64,
out_maxdd: ?*?f64,
out_total_return: ?*?f64,
flag_vol: ?*bool,
flag_sharpe: ?*bool,
flag_maxdd: ?*bool,
flag_total_return: ?*bool,
}{
.{
.years = 1,
.out_vol = null,
.out_sharpe = null,
.out_maxdd = null,
.out_total_return = &result.return_1y,
.flag_vol = null,
.flag_sharpe = null,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_1y,
},
.{
.years = 3,
.out_vol = &result.vol_3y,
.out_sharpe = &result.sharpe_3y,
.out_maxdd = null,
.out_total_return = &result.return_3y,
.flag_vol = &result.reweight_flags.vol_3y,
.flag_sharpe = &result.reweight_flags.sharpe_3y,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_3y,
},
.{
.years = 5,
.out_vol = null,
.out_sharpe = null,
.out_maxdd = &result.maxdd_5y,
.out_total_return = &result.return_5y,
.flag_vol = null,
.flag_sharpe = null,
.flag_maxdd = &result.reweight_flags.maxdd_5y,
.flag_total_return = &result.reweight_flags.return_5y,
},
.{
.years = 10,
.out_vol = &result.vol_10y,
.out_sharpe = &result.sharpe_10y,
.out_maxdd = null,
.out_total_return = &result.return_10y,
.flag_vol = &result.reweight_flags.vol_10y,
.flag_sharpe = &result.reweight_flags.sharpe_10y,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_10y,
},
};
for (window_specs) |spec| {
const synthesized = try synthesizeWindow(allocator, positions, as_of, spec.years, max_months);
defer if (synthesized.monthly_returns) |mr| allocator.free(mr);
if (synthesized.monthly_returns) |mr| {
// Total compound return for ALL windows that asked for it
// (1Y/3Y/5Y/10Y). Computed regardless of whether
// there are 12+ months - even an under-12-month window
// can produce a meaningful compound if every month is
// present, but for shape consistency with vol/Sharpe we
// require ≥12 months for total return at multi-year windows
// and accept 12+ for 1Y. (Annualization isn't done here;
// the renderer reports total return as the cumulative
// monthly compound, matching how Morningstar quotes
// ≤1Y trailing returns.)
//
// Practical guard: require at least as many months as the
// window's years*10 to suppress totally-undercovered series.
const min_months_required: usize = @as(usize, spec.years) * 10;
if (mr.len >= min_months_required) {
if (spec.out_total_return) |out| {
var compound: f64 = 1.0;
for (mr) |r| compound *= (1.0 + r);
const total = compound - 1.0;
// Annualize for multi-year windows so the totals
// row is comparable to per-position annualized
// trailing returns. 1Y stays as cumulative - over
// a 1-year window the cumulative IS the annual.
const annualized = if (spec.years > 1) blk: {
const years_f: f64 = @floatFromInt(spec.years);
break :blk std.math.pow(f64, 1.0 + total, 1.0 / years_f) - 1.0;
} else total;
out.* = annualized;
if (spec.flag_total_return) |fp| fp.* = synthesized.reweighted;
}
}
if (mr.len >= 12) {
const window_end = as_of;
const window_start = window_end.subtractYears(spec.years);
const rfr = risk.avgRiskFreeRateForRange(window_start, window_end);
const stats = risk.statsFromMonthlyReturns(mr, rfr);
if (spec.out_vol) |out| {
out.* = stats.volatility;
if (spec.flag_vol) |fp| fp.* = synthesized.reweighted;
}
if (spec.out_sharpe) |out| {
out.* = stats.sharpe;
if (spec.flag_sharpe) |fp| fp.* = synthesized.reweighted;
}
if (spec.out_maxdd) |out| {
out.* = stats.max_drawdown;
if (spec.flag_maxdd) |fp| fp.* = synthesized.reweighted;
}
}
}
}
return result;
}
const SynthesizedSeries = struct {
/// Synthetic monthly returns. `null` when no series could be built
/// (no participating holdings or insufficient overlap). Caller frees.
monthly_returns: ?[]f64,
/// True iff at least one position was dropped from this window
/// (lacked candle coverage), forcing a weight renormalization.
reweighted: bool,
};
/// Build the weighted synthetic monthly-return series for one window.
fn synthesizeWindow(
allocator: std.mem.Allocator,
positions: []const PositionCandles,
as_of: Date,
years: u16,
comptime max_months: usize,
) !SynthesizedSeries {
const window_end = as_of;
const window_start = window_end.subtractYears(years);
// First pass: figure out which positions can participate. A
// participating position has candles whose first date is at most
// 45 days after window_start (matching `risk.zig`'s freshness
// tolerance) and at least one candle on or before window_end.
var participants_buf: [256]usize = undefined;
var n_participants: usize = 0;
var dropped = false;
for (positions, 0..) |p, i| {
if (p.weight <= 0) continue; // skip zero-weight positions silently
if (p.candles.len == 0) {
dropped = true;
continue;
}
// First candle must be reasonably close to the window start.
const first = p.candles[0].date;
if (first.days > window_start.days + 45) {
dropped = true;
continue;
}
// Need at least one candle within the window.
const last = p.candles[p.candles.len - 1].date;
if (last.days < window_start.days) {
dropped = true;
continue;
}
if (n_participants >= participants_buf.len) {
// Hard cap: portfolios with >256 positions just don't fit
// in our scratch buffer. Returning what we have is fine -
// this is an extreme edge case for personal-portfolio use.
break;
}
participants_buf[n_participants] = i;
n_participants += 1;
}
if (n_participants == 0) {
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Renormalize weights across participants.
var total_weight: f64 = 0;
for (participants_buf[0..n_participants]) |idx| total_weight += positions[idx].weight;
if (total_weight <= 0) return .{ .monthly_returns = null, .reweighted = dropped };
// Total months in `[window_start, window_end]`. We pre-allocate a
// (n_participants × n_months_total) prices grid, with NaN sentinels
// marking months a position lacked data for.
const months_diff = Date.monthsBetween(window_start, window_end);
if (months_diff < 1) return .{ .monthly_returns = null, .reweighted = dropped };
const n_months_total: usize = @intCast(months_diff + 1);
if (n_months_total > max_months) {
// If a caller asks for an absurd window, cap at max_months from
// the end. This keeps the math correct for the supported windows.
// Practically only matters if `years` is huge.
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Prices[participant][month_index]. NaN sentinel = no data that month.
var prices = try allocator.alloc(f64, n_participants * n_months_total);
defer allocator.free(prices);
for (prices) |*p| p.* = std.math.nan(f64);
for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| {
const cand = positions[orig_idx].candles;
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_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 value,
// but only emit it once we cross into the window.
last_value = value;
prev_ym = c.date.yearMonth();
prev_date = c.date;
have_any = true;
continue;
}
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 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_value;
}
}
}
last_value = value;
prev_ym = ym;
prev_date = c.date;
have_any = true;
}
// 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_value;
}
}
}
}
// Now compute portfolio monthly returns. For each month transition
// (m -> m+1), include only participants with valid prices in BOTH
// months; renormalize their weights for that single transition.
// This handles the "I have data starting in month 5" case naturally:
// months 1-4 simply lack that participant's contribution, weights
// for those months are over the participants who DO have data.
var monthly_returns = try allocator.alloc(f64, n_months_total - 1);
var n_valid_returns: usize = 0;
for (0..n_months_total - 1) |m| {
var month_weight_sum: f64 = 0;
var month_return_sum: f64 = 0;
for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| {
const prev_p = prices[p_idx * n_months_total + m];
const curr_p = prices[p_idx * n_months_total + m + 1];
if (std.math.isNan(prev_p) or std.math.isNan(curr_p)) continue;
if (prev_p <= 0) continue;
const r = (curr_p / prev_p) - 1.0;
const w = positions[orig_idx].weight;
month_return_sum += w * r;
month_weight_sum += w;
}
if (month_weight_sum > 0) {
monthly_returns[n_valid_returns] = month_return_sum / month_weight_sum;
n_valid_returns += 1;
}
}
if (n_valid_returns == 0) {
allocator.free(monthly_returns);
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Trim to actual length.
const trimmed = try allocator.realloc(monthly_returns, n_valid_returns);
return .{ .monthly_returns = trimmed, .reweighted = dropped };
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
fn makeCandle(date: Date, price: f64) Candle {
return .{
.date = date,
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
/// Build a candle slice spanning `n_months` months of business days
/// where the close on month `i` is `price_at_month(i)`. Month-end is
/// the last business day of each month - we approximate with day 28.
fn buildMonthlyCandles(
allocator: std.mem.Allocator,
start_year: u16,
n_months: u16,
price_at_month: *const fn (i: u16) f64,
) ![]Candle {
var candles = std.ArrayList(Candle).empty;
errdefer candles.deinit(allocator);
var month: u16 = 0;
while (month < n_months) : (month += 1) {
const year_offset = month / 12;
const m_in_year: u8 = @intCast((month % 12) + 1);
const y_signed: i16 = @intCast(start_year + year_offset);
const date = Date.fromYmd(y_signed, m_in_year, 15);
const p = price_at_month(month);
try candles.append(allocator, makeCandle(date, p));
// Add an end-of-month candle so the resampler captures a
// distinct month-boundary close.
const end_date = Date.fromYmd(y_signed, m_in_year, 28);
try candles.append(allocator, makeCandle(end_date, p));
}
return candles.toOwnedSlice(allocator);
}
fn linearGrowth(i: u16) f64 {
return 100.0 + @as(f64, @floatFromInt(i)) * 1.0;
}
fn slowGrowth(i: u16) f64 {
return 100.0 + @as(f64, @floatFromInt(i)) * 0.3;
}
test "syntheticPortfolioRisk: empty positions returns empty result" {
const r = try syntheticPortfolioRisk(testing.allocator, &.{}, Date.fromYmd(2026, 1, 1));
try testing.expect(r.vol_3y == null);
try testing.expect(r.vol_10y == null);
try testing.expect(r.maxdd_5y == null);
try testing.expect(r.return_3y == null);
try testing.expect(r.reweight_flags.vol_3y == false);
}
test "syntheticPortfolioRisk: single position, 3Y window populates" {
// Build 40 months of monthly data - enough for a 3Y window.
const candles = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(candles);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = candles, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
try testing.expect(r.sharpe_3y != null);
try testing.expect(r.vol_3y.? > 0);
try testing.expectEqual(false, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: holding missing 10Y data flags 10Y but not 3Y" {
// Position A: 10+ years of data
const cand_a = try buildMonthlyCandles(testing.allocator, 2014, 145, &linearGrowth);
defer testing.allocator.free(cand_a);
// Position B: only 4 years of data (covers 3Y but not 10Y)
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &slowGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "OLD", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "NEW", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// 3Y window: both holdings participate.
try testing.expect(r.vol_3y != null);
try testing.expectEqual(false, r.reweight_flags.vol_3y);
// 10Y window: only OLD participates -> reweighted.
try testing.expect(r.vol_10y != null);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
test "syntheticPortfolioRisk: zero-weight position is silently skipped" {
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = cand_a, .weight = 1.0 },
.{ .symbol = "ZERO", .candles = &.{}, .weight = 0.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
// The zero-weight slot doesn't trigger reweight (skipped before participation check).
try testing.expectEqual(false, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: empty-candle position drops out and flags reweight" {
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "MISSING", .candles = &.{}, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
try testing.expectEqual(true, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: position with all candles before window drops out" {
// Build candles that ALL fall before the window (candles end in
// 2014, window asks for 2026-3Y backward = 2023+). The
// participation check at `last.days < window_start.days` should
// mark this position as dropped.
const cand_old = try buildMonthlyCandles(testing.allocator, 2010, 36, &linearGrowth);
defer testing.allocator.free(cand_old);
// Plus one position with current data so the window can populate.
const cand_current = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_current);
const positions = [_]PositionCandles{
.{ .symbol = "OLD_DEAD", .candles = cand_old, .weight = 0.5 },
.{ .symbol = "VTI", .candles = cand_current, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
// OLD_DEAD's candles are all >>45 days before the 3Y window
// start; it drops, reweight flag fires.
try testing.expectEqual(true, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: all-positions-drop produces null result" {
// Both positions have candles that end before the window starts
// -> no participants -> null returns. Reweight flags are NOT set
// here because there's no successful stats pass to set them on
// (the flags are written as a side-effect of stats computation).
// That's a known asymmetry - when *some* positions drop but
// others remain, the kept window's flags fire; when ALL
// positions drop, the field stays null and the flag stays
// false because no metric was computed at all.
const cand_old1 = try buildMonthlyCandles(testing.allocator, 2010, 24, &linearGrowth);
defer testing.allocator.free(cand_old1);
const cand_old2 = try buildMonthlyCandles(testing.allocator, 2011, 24, &linearGrowth);
defer testing.allocator.free(cand_old2);
const positions = [_]PositionCandles{
.{ .symbol = "DEAD1", .candles = cand_old1, .weight = 0.5 },
.{ .symbol = "DEAD2", .candles = cand_old2, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y == null);
try testing.expect(r.vol_10y == null);
}
test "syntheticPortfolioRisk: perfectly correlated positions yield ~weighted-avg vol" {
// Two positions with IDENTICAL candle series -> portfolio vol should
// equal the per-position vol (within float tolerance), since the
// synthetic series is a weighted average of identical series, which
// is itself the same series.
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "A", .candles = cand_a, .weight = 0.6 },
.{ .symbol = "B", .candles = cand_b, .weight = 0.4 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// Per-position vol from risk.zig
const tr_a = risk.trailingRisk(cand_a);
try testing.expect(tr_a.three_year != null);
const per_vol = tr_a.three_year.?.volatility;
try testing.expect(r.vol_3y != null);
// Should match within numerical tolerance.
try testing.expectApproxEqAbs(per_vol, r.vol_3y.?, 0.01);
}
test "syntheticPortfolioRisk: anti-correlated positions yield lower vol than weighted-avg" {
// Position A grows; position B falls. Weighted-avg of
// their per-position vols would suggest the portfolio is volatile,
// but the synthetic series (50/50 of two anti-correlated streams)
// should be flatter - that's the diversification benefit.
const Anti = struct {
fn fall(i: u16) f64 {
return 200.0 - @as(f64, @floatFromInt(i)) * 1.0;
}
};
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &Anti.fall);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "UP", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "DOWN", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
const tr_a = risk.trailingRisk(cand_a);
const tr_b = risk.trailingRisk(cand_b);
try testing.expect(tr_a.three_year != null);
try testing.expect(tr_b.three_year != null);
const avg_vol = (tr_a.three_year.?.volatility + tr_b.three_year.?.volatility) / 2.0;
try testing.expect(r.vol_3y != null);
// Synthetic series should have meaningfully lower vol than the
// weighted average. (Strict equality wouldn't hold because the
// series aren't perfectly anti-correlated under monthly compounding,
// but it should be measurably below.)
try testing.expect(r.vol_3y.? < avg_vol);
}
test "syntheticPortfolioRisk: reweight flags don't bleed across windows" {
// Position A: 10Y available. Position B: only 6 months (insufficient
// for ANY window). 3Y vol should be A-only with reweight=true.
const cand_a = try buildMonthlyCandles(testing.allocator, 2014, 145, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2025, 6, &slowGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "A", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "B", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// Both windows should be reweighted - B doesn't qualify for either.
try testing.expectEqual(true, r.reweight_flags.vol_3y);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" {
// 6 years of growing-then-falling data so max_drawdown is non-zero.
const Curve = struct {
fn shape(i: u16) f64 {
// 30 months up to peak at 200, then 42 months down to 100,
// then recovery - produces a clean ~50% drawdown.
if (i <= 30) return 100.0 + @as(f64, @floatFromInt(i)) * 3.33;
if (i <= 60) return 200.0 - @as(f64, @floatFromInt(i - 30)) * 3.33;
return 100.0 + @as(f64, @floatFromInt(i - 60)) * 1.0;
}
};
const cand = try buildMonthlyCandles(testing.allocator, 2020, 75, &Curve.shape);
defer testing.allocator.free(cand);
const positions = [_]PositionCandles{
.{ .symbol = "X", .candles = cand, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.maxdd_5y != null);
try testing.expect(r.maxdd_5y.? > 0.30); // >30% - the 200->100 leg
}
test "syntheticPortfolioRisk: return_3y annualizes correctly" {
// 50 months of constant +1%/month returns. Annualized to ~12.7%.
const Constant = struct {
fn p(i: u16) f64 {
return std.math.pow(f64, 1.01, @as(f64, @floatFromInt(i))) * 100.0;
}
};
const cand = try buildMonthlyCandles(testing.allocator, 2022, 50, &Constant.p);
defer testing.allocator.free(cand);
const positions = [_]PositionCandles{
.{ .symbol = "X", .candles = cand, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.return_3y != null);
// Annualized 1%/month ≈ (1.01^12 - 1) ≈ 0.1268. Allow a generous
// tolerance to absorb the calendar-edge effects.
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);
}