zfin/src/analytics/performance.zig

507 lines
22 KiB
Zig

const std = @import("std");
const Date = @import("../models/date.zig").Date;
const Candle = @import("../models/candle.zig").Candle;
const Dividend = @import("../models/dividend.zig").Dividend;
/// Minimum holding period (in years) before annualizing returns.
/// Set below 1.0 to handle trading-day snap (e.g. a "1-year" lookback
/// that lands on 362 days due to weekends).
const min_annualize_years = 0.95;
/// Compute CAGR from a total return over a given number of years.
/// Returns null for periods shorter than `min_annualize_years` where
/// extrapolating to a full year would be misleading.
inline fn annualizedReturn(total: f64, years: f64) ?f64 {
if (years < min_annualize_years) return null;
return std.math.pow(f64, 1.0 + total, 1.0 / years) - 1.0;
}
/// Performance calculation results, Morningstar-style.
pub const PerformanceResult = struct {
/// Total return over the period (e.g., 0.25 = 25%)
total_return: f64,
/// Annualized return (for periods > 1 year)
annualized_return: ?f64,
/// Start date used
from: Date,
/// End date used
to: Date,
};
/// Compute total return from adjusted close prices.
/// Candles must be sorted by date ascending.
/// `from` snaps forward (first trading day on/after), `to` snaps backward.
pub fn totalReturnFromAdjClose(candles: []const Candle, from: Date, to: Date) ?PerformanceResult {
return totalReturnFromAdjCloseSnap(candles, from, to, .forward);
}
/// Same as totalReturnFromAdjClose but both dates snap backward
/// (last trading day on or before). Used for month-end methodology where
/// both from and to represent month-end reference dates.
fn totalReturnFromAdjCloseBackward(candles: []const Candle, from: Date, to: Date) ?PerformanceResult {
return totalReturnFromAdjCloseSnap(candles, from, to, .backward);
}
fn totalReturnFromAdjCloseSnap(candles: []const Candle, from: Date, to: Date, start_dir: SearchDirection) ?PerformanceResult {
const start = findNearestCandle(candles, from, start_dir) orelse return null;
const end = findNearestCandle(candles, to, .backward) orelse return null;
if (start.adj_close == 0) return null;
const total = (end.adj_close / start.adj_close) - 1.0;
const years = Date.yearsBetween(start.date, end.date);
return .{
.total_return = total,
.annualized_return = annualizedReturn(total, years),
.from = start.date,
.to = end.date,
};
}
/// Compute total return with manual dividend reinvestment.
/// Uses raw close prices and dividend records independently.
/// Candles and dividends must be sorted by date ascending.
/// `from` snaps forward, `to` snaps backward.
pub fn totalReturnWithDividends(
candles: []const Candle,
dividends: []const Dividend,
from: Date,
to: Date,
) ?PerformanceResult {
return totalReturnWithDividendsSnap(candles, dividends, from, to, .forward);
}
/// Same as totalReturnWithDividends but both dates snap backward.
fn totalReturnWithDividendsBackward(
candles: []const Candle,
dividends: []const Dividend,
from: Date,
to: Date,
) ?PerformanceResult {
return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward);
}
fn totalReturnWithDividendsSnap(
candles: []const Candle,
dividends: []const Dividend,
from: Date,
to: Date,
start_dir: SearchDirection,
) ?PerformanceResult {
const start = findNearestCandle(candles, from, start_dir) orelse return null;
const end = findNearestCandle(candles, to, .backward) orelse return null;
if (start.close == 0) return null;
// Simulate: start with 1 share, reinvest dividends at ex-date close
var shares: f64 = 1.0;
for (dividends) |div| {
if (div.ex_date.lessThan(start.date)) continue;
if (end.date.lessThan(div.ex_date)) break;
// Find close price on or near the ex-date
const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse continue;
if (price_candle.close > 0) {
shares += (div.amount * shares) / price_candle.close;
}
}
const final_value = shares * end.close;
const total = (final_value / start.close) - 1.0;
const years = Date.yearsBetween(start.date, end.date);
return .{
.total_return = total,
.annualized_return = annualizedReturn(total, years),
.from = start.date,
.to = end.date,
};
}
/// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close.
/// Uses the last available date as the endpoint.
pub const TrailingReturns = struct {
one_year: ?PerformanceResult = null,
three_year: ?PerformanceResult = null,
five_year: ?PerformanceResult = null,
ten_year: ?PerformanceResult = null,
};
/// Trailing returns from exact calendar date N years ago to latest candle date.
/// Start dates snap forward to the next trading day (e.g., weekend → Monday).
pub fn trailingReturns(candles: []const Candle) TrailingReturns {
if (candles.len == 0) return .{};
const end_date = candles[candles.len - 1].date;
return .{
.one_year = totalReturnFromAdjClose(candles, end_date.subtractYears(1), end_date),
.three_year = totalReturnFromAdjClose(candles, end_date.subtractYears(3), end_date),
.five_year = totalReturnFromAdjClose(candles, end_date.subtractYears(5), end_date),
.ten_year = totalReturnFromAdjClose(candles, end_date.subtractYears(10), end_date),
};
}
/// Same as trailingReturns but with dividend reinvestment.
pub fn trailingReturnsWithDividends(
candles: []const Candle,
dividends: []const Dividend,
) TrailingReturns {
if (candles.len == 0) return .{};
const end_date = candles[candles.len - 1].date;
return .{
.one_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(1), end_date),
.three_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(3), end_date),
.five_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(5), end_date),
.ten_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(10), end_date),
};
}
/// Morningstar-style trailing returns using month-end reference dates.
/// End date = last calendar day of prior month. Start date = that month-end minus N years.
/// Both dates snap backward to the last trading day on or before, matching
/// Morningstar's "last business day of the month" convention.
pub fn trailingReturnsMonthEnd(candles: []const Candle, today: Date) TrailingReturns {
if (candles.len == 0) return .{};
// End reference = last day of the prior month (snaps backward to last trading day)
const month_end = today.lastDayOfPriorMonth();
return .{
.one_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(1), month_end),
.three_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(3), month_end),
.five_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(5), month_end),
.ten_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(10), month_end),
};
}
/// Same as trailingReturnsMonthEnd but with dividend reinvestment.
pub fn trailingReturnsMonthEndWithDividends(
candles: []const Candle,
dividends: []const Dividend,
today: Date,
) TrailingReturns {
if (candles.len == 0) return .{};
const month_end = today.lastDayOfPriorMonth();
return .{
.one_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(1), month_end),
.three_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(3), month_end),
.five_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(5), month_end),
.ten_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(10), month_end),
};
}
const SearchDirection = enum { forward, backward };
/// Maximum calendar days a snapped candle can be from the target date.
/// Covers weekends + holidays (e.g., Christmas week). Beyond this, the data
/// is likely missing and the result would be misleading.
const max_snap_days: i32 = 10;
fn findNearestCandle(candles: []const Candle, target: Date, direction: SearchDirection) ?Candle {
if (candles.len == 0) return null;
// Binary search: lo = first index where candles[lo].date >= target
var lo: usize = 0;
var hi: usize = candles.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
if (candles[mid].date.lessThan(target)) {
lo = mid + 1;
} else {
hi = mid;
}
}
const candidate = switch (direction) {
// First candle on or after target
.forward => if (lo < candles.len) candles[lo] else return null,
// Last candle on or before target
.backward => if (lo < candles.len and candles[lo].date.eql(target))
candles[lo]
else if (lo > 0)
candles[lo - 1]
else
return null,
};
// Reject if the snap distance exceeds tolerance
const gap = candidate.date.days - target.days;
if (gap > max_snap_days or gap < -max_snap_days) return null;
return candidate;
}
/// Format a return value as a percentage string (e.g., "12.34%")
pub fn formatReturn(buf: []u8, value: f64) []const u8 {
return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%";
}
test "total return simple" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 6, 28), .open = 110, .high = 111, .low = 109, .close = 110, .adj_close = 110, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 12, 31), .open = 120, .high = 121, .low = 119, .close = 120, .adj_close = 120, .volume = 1000 },
};
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result != null);
// 120/100 - 1 = 0.20
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.?.total_return, 0.001);
}
test "total return with dividends -- single dividend" {
// Stock at $100, pays $2 dividend, price stays $100.
// Without reinvestment: 0% return.
// With reinvestment: $2/$100 = 0.02 extra shares -> 1.02 * $100 / $100 - 1 = 2%
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 3, 15), 100),
makeCandle(Date.fromYmd(2024, 12, 31), 100),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 2.0 },
};
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001);
}
test "total return with dividends -- quarterly dividends" {
// Stock at $100 all year, pays $1 quarterly. Each $1 reinvested at $100 = 0.01 shares.
// After Q1: 1.01 shares
// After Q2: 1.01 + 1.01*1/100 = 1.01 * 1.01 = 1.0201
// After Q3: 1.0201 * 1.01 = 1.030301
// After Q4: 1.030301 * 1.01 = 1.04060401
// Total return: 4.06%
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 3, 15), 100),
makeCandle(Date.fromYmd(2024, 6, 14), 100),
makeCandle(Date.fromYmd(2024, 9, 13), 100),
makeCandle(Date.fromYmd(2024, 12, 13), 100),
makeCandle(Date.fromYmd(2024, 12, 31), 100),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 },
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 1.0 },
.{ .ex_date = Date.fromYmd(2024, 9, 13), .amount = 1.0 },
.{ .ex_date = Date.fromYmd(2024, 12, 13), .amount = 1.0 },
};
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result != null);
// (1.01)^4 - 1 = 0.04060401
try std.testing.expectApproxEqAbs(@as(f64, 0.04060401), result.?.total_return, 0.0001);
}
test "total return with dividends -- price growth plus dividends" {
// Start $100, end $120 (20% price return).
// One $3 dividend at mid-year when price is $110.
// Shares: 1 + 3/110 = 1.027273
// Final value: 1.027273 * 120 = 123.2727
// Total return: 123.2727 / 100 - 1 = 23.27%
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 6, 14), 110),
makeCandle(Date.fromYmd(2024, 12, 31), 120),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 3.0 },
};
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result != null);
const expected = (1.0 + 3.0 / 110.0) * 120.0 / 100.0 - 1.0; // 0.23272727...
try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.0001);
}
test "annualized return -- 3 year period" {
// 3 years: $100 -> $150. Total return = 50%.
// Annualized = (1.50)^(1/3) - 1 = 14.47%
const candles = [_]Candle{
makeCandle(Date.fromYmd(2021, 1, 4), 100),
makeCandle(Date.fromYmd(2024, 1, 2), 150),
};
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2021, 1, 1), Date.fromYmd(2024, 1, 3));
try std.testing.expect(result != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.?.total_return, 0.001);
const ann = result.?.annualized_return.?;
// (1.50)^(1/years) - 1, years ~ 3.0 (via 365.25)
const years = Date.yearsBetween(Date.fromYmd(2021, 1, 4), Date.fromYmd(2024, 1, 2));
const expected_ann = std.math.pow(f64, 1.50, 1.0 / years) - 1.0;
try std.testing.expectApproxEqAbs(expected_ann, ann, 0.0001);
}
test "findNearestCandle -- 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),
};
// Forward exact
const fwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .forward).?;
try std.testing.expect(fwd.date.eql(Date.fromYmd(2024, 1, 3)));
// Backward exact
const bwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .backward).?;
try std.testing.expect(bwd.date.eql(Date.fromYmd(2024, 1, 3)));
}
test "findNearestCandle -- weekend snap" {
// Jan 4 2025 is Saturday, Jan 5 is Sunday
const candles = [_]Candle{
makeCandle(Date.fromYmd(2025, 1, 3), 100), // Friday
makeCandle(Date.fromYmd(2025, 1, 6), 101), // Monday
};
// Forward from Saturday -> Monday
const fwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .forward).?;
try std.testing.expect(fwd.date.eql(Date.fromYmd(2025, 1, 6)));
// Backward from Saturday -> Friday
const bwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .backward).?;
try std.testing.expect(bwd.date.eql(Date.fromYmd(2025, 1, 3)));
}
test "month-end trailing returns -- date windowing" {
// Verify month-end logic uses correct reference dates.
// "Today" = 2026-02-15, prior month end = 2026-01-31
// 1yr window: 2025-01-31 to 2026-01-31
const candles = [_]Candle{
makeCandle(Date.fromYmd(2025, 1, 31), 100), // Jan 31 2025 is Friday
makeCandle(Date.fromYmd(2025, 7, 1), 110),
makeCandle(Date.fromYmd(2026, 1, 30), 120), // Jan 31 is Sat, trading day is 30th
makeCandle(Date.fromYmd(2026, 2, 14), 125),
};
const today = Date.fromYmd(2026, 2, 15);
const ret = trailingReturnsMonthEnd(&candles, today);
// Month-end = Jan 31 2026. backward snap -> Jan 30.
// Start = Jan 31 2025 (exact match, backward snap). End = Jan 30 2026.
// Return = 120/100 - 1 = 20%
try std.testing.expect(ret.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), ret.one_year.?.total_return, 0.001);
}
test "month-end trailing returns -- weekend start snaps backward" {
// When the start month-end falls on a weekend, it should snap BACKWARD
// to the last trading day (Friday), not forward to Monday.
// This matches Morningstar's "last business day of the month" convention.
const candles = [_]Candle{
makeCandle(Date.fromYmd(2016, 1, 29), 100), // Friday (last biz day of Jan 2016)
makeCandle(Date.fromYmd(2016, 2, 1), 95), // Monday (NOT what we want)
makeCandle(Date.fromYmd(2026, 1, 30), 240), // End: Friday (last biz day of Jan 2026)
};
// Jan 31 2016 is Sunday. Backward snap -> Jan 29 (Friday).
// Jan 31 2026 is Saturday. Backward snap -> Jan 30 (Friday).
// Return = 240/100 - 1 = 140%
const today = Date.fromYmd(2026, 2, 15);
const ret = trailingReturnsMonthEnd(&candles, today);
try std.testing.expect(ret.ten_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 1.40), ret.ten_year.?.total_return, 0.001);
// Verify start date is Jan 29 (Friday), not Feb 1 (Monday)
try std.testing.expect(ret.ten_year.?.from.eql(Date.fromYmd(2016, 1, 29)));
}
test "dividends outside window are excluded" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 6, 14), 100),
makeCandle(Date.fromYmd(2024, 12, 31), 100),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2023, 12, 15), .amount = 5.0 }, // before window
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 2.0 }, // inside
.{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 5.0 }, // after window
};
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result != null);
// Only the $2 mid-year dividend counts: 2/100 = 2%
try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001);
}
test "zero price candle returns null" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 0),
makeCandle(Date.fromYmd(2024, 12, 31), 100),
};
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result == null);
}
test "empty candles returns null" {
const candles = [_]Candle{};
const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1));
try std.testing.expect(result == null);
}
fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}
// Morningstar reference data, captured 2026-02-24.
//
// AMZN Trailing Returns (as-of-date, from morningstar.com/stocks/xnas/amzn/trailing-returns):
// Day end 2026-02-24: 1yr=-1.95% 3yr=30.66% 5yr=5.71% 10yr=22.37%
// AMZN has no dividends, so price return = total return.
//
// VTI Trailing Returns (as-of-date, from morningstar.com/etfs/arcx/vti/trailing-returns):
// Day end 2026-02-24: 1yr=16.62% 3yr=21.01% 5yr=12.03% 10yr=15.10% (price)
//
// VTI Performance (month-end, from morningstar.com/etfs/arcx/vti/performance):
// Month-end Jan 31: 10yr total=15.10% 3yr total=20.20% (NAV ~20.24%)
test "as-of-date trailing returns -- AMZN vs Morningstar" {
// Real AMZN split-adjusted closing prices from Twelve Data.
// AMZN pays no dividends, so adj_close == close.
const candles = [_]Candle{
makeCandle(Date.fromYmd(2016, 2, 24), 27.702), // 10yr start
makeCandle(Date.fromYmd(2021, 2, 24), 157.9765), // 5yr start
makeCandle(Date.fromYmd(2023, 2, 24), 93.50), // 3yr start
makeCandle(Date.fromYmd(2025, 2, 24), 212.71), // 1yr start
makeCandle(Date.fromYmd(2026, 2, 24), 208.56), // end (latest close)
};
const ret = trailingReturns(&candles);
// 1yr: 208.56 / 212.71 - 1 = -1.95%
try std.testing.expect(ret.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, -0.0195), ret.one_year.?.total_return, 0.001);
// 3yr: annualized. Morningstar shows 30.66%.
try std.testing.expect(ret.three_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.3066), ret.three_year.?.annualized_return.?, 0.002);
// 5yr: annualized. Morningstar shows 5.71%.
try std.testing.expect(ret.five_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0571), ret.five_year.?.annualized_return.?, 0.002);
// 10yr: annualized. Morningstar shows 22.37%.
try std.testing.expect(ret.ten_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.2237), ret.ten_year.?.annualized_return.?, 0.002);
}
test "as-of-date vs month-end -- different results from same data" {
// Demonstrates that as-of-date and month-end give different results
// when the latest close differs significantly from the month-end close.
//
// "Today" = 2026-02-25, month-end = Jan 31 2026
// As-of end = Feb 24 (latest candle), month-end = Jan 30 (snap from Jan 31 Sat)
const candles = [_]Candle{
makeCandle(Date.fromYmd(2025, 1, 31), 100), // month-end 1yr start (Friday)
makeCandle(Date.fromYmd(2025, 2, 24), 100), // as-of 1yr start
makeCandle(Date.fromYmd(2025, 7, 1), 110),
makeCandle(Date.fromYmd(2026, 1, 30), 115), // month-end end (Friday, Jan 31 is Sat)
makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest)
};
// As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) → 20%
const asof = trailingReturns(&candles);
try std.testing.expect(asof.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001);
// Month-end: end=Jan 30 ($115), start=Jan 31 ($100) → 15%
const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25));
try std.testing.expect(me.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001);
}