cache chart and use sliding windows on bollinger bands

This commit is contained in:
Emil Lerch 2026-03-20 09:23:41 -07:00
parent be42f9e15a
commit 04882a4ff8
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 303 additions and 25 deletions

View file

@ -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);
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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;