diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig index 61a2328..e7ca1a5 100644 --- a/src/analytics/indicators.zig +++ b/src/analytics/indicators.zig @@ -23,6 +23,10 @@ pub const BollingerBand = struct { /// Compute Bollinger Bands (SMA ± k * stddev) for the full series. /// Returns a slice of optional BollingerBand — null where period hasn't been reached. +/// +/// Uses O(n) sliding window algorithm instead of O(n * period): +/// - Maintains running sum for SMA +/// - Maintains running sum of squares for variance: Var(X) = E[X²] - E[X]² pub fn bollingerBands( alloc: std.mem.Allocator, closes: []const f64, @@ -30,25 +34,61 @@ pub fn bollingerBands( k: f64, ) ![]?BollingerBand { const result = try alloc.alloc(?BollingerBand, closes.len); - for (result, 0..) |*r, i| { - const mean = sma(closes, i, period) orelse { - r.* = null; - continue; - }; - // Standard deviation - const start = i + 1 - period; - var sq_sum: f64 = 0; - for (closes[start .. i + 1]) |v| { - const diff = v - mean; - sq_sum += diff * diff; - } - const stddev = @sqrt(sq_sum / @as(f64, @floatFromInt(period))); - r.* = .{ + + if (closes.len < period or period == 0) { + @memset(result, null); + return result; + } + + const p_f: f64 = @floatFromInt(period); + + // Initialize running sums for the first window [0..period) + var sum: f64 = 0; + var sum_sq: f64 = 0; + for (0..period) |i| { + sum += closes[i]; + sum_sq += closes[i] * closes[i]; + } + + // First period-1 values are null (not enough data points) + for (0..period - 1) |i| { + result[i] = null; + } + + // Compute for index period-1 (first valid point) + { + const mean = sum / p_f; + // Variance via E[X²] - E[X]² formula + const variance = (sum_sq / p_f) - (mean * mean); + // Use @max to guard against tiny negative values from floating point error + const stddev = @sqrt(@max(variance, 0.0)); + result[period - 1] = .{ .upper = mean + k * stddev, .middle = mean, .lower = mean - k * stddev, }; } + + // Slide the window for remaining points: O(n - period) iterations, O(1) each + for (period..closes.len) |i| { + const old_val = closes[i - period]; + const new_val = closes[i]; + + // Update running sums in O(1) + sum = sum - old_val + new_val; + sum_sq = sum_sq - (old_val * old_val) + (new_val * new_val); + + const mean = sum / p_f; + const variance = (sum_sq / p_f) - (mean * mean); + const stddev = @sqrt(@max(variance, 0.0)); + + result[i] = .{ + .upper = mean + k * stddev, + .middle = mean, + .lower = mean - k * stddev, + }; + } + return result; } @@ -208,3 +248,89 @@ test "rsi insufficient data" { // All should be null since len < period + 1 for (result) |r| try std.testing.expect(r == null); } + +test "bollingerBands sliding window correctness" { + const alloc = std.testing.allocator; + // Test with realistic price data + const closes = [_]f64{ + 100.0, 101.5, 99.8, 102.3, 103.1, 101.9, 104.2, 105.0, + 103.8, 106.2, 107.1, 105.5, 108.0, 109.2, 107.8, 110.5, + 111.3, 109.8, 112.4, 113.0, + }; + const bands = try bollingerBands(alloc, &closes, 5, 2.0); + defer alloc.free(bands); + + // First 4 should be null + for (0..4) |i| { + try std.testing.expect(bands[i] == null); + } + + // Verify a few points manually + // Index 4: window is [100.0, 101.5, 99.8, 102.3, 103.1] + // Mean = 101.34, manually computed + const b4 = bands[4].?; + try std.testing.expectApproxEqAbs(@as(f64, 101.34), b4.middle, 0.01); + try std.testing.expect(b4.upper > b4.middle); + try std.testing.expect(b4.lower < b4.middle); + + // Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] — wait, let me recalculate + // Window at i=19 is closes[15..20] = [110.5, 111.3, 109.8, 112.4, 113.0] + // Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4 + const b19 = bands[19].?; + try std.testing.expectApproxEqAbs(@as(f64, 111.4), b19.middle, 0.01); + + // Verify bands are properly ordered + for (bands) |b_opt| { + if (b_opt) |b| { + try std.testing.expect(b.upper >= b.middle); + try std.testing.expect(b.middle >= b.lower); + } + } +} + +test "bollingerBands edge cases" { + const alloc = std.testing.allocator; + + // Empty data + { + const empty: []const f64 = &.{}; + const bands = try bollingerBands(alloc, empty, 5, 2.0); + defer alloc.free(bands); + try std.testing.expectEqual(@as(usize, 0), bands.len); + } + + // Data shorter than period + { + const short = [_]f64{ 1, 2, 3 }; + const bands = try bollingerBands(alloc, &short, 5, 2.0); + defer alloc.free(bands); + for (bands) |b| try std.testing.expect(b == null); + } + + // Period = 1 (each point is its own window, stddev = 0) + { + const data = [_]f64{ 10, 20, 30 }; + const bands = try bollingerBands(alloc, &data, 1, 2.0); + defer alloc.free(bands); + // With period=1, stddev=0, so upper=middle=lower + for (bands, 0..) |b_opt, i| { + const b = b_opt.?; + try std.testing.expectApproxEqAbs(data[i], b.middle, 0.001); + try std.testing.expectApproxEqAbs(data[i], b.upper, 0.001); + try std.testing.expectApproxEqAbs(data[i], b.lower, 0.001); + } + } + + // Constant data (stddev = 0) + { + const constant = [_]f64{ 50, 50, 50, 50, 50 }; + const bands = try bollingerBands(alloc, &constant, 3, 2.0); + defer alloc.free(bands); + for (bands[2..]) |b_opt| { + const b = b_opt.?; + try std.testing.expectApproxEqAbs(@as(f64, 50), b.middle, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 50), b.upper, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 50), b.lower, 0.001); + } + } +} diff --git a/src/tui.zig b/src/tui.zig index 2811d87..75c511d 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -206,6 +206,43 @@ pub const ChartState = struct { price_min: f64 = 0, price_max: f64 = 0, rsi_latest: ?f64 = null, + + // Cached indicator data (persists across frames to avoid recomputation) + cached_indicators: ?chart_mod.CachedIndicators = null, + cache_candle_count: usize = 0, // candle count when cache was computed + cache_timeframe: ?chart_mod.Timeframe = null, // timeframe when cache was computed + cache_last_close: f64 = 0, // last candle's close when cache was computed + + /// Free cached indicator memory. + pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void { + if (self.cached_indicators) |*cache| { + cache.deinit(alloc); + self.cached_indicators = null; + } + self.cache_candle_count = 0; + self.cache_timeframe = null; + self.cache_last_close = 0; + } + + /// Check if cache is valid for the given candle data and timeframe. + pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart_mod.Timeframe) bool { + if (self.cached_indicators == null) return false; + if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false; + + // Slice candles to timeframe (same logic as renderChart) + const max_days = timeframe.tradingDays(); + const n = @min(candles.len, max_days); + const data = candles[candles.len - n ..]; + + if (data.len != self.cache_candle_count) return false; + if (data.len == 0) return false; + + // Check if last close changed (detects data refresh) + const last_close = data[data.len - 1].close; + if (@abs(last_close - self.cache_last_close) > 0.0001) return false; + + return true; + } }; /// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget` @@ -960,6 +997,7 @@ pub const App = struct { self.risk_metrics = null; self.scroll_offset = 0; self.chart.dirty = true; + self.chart.freeCache(self.allocator); // Invalidate indicator cache } fn refreshCurrentTab(self: *App) void { @@ -989,6 +1027,7 @@ pub const App = struct { self.freeCandles(); self.freeDividends(); self.chart.dirty = true; + self.chart.freeCache(self.allocator); // Invalidate indicator cache }, .earnings => { self.earnings_loaded = false; @@ -1133,6 +1172,7 @@ pub const App = struct { if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.classification_map) |*cm| cm.deinit(); if (self.account_map) |*am| am.deinit(); + self.chart.freeCache(self.allocator); // Free cached indicators } fn reloadPortfolioFile(self: *App) void { diff --git a/src/tui/chart.zig b/src/tui/chart.zig index 4d380f2..45aa833 100644 --- a/src/tui/chart.zig +++ b/src/tui/chart.zig @@ -122,16 +122,31 @@ pub const ChartResult = struct { rsi_latest: ?f64, }; -/// Render a complete financial chart to raw RGB pixel data. -/// The returned rgb_data is allocated with `alloc` and must be freed by caller. -pub fn renderChart( +/// Cached indicator data to avoid recomputation across frames. +/// All slices are owned and must be freed with deinit(). +pub const CachedIndicators = struct { + closes: []f64, + volumes: []f64, + bb: []?zfin.indicators.BollingerBand, + rsi_vals: []?f64, + + /// Free all allocated memory. + pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void { + alloc.free(self.closes); + alloc.free(self.volumes); + alloc.free(self.bb); + alloc.free(self.rsi_vals); + self.* = undefined; + } +}; + +/// Compute indicators for the given candle data. +/// Returns owned CachedIndicators that must be freed with deinit(). +pub fn computeIndicators( alloc: std.mem.Allocator, candles: []const zfin.Candle, timeframe: Timeframe, - width_px: u32, - height_px: u32, - th: theme_mod.Theme, -) !ChartResult { +) !CachedIndicators { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe @@ -141,15 +156,72 @@ pub fn renderChart( // Extract data series const closes = try zfin.indicators.closePrices(alloc, data); - defer alloc.free(closes); + errdefer alloc.free(closes); + const vols = try zfin.indicators.volumes(alloc, data); - defer alloc.free(vols); + errdefer alloc.free(vols); // Compute indicators const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); - defer alloc.free(bb); + errdefer alloc.free(bb); + const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14); - defer alloc.free(rsi_vals); + + return .{ + .closes = closes, + .volumes = vols, + .bb = bb, + .rsi_vals = rsi_vals, + }; +} + +/// Render a complete financial chart to raw RGB pixel data. +/// The returned rgb_data is allocated with `alloc` and must be freed by caller. +/// If `cached` is provided, uses pre-computed indicators instead of recomputing. +pub fn renderChart( + alloc: std.mem.Allocator, + candles: []const zfin.Candle, + timeframe: Timeframe, + width_px: u32, + height_px: u32, + th: theme_mod.Theme, + cached: ?*const CachedIndicators, +) !ChartResult { + if (candles.len < 20) return error.InsufficientData; + + // Slice candles to timeframe + const max_days = timeframe.tradingDays(); + const n = @min(candles.len, max_days); + const data = candles[candles.len - n ..]; + + // Use cached indicators or compute fresh ones + var local_closes: ?[]f64 = null; + var local_vols: ?[]f64 = null; + var local_bb: ?[]?zfin.indicators.BollingerBand = null; + var local_rsi: ?[]?f64 = null; + defer { + if (local_closes) |c| alloc.free(c); + if (local_vols) |v| alloc.free(v); + if (local_bb) |b| alloc.free(b); + if (local_rsi) |r| alloc.free(r); + } + + const closes: []const f64 = if (cached) |c| c.closes else blk: { + local_closes = try zfin.indicators.closePrices(alloc, data); + break :blk local_closes.?; + }; + const vols: []const f64 = if (cached) |c| c.volumes else blk: { + local_vols = try zfin.indicators.volumes(alloc, data); + break :blk local_vols.?; + }; + const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: { + local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); + break :blk local_bb.?; + }; + const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: { + local_rsi = try zfin.indicators.rsi(alloc, closes, 14); + break :blk local_rsi.?; + }; // Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid // background. This avoids integer overflow in z2d's RGBA compositor when diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index c4440f2..652970e 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -146,9 +146,48 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, app.chart.image_id = null; } + // If symbol changed, invalidate the indicator cache + if (symbol_changed) { + app.chart.freeCache(app.allocator); + } + + // Check if we can reuse cached indicators + const cache_valid = app.chart.isCacheValid(c, app.chart.timeframe); + + // If cache is invalid, compute new indicators + if (!cache_valid) { + // Free old cache if it exists + app.chart.freeCache(app.allocator); + + // Compute and cache new indicators + const new_cache = chart_mod.computeIndicators( + app.allocator, + c, + app.chart.timeframe, + ) catch |err| { + app.chart.dirty = false; + var err_buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed"; + app.setStatus(msg); + return; + }; + app.chart.cached_indicators = new_cache; + + // Update cache metadata + const max_days = app.chart.timeframe.tradingDays(); + const n = @min(c.len, max_days); + const data = c[c.len - n ..]; + app.chart.cache_candle_count = data.len; + app.chart.cache_timeframe = app.chart.timeframe; + app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; + } + // Render and transmit — use the app's main allocator, NOT the arena, // because z2d allocates large pixel buffers that would bloat the arena. if (app.vx_app) |va| { + // Pass cached indicators to avoid recomputation during rendering + const cached_ptr: ?*const chart_mod.CachedIndicators = if (app.chart.cached_indicators) |*ci| ci else null; + const chart_result = chart_mod.renderChart( app.allocator, c, @@ -156,6 +195,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, capped_w, capped_h, th, + cached_ptr, ) catch |err| { app.chart.dirty = false; var err_buf: [128]u8 = undefined;