const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; const Date = @import("../Date.zig"); const months_per_year: f64 = 12.0; /// Risk metrics for a specific trailing period, computed from monthly returns. pub const RiskMetrics = struct { /// Annualized standard deviation of monthly returns volatility: f64, /// Sharpe ratio: (annualized return - risk-free rate) / annualized volatility sharpe: f64, /// Maximum drawdown as a positive decimal (e.g., 0.30 = 30% drawdown) max_drawdown: f64, /// Number of monthly returns used sample_size: usize, }; /// Risk metrics for all standard trailing periods. pub const TrailingRisk = struct { one_year: ?RiskMetrics = null, three_year: ?RiskMetrics = null, five_year: ?RiskMetrics = null, ten_year: ?RiskMetrics = null, }; /// Average annual 3-month T-bill rate by year (source: FRED series DTB3). /// Used to compute period-appropriate risk-free rates for Sharpe ratio. /// Update annually - bump `tbill_rates_last_updated` below when you /// refresh the table. `src/data/staleness.zig` nags on stderr every /// invocation once it's past the annual due date (Jan 31). /// /// To update mid-year (e.g. refresh the current year's YTD average): /// curl -s "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3&cosd=2026-01-01&coed=2026-12-31&fq=Daily" \ /// | tail -n +2 | awk -F, '{s+=$2;n++} END {printf "%.4f\n", s/n/100}' /// /// To get the prior year's final average (e.g. 2026 final, run in Jan 2027): /// curl -s "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3&cosd=2026-01-01&coed=2026-12-31&fq=Annual&fam=avg" \ /// | tail -1 | awk -F, '{printf "%.4f\n", $2/100}' /// Then add a new entry for 2027 using the mid-year command above. /// Finally, bump `tbill_rates_last_updated` to today's date. /// /// Registered with the staleness checker in `src/data/staleness.zig`. pub const tbill_rates_last_updated: Date = Date.fromYmd(2026, 3, 15); const tbill_rates = [_]struct { year: u16, rate: f64 }{ .{ .year = 2015, .rate = 0.0005 }, .{ .year = 2016, .rate = 0.0032 }, .{ .year = 2017, .rate = 0.0093 }, .{ .year = 2018, .rate = 0.0194 }, .{ .year = 2019, .rate = 0.0206 }, .{ .year = 2020, .rate = 0.0035 }, .{ .year = 2021, .rate = 0.0005 }, .{ .year = 2022, .rate = 0.0202 }, .{ .year = 2023, .rate = 0.0507 }, .{ .year = 2024, .rate = 0.0497 }, .{ .year = 2025, .rate = 0.0407 }, .{ .year = 2026, .rate = 0.0345 }, }; /// Look up the average risk-free rate for a date range from the T-bill /// table. Returns the simple average of annual rates for all years /// that overlap the range. Used both for per-symbol Sharpe (`computeRisk` /// in this file) and for synthetic-portfolio Sharpe /// (`analytics/portfolio_risk.zig`) so both paths use the same rate. pub fn avgRiskFreeRateForRange(start: Date, end: Date) f64 { const start_year: u16 = @intCast(start.year()); const end_year: u16 = @intCast(end.year()); var sum: f64 = 0; var count: f64 = 0; for (tbill_rates) |entry| { if (entry.year >= start_year and entry.year <= end_year) { sum += entry.rate; count += 1; } } if (count > 0) return sum / count; // Fallback: return the latest available rate return tbill_rates[tbill_rates.len - 1].rate; } /// Compute trailing risk metrics (1Y, 3Y, 5Y, 10Y) from daily candles. /// Uses monthly total returns (from adj_close, which includes dividends) /// to match Morningstar's methodology. Risk-free rate is the average /// 3-month T-bill rate over each period (from FRED historical data). /// Candles must be sorted by date ascending. pub fn trailingRisk(candles: []const Candle) TrailingRisk { if (candles.len == 0) return .{}; const end_date = candles[candles.len - 1].date; const start_1y = end_date.subtractYears(1); const start_3y = end_date.subtractYears(3); const start_5y = end_date.subtractYears(5); const start_10y = end_date.subtractYears(10); return .{ .one_year = computeRisk(candles, start_1y, end_date, avgRiskFreeRateForRange(start_1y, end_date)), .three_year = computeRisk(candles, start_3y, end_date, avgRiskFreeRateForRange(start_3y, end_date)), .five_year = computeRisk(candles, start_5y, end_date, avgRiskFreeRateForRange(start_5y, end_date)), .ten_year = computeRisk(candles, start_10y, end_date, avgRiskFreeRateForRange(start_10y, end_date)), }; } /// Maximum monthly observations we'll resample from a 10+ year window. /// Public so callers (notably `analytics/portfolio_risk.zig`) can size /// their stack-allocated buffers consistently. pub const max_months: usize = 130; /// Stats derived from a month-end return series. Pure-data result of /// `statsFromMonthlyReturns`; callers wrap it (or extend it with date /// attribution) for their richer outward types like `RiskMetrics`. pub const MonthlyStats = struct { /// Annualized standard deviation of monthly returns. volatility: f64, /// Sharpe ratio: (annualized return - risk-free rate) / annualized vol. sharpe: f64, /// Maximum drawdown over the synthetic compound series, as a /// positive decimal (e.g. 0.30 = 30% drawdown). max_drawdown: f64, /// Number of monthly returns the stats were derived from. sample_size: usize, }; /// Result of `monthEndReturns`: a slice into the caller's buffer /// containing one return per month transition, plus the parallel /// month-end dates for date-attribution callers (e.g. drawdown /// start/trough). The `dates` slice is one element LONGER than /// `returns` because returns are between consecutive month-ends. pub const MonthEndSeries = struct { returns: []const f64, dates: []const Date, }; /// Resample daily candles to a month-end return series, scoped to /// `[start, end]`. Stack-only - caller provides two scratch buffers /// of size at least `max_months`. Returns null when the period /// isn't sufficiently covered: /// /// - no candles inside the window, OR /// - data starts more than 45 days after `start`, OR /// - fewer than 2 month-end observations (need at least 1 return). /// /// Uses `Candle.adj_close` so synthetic returns include dividends /// already baked into the adjusted price (Tiingo / Polygon /// adj_close are split-and-dividend adjusted). pub fn monthEndReturns( candles: []const Candle, start: Date, end: Date, out_returns: []f64, out_dates: []Date, ) ?MonthEndSeries { std.debug.assert(out_returns.len >= max_months); std.debug.assert(out_dates.len >= max_months); // Find the candles within [start, end]. var first: usize = 0; for (candles, 0..) |c, i| { if (c.date.days >= start.days) { first = i; break; } } else return null; var last: usize = first; for (candles[first..], first..) |c, i| { if (c.date.days > end.days) break; last = i; } const slice = candles[first .. last + 1]; if (slice.len < 2) return null; // Period not sufficiently covered. if (slice[0].date.days - start.days > 45) return null; // Resample: for each calendar month, take the last available // close. We track the closes in a local buffer to avoid stomping // the caller's `out_returns` (which we'll fill with returns after // the resample completes). var month_closes: [max_months]f64 = undefined; var n_months: usize = 0; var prev_ym: u32 = slice[0].date.yearMonth(); var last_close: f64 = slice[0].adj_close; var last_date: Date = slice[0].date; for (slice[1..]) |c| { const ym = c.date.yearMonth(); if (ym != prev_ym) { if (n_months < max_months) { month_closes[n_months] = last_close; out_dates[n_months] = last_date; n_months += 1; } prev_ym = ym; } last_close = c.adj_close; last_date = c.date; } // Record the final (possibly partial) month. if (n_months < max_months) { month_closes[n_months] = last_close; out_dates[n_months] = last_date; n_months += 1; } if (n_months < 2) return null; // Compute month-over-month returns into `out_returns`. const n_returns = n_months - 1; for (0..n_returns) |i| { const prev = month_closes[i]; const curr = month_closes[i + 1]; out_returns[i] = if (prev > 0) (curr / prev) - 1.0 else 0; } return .{ .returns = out_returns[0..n_returns], .dates = out_dates[0..n_months], }; } /// Compute volatility / Sharpe / max-drawdown from a month-end /// return series. Pure: no allocation, no I/O. Returns /// zero-vol / zero-sharpe when the input is empty or trivially /// non-volatile; callers that want a "no data" sentinel should /// gate on `returns.len` themselves. /// /// MaxDD walks a synthetic compound series anchored at 1.0. This /// is mathematically equivalent to walking month-end prices - /// `(p_t / p_0)` is exactly the compounded return - but lets the /// helper operate on returns alone, which is what the synthetic- /// portfolio path produces. pub fn statsFromMonthlyReturns(returns: []const f64, risk_free_rate: f64) MonthlyStats { var sum: f64 = 0; var sum_sq: f64 = 0; var price: f64 = 1.0; var peak: f64 = 1.0; var max_dd: f64 = 0; for (returns) |r| { sum += r; sum_sq += r * r; price *= (1.0 + r); if (price > peak) peak = price; if (peak > 0) { const dd = (peak - price) / peak; if (dd > max_dd) max_dd = dd; } } const n = returns.len; const nf: f64 = @floatFromInt(n); const mean: f64 = if (n > 0) sum / nf else 0; // Sample variance (Bessel's correction) for unbiased estimate. const variance: f64 = if (n > 1) (sum_sq - nf * mean * mean) / (nf - 1.0) else 0; const monthly_vol = @sqrt(@max(variance, 0)); const annual_vol = monthly_vol * @sqrt(months_per_year); const annual_return = std.math.pow(f64, 1.0 + mean, months_per_year) - 1.0; const sharpe: f64 = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0; return .{ .volatility = annual_vol, .sharpe = sharpe, .max_drawdown = max_dd, .sample_size = n, }; } /// Compute risk metrics for a specific date range using monthly returns. /// Returns null if fewer than 12 monthly returns are available. fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate: f64) ?RiskMetrics { var ret_buf: [max_months]f64 = undefined; var date_buf: [max_months]Date = undefined; const series = monthEndReturns(candles, start, end, &ret_buf, &date_buf) orelse return null; if (series.returns.len < 12) return null; // need at least 12 monthly returns const stats = statsFromMonthlyReturns(series.returns, risk_free_rate); return .{ .volatility = stats.volatility, .sharpe = stats.sharpe, .max_drawdown = stats.max_drawdown, .sample_size = stats.sample_size, }; } // ── Tests ──────────────────────────────────────────────────── fn makeCandle(date: Date, price: f64) Candle { return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; } test "trailingRisk 3-year period" { // Build 3+ years of monthly data (40 months). // Price goes from 100 to 140 linearly over 40 months. var candles: [40 * 21]Candle = undefined; // ~21 trading days per month var idx: usize = 0; var date = Date.fromYmd(2022, 1, 3); for (0..40) |month| { const base_price: f64 = 100.0 + @as(f64, @floatFromInt(month)); for (0..21) |day| { if (idx >= candles.len) break; // Small daily variation within the month const daily_offset: f64 = @as(f64, @floatFromInt(day)) * 0.01; candles[idx] = makeCandle(date, base_price + daily_offset); date = date.addDays(1); // Skip weekends while (date.dayOfWeek() >= 5) date = date.addDays(1); idx += 1; } } const tr = trailingRisk(candles[0..idx]); // 3-year should exist (40 months > 36) try std.testing.expect(tr.three_year != null); const m3 = tr.three_year.?; // Monthly returns are ~1% (price goes up ~1/month from ~100) // Annualized return should be roughly 12-13% // Sharpe should be positive try std.testing.expect(m3.sharpe > 0); try std.testing.expect(m3.volatility > 0); try std.testing.expect(m3.sample_size >= 35); // ~36 monthly returns // 5-year should be null (only ~40 months of data, period not covered) try std.testing.expect(tr.five_year == null); } test "trailingRisk max drawdown" { // Build 2 years of data with a 20% drawdown in the middle var candles: [500]Candle = undefined; var idx: usize = 0; var date = Date.fromYmd(2023, 1, 3); // 6 months up: 100 -> 120 for (0..126) |i| { const price = 100.0 + @as(f64, @floatFromInt(i)) * (20.0 / 126.0); candles[idx] = makeCandle(date, price); date = date.addDays(1); while (date.dayOfWeek() >= 5) date = date.addDays(1); idx += 1; } // 3 months down: 120 -> 96 (20% drawdown from 120) for (0..63) |i| { const price = 120.0 - @as(f64, @floatFromInt(i)) * (24.0 / 63.0); candles[idx] = makeCandle(date, price); date = date.addDays(1); while (date.dayOfWeek() >= 5) date = date.addDays(1); idx += 1; } // 6 months recovery: 96 -> 130 for (0..126) |i| { const price = 96.0 + @as(f64, @floatFromInt(i)) * (34.0 / 126.0); candles[idx] = makeCandle(date, price); date = date.addDays(1); while (date.dayOfWeek() >= 5) date = date.addDays(1); idx += 1; } const tr = trailingRisk(candles[0..idx]); try std.testing.expect(tr.one_year != null); const m = tr.one_year.?; // Drawdown should be approximately 20% (from monthly closes, may not be exact) try std.testing.expect(m.max_drawdown > 0.15); try std.testing.expect(m.max_drawdown < 0.25); } test "trailingRisk insufficient data" { // Only 6 months of data -> 1-year should be null var candles: [126]Candle = undefined; var date = Date.fromYmd(2025, 7, 1); for (0..126) |i| { candles[i] = makeCandle(date, 100.0 + @as(f64, @floatFromInt(i)) * 0.1); date = date.addDays(1); while (date.dayOfWeek() >= 5) date = date.addDays(1); } const tr = trailingRisk(&candles); // Less than 12 monthly returns for any period try std.testing.expect(tr.one_year == null); try std.testing.expect(tr.three_year == null); } test "avgRiskFreeRateForRange uses historical T-bill data" { // 2023-2025: average of 5.07%, 4.97%, 4.07% = 4.70% const rate_3y = avgRiskFreeRateForRange(Date.fromYmd(2023, 3, 16), Date.fromYmd(2025, 12, 31)); try std.testing.expectApproxEqAbs(@as(f64, 0.047), rate_3y, 0.002); // 2020-2025: includes the near-zero years const rate_5y = avgRiskFreeRateForRange(Date.fromYmd(2020, 1, 1), Date.fromYmd(2025, 12, 31)); try std.testing.expect(rate_5y < rate_3y); // should be lower due to 2020-2021 near-zero rates } test "monthEndReturns: empty range returns null" { var ret_buf: [max_months]f64 = undefined; var date_buf: [max_months]Date = undefined; const candles: []const Candle = &.{}; const series = monthEndReturns(candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1), &ret_buf, &date_buf); try std.testing.expect(series == null); } test "monthEndReturns: data starting >45 days late returns null" { var ret_buf: [max_months]f64 = undefined; var date_buf: [max_months]Date = undefined; // Window starts 2022-01-01, but candles start 2022-04-01 (90 days late). var candles: [60]Candle = undefined; var d = Date.fromYmd(2022, 4, 1); for (0..candles.len) |i| { candles[i] = makeCandle(d, 100.0 + @as(f64, @floatFromInt(i))); d = d.addDays(7); } const series = monthEndReturns(&candles, Date.fromYmd(2022, 1, 1), Date.fromYmd(2024, 1, 1), &ret_buf, &date_buf); try std.testing.expect(series == null); } test "monthEndReturns: happy path returns one fewer return than month-end dates" { var ret_buf: [max_months]f64 = undefined; var date_buf: [max_months]Date = undefined; // 6 months of data, daily candles. var candles: [180]Candle = undefined; var d = Date.fromYmd(2024, 1, 3); for (0..candles.len) |i| { candles[i] = makeCandle(d, 100.0 + @as(f64, @floatFromInt(i)) * 0.1); d = d.addDays(1); while (d.dayOfWeek() >= 5) d = d.addDays(1); } const series = monthEndReturns(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1), &ret_buf, &date_buf); try std.testing.expect(series != null); const s = series.?; // returns is one shorter than dates: returns[i] is the return // from dates[i] to dates[i+1]. try std.testing.expectEqual(s.dates.len - 1, s.returns.len); try std.testing.expect(s.returns.len >= 5); // at least Feb..Jun returns } test "statsFromMonthlyReturns: zero-variance series has zero vol" { const constant = [_]f64{ 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01 }; const stats = statsFromMonthlyReturns(&constant, 0.04); try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001); try std.testing.expectEqual(@as(usize, 12), stats.sample_size); // Drawdown is zero - every month is +1%, no peak retreats. try std.testing.expectApproxEqAbs(@as(f64, 0), stats.max_drawdown, 0.0001); } test "statsFromMonthlyReturns: max drawdown matches hand calc" { // Up 10%, up 10%, down 20%, up 5%. Peak at month 2 (1.21); // trough at month 3 (1.21 × 0.80 = 0.968). Drawdown = // (1.21 - 0.968) / 1.21 ≈ 0.20. const returns = [_]f64{ 0.10, 0.10, -0.20, 0.05 }; const stats = statsFromMonthlyReturns(&returns, 0.04); try std.testing.expectApproxEqAbs(@as(f64, 0.20), stats.max_drawdown, 0.001); } test "statsFromMonthlyReturns: empty input returns zeros" { const stats = statsFromMonthlyReturns(&.{}, 0.04); try std.testing.expectEqual(@as(f64, 0), stats.volatility); try std.testing.expectEqual(@as(f64, 0), stats.sharpe); try std.testing.expectEqual(@as(f64, 0), stats.max_drawdown); try std.testing.expectEqual(@as(usize, 0), stats.sample_size); }