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