124 lines
4.2 KiB
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;
|
|
}
|
|
}
|