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

/// 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,
};

/// 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;
        // Walk candles, recording the LAST close in each month bucket
        // that falls within the window. We use yearMonth() for the
        // month-boundary comparison (cheap integer compare) and
        // Date.monthsBetween for the slot index into `prices`.
        var prev_ym: u32 = 0;
        var prev_date: Date = window_start;
        var last_close: f64 = 0;
        var have_any = false;

        for (cand) |c| {
            if (c.date.days < window_start.days) {
                // Before window: still track the running last-close,
                // but only emit it once we cross into the window.
                last_close = c.adj_close;
                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 close.
                const m_idx_signed = Date.monthsBetween(window_start, prev_date);
                if (m_idx_signed >= 0) {
                    const m_idx: usize = @intCast(m_idx_signed);
                    if (m_idx < n_months_total) {
                        prices[p_idx * n_months_total + m_idx] = last_close;
                    }
                }
            }
            last_close = c.adj_close;
            prev_ym = ym;
            prev_date = c.date;
            have_any = true;
        }
        // Final partial month: stash whatever the latest close was.
        if (have_any) {
            const m_idx_signed = Date.monthsBetween(window_start, prev_date);
            if (m_idx_signed >= 0) {
                const m_idx: usize = @intCast(m_idx_signed);
                if (m_idx < n_months_total) {
                    prices[p_idx * n_months_total + m_idx] = last_close;
                }
            }
        }
    }

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