zfin/src/analytics/indicators.zig

336 lines
12 KiB
Zig

//! Technical indicators for financial charting.
//! Bollinger Bands, RSI, SMA — all computed from candle close prices.
const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
/// Simple Moving Average for a window of `period` values ending at index `end` (inclusive).
/// Returns null if there aren't enough data points.
pub fn sma(closes: []const f64, end: usize, period: usize) ?f64 {
if (end + 1 < period) return null;
var sum: f64 = 0;
const start = end + 1 - period;
for (closes[start .. end + 1]) |v| sum += v;
return sum / @as(f64, @floatFromInt(period));
}
/// Bollinger Bands output for a single data point.
pub const BollingerBand = struct {
upper: f64,
middle: f64, // SMA
lower: f64,
};
/// 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,
period: usize,
k: f64,
) ![]?BollingerBand {
const result = try alloc.alloc(?BollingerBand, closes.len);
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;
}
/// RSI (Relative Strength Index) for the full series using Wilder's smoothing.
/// Returns a slice of optional f64 — null for the first `period` data points.
pub fn rsi(
alloc: std.mem.Allocator,
closes: []const f64,
period: usize,
) ![]?f64 {
const result = try alloc.alloc(?f64, closes.len);
if (closes.len < period + 1) {
@memset(result, null);
return result;
}
// Seed: average gain/loss over first `period` changes
var avg_gain: f64 = 0;
var avg_loss: f64 = 0;
for (1..period + 1) |i| {
const change = closes[i] - closes[i - 1];
if (change > 0) avg_gain += change else avg_loss += -change;
}
const p_f: f64 = @floatFromInt(period);
avg_gain /= p_f;
avg_loss /= p_f;
// First `period` values are null
for (0..period) |i| result[i] = null;
// Value at index `period`
if (avg_loss == 0) {
result[period] = 100.0;
} else {
const rs = avg_gain / avg_loss;
result[period] = 100.0 - (100.0 / (1.0 + rs));
}
// Wilder's smoothing for the rest
for (period + 1..closes.len) |i| {
const change = closes[i] - closes[i - 1];
const gain = if (change > 0) change else 0;
const loss = if (change < 0) -change else 0;
avg_gain = (avg_gain * (p_f - 1.0) + gain) / p_f;
avg_loss = (avg_loss * (p_f - 1.0) + loss) / p_f;
if (avg_loss == 0) {
result[i] = 100.0;
} else {
const rs = avg_gain / avg_loss;
result[i] = 100.0 - (100.0 / (1.0 + rs));
}
}
return result;
}
/// Extract close prices from candles into a contiguous f64 slice.
pub fn closePrices(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 {
const result = try alloc.alloc(f64, candles.len);
for (candles, 0..) |c, i| result[i] = c.close;
return result;
}
/// Extract volumes from candles.
pub fn volumes(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 {
const result = try alloc.alloc(f64, candles.len);
for (candles, 0..) |c, i| result[i] = @floatFromInt(c.volume);
return result;
}
test "sma basic" {
const closes = [_]f64{ 1, 2, 3, 4, 5 };
try std.testing.expectEqual(@as(?f64, null), sma(&closes, 1, 3));
try std.testing.expectApproxEqAbs(@as(f64, 2.0), sma(&closes, 2, 3).?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 3.0), sma(&closes, 3, 3).?, 0.001);
}
test "rsi basic" {
const alloc = std.testing.allocator;
// 15 prices with a clear uptrend
const closes = [_]f64{ 44, 44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28 };
const result = try rsi(alloc, &closes, 14);
defer alloc.free(result);
// First 14 should be null, last should have a value
try std.testing.expect(result[13] == null);
try std.testing.expect(result[14] != null);
}
test "bollingerBands basic" {
const alloc = std.testing.allocator;
const closes = [_]f64{ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 };
const bands = try bollingerBands(alloc, &closes, 5, 2.0);
defer alloc.free(bands);
// First 4 (indices 0-3) should be null (period=5, need indices 0..4)
try std.testing.expect(bands[0] == null);
try std.testing.expect(bands[3] == null);
// Index 4 onward should have values
try std.testing.expect(bands[4] != null);
const b4 = bands[4].?;
// SMA of [10,11,12,13,14] = 12.0
try std.testing.expectApproxEqAbs(@as(f64, 12.0), b4.middle, 0.001);
// upper > middle > lower
try std.testing.expect(b4.upper > b4.middle);
try std.testing.expect(b4.middle > b4.lower);
}
test "closePrices" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1000 },
.{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 2000 },
};
const prices = try closePrices(alloc, &candles);
defer alloc.free(prices);
try std.testing.expectEqual(@as(usize, 2), prices.len);
try std.testing.expectApproxEqAbs(@as(f64, 102), prices[0], 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 105), prices[1], 0.001);
}
test "volumes" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1500 },
.{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 3000 },
};
const vols = try volumes(alloc, &candles);
defer alloc.free(vols);
try std.testing.expectEqual(@as(usize, 2), vols.len);
try std.testing.expectApproxEqAbs(@as(f64, 1500), vols[0], 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 3000), vols[1], 0.001);
}
test "sma edge cases" {
// period=1: should equal the value itself
const closes = [_]f64{ 5, 10, 15 };
try std.testing.expectApproxEqAbs(@as(f64, 5.0), sma(&closes, 0, 1).?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), sma(&closes, 1, 1).?, 0.001);
// period > data length: always null
try std.testing.expect(sma(&closes, 2, 10) == null);
}
test "rsi all up" {
const alloc = std.testing.allocator;
// Prices going up by 1 each day for 20 days
var closes: [20]f64 = undefined;
for (0..20) |i| closes[i] = 100.0 + @as(f64, @floatFromInt(i));
const result = try rsi(alloc, &closes, 14);
defer alloc.free(result);
// RSI should be 100 (all gains, no losses)
try std.testing.expect(result[14] != null);
try std.testing.expectApproxEqAbs(@as(f64, 100.0), result[14].?, 0.001);
}
test "rsi insufficient data" {
const alloc = std.testing.allocator;
const closes = [_]f64{ 1, 2, 3 };
const result = try rsi(alloc, &closes, 14);
defer alloc.free(result);
// 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);
}
}
}