zfin/src/analytics/risk.zig

612 lines
24 KiB
Zig

const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
const Date = @import("../models/date.zig").Date;
const fmt = @import("../format.zig");
/// Daily return series statistics.
pub const RiskMetrics = struct {
/// Annualized standard deviation of returns
volatility: f64,
/// Sharpe ratio (assuming risk-free rate of ~4.5% -- current T-bill)
sharpe: f64,
/// Maximum drawdown as a positive decimal (e.g., 0.30 = 30% drawdown)
max_drawdown: f64,
/// Start date of max drawdown period
drawdown_start: ?Date = null,
/// Trough date of max drawdown
drawdown_trough: ?Date = null,
/// Number of daily returns used
sample_size: usize,
};
const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy
const trading_days_per_year: f64 = 252.0;
/// Compute risk metrics from a series of daily candles.
/// Candles must be sorted by date ascending.
pub fn computeRisk(candles: []const Candle) ?RiskMetrics {
if (candles.len < 21) return null; // need at least ~1 month
// Compute daily log returns
const n = candles.len - 1;
var sum: f64 = 0;
var sum_sq: f64 = 0;
var peak: f64 = candles[0].close;
var max_dd: f64 = 0;
var dd_start: ?Date = null;
var dd_trough: ?Date = null;
var current_dd_start: Date = candles[0].date;
for (1..candles.len) |i| {
const prev = candles[i - 1].close;
const curr = candles[i].close;
if (prev <= 0 or curr <= 0) continue;
const ret = (curr / prev) - 1.0;
sum += ret;
sum_sq += ret * ret;
// Drawdown tracking
if (curr > peak) {
peak = curr;
current_dd_start = candles[i].date;
}
const dd = (peak - curr) / peak;
if (dd > max_dd) {
max_dd = dd;
dd_start = current_dd_start;
dd_trough = candles[i].date;
}
}
const mean = sum / @as(f64, @floatFromInt(n));
const variance = (sum_sq / @as(f64, @floatFromInt(n))) - (mean * mean);
const daily_vol = @sqrt(@max(variance, 0));
const annual_vol = daily_vol * @sqrt(trading_days_per_year);
const annual_return = mean * trading_days_per_year;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_annual) / annual_vol else 0;
return .{
.volatility = annual_vol,
.sharpe = sharpe,
.max_drawdown = max_dd,
.drawdown_start = dd_start,
.drawdown_trough = dd_trough,
.sample_size = n,
};
}
/// Portfolio-level metrics computed from weighted position data.
pub const PortfolioSummary = struct {
/// Total market value of open positions
total_value: f64,
/// Total cost basis of open positions
total_cost: f64,
/// Total unrealized P&L
unrealized_gain_loss: f64,
/// Total unrealized return (decimal)
unrealized_return: f64,
/// Total realized P&L from closed lots
realized_gain_loss: f64,
/// Per-symbol breakdown
allocations: []Allocation,
pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void {
allocator.free(self.allocations);
}
/// Adjust the summary to include non-stock assets (cash, CDs, options) in the totals.
/// Cash and CDs add equally to value and cost (no gain/loss).
/// Options add at cost basis (no live pricing).
/// This keeps unrealized_gain_loss correct (only stocks contribute market gains)
/// but dilutes the return% against the full portfolio cost base.
pub fn adjustForNonStockAssets(self: *PortfolioSummary, portfolio: @import("../models/portfolio.zig").Portfolio) void {
const cash_total = portfolio.totalCash();
const cd_total = portfolio.totalCdFaceValue();
const opt_total = portfolio.totalOptionCost();
const non_stock = cash_total + cd_total + opt_total;
self.total_value += non_stock;
self.total_cost += non_stock;
if (self.total_cost > 0) {
self.unrealized_return = self.unrealized_gain_loss / self.total_cost;
}
// Reweight allocations against grand total
if (self.total_value > 0) {
for (self.allocations) |*a| {
a.weight = a.market_value / self.total_value;
}
}
}
};
pub const Allocation = struct {
symbol: []const u8,
/// Display label for the symbol column. For CUSIPs with notes, this is a
/// short label derived from the note (e.g. "TGT2035"). Otherwise same as symbol.
display_symbol: []const u8,
shares: f64,
avg_cost: f64,
current_price: f64,
market_value: f64,
cost_basis: f64,
weight: f64, // fraction of total portfolio
unrealized_gain_loss: f64,
unrealized_return: f64,
/// True if current_price came from a manual override rather than live API data.
is_manual_price: bool = false,
/// Account name (from lots; "Multiple" if lots span different accounts).
account: []const u8 = "",
};
/// Compute portfolio summary given positions and current prices.
/// `prices` maps symbol -> current price.
/// `manual_prices` optionally marks symbols whose price came from manual override (not live API).
pub fn portfolioSummary(
allocator: std.mem.Allocator,
positions: []const @import("../models/portfolio.zig").Position,
prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
) !PortfolioSummary {
var allocs = std.ArrayList(Allocation).empty;
errdefer allocs.deinit(allocator);
var total_value: f64 = 0;
var total_cost: f64 = 0;
var total_realized: f64 = 0;
for (positions) |pos| {
if (pos.shares <= 0) continue;
const price = prices.get(pos.symbol) orelse continue;
const mv = pos.shares * price;
total_value += mv;
total_cost += pos.total_cost;
total_realized += pos.realized_gain_loss;
// For CUSIPs with a note, derive a short display label from the note.
const display = if (fmt.isCusipLike(pos.symbol) and pos.note != null)
shortLabel(pos.note.?)
else
pos.symbol;
try allocs.append(allocator, .{
.symbol = pos.symbol,
.display_symbol = display,
.shares = pos.shares,
.avg_cost = pos.avg_cost,
.current_price = price,
.market_value = mv,
.cost_basis = pos.total_cost,
.weight = 0, // filled below
.unrealized_gain_loss = mv - pos.total_cost,
.unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0,
.is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false,
.account = pos.account,
});
}
// Fill weights
if (total_value > 0) {
for (allocs.items) |*a| {
a.weight = a.market_value / total_value;
}
}
return .{
.total_value = total_value,
.total_cost = total_cost,
.unrealized_gain_loss = total_value - total_cost,
.unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0,
.realized_gain_loss = total_realized,
.allocations = try allocs.toOwnedSlice(allocator),
};
}
/// Build fallback prices for symbols that failed API fetch.
/// 1. Use manual `price::` from SRF if available
/// 2. Otherwise use position avg_cost so the position still appears
/// Populates `prices` and returns a set of symbols whose price is manual/fallback.
pub fn buildFallbackPrices(
allocator: std.mem.Allocator,
lots: []const @import("../models/portfolio.zig").Lot,
positions: []const @import("../models/portfolio.zig").Position,
prices: *std.StringHashMap(f64),
) !std.StringHashMap(void) {
var manual_price_set = std.StringHashMap(void).init(allocator);
errdefer manual_price_set.deinit();
// First pass: manual price:: overrides
for (lots) |lot| {
if (lot.security_type != .stock) continue;
const sym = lot.priceSymbol();
if (lot.price) |p| {
if (!prices.contains(sym)) {
try prices.put(sym, p);
try manual_price_set.put(sym, {});
}
}
}
// Second pass: fall back to avg_cost for anything still missing
for (positions) |pos| {
if (!prices.contains(pos.symbol) and pos.shares > 0) {
try prices.put(pos.symbol, pos.avg_cost);
try manual_price_set.put(pos.symbol, {});
}
}
return manual_price_set;
}
// ── Historical portfolio value ───────────────────────────────
/// A lookback period for historical portfolio value.
pub const HistoricalPeriod = enum {
@"1M",
@"3M",
@"1Y",
@"3Y",
@"5Y",
@"10Y",
pub fn label(self: HistoricalPeriod) []const u8 {
return switch (self) {
.@"1M" => "1M",
.@"3M" => "3M",
.@"1Y" => "1Y",
.@"3Y" => "3Y",
.@"5Y" => "5Y",
.@"10Y" => "10Y",
};
}
/// Compute the target date by subtracting this period from `today`.
pub fn targetDate(self: HistoricalPeriod, today: Date) Date {
return switch (self) {
.@"1M" => today.subtractMonths(1),
.@"3M" => today.subtractMonths(3),
.@"1Y" => today.subtractYears(1),
.@"3Y" => today.subtractYears(3),
.@"5Y" => today.subtractYears(5),
.@"10Y" => today.subtractYears(10),
};
}
pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };
};
/// One snapshot of portfolio value at a historical date.
pub const HistoricalSnapshot = struct {
period: HistoricalPeriod,
target_date: Date,
/// Value of current holdings at historical prices (only positions with data)
historical_value: f64,
/// Current value of same positions (only those with historical data)
current_value: f64,
/// Number of positions with data at this date
position_count: usize,
/// Total positions attempted
total_positions: usize,
pub fn change(self: HistoricalSnapshot) f64 {
return self.current_value - self.historical_value;
}
pub fn changePct(self: HistoricalSnapshot) f64 {
if (self.historical_value == 0) return 0;
return (self.current_value / self.historical_value - 1.0) * 100.0;
}
};
/// Find the closing price on or just before `target_date` in a sorted candle array.
/// Returns null if no candle is within 5 trading days before the target.
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
if (candles.len == 0) return null;
// Binary search for the target date
var lo: usize = 0;
var hi: usize = candles.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
if (candles[mid].date.days <= target.days) {
lo = mid + 1;
} else {
hi = mid;
}
}
// lo points to first candle after target; we want the one at or before
if (lo == 0) return null; // all candles are after target
const idx = lo - 1;
// Allow up to 5 trading days slack (weekends, holidays)
if (target.days - candles[idx].date.days > 7) return null;
return candles[idx].close;
}
/// Compute historical portfolio snapshots for all standard lookback periods.
/// `candle_map` maps symbol -> sorted candle slice.
/// `current_prices` maps symbol -> current price.
/// Only equity positions are considered.
pub fn computeHistoricalSnapshots(
today: Date,
positions: []const @import("../models/portfolio.zig").Position,
current_prices: std.StringHashMap(f64),
candle_map: std.StringHashMap([]const Candle),
) [HistoricalPeriod.all.len]HistoricalSnapshot {
var result: [HistoricalPeriod.all.len]HistoricalSnapshot = undefined;
for (HistoricalPeriod.all, 0..) |period, pi| {
const target = period.targetDate(today);
var hist_value: f64 = 0;
var curr_value: f64 = 0;
var count: usize = 0;
for (positions) |pos| {
if (pos.shares <= 0) continue;
const curr_price = current_prices.get(pos.symbol) orelse continue;
const candles = candle_map.get(pos.symbol) orelse continue;
const hist_price = findPriceAtDate(candles, target) orelse continue;
hist_value += pos.shares * hist_price;
curr_value += pos.shares * curr_price;
count += 1;
}
result[pi] = .{
.period = period,
.target_date = target,
.historical_value = hist_value,
.current_value = curr_value,
.position_count = count,
.total_positions = positions.len,
};
}
return result;
}
/// Derive a short display label (max 7 chars) from a descriptive note.
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
/// Falls back to first 7 characters of the note if no pattern matches.
fn shortLabel(note: []const u8) []const u8 {
// Look for "TARGET <year>" pattern (Vanguard Target Retirement funds)
const target_labels = .{
.{ "2025", "TGT2025" },
.{ "2030", "TGT2030" },
.{ "2035", "TGT2035" },
.{ "2040", "TGT2040" },
.{ "2045", "TGT2045" },
.{ "2050", "TGT2050" },
.{ "2055", "TGT2055" },
.{ "2060", "TGT2060" },
.{ "2065", "TGT2065" },
.{ "2070", "TGT2070" },
};
if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
inline for (target_labels) |entry| {
if (std.mem.indexOf(u8, note, entry[0]) != null) {
return entry[1];
}
}
}
// Fallback: take up to 7 chars from the note
const max = @min(note.len, 7);
return note[0..max];
}
test "shortLabel" {
try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035"));
try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040"));
try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK"));
try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT"));
try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND"));
}
test "risk metrics basic" {
// Construct a simple price series: $100 going up $1/day for 60 days
var candles: [60]Candle = undefined;
for (0..60) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
const metrics = computeRisk(&candles);
try std.testing.expect(metrics != null);
const m = metrics.?;
// Monotonically increasing price -> 0 drawdown
try std.testing.expectApproxEqAbs(@as(f64, 0), m.max_drawdown, 0.001);
// Should have positive Sharpe
try std.testing.expect(m.sharpe > 0);
try std.testing.expect(m.volatility > 0);
try std.testing.expectEqual(@as(usize, 59), m.sample_size);
}
test "max drawdown" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 1, 3), 110),
makeCandle(Date.fromYmd(2024, 1, 4), 120), // peak
makeCandle(Date.fromYmd(2024, 1, 5), 100),
makeCandle(Date.fromYmd(2024, 1, 8), 90), // trough: 25% drawdown from 120
makeCandle(Date.fromYmd(2024, 1, 9), 95),
makeCandle(Date.fromYmd(2024, 1, 10), 100),
makeCandle(Date.fromYmd(2024, 1, 11), 105),
makeCandle(Date.fromYmd(2024, 1, 12), 110),
makeCandle(Date.fromYmd(2024, 1, 15), 115),
makeCandle(Date.fromYmd(2024, 1, 16), 118),
makeCandle(Date.fromYmd(2024, 1, 17), 120),
makeCandle(Date.fromYmd(2024, 1, 18), 122),
makeCandle(Date.fromYmd(2024, 1, 19), 125),
makeCandle(Date.fromYmd(2024, 1, 22), 128),
makeCandle(Date.fromYmd(2024, 1, 23), 130),
makeCandle(Date.fromYmd(2024, 1, 24), 132),
makeCandle(Date.fromYmd(2024, 1, 25), 135),
makeCandle(Date.fromYmd(2024, 1, 26), 137),
makeCandle(Date.fromYmd(2024, 1, 29), 140),
makeCandle(Date.fromYmd(2024, 1, 30), 142),
};
const metrics = computeRisk(&candles);
try std.testing.expect(metrics != null);
// Max drawdown: (120 - 90) / 120 = 0.25
try std.testing.expectApproxEqAbs(@as(f64, 0.25), metrics.?.max_drawdown, 0.001);
try std.testing.expect(metrics.?.drawdown_trough.?.eql(Date.fromYmd(2024, 1, 8)));
}
fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}
test "findPriceAtDate exact match" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 1, 3), 101),
makeCandle(Date.fromYmd(2024, 1, 4), 102),
};
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 3));
try std.testing.expect(price != null);
try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}
test "findPriceAtDate snap backward" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 1, 3), 101),
makeCandle(Date.fromYmd(2024, 1, 8), 105), // gap (weekend)
};
// Target is Jan 5 (Saturday), should snap back to Jan 3
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 5));
try std.testing.expect(price != null);
try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}
test "findPriceAtDate too far back" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 15), 100),
makeCandle(Date.fromYmd(2024, 1, 16), 101),
};
// Target is Jan 2, closest is Jan 15 (13 days gap > 7 days)
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 2));
try std.testing.expect(price == null);
}
test "findPriceAtDate empty" {
const candles: []const Candle = &.{};
try std.testing.expect(findPriceAtDate(candles, Date.fromYmd(2024, 1, 1)) == null);
}
test "findPriceAtDate before all candles" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 6, 1), 150),
makeCandle(Date.fromYmd(2024, 6, 2), 151),
};
// Target is way before all candles
try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 1, 1)) == null);
}
test "HistoricalSnapshot change and changePct" {
const snap = HistoricalSnapshot{
.period = .@"1Y",
.target_date = Date.fromYmd(2023, 1, 1),
.historical_value = 100_000,
.current_value = 120_000,
.position_count = 5,
.total_positions = 5,
};
try std.testing.expectApproxEqAbs(@as(f64, 20_000), snap.change(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 20.0), snap.changePct(), 0.01);
// Zero historical value -> changePct returns 0
const zero = HistoricalSnapshot{
.period = .@"1M",
.target_date = Date.fromYmd(2024, 1, 1),
.historical_value = 0,
.current_value = 100,
.position_count = 0,
.total_positions = 0,
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero.changePct(), 0.001);
}
test "HistoricalPeriod label and targetDate" {
try std.testing.expectEqualStrings("1M", HistoricalPeriod.@"1M".label());
try std.testing.expectEqualStrings("3M", HistoricalPeriod.@"3M".label());
try std.testing.expectEqualStrings("1Y", HistoricalPeriod.@"1Y".label());
try std.testing.expectEqualStrings("10Y", HistoricalPeriod.@"10Y".label());
// targetDate: 1Y from 2025-06-15 -> 2024-06-15
const today = Date.fromYmd(2025, 6, 15);
const one_year = HistoricalPeriod.@"1Y".targetDate(today);
try std.testing.expectEqual(@as(i16, 2024), one_year.year());
try std.testing.expectEqual(@as(u8, 6), one_year.month());
// targetDate: 1M from 2025-03-15 -> 2025-02-15
const one_month = HistoricalPeriod.@"1M".targetDate(Date.fromYmd(2025, 3, 15));
try std.testing.expectEqual(@as(u8, 2), one_month.month());
}
test "computeRisk insufficient data" {
var candles: [10]Candle = undefined;
for (0..10) |i| {
candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i)));
}
// Less than 21 candles -> returns null
try std.testing.expect(computeRisk(&candles) == null);
}
test "adjustForNonStockAssets" {
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200 },
.{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
.{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
.{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option },
};
const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
var allocs = [_]Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200, .current_price = 220, .market_value = 2200, .cost_basis = 2000, .weight = 1.0, .unrealized_gain_loss = 200, .unrealized_return = 0.1 },
};
var summary = PortfolioSummary{
.total_value = 2200,
.total_cost = 2000,
.unrealized_gain_loss = 200,
.unrealized_return = 0.1,
.realized_gain_loss = 0,
.allocations = &allocs,
};
summary.adjustForNonStockAssets(pf);
// non_stock = 5000 + 10000 + (2*5) = 15010
try std.testing.expectApproxEqAbs(@as(f64, 17210), summary.total_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 17010), summary.total_cost, 0.01);
// unrealized_gain_loss unchanged (200), unrealized_return = 200 / 17010
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_gain_loss, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 17010.0), summary.unrealized_return, 0.001);
// Weight recomputed against new total
try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 17210.0), allocs[0].weight, 0.001);
}
test "buildFallbackPrices" {
const Lot = @import("../models/portfolio.zig").Lot;
const Position = @import("../models/portfolio.zig").Position;
const alloc = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
.{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 },
};
var positions = [_]Position{
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
.{ .symbol = "CUSIP1", .shares = 5, .avg_cost = 100, .total_cost = 500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
// AAPL already has a live price
try prices.put("AAPL", 175.0);
// CUSIP1 has no live price -- should get manual price:: fallback
var manual = try buildFallbackPrices(alloc, &lots, &positions, &prices);
defer manual.deinit();
// AAPL should NOT be in manual set (already had live price)
try std.testing.expect(!manual.contains("AAPL"));
// CUSIP1 should be in manual set with price 105.5
try std.testing.expect(manual.contains("CUSIP1"));
try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01);
}