From 1e7ed093b42ff52fd2ebb60d646c890fa0cddc6e Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 7 Jun 2026 14:15:35 -0700 Subject: [PATCH] Implement Z-Score Indicator (closes #364) --- README.md | 2 + volatility/README.md | 95 ++++++++++++ volatility/testdata/z_score.csv | 252 ++++++++++++++++++++++++++++++++ volatility/z_score.go | 75 ++++++++++ volatility/z_score_test.go | 58 ++++++++ 5 files changed, 482 insertions(+) create mode 100644 volatility/testdata/z_score.csv create mode 100644 volatility/z_score.go create mode 100644 volatility/z_score_test.go diff --git a/README.md b/README.md index 88d41661..139a2ae4 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ The following list of indicators are currently supported by this package: - [Super Trend](volatility/README.md#SuperTrend) - [True Range (TR)](volatility/README.md#TrueRange) - [Ulcer Index (UI)](volatility/README.md#UlcerIndex) +- [Z-Score](volatility/README.md#ZScore) + ### 📢 Volume Indicators diff --git a/volatility/README.md b/volatility/README.md index 927a7f49..453a2295 100644 --- a/volatility/README.md +++ b/volatility/README.md @@ -123,6 +123,13 @@ The information provided on this project is strictly for informational purposes - [func \(u \*UlcerIndex\[T\]\) Compute\(closings \<\-chan T\) \<\-chan T](<#UlcerIndex[T].Compute>) - [func \(u \*UlcerIndex\[T\]\) ComputeWithContext\(ctx context.Context, closings \<\-chan T\) \<\-chan T](<#UlcerIndex[T].ComputeWithContext>) - [func \(u \*UlcerIndex\[T\]\) IdlePeriod\(\) int](<#UlcerIndex[T].IdlePeriod>) +- [type ZScore](<#ZScore>) + - [func NewZScore\[T helper.Number\]\(\) \*ZScore\[T\]](<#NewZScore>) + - [func NewZScoreWithPeriod\[T helper.Number\]\(period int\) \*ZScore\[T\]](<#NewZScoreWithPeriod>) + - [func \(z \*ZScore\[T\]\) Compute\(c \<\-chan T\) \<\-chan T](<#ZScore[T].Compute>) + - [func \(z \*ZScore\[T\]\) ComputeWithContext\(ctx context.Context, c \<\-chan T\) \<\-chan T](<#ZScore[T].ComputeWithContext>) + - [func \(z \*ZScore\[T\]\) IdlePeriod\(\) int](<#ZScore[T].IdlePeriod>) + - [func \(z \*ZScore\[T\]\) String\(\) string](<#ZScore[T].String>) ## Constants @@ -254,6 +261,15 @@ const ( ) ``` + + +```go +const ( + // DefaultZScorePeriod is the default period for Z-Score. + DefaultZScorePeriod = 20 +) +``` + ## type [AccelerationBands]() @@ -1442,4 +1458,83 @@ func (u *UlcerIndex[T]) IdlePeriod() int IdlePeriod is the initial period that Ulcer Index won't yield any results. + +## type [ZScore]() + +ZScore represents the configuration parameters for Z\-Score. It measures how many standard deviations price is away from its SMA. + +``` +Z-Score = (Price - SMA) / StdDev +``` + +Example: + +``` +z := NewZScore[float64]() +z.Compute(c) +``` + +```go +type ZScore[T helper.Number] struct { + // Period is the time period. + Period int +} +``` + + +### func [NewZScore]() + +```go +func NewZScore[T helper.Number]() *ZScore[T] +``` + +NewZScore function initializes a new Z\-Score instance with default parameters. + + +### func [NewZScoreWithPeriod]() + +```go +func NewZScoreWithPeriod[T helper.Number](period int) *ZScore[T] +``` + +NewZScoreWithPeriod function initializes a new Z\-Score instance with the given period. + + +### func \(\*ZScore\[T\]\) [Compute]() + +```go +func (z *ZScore[T]) Compute(c <-chan T) <-chan T +``` + +Compute wraps ComputeWithContext for backwards compatibility. + +Deprecated: Use ComputeWithContext instead. + + +### func \(\*ZScore\[T\]\) [ComputeWithContext]() + +```go +func (z *ZScore[T]) ComputeWithContext(ctx context.Context, c <-chan T) <-chan T +``` + +ComputeWithContext function takes a channel of numbers and computes the Z\-Score over the specified period. + + +### func \(\*ZScore\[T\]\) [IdlePeriod]() + +```go +func (z *ZScore[T]) IdlePeriod() int +``` + +IdlePeriod is the initial period that Z\-Score won't yield any results. + + +### func \(\*ZScore\[T\]\) [String]() + +```go +func (z *ZScore[T]) String() string +``` + +String is the string representation of Z\-Score. + Generated by [gomarkdoc]() diff --git a/volatility/testdata/z_score.csv b/volatility/testdata/z_score.csv new file mode 100644 index 00000000..4ab5c4eb --- /dev/null +++ b/volatility/testdata/z_score.csv @@ -0,0 +1,252 @@ +Close,Expected +318.600006,0 +315.839996,0 +316.149994,0 +310.570007,0 +307.779999,0 +305.820007,0 +305.98999,0 +306.390015,0 +311.450012,0 +312.329987,0 +309.290009,0 +301.910004,0 +300,0 +300.029999,0 +302,0 +307.820007,0 +302.690002,0 +306.48999,0 +305.549988,0 +303.429993,-0.7818245201783479 +309.059998,0.44386708800174934 +308.899994,0.5362477750787341 +309.910004,0.9801095220341718 +314.549988,2.0293027206369354 +312.899994,1.4591655753270345 +318.690002,2.2950636380966167 +315.529999,1.4645329688392388 +316.350006,1.443948525777569 +320.369995,1.90176400896643 +318.929993,1.52055333123116 +317.640015,1.2043857982759751 +314.859985,0.7050311757089405 +308.299988,-0.395395666875024 +305.230011,-1.0038777863570185 +309.869995,-0.2780314866723805 +310.420013,-0.20143900697090736 +311.299988,-0.12580001787849465 +311.899994,-0.060276113847900874 +310.950012,-0.3357354989863008 +309.170013,-0.8801522599853688 +307.329987,-1.2823035787971355 +311.519989,-0.31085063693906695 +310.570007,-0.5550448446819526 +311.859985,-0.20424742360541354 +308.51001,-0.9559455586073401 +308.429993,-0.8880195803926622 +312.970001,0.29466575663229827 +308.480011,-0.7746868070319617 +307.209991,-1.075725288726656 +309.890015,-0.15745429282962412 +313.73999,1.5637350055610275 +310.790009,0.42340951815663813 +309.630005,-0.17767840551442882 +308.179993,-1.114429910626945 +308.23999,-1.0064837823597135 +302.720001,-2.889360559532872 +303.160004,-2.2128650229311475 +303.070007,-1.9191622035206075 +304.019989,-1.4239950234198222 +304.660004,-1.1108734717334017 +305.179993,-0.8988661591869134 +304.619995,-0.9672312780422148 +307.75,0.029330140836829555 +312.450012,1.4604186400923211 +316.970001,2.3079947121635165 +311.119995,0.7387034594312188 +311.369995,0.8412046209959448 +304.820007,-0.8141809410368722 +303.630005,-1.0444812963968493 +302.880005,-1.1145482604444217 +305.329987,-0.44054476244466695 +297.880005,-2.007526040565908 +302.01001,-0.9352321248395642 +293.51001,-2.3398771011768384 +301.059998,-0.7614173258388136 +303.850006,-0.22169160841011898 +299.730011,-0.9827993840695142 +298.369995,-1.1611070757544648 +298.920013,-0.9842919321503065 +302.140015,-0.37143714784381743 +302.320007,-0.3120836006550457 +305.299988,0.22331049490758859 +305.079987,0.20983980503871374 +308.769989,0.9623244219319945 +310.309998,1.5223411861481093 +309.070007,1.3160804620303015 +310.390015,1.6619470273629715 +312.51001,1.872904315879046 +312.619995,1.6646040970667328 +313.700012,1.6420270228377845 +314.549988,1.5945075988426614 +318.049988,1.8911061123196824 +319.73999,1.8485719698604104 +323.790009,2.155983766168164 +324.630005,1.944323022457516 +323.089996,1.538122717286768 +323.820007,1.46549475280849 +324.329987,1.4009070794485172 +326.049988,1.490002285238737 +324.339996,1.165952669774801 +320.529999,0.580354171268126 +326.230011,1.2935315372684537 +328.549988,1.51988422606345 +330.170013,1.5977665676831478 +325.859985,0.8461826951582786 +323.220001,0.33886416450274265 +320,-0.35279777612996377 +323.880005,0.3298564831460812 +326.140015,0.7574208979046654 +324.869995,0.36406143935435065 +322.98999,-0.36035732647833446 +322.640015,-0.6369151334031727 +322.48999,-0.8093543846174371 +323.529999,-0.35805634710340467 +323.75,-0.24494852680859133 +327.390015,1.1798668450651713 +329.76001,1.8495354402438706 +330.390015,1.7983809721655972 +329.130005,1.261138399054557 +323.109985,-0.6902814774401368 +320.200012,-1.618062650914307 +319.019989,-1.732324672885001 +320.600006,-1.1419535718969562 +322.190002,-0.5944815755688119 +321.079987,-0.8619572745473221 +323.119995,-0.21833125174547793 +329.480011,1.583016288116815 +328.579987,1.1904164755612572 +333.410004,2.1790190708258557 +335.420013,2.2061799721934268 +335.950012,1.9616998653150342 +335.290009,1.6050476689837074 +333.600006,1.1618467680967748 +336.390015,1.4804268422218436 +335.899994,1.2521568251918307 +339.820007,1.6732061285700697 +338.309998,1.3074281295162973 +338.670013,1.2456964072585837 +338.609985,1.1297556943822218 +336.959991,0.8140382904338024 +335.25,0.4986792619872186 +334.119995,0.2436912425451558 +335.339996,0.36135182521815834 +334.149994,0.03700702058384454 +336.910004,0.5636116719499273 +341,1.7942997552105606 +342,1.9520306570644763 +341.559998,1.8041259013596094 +341.459991,1.579388515150803 +340.899994,1.2266007185571075 +341.130005,1.1821914723143536 +343.369995,1.735307763425763 +345.350006,2.0841599716675514 +343.540009,1.3429986940436196 +341.089996,0.5122109459767898 +344.25,1.3722741336593396 +345.339996,1.503941490540785 +342.429993,0.6193729478365246 +346.609985,1.5813243045170298 +345.76001,1.220986145995572 +349.630005,1.9493854518634202 +347.579987,1.3457467232072482 +349.799988,1.76326674804902 +349.309998,1.5979113261313147 +349.809998,1.6431322027952942 +351.959991,1.981376320665765 +352.26001,1.7899481572660285 +351.190002,1.346810331863959 +353.809998,1.7897129956655795 +349.98999,0.7392561304434329 +362.579987,2.984461576448763 +363.730011,2.5257387083525744 +358.019989,1.3617427684468164 +356.980011,1.0736293923623093 +358.350006,1.1983565118639121 +358.480011,1.1081419717266858 +354.5,0.3395985969543774 +354.109985,0.1788833347362968 +353.190002,-0.07667156012909344 +352.559998,-0.30406966574020183 +352.089996,-0.44842183607721453 +350.570007,-0.873617801261981 +354.26001,-0.03194514937519997 +354.299988,-0.08827134166517518 +355.929993,0.26961257667020694 +355.549988,0.11882655773343154 +358.290009,0.7966151439385621 +361.059998,1.4052411204731305 +360.200012,1.0597321197585208 +362.459991,1.519428627631854 +360.470001,1.0471723581359316 +361.670013,1.4894417408746745 +361.799988,1.4000210424396031 +363.149994,1.5790573521890885 +365.519989,1.9020695459568842 +367.779999,2.0558677240065837 +367.820007,1.7815680693701117 +369.5,1.8172665759699478 +367.859985,1.3657046749032746 +370.429993,1.621754764610057 +370.480011,1.4802097796729616 +366.820007,0.7768587033448849 +363.279999,0.012712881696167005 +360.160004,-0.7530413821510269 +361.709991,-0.5068328260899367 +359.420013,-1.1998984763055782 +357.779999,-1.606755512964339 +357.059998,-1.6387641165774691 +350.299988,-2.597500895387828 +348.079987,-2.4144904403810443 +343.040009,-2.536936953405212 +343.690002,-2.0522109114156963 +345.059998,-1.6541984430407575 +346.339996,-1.353021746175078 +345.450012,-1.2961860845088597 +348.559998,-0.8840445468388387 +348.429993,-0.8098898000026656 +345.660004,-1.0014728621229647 +345.089996,-0.9669857028085983 +346.230011,-0.7674233052340442 +345.390015,-0.8094665754914792 +340.890015,-1.3462692885841976 +338.660004,-1.5556992439592987 +335.859985,-1.785961755769725 +336.839996,-1.5526500247535628 +338.630005,-1.2017304698478237 +336.899994,-1.4515056525712233 +336.160004,-1.5825815449999596 +331.709991,-2.192493067415746 +337.410004,-0.9203079766352411 +341.329987,-0.08073831112706527 +343.75,0.42666149875115156 +349.019989,1.427721227918211 +351.809998,1.7989285949347475 +346.630005,0.8129615733078429 +346.170013,0.7662857057490063 +346.299988,0.8280660407970325 +348.179993,1.1418077989641282 +350.559998,1.4631585272910645 +350.01001,1.2869729428696268 +354.25,1.7871869672584084 +356.790009,1.8715137181789667 +359.859985,1.960424111266184 +358.929993,1.6267539386786765 +361.329987,1.6970984810567131 +361,1.4753381459646424 +361.799988,1.4154345584816068 +362.679993,1.3859317660637405 +361.339996,1.187690392272072 +360.049988,0.9503603912423864 +358.690002,0.6756284675797073 diff --git a/volatility/z_score.go b/volatility/z_score.go new file mode 100644 index 00000000..e6a641bf --- /dev/null +++ b/volatility/z_score.go @@ -0,0 +1,75 @@ +// Copyright (c) 2021-2026 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package volatility + +import ( + "context" + "fmt" + + "github.com/cinar/indicator/v2/helper" + "github.com/cinar/indicator/v2/trend" +) + +const ( + // DefaultZScorePeriod is the default period for Z-Score. + DefaultZScorePeriod = 20 +) + +// ZScore represents the configuration parameters for Z-Score. +// It measures how many standard deviations price is away from its SMA. +// +// Z-Score = (Price - SMA) / StdDev +// +// Example: +// +// z := NewZScore[float64]() +// z.Compute(c) +type ZScore[T helper.Number] struct { + // Period is the time period. + Period int +} + +// NewZScore function initializes a new Z-Score instance with default parameters. +func NewZScore[T helper.Number]() *ZScore[T] { + return NewZScoreWithPeriod[T](DefaultZScorePeriod) +} + +// NewZScoreWithPeriod function initializes a new Z-Score instance with the given period. +func NewZScoreWithPeriod[T helper.Number](period int) *ZScore[T] { + return &ZScore[T]{ + Period: period, + } +} + +// ComputeWithContext function takes a channel of numbers and computes the Z-Score over the specified period. +func (z *ZScore[T]) ComputeWithContext(ctx context.Context, c <-chan T) <-chan T { + cs := helper.DuplicateWithContext(ctx, c, 3) + + sma := trend.NewSmaWithPeriod[T](z.Period) + std := NewMovingStdWithPeriod[T](z.Period) + + smaChan := sma.ComputeWithContext(ctx, cs[0]) + stdChan := std.ComputeWithContext(ctx, cs[1]) + priceChan := helper.SkipWithContext(ctx, cs[2], z.IdlePeriod()) + + return helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, priceChan, smaChan), stdChan) +} + +// IdlePeriod is the initial period that Z-Score won't yield any results. +func (z *ZScore[T]) IdlePeriod() int { + return z.Period - 1 +} + +// String is the string representation of Z-Score. +func (z *ZScore[T]) String() string { + return fmt.Sprintf("ZSCORE(%d)", z.Period) +} + +// Compute wraps ComputeWithContext for backwards compatibility. +// +// Deprecated: Use ComputeWithContext instead. +func (z *ZScore[T]) Compute(c <-chan T) <-chan T { + return z.ComputeWithContext(context.Background(), c) +} diff --git a/volatility/z_score_test.go b/volatility/z_score_test.go new file mode 100644 index 00000000..978abbd1 --- /dev/null +++ b/volatility/z_score_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2021-2026 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package volatility_test + +import ( + "testing" + + "github.com/cinar/indicator/v2/helper" + "github.com/cinar/indicator/v2/volatility" +) + +func TestZScore(t *testing.T) { + type Data struct { + Close float64 `header:"Close"` + Expected float64 `header:"Expected"` + } + + input, err := helper.ReadFromCsvFile[Data]("testdata/z_score.csv") + if err != nil { + t.Fatal(err) + } + + inputs := helper.Duplicate(input, 2) + closings := helper.Map(inputs[0], func(d *Data) float64 { return d.Close }) + expected := helper.Map(inputs[1], func(d *Data) float64 { return d.Expected }) + + z := volatility.NewZScore[float64]() + actual := z.Compute(closings) + actual = helper.RoundDigits(actual, 2) + + expected = helper.RoundDigits(expected, 2) + expected = helper.Skip(expected, z.IdlePeriod()) + + err = helper.CheckEquals(actual, expected) + if err != nil { + t.Fatal(err) + } +} + +func TestZScoreString(t *testing.T) { + z := volatility.NewZScoreWithPeriod[float64](14) + expected := "ZSCORE(14)" + + if z.String() != expected { + t.Fatalf("expected %s actual %s", expected, z.String()) + } +} + +func TestNewZScore(t *testing.T) { + z := volatility.NewZScore[float64]() + expectedPeriod := 20 + + if z.Period != expectedPeriod { + t.Fatalf("expected period %d actual %d", expectedPeriod, z.Period) + } +}