176 lines
5.3 KiB
Go
176 lines
5.3 KiB
Go
package mujica
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func approx(t *testing.T, name string, got, want []float64) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("%s: len %d, want %d", name, len(got), len(want))
|
|
}
|
|
for i := range got {
|
|
if math.IsNaN(want[i]) != math.IsNaN(got[i]) || !math.IsNaN(want[i]) && math.Abs(got[i]-want[i]) > 1e-9 {
|
|
t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
var nan = math.NaN()
|
|
|
|
func TestSMAAndEMA(t *testing.T) {
|
|
v := []float64{1, 2, 3, 4, 5, 6}
|
|
approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
|
// k = 0.5: seed 2, then 4*0.5+2*0.5 = 3, 5*0.5+3*0.5 = 4, 6*0.5+4*0.5 = 5.
|
|
approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
|
approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10})
|
|
approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3})
|
|
}
|
|
|
|
func TestBollinger(t *testing.T) {
|
|
mid, up, lo := Bollinger([]float64{2, 4, 4, 4, 5, 5, 7, 9}, 8, 2)
|
|
// Mean 5, population deviation 2.
|
|
approx(t, "mid", mid[7:], []float64{5})
|
|
approx(t, "upper", up[7:], []float64{9})
|
|
approx(t, "lower", lo[7:], []float64{1})
|
|
if !math.IsNaN(up[6]) {
|
|
t.Fatal("band before the period")
|
|
}
|
|
}
|
|
|
|
func candlesOf(rows ...[5]float64) []Candle {
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
out := make([]Candle, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: r[0], High: r[1], Low: r[2], Close: r[3], Volume: r[4]}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestVWAPAndOBV(t *testing.T) {
|
|
cs := candlesOf([5]float64{10, 12, 9, 12, 100}, [5]float64{12, 15, 12, 12, 300}, [5]float64{12, 12, 6, 9, 100})
|
|
// Typical prices 11, 13, 9.
|
|
approx(t, "VWAP", VWAP(cs), []float64{11, (1100 + 3900) / 400.0, (1100 + 3900 + 900) / 500.0})
|
|
approx(t, "OBV", OBV(cs), []float64{0, 0, -100})
|
|
}
|
|
|
|
func TestMACD(t *testing.T) {
|
|
v := []float64{1, 2, 3, 4, 5, 6, 7}
|
|
dif, dea, hist := MACD(v, 2, 3, 2)
|
|
f, s := EMA(v, 2), EMA(v, 3)
|
|
for i := 2; i < len(v); i++ {
|
|
if math.Abs(dif[i]-(f[i]-s[i])) > 1e-12 {
|
|
t.Fatalf("dif[%d]", i)
|
|
}
|
|
}
|
|
// DEA is the EMA(2) of DIF from index 2: seed is the mean of dif[2], dif[3].
|
|
want := EMA(dif[2:], 2)
|
|
approx(t, "dea", dea[2:], want)
|
|
if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 {
|
|
t.Fatal("hist or warm-up wrong")
|
|
}
|
|
// A straight line: both EMAs trail by a constant, DIF = 0.5, DEA = 0.5, hist = 0.
|
|
approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5})
|
|
approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0})
|
|
}
|
|
|
|
func TestRSI(t *testing.T) {
|
|
// Gains 1,1 then a loss of 1: first RSI (n=2) at i=2 is all gains = 100; then Wilder smoothing.
|
|
r := RSI([]float64{1, 2, 3, 2}, 2)
|
|
approx(t, "RSI", r, []float64{nan, nan, 100, 100 - 100/(1+0.5/0.5)})
|
|
}
|
|
|
|
func TestKDJ(t *testing.T) {
|
|
cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0})
|
|
k, d, j := KDJ(cs, 2, 3, 3)
|
|
// i=1: range 0..4, RSV 100, K = (2*50+100)/3, D = (2*50+K)/3.
|
|
k1 := 200.0 / 3
|
|
d1 := (100 + k1) / 3
|
|
// i=2: range 0..4, RSV 75.
|
|
k2 := (2*k1 + 75) / 3
|
|
d2 := (2*d1 + k2) / 3
|
|
approx(t, "K", k, []float64{nan, k1, k2})
|
|
approx(t, "D", d, []float64{nan, d1, d2})
|
|
approx(t, "J", j, []float64{nan, 3*k1 - 2*d1, 3*k2 - 2*d2})
|
|
}
|
|
|
|
func TestHeikinAshi(t *testing.T) {
|
|
cs := candlesOf([5]float64{10, 14, 8, 12, 5}, [5]float64{12, 13, 9, 10, 6})
|
|
h := HeikinAshi(cs)
|
|
if h[0].Open != 11 || h[0].Close != 11 || h[0].High != 14 || h[0].Low != 8 {
|
|
t.Fatalf("first %+v", h[0])
|
|
}
|
|
if h[1].Open != 11 || h[1].Close != 11 || h[1].High != 13 || h[1].Low != 9 || h[1].Volume != 6 {
|
|
t.Fatalf("second %+v", h[1])
|
|
}
|
|
}
|
|
|
|
func TestIndicatorsPanicOnBadPeriods(t *testing.T) {
|
|
for name, f := range map[string]func(){
|
|
"SMA": func() { SMA(nil, 0) },
|
|
"MACD": func() { MACD(nil, 26, 12, 9) },
|
|
"BOLL": func() { Bollinger(nil, 20, 0) },
|
|
"KDJ m": func() { KDJ(nil, 9, 0, 3) },
|
|
} {
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s: no panic", name)
|
|
}
|
|
}()
|
|
f()
|
|
}()
|
|
}
|
|
}
|
|
|
|
func TestMarketWindowPanZoom(t *testing.T) {
|
|
v := MarketView{}
|
|
if f, to := marketWindow(v, 1000, 400); f != 880 || to != 1000 {
|
|
t.Fatalf("default %d..%d", f, to)
|
|
}
|
|
marketPan(&v, 1000, 400, -100)
|
|
if v.End != 900 || v.Count != 120 {
|
|
t.Fatalf("pan back %+v", v)
|
|
}
|
|
marketZoom(&v, 1000, 400, 0.5)
|
|
if f, to := marketWindow(v, 1000, 400); f != 840 || to != 900 {
|
|
t.Fatalf("zoom in %d..%d", f, to)
|
|
}
|
|
marketZoom(&v, 1000, 100, 10)
|
|
if v.Count != 100 {
|
|
t.Fatalf("zoom out capped by room: %+v", v)
|
|
}
|
|
marketPan(&v, 1000, 100, 5000)
|
|
if v.End != 0 {
|
|
t.Fatalf("pan to the newest follows: %+v", v)
|
|
}
|
|
if f, to := marketWindow(MarketView{Count: 50, End: 10}, 1000, 400); f != 0 || to != 50 {
|
|
t.Fatalf("start clamp %d..%d", f, to)
|
|
}
|
|
}
|
|
|
|
func TestIntervalClose(t *testing.T) {
|
|
loc := time.UTC
|
|
at := time.Date(2026, 10, 7, 13, 47, 10, 0, loc) // a Wednesday
|
|
for iv, want := range map[Interval]time.Time{
|
|
"1m": time.Date(2026, 10, 7, 13, 48, 0, 0, loc),
|
|
"15m": time.Date(2026, 10, 7, 14, 0, 0, 0, loc),
|
|
"4h": time.Date(2026, 10, 7, 16, 0, 0, 0, loc),
|
|
"1D": time.Date(2026, 10, 8, 0, 0, 0, 0, loc),
|
|
"1W": time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
|
|
"1M": time.Date(2026, 11, 1, 0, 0, 0, 0, loc),
|
|
} {
|
|
if got := iv.Close(at, loc); !got.Equal(want) {
|
|
t.Errorf("%s: %v, want %v", iv, got, want)
|
|
}
|
|
}
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("unknown interval accepted")
|
|
}
|
|
}()
|
|
Interval("7m").Close(at, loc)
|
|
}
|