//! 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); }