465 lines
18 KiB
Zig
465 lines
18 KiB
Zig
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);
|
||
}
|