zfin/src/analytics/risk.zig

465 lines
18 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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