515 lines
20 KiB
Zig
515 lines
20 KiB
Zig
//! Pure crossing-detection for the `zfin milestones` command.
|
||
//!
|
||
//! Given a `(date, value)` series, find the dates the value
|
||
//! first reaches each of a configured set of thresholds. Two
|
||
//! threshold modes:
|
||
//!
|
||
//! - **Absolute** - fixed multiples of a step (e.g.,
|
||
//! `$1M, $2M, $3M, ...`).
|
||
//! - **Relative** - geometric multiples of the series'
|
||
//! starting value (e.g., `1x, 2x, 4x, 8x, ...`).
|
||
//!
|
||
//! No I/O, no allocation outside the result slice. The caller
|
||
//! supplies the merged `(date, value)` series. Inflation
|
||
//! adjustment is applied at the call site by deflating the
|
||
//! series before invoking `detectCrossings` - this keeps the
|
||
//! detector ignorant of inflation semantics.
|
||
|
||
const std = @import("std");
|
||
const Date = @import("../Date.zig");
|
||
|
||
// ── Step expression parser ───────────────────────────────────
|
||
|
||
/// User-specified step. Drives whether thresholds are absolute
|
||
/// dollar multiples or geometric multipliers of the starting
|
||
/// value.
|
||
pub const Step = union(enum) {
|
||
/// `--step 1M` -> `.{ .absolute = 1_000_000 }`.
|
||
absolute: f64,
|
||
/// `--step 2x` -> `.{ .relative = 2.0 }`.
|
||
relative: f64,
|
||
};
|
||
|
||
pub const ParseStepError = error{
|
||
Empty,
|
||
InvalidNumber,
|
||
NonFiniteValue,
|
||
NonPositiveAbsolute,
|
||
NonProgressiveRelative,
|
||
UnknownSuffix,
|
||
};
|
||
|
||
/// Parse a step expression. Accepts:
|
||
/// - Bare numbers: `1000000`
|
||
/// - Absolute with `K`/`k`/`M`/`m` suffix: `1M`, `500k`, `1.5M`
|
||
/// - Relative with `x`/`X` suffix: `2x`, `1.5x`
|
||
///
|
||
/// Rejects:
|
||
/// - Empty input
|
||
/// - Non-finite values (NaN, Inf)
|
||
/// - Zero or negative absolute values
|
||
/// - Relative values ≤ 1.0 (no progression)
|
||
/// - `%` suffix (intentionally unsupported - see spec)
|
||
/// - Any other suffix character
|
||
///
|
||
/// Whitespace is NOT trimmed; callers should pre-trim.
|
||
pub fn parseStep(input: []const u8) ParseStepError!Step {
|
||
if (input.len == 0) return error.Empty;
|
||
|
||
const last = input[input.len - 1];
|
||
|
||
// Relative form.
|
||
if (last == 'x' or last == 'X') {
|
||
if (input.len == 1) return error.InvalidNumber;
|
||
const num_str = input[0 .. input.len - 1];
|
||
const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
|
||
if (!std.math.isFinite(v)) return error.NonFiniteValue;
|
||
if (v <= 1.0) return error.NonProgressiveRelative;
|
||
return .{ .relative = v };
|
||
}
|
||
|
||
// Absolute with K/M suffix.
|
||
var multiplier: f64 = 1.0;
|
||
var num_str: []const u8 = input;
|
||
if (last == 'K' or last == 'k') {
|
||
if (input.len == 1) return error.InvalidNumber;
|
||
multiplier = 1_000.0;
|
||
num_str = input[0 .. input.len - 1];
|
||
} else if (last == 'M' or last == 'm') {
|
||
if (input.len == 1) return error.InvalidNumber;
|
||
multiplier = 1_000_000.0;
|
||
num_str = input[0 .. input.len - 1];
|
||
} else if (!std.ascii.isDigit(last) and last != '.') {
|
||
// A non-digit, non-period trailing character that isn't
|
||
// x/X/K/k/M/m may still be a legitimate float literal
|
||
// (`inf`, `nan`, `1e10`). Defer to `parseFloat` and only
|
||
// reject as unknown suffix if parsing fails AND we're
|
||
// confident the input doesn't look like a float.
|
||
const v_try = std.fmt.parseFloat(f64, input) catch {
|
||
return error.UnknownSuffix;
|
||
};
|
||
if (!std.math.isFinite(v_try)) return error.NonFiniteValue;
|
||
if (v_try <= 0.0) return error.NonPositiveAbsolute;
|
||
return .{ .absolute = v_try };
|
||
}
|
||
|
||
const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
|
||
if (!std.math.isFinite(v)) return error.NonFiniteValue;
|
||
const absolute = v * multiplier;
|
||
if (absolute <= 0.0) return error.NonPositiveAbsolute;
|
||
return .{ .absolute = absolute };
|
||
}
|
||
|
||
// ── Series + crossing types ──────────────────────────────────
|
||
|
||
/// Input series row. Caller supplies these in date-ascending
|
||
/// order (the detector trusts that ordering and does not sort).
|
||
pub const Point = struct {
|
||
date: Date,
|
||
value: f64,
|
||
};
|
||
|
||
/// One detected crossing. `days_since_prev` is null for the
|
||
/// first crossing in the result; `days_since_first` is 0 for
|
||
/// the first crossing.
|
||
pub const Crossing = struct {
|
||
/// 1-indexed crossing number. Useful for relative mode where
|
||
/// the threshold is `start * factor^(index-1)`.
|
||
index: u32,
|
||
/// The threshold reached.
|
||
threshold: f64,
|
||
/// Date of the first observed value at or above `threshold`.
|
||
/// Per spec, this is "first observed at" rather than
|
||
/// "actually crossed on" - the resolution is bounded by the
|
||
/// source series' cadence (typically weekly).
|
||
date: Date,
|
||
/// Days from the previous crossing in this result. Null for
|
||
/// the first crossing.
|
||
days_since_prev: ?i32,
|
||
/// Days from the first crossing in this result.
|
||
days_since_first: i32,
|
||
/// True iff this crossing is the synthetic "starting point"
|
||
/// row - value at the start of the series, not a true
|
||
/// crossing. Renderers typically annotate this with a
|
||
/// footnote.
|
||
is_start: bool,
|
||
};
|
||
|
||
// ── Crossing detection ───────────────────────────────────────
|
||
|
||
/// Detect threshold crossings in `series`. Result is allocated
|
||
/// via `allocator`; caller owns the slice and frees with
|
||
/// `allocator.free`.
|
||
///
|
||
/// Returns an empty slice if `series` is empty.
|
||
///
|
||
/// Algorithm:
|
||
/// - For absolute mode, thresholds are `step, 2*step, 3*step, ...`
|
||
/// up to the series' maximum value.
|
||
/// - For relative mode, thresholds are `start, start*f, start*f^2, ...`
|
||
/// up to the series' maximum value. The starting value is
|
||
/// emitted as a synthetic crossing (`is_start = true`).
|
||
/// - For each threshold T, scan forward from the current
|
||
/// position to find the first index i where
|
||
/// `series[i].value >= T`. Record `series[i].date`.
|
||
/// - When multiple thresholds fall between two adjacent points
|
||
/// (a single big jump), all those thresholds are recorded
|
||
/// with the same crossing date.
|
||
pub fn detectCrossings(
|
||
allocator: std.mem.Allocator,
|
||
series: []const Point,
|
||
step: Step,
|
||
) ![]Crossing {
|
||
if (series.len == 0) return &.{};
|
||
|
||
const start_value = series[0].value;
|
||
const start_date = series[0].date;
|
||
var max_value: f64 = start_value;
|
||
for (series) |p| {
|
||
if (p.value > max_value) max_value = p.value;
|
||
}
|
||
|
||
var out: std.ArrayList(Crossing) = .empty;
|
||
errdefer out.deinit(allocator);
|
||
|
||
var index: u32 = 1;
|
||
var prev_date: ?Date = null;
|
||
|
||
switch (step) {
|
||
.absolute => |s| {
|
||
// Absolute thresholds: s, 2s, 3s, ...
|
||
// Skip thresholds entirely below the starting value
|
||
// (no crossing observable).
|
||
var k: u32 = 1;
|
||
// Position the scan cursor; thresholds are detected
|
||
// by walking forward through the series for each one.
|
||
while (true) : (k += 1) {
|
||
const T = s * @as(f64, @floatFromInt(k));
|
||
if (T > max_value) break;
|
||
|
||
// Find first index where value >= T.
|
||
const cross = findFirstAtOrAbove(series, T) orelse continue;
|
||
|
||
// Skip thresholds that the starting value is
|
||
// ALREADY at or above - those aren't observed
|
||
// crossings.
|
||
if (T <= start_value) continue;
|
||
|
||
const ds_first: i32 = cross.date.days - start_date.days;
|
||
const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;
|
||
|
||
try out.append(allocator, .{
|
||
.index = index,
|
||
.threshold = T,
|
||
.date = cross.date,
|
||
.days_since_prev = ds_prev,
|
||
.days_since_first = ds_first,
|
||
.is_start = false,
|
||
});
|
||
index += 1;
|
||
prev_date = cross.date;
|
||
}
|
||
},
|
||
.relative => |f| {
|
||
// Relative thresholds: emit the starting value as
|
||
// index 1 (synthetic), then start*f, start*f^2, ...
|
||
try out.append(allocator, .{
|
||
.index = 1,
|
||
.threshold = start_value,
|
||
.date = start_date,
|
||
.days_since_prev = null,
|
||
.days_since_first = 0,
|
||
.is_start = true,
|
||
});
|
||
index = 2;
|
||
prev_date = start_date;
|
||
|
||
var T = start_value * f;
|
||
while (T <= max_value) {
|
||
const cross = findFirstAtOrAbove(series, T) orelse {
|
||
// Shouldn't happen given T <= max_value, but be safe.
|
||
break;
|
||
};
|
||
|
||
const ds_first: i32 = cross.date.days - start_date.days;
|
||
const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;
|
||
|
||
try out.append(allocator, .{
|
||
.index = index,
|
||
.threshold = T,
|
||
.date = cross.date,
|
||
.days_since_prev = ds_prev,
|
||
.days_since_first = ds_first,
|
||
.is_start = false,
|
||
});
|
||
index += 1;
|
||
prev_date = cross.date;
|
||
T *= f;
|
||
}
|
||
},
|
||
}
|
||
|
||
return out.toOwnedSlice(allocator);
|
||
}
|
||
|
||
fn findFirstAtOrAbove(series: []const Point, threshold: f64) ?Point {
|
||
for (series) |p| {
|
||
if (p.value >= threshold) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── CPI deflation ────────────────────────────────────────────
|
||
|
||
/// Convert a nominal value at calendar year `from_year` to its
|
||
/// equivalent in `to_year` dollars, given a slice of year/CPI
|
||
/// pairs. The CPI series is the per-year inflation rate (as
|
||
/// from `shiller.annual_returns[i].cpi_inflation`).
|
||
///
|
||
/// If `from_year == to_year`, returns `nominal` unchanged.
|
||
/// If `from_year > to_year`, deflates (compounds backward).
|
||
/// If `from_year < to_year`, inflates (compounds forward).
|
||
///
|
||
/// Years outside the available CPI series are clamped to the
|
||
/// nearest endpoint (with the caller's responsibility to warn).
|
||
pub const YearCpi = struct {
|
||
year: u16,
|
||
cpi: f64,
|
||
};
|
||
|
||
pub fn deflate(
|
||
nominal: f64,
|
||
from_year: u16,
|
||
to_year: u16,
|
||
cpi: []const YearCpi,
|
||
) f64 {
|
||
if (cpi.len == 0) return nominal;
|
||
if (from_year == to_year) return nominal;
|
||
|
||
var factor: f64 = 1.0;
|
||
if (from_year < to_year) {
|
||
// Inflate forward: multiply by (1 + cpi) for each year
|
||
// from `from_year` (inclusive) up to `to_year` (exclusive).
|
||
var y: u16 = from_year;
|
||
while (y < to_year) : (y += 1) {
|
||
factor *= (1.0 + cpiForYear(y, cpi));
|
||
}
|
||
return nominal * factor;
|
||
} else {
|
||
// Deflate backward: divide by (1 + cpi) for each year
|
||
// from `to_year` (inclusive) up to `from_year` (exclusive).
|
||
var y: u16 = to_year;
|
||
while (y < from_year) : (y += 1) {
|
||
factor *= (1.0 + cpiForYear(y, cpi));
|
||
}
|
||
return nominal / factor;
|
||
}
|
||
}
|
||
|
||
fn cpiForYear(year: u16, cpi: []const YearCpi) f64 {
|
||
// Linear scan - small slice (<200 entries), and it's called
|
||
// O(years × series_length) times in the worst case which is
|
||
// still trivial.
|
||
if (year <= cpi[0].year) return cpi[0].cpi;
|
||
if (year >= cpi[cpi.len - 1].year) return cpi[cpi.len - 1].cpi;
|
||
for (cpi) |yc| {
|
||
if (yc.year == year) return yc.cpi;
|
||
}
|
||
return cpi[cpi.len - 1].cpi;
|
||
}
|
||
|
||
// ── Tests ────────────────────────────────────────────────────
|
||
|
||
test "parseStep: absolute bare numbers" {
|
||
const a = try parseStep("1000000");
|
||
try std.testing.expect(a == .absolute);
|
||
try std.testing.expectEqual(@as(f64, 1_000_000), a.absolute);
|
||
|
||
const b = try parseStep("500");
|
||
try std.testing.expect(b == .absolute);
|
||
try std.testing.expectEqual(@as(f64, 500), b.absolute);
|
||
}
|
||
|
||
test "parseStep: K and M suffixes" {
|
||
const cases = [_]struct { in: []const u8, out: f64 }{
|
||
.{ .in = "1M", .out = 1_000_000 },
|
||
.{ .in = "1m", .out = 1_000_000 },
|
||
.{ .in = "1.5M", .out = 1_500_000 },
|
||
.{ .in = "500K", .out = 500_000 },
|
||
.{ .in = "500k", .out = 500_000 },
|
||
.{ .in = "0.5M", .out = 500_000 },
|
||
};
|
||
for (cases) |c| {
|
||
const s = try parseStep(c.in);
|
||
try std.testing.expect(s == .absolute);
|
||
try std.testing.expectEqual(c.out, s.absolute);
|
||
}
|
||
}
|
||
|
||
test "parseStep: relative x suffix" {
|
||
const cases = [_]struct { in: []const u8, out: f64 }{
|
||
.{ .in = "2x", .out = 2.0 },
|
||
.{ .in = "2X", .out = 2.0 },
|
||
.{ .in = "1.5x", .out = 1.5 },
|
||
};
|
||
for (cases) |c| {
|
||
const s = try parseStep(c.in);
|
||
try std.testing.expect(s == .relative);
|
||
try std.testing.expectEqual(c.out, s.relative);
|
||
}
|
||
}
|
||
|
||
test "parseStep: rejects" {
|
||
try std.testing.expectError(error.Empty, parseStep(""));
|
||
try std.testing.expectError(error.NonProgressiveRelative, parseStep("1x"));
|
||
try std.testing.expectError(error.NonProgressiveRelative, parseStep("1.0x"));
|
||
try std.testing.expectError(error.NonProgressiveRelative, parseStep("0.5x"));
|
||
try std.testing.expectError(error.NonPositiveAbsolute, parseStep("0"));
|
||
try std.testing.expectError(error.NonPositiveAbsolute, parseStep("-1M"));
|
||
try std.testing.expectError(error.UnknownSuffix, parseStep("1G"));
|
||
try std.testing.expectError(error.UnknownSuffix, parseStep("100%"));
|
||
try std.testing.expectError(error.InvalidNumber, parseStep("2x.5"));
|
||
try std.testing.expectError(error.InvalidNumber, parseStep("M"));
|
||
try std.testing.expectError(error.InvalidNumber, parseStep("x"));
|
||
try std.testing.expectError(error.NonFiniteValue, parseStep("inf"));
|
||
try std.testing.expectError(error.NonFiniteValue, parseStep("infM"));
|
||
try std.testing.expectError(error.NonFiniteValue, parseStep("infx"));
|
||
}
|
||
|
||
test "detectCrossings: empty series" {
|
||
const result = try detectCrossings(std.testing.allocator, &.{}, .{ .absolute = 1_000_000 });
|
||
defer std.testing.allocator.free(result);
|
||
try std.testing.expectEqual(@as(usize, 0), result.len);
|
||
}
|
||
|
||
test "detectCrossings: absolute mode, simple progression" {
|
||
// Series climbs from $1.28M to $7.0M over 12 years.
|
||
// $1M step: starting value already past, so first observable
|
||
// crossing is $2M.
|
||
const series = [_]Point{
|
||
.{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 },
|
||
.{ .date = Date.fromYmd(2017, 1, 1), .value = 2_100_000 },
|
||
.{ .date = Date.fromYmd(2019, 1, 1), .value = 3_500_000 },
|
||
.{ .date = Date.fromYmd(2024, 6, 1), .value = 7_000_000 },
|
||
};
|
||
|
||
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||
defer std.testing.allocator.free(result);
|
||
|
||
// Expect crossings at $2M (2017-01-01), $3M (2019-01-01),
|
||
// $4M, $5M, $6M, $7M (all 2024-06-01 due to the jump).
|
||
try std.testing.expectEqual(@as(usize, 6), result.len);
|
||
try std.testing.expectEqual(@as(f64, 2_000_000), result[0].threshold);
|
||
try std.testing.expectEqual(Date.fromYmd(2017, 1, 1), result[0].date);
|
||
try std.testing.expectEqual(@as(f64, 3_000_000), result[1].threshold);
|
||
try std.testing.expectEqual(Date.fromYmd(2019, 1, 1), result[1].date);
|
||
// $4M, $5M, $6M, $7M all hit at 2024-06-01.
|
||
for (result[2..]) |c| {
|
||
try std.testing.expectEqual(Date.fromYmd(2024, 6, 1), c.date);
|
||
}
|
||
|
||
// First crossing has null days_since_prev.
|
||
try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
|
||
// days_since_first is measured from the series start, not the
|
||
// first crossing.
|
||
const expected_first = Date.fromYmd(2017, 1, 1).days - Date.fromYmd(2014, 7, 3).days;
|
||
try std.testing.expectEqual(expected_first, result[0].days_since_first);
|
||
}
|
||
|
||
test "detectCrossings: relative mode, doubling" {
|
||
// Synthetic doubling: 1M, 2M, 4M, 8M.
|
||
const series = [_]Point{
|
||
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||
.{ .date = Date.fromYmd(2018, 1, 1), .value = 2_100_000 },
|
||
.{ .date = Date.fromYmd(2022, 1, 1), .value = 4_200_000 },
|
||
.{ .date = Date.fromYmd(2026, 1, 1), .value = 8_400_000 },
|
||
};
|
||
|
||
const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
|
||
defer std.testing.allocator.free(result);
|
||
|
||
// 1x synthetic + 3 real crossings (2x, 4x, 8x).
|
||
try std.testing.expectEqual(@as(usize, 4), result.len);
|
||
try std.testing.expect(result[0].is_start);
|
||
try std.testing.expectEqual(@as(f64, 1_000_000), result[0].threshold);
|
||
try std.testing.expectEqual(@as(f64, 2_000_000), result[1].threshold);
|
||
try std.testing.expectEqual(@as(f64, 4_000_000), result[2].threshold);
|
||
try std.testing.expectEqual(@as(f64, 8_000_000), result[3].threshold);
|
||
|
||
// days_since_prev is set on all but the first.
|
||
try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
|
||
try std.testing.expect(result[1].days_since_prev != null);
|
||
try std.testing.expect(result[1].days_since_prev.? > 0);
|
||
}
|
||
|
||
test "detectCrossings: series shorter than first threshold" {
|
||
const series = [_]Point{
|
||
.{ .date = Date.fromYmd(2014, 1, 1), .value = 100_000 },
|
||
.{ .date = Date.fromYmd(2015, 1, 1), .value = 150_000 },
|
||
};
|
||
|
||
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||
defer std.testing.allocator.free(result);
|
||
try std.testing.expectEqual(@as(usize, 0), result.len);
|
||
}
|
||
|
||
test "detectCrossings: relative mode with single point" {
|
||
const series = [_]Point{
|
||
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||
};
|
||
const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
|
||
defer std.testing.allocator.free(result);
|
||
// Just the synthetic starting point.
|
||
try std.testing.expectEqual(@as(usize, 1), result.len);
|
||
try std.testing.expect(result[0].is_start);
|
||
}
|
||
|
||
test "detectCrossings: multiple thresholds in single jump" {
|
||
// Big jump from $1M to $5M: $2M, $3M, $4M, $5M all hit at the
|
||
// same observation.
|
||
const series = [_]Point{
|
||
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||
.{ .date = Date.fromYmd(2014, 1, 8), .value = 5_500_000 },
|
||
};
|
||
|
||
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||
defer std.testing.allocator.free(result);
|
||
// $2M, $3M, $4M, $5M - all four at 2014-01-08.
|
||
try std.testing.expectEqual(@as(usize, 4), result.len);
|
||
for (result) |c| {
|
||
try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date);
|
||
}
|
||
}
|
||
|
||
test "deflate: identity when from == to" {
|
||
const cpi = [_]YearCpi{ .{ .year = 2020, .cpi = 0.02 }, .{ .year = 2024, .cpi = 0.03 } };
|
||
const v = deflate(1000, 2022, 2022, &cpi);
|
||
try std.testing.expectEqual(@as(f64, 1000), v);
|
||
}
|
||
|
||
test "deflate: simple inflation forward" {
|
||
const cpi = [_]YearCpi{
|
||
.{ .year = 2020, .cpi = 0.10 },
|
||
.{ .year = 2021, .cpi = 0.10 },
|
||
.{ .year = 2022, .cpi = 0.10 },
|
||
};
|
||
// From 2020 -> 2023: 3 years of 10% compounds to 1.331x.
|
||
const v = deflate(1000, 2020, 2023, &cpi);
|
||
try std.testing.expectApproxEqAbs(1331.0, v, 0.01);
|
||
}
|
||
|
||
test "deflate: simple deflation backward" {
|
||
const cpi = [_]YearCpi{
|
||
.{ .year = 2020, .cpi = 0.10 },
|
||
.{ .year = 2021, .cpi = 0.10 },
|
||
.{ .year = 2022, .cpi = 0.10 },
|
||
};
|
||
// From 2023 -> 2020 dollars: divide by 1.331.
|
||
const v = deflate(1331, 2023, 2020, &cpi);
|
||
try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
|
||
}
|
||
|
||
test "deflate: empty cpi returns nominal" {
|
||
const v = deflate(1000, 2020, 2024, &.{});
|
||
try std.testing.expectEqual(@as(f64, 1000), v);
|
||
}
|