957 lines
41 KiB
Zig
957 lines
41 KiB
Zig
//! 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);
|
||
}
|