zfin/src/analytics/indicators.zig
2026-03-01 11:22:37 -08:00

210 lines
7.7 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.
pub fn bollingerBands(
alloc: std.mem.Allocator,
closes: []const f64,
period: usize,
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.* = .{
.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);
}