210 lines
7.7 KiB
Zig
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);
|
|
}
|