From 3f9cb33be0ff7a46672587e8eb12fc752fa961af Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 31 Aug 2026 08:18:51 -0700 Subject: [PATCH] snap performance for weekly returns --- src/analytics/performance.zig | 47 ++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index 1324ce3..cb0db55 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -487,15 +487,33 @@ pub fn formatReturn(buf: []u8, value: f64) []const u8 { /// Compute 1-week return from candle data: (latest_close / close_7_days_ago) - 1. /// Candles must be sorted by date ascending. +/// +/// Bounded by `max_snap_days`, same as `findNearestCandle`. The backward scan used +/// to be unbounded with a comment asserting "at most ~10 steps for daily candles" +/// - an assumption about the data, not a constraint on the code. A hole in the +/// candle cache therefore turned this into a month's return still labelled "Week", +/// silently and with no way to tell from the output. That is exactly the failure +/// `max_snap_days` was introduced to prevent ("Beyond this, the data is likely +/// missing and the result would be misleading"); this function simply never +/// applied it. +/// +/// Real caches do have holes: a snapshot taken 2026-08-29 carried +/// `stale_count:num:3`, with three symbols a full day behind their peers. +/// +/// Returns null rather than a wrong number, so the caller renders "--" and the +/// gap is visible instead of plausible. pub fn weekReturn(candles: []const Candle) ?f64 { if (candles.len < 2) return null; const latest = candles[candles.len - 1]; const target_date = latest.date.addDays(-7); - // Linear scan backward (at most ~10 steps for daily candles) var i: usize = candles.len - 2; while (true) { if (candles[i].date.days <= target_date.days) { + // How far BEFORE the target we landed. A weekend or holiday puts the + // nearest bar a few days early, which is normal and fine; a cache hole + // puts it weeks early, which is not a week's return. + if (target_date.days - candles[i].date.days > max_snap_days) return null; if (candles[i].close == 0) return null; return (latest.close / candles[i].close) - 1.0; } @@ -543,6 +561,33 @@ test "weekReturn snaps to nearest trading day" { try std.testing.expectApproxEqAbs(@as(f64, 0.1667), r, 0.001); } +test "weekReturn refuses a gap wider than max_snap_days" { + // A hole in the candle cache. Latest is Feb 10; 7 days back is Feb 3, but the + // previous bar is Jan 1 - forty days earlier. The old unbounded scan happily + // returned (105/90 - 1) = +16.67% and labelled it "Week". + const holed = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 1), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 2, 10), .open = 105, .high = 105, .low = 105, .close = 105, .adj_close = 105, .volume = 0 }, + }; + try std.testing.expect(weekReturn(&holed) == null); + + // The boundary is inclusive and generous enough for a holiday week: landing + // exactly `max_snap_days` before the target still counts. Target is Jan 21 - 7 + // = Jan 14; Jan 4 is 10 days before that. + const at_limit = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 4), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 21), .open = 99, .high = 99, .low = 99, .close = 99, .adj_close = 99, .volume = 0 }, + }; + try std.testing.expectApproxEqAbs(@as(f64, 0.10), weekReturn(&at_limit).?, 0.001); + + // One day past it is refused, so the cutoff is actually enforced rather than + // merely present. + const past_limit = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 3), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 21), .open = 99, .high = 99, .low = 99, .close = 99, .adj_close = 99, .volume = 0 }, + }; + try std.testing.expect(weekReturn(&past_limit) == null); +} test "weekReturn zero close returns null" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 1), .open = 0, .high = 0, .low = 0, .close = 0, .adj_close = 0, .volume = 0 },