zfin/src/data/shiller.zig

124 lines
4.2 KiB
Zig

/// Embedded historical market data derived from Robert Shiller's CAPE dataset.
///
/// Source: https://shillerdata.com/ (ie_data.xls, "Data" tab saved as CSV)
///
/// To update:
/// 1. Download ie_data.xls from https://shillerdata.com/
/// 2. Open in LibreOffice Calc, select the "Data" tab
/// 3. File -> Save As -> CSV (ie_data.csv)
/// 4. Replace src/data/ie_data.csv with the new file
/// 5. Rebuild - build/gen_shiller.zig regenerates the data automatically
/// 6. Bump `ie_data_last_updated` below to today's date.
///
/// All returns are nominal, expressed as decimals (0.12 = 12%).
const std = @import("std");
const Date = @import("../Date.zig");
const generated = @import("shiller_generated");
pub const ShillerYear = @import("shiller_year").ShillerYear;
/// Last time `ie_data.csv` was refreshed. Bump this whenever you
/// replace the CSV - drives the annual staleness nag in
/// `src/data/staleness.zig` (nags on stderr from April 1 each year
/// until refreshed).
pub const ie_data_last_updated: Date = Date.fromYmd(2026, 4, 27);
/// Annual returns from the Shiller dataset, generated at build time from ie_data.csv.
pub const annual_returns: []const ShillerYear = &generated.data;
/// Number of available years of historical data.
pub const year_count: usize = annual_returns.len;
/// First year with complete data.
pub const first_year: u16 = if (annual_returns.len > 0) annual_returns[0].year else 0;
/// Last year with complete data.
pub const last_year: u16 = if (annual_returns.len > 0) annual_returns[annual_returns.len - 1].year else 0;
/// Convert a nominal return to a real (inflation-adjusted) return.
pub fn realReturn(nominal: f64, inflation: f64) f64 {
return (1.0 + nominal) / (1.0 + inflation) - 1.0;
}
/// Return the maximum number of complete simulation cycles for a given horizon.
/// A cycle starting in year Y needs data through year Y + horizon - 1.
pub fn maxCycles(horizon: u16) usize {
if (annual_returns.len == 0) return 0;
const span = last_year - first_year + 1;
if (horizon > span) return 0;
return span - horizon + 1;
}
// --- Tests ---
test "annual returns are populated" {
try std.testing.expect(annual_returns.len >= 150);
try std.testing.expectEqual(@as(u16, 1871), first_year);
try std.testing.expect(last_year >= 2024);
}
test "spot check 2008 crash" {
for (annual_returns) |yr| {
if (yr.year == 2008) {
try std.testing.expect(yr.sp500_total_return < -0.30);
try std.testing.expect(yr.sp500_total_return > -0.50);
return;
}
}
return error.YearNotFound;
}
test "spot check 1929 crash" {
for (annual_returns) |yr| {
if (yr.year == 1929) {
try std.testing.expect(yr.sp500_total_return < -0.05);
return;
}
}
return error.YearNotFound;
}
test "realReturn calculation" {
const real = realReturn(0.10, 0.03);
try std.testing.expectApproxEqAbs(0.06796, real, 0.001);
}
test "maxCycles" {
const cycles = maxCycles(30);
try std.testing.expect(cycles >= 120);
try std.testing.expect(cycles <= 130);
try std.testing.expectEqual(@as(usize, 0), maxCycles(200));
}
test "spot check known annual total returns" {
const checks = [_]struct { year: u16, min: f64, max: f64 }{
.{ .year = 1931, .min = -0.55, .max = -0.25 },
.{ .year = 1933, .min = 0.30, .max = 0.80 },
.{ .year = 1974, .min = -0.40, .max = -0.05 },
.{ .year = 2008, .min = -0.50, .max = -0.25 },
.{ .year = 2009, .min = 0.15, .max = 0.50 },
.{ .year = 2021, .min = 0.10, .max = 0.45 },
};
for (checks) |chk| {
for (annual_returns) |yr| {
if (yr.year == chk.year) {
if (yr.sp500_total_return < chk.min or yr.sp500_total_return > chk.max) {
return error.TestExpectedEqual;
}
break;
}
}
}
}
test "bond returns are reasonable" {
for (annual_returns) |yr| {
if (yr.bond_total_return < 0.0 or yr.bond_total_return > 0.16) return error.TestExpectedEqual;
}
}
test "CPI inflation is reasonable" {
for (annual_returns) |yr| {
if (yr.cpi_inflation < -0.20 or yr.cpi_inflation > 0.25) return error.TestExpectedEqual;
}
}