zfin/src/analytics/milestones.zig

515 lines
20 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}