cache chart and use sliding windows on bollinger bands
This commit is contained in:
parent
be42f9e15a
commit
04882a4ff8
4 changed files with 303 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/tui.zig
40
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue