diff --git a/examples/linear_regression/main.go b/examples/linear_regression/main.go new file mode 100644 index 0000000..402a91a --- /dev/null +++ b/examples/linear_regression/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + + /* + In this example we add a new type of series, a `SimpleMovingAverageSeries` that takes another series as a required argument. + InnerSeries only needs to implement `ValueProvider`, so really you could chain `SimpleMovingAverageSeries` together if you wanted. + */ + + mainSeries := chart.ContinuousSeries{ + Name: "A test series", + XValues: chart.Seq(1.0, 100.0), //generates a []float64 from 1.0 to 100.0 in 1.0 step increments, or 100 elements. + YValues: chart.SeqRand(100, 100), //generates a []float64 randomly from 0 to 100 with 100 elements. + } + + // note we create a LinearRegressionSeries series by assignin the inner series. + // we need to use a reference because `.Render()` needs to modify state within the series. + linRegSeries := &chart.LinearRegressionSeries{ + InnerSeries: mainSeries, + } // we can optionally set the `WindowSize` property which alters how the moving average is calculated. + + graph := chart.Chart{ + Series: []chart.Series{ + mainSeries, + linRegSeries, + }, + } + + res.Header().Set("Content-Type", "image/png") + graph.Render(chart.PNG, res) +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/linear_regression_series.go b/linear_regression_series.go new file mode 100644 index 0000000..e430c96 --- /dev/null +++ b/linear_regression_series.go @@ -0,0 +1,135 @@ +package chart + +// LinearRegressionSeries is a series that plots the n-nearest neighbors +// linear regression for the values. +type LinearRegressionSeries struct { + Name string + Style Style + YAxis YAxisType + + Window int + Offset int + InnerSeries ValueProvider + + m float64 + b float64 + avgx float64 + stddevx float64 +} + +// GetName returns the name of the time series. +func (lrs LinearRegressionSeries) GetName() string { + return lrs.Name +} + +// GetStyle returns the line style. +func (lrs LinearRegressionSeries) GetStyle() Style { + return lrs.Style +} + +// GetYAxis returns which YAxis the series draws on. +func (lrs LinearRegressionSeries) GetYAxis() YAxisType { + return lrs.YAxis +} + +// Len returns the number of elements in the series. +func (lrs LinearRegressionSeries) Len() int { + return lrs.InnerSeries.Len() +} + +// GetWindow returns the window size. +func (lrs LinearRegressionSeries) GetWindow() int { + if lrs.Window == 0 { + return lrs.InnerSeries.Len() + } + return lrs.Window +} + +// GetEffectiveWindowEnd returns the effective window end. +func (lrs LinearRegressionSeries) GetEffectiveWindowEnd() int { + offset := lrs.GetOffset() + windowEnd := offset + lrs.GetWindow() + return MinInt(windowEnd, lrs.Len()-1) +} + +// GetOffset returns the data offset. +func (lrs LinearRegressionSeries) GetOffset() int { + if lrs.Offset == 0 { + return 0 + } + return lrs.Offset +} + +// GetValue gets a value at a given index. +func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { + if lrs.InnerSeries == nil { + return + } + if lrs.m == 0 && lrs.b == 0 { + lrs.computeCoefficients() + } + offset := lrs.GetOffset() + x, y = lrs.InnerSeries.GetValue(index + offset) + y = (lrs.m * lrs.normalize(x)) + lrs.b + return +} + +// GetLastValue computes the last moving average value but walking back window size samples, +// and recomputing the last moving average chunk. +func (lrs *LinearRegressionSeries) GetLastValue() (x, y float64) { + if lrs.InnerSeries == nil { + return + } + if lrs.m == 0 && lrs.b == 0 { + lrs.computeCoefficients() + } + endIndex := lrs.GetEffectiveWindowEnd() + x, y = lrs.InnerSeries.GetValue(endIndex) + y = (lrs.m * lrs.normalize(x)) + lrs.b + return +} + +func (lrs *LinearRegressionSeries) normalize(xvalue float64) float64 { + return (xvalue - lrs.avgx) / lrs.stddevx +} + +// computeCoefficients computes the `m` and `b` terms in the linear formula given by `y = mx+b`. +func (lrs *LinearRegressionSeries) computeCoefficients() { + + startIndex := lrs.GetOffset() + endIndex := lrs.GetEffectiveWindowEnd() + + valueCount := endIndex - startIndex + + p := float64(endIndex - startIndex) + + xvalues := NewRingBufferWithCapacity(valueCount) + for index := startIndex; index < endIndex; index++ { + x, _ := lrs.InnerSeries.GetValue(index) + xvalues.Enqueue(x) + } + + lrs.avgx = xvalues.Average() + lrs.stddevx = xvalues.StdDev() + + var sumx, sumy, sumxx, sumxy float64 + for index := startIndex; index < endIndex; index++ { + x, y := lrs.InnerSeries.GetValue(index) + + x = lrs.normalize(x) + + sumx += x + sumy += y + sumxx += x * x + sumxy += x * y + } + + lrs.m = (p*sumxy - sumx*sumy) / (p*sumxx - sumx*sumx) + lrs.b = (sumy / p) - (lrs.m * sumx / p) +} + +// Render renders the series. +func (lrs *LinearRegressionSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { + style := lrs.Style.InheritFrom(defaults) + DrawLineSeries(r, canvasBox, xrange, yrange, style, lrs) +} diff --git a/linear_regression_series_test.go b/linear_regression_series_test.go new file mode 100644 index 0000000..0956e11 --- /dev/null +++ b/linear_regression_series_test.go @@ -0,0 +1,75 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestLinearRegressionSeries(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Seq(1.0, 100.0), + YValues: Seq(1.0, 100.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + } + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(1.0, lrx0, 0.0000001) + assert.InDelta(1.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(100.0, lrxn, 0.0000001) + assert.InDelta(100.0, lryn, 0.0000001) +} + +func TestLinearRegressionSeriesDesc(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Seq(100.0, 1.0), + YValues: Seq(100.0, 1.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + } + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(100.0, lrx0, 0.0000001) + assert.InDelta(100.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(1.0, lrxn, 0.0000001) + assert.InDelta(1.0, lryn, 0.0000001) +} + +func TestLinearRegressionSeriesWindowAndOffset(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Seq(100.0, 1.0), + YValues: Seq(100.0, 1.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + Offset: 10, + Window: 10, + } + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(90.0, lrx0, 0.0000001) + assert.InDelta(90.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(80.0, lrxn, 0.0000001) + assert.InDelta(80.0, lryn, 0.0000001) +} diff --git a/market_hours_range_test.go b/market_hours_range_test.go index 8698b36..c2eaa3d 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -55,7 +55,7 @@ func TestMarketHoursRangeGetTicks(t *testing.T) { ticks := r.GetTicks(TimeValueFormatter) assert.NotEmpty(ticks) - assert.Len(ticks, 5) + assert.Len(ticks, 6) assert.NotEqual(TimeToFloat64(r.Min), ticks[0].Value) assert.NotEmpty(ticks[0].Label) } diff --git a/ring_buffer.go b/ring_buffer.go index a5e91cd..6fb4288 100644 --- a/ring_buffer.go +++ b/ring_buffer.go @@ -2,6 +2,7 @@ package chart import ( "fmt" + "math" "strings" ) @@ -200,6 +201,40 @@ func (rb *RingBuffer) String() string { return strings.Join(values, " <= ") } +// Average returns the float average of the values in the buffer. +func (rb *RingBuffer) Average() float64 { + var accum float64 + rb.Each(func(v interface{}) { + if typed, isTyped := v.(float64); isTyped { + accum += typed + } + }) + return accum / float64(rb.Len()) +} + +// Variance computes the variance of the buffer. +func (rb *RingBuffer) Variance() float64 { + if rb.Len() == 0 { + return 0 + } + + var variance float64 + m := rb.Average() + + rb.Each(func(v interface{}) { + if n, isTyped := v.(float64); isTyped { + variance += (float64(n) - m) * (float64(n) - m) + } + }) + + return variance / float64(rb.Len()) +} + +// StdDev returns the standard deviation. +func (rb *RingBuffer) StdDev() float64 { + return math.Pow(rb.Variance(), 0.5) +} + func arrayClear(source []interface{}, index, length int) { for x := 0; x < length; x++ { absoluteIndex := x + index diff --git a/sma_series.go b/sma_series.go index 245f8c9..63a8708 100644 --- a/sma_series.go +++ b/sma_series.go @@ -35,6 +35,17 @@ func (sma SMASeries) Len() int { return sma.InnerSeries.Len() } +// GetPeriod returns the window size. +func (sma SMASeries) GetPeriod(defaults ...int) int { + if sma.Period == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultSimpleMovingAveragePeriod + } + return sma.Period +} + // GetValue gets a value at a given index. func (sma SMASeries) GetValue(index int) (x, y float64) { if sma.InnerSeries == nil { @@ -59,17 +70,6 @@ func (sma SMASeries) GetLastValue() (x, y float64) { return } -// GetPeriod returns the window size. -func (sma SMASeries) GetPeriod(defaults ...int) int { - if sma.Period == 0 { - if len(defaults) > 0 { - return defaults[0] - } - return DefaultSimpleMovingAveragePeriod - } - return sma.Period -} - func (sma SMASeries) getAverage(index int) float64 { period := sma.GetPeriod() floor := MaxInt(0, index-period) diff --git a/xaxis.go b/xaxis.go index bf8522c..5ebe2cb 100644 --- a/xaxis.go +++ b/xaxis.go @@ -28,8 +28,11 @@ func (xa XAxis) GetStyle() Style { return xa.Style } -// GetTicks returns the ticks for a series. It coalesces between user provided ticks and -// generated ticks. +// GetTicks returns the ticks for a series. +// The coalesce priority is: +// - User Supplied Ticks (i.e. Ticks array on the axis itself). +// - Range ticks (i.e. if the range provides ticks). +// - Generating continuous ticks based on minimum spacing and canvas width. func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { if len(xa.Ticks) > 0 { return xa.Ticks diff --git a/yaxis.go b/yaxis.go index afad3ce..0d81f56 100644 --- a/yaxis.go +++ b/yaxis.go @@ -35,8 +35,11 @@ func (ya YAxis) GetStyle() Style { return ya.Style } -// GetTicks returns the ticks for a series. It coalesces between user provided ticks and -// generated ticks. +// GetTicks returns the ticks for a series. +// The coalesce priority is: +// - User Supplied Ticks (i.e. Ticks array on the axis itself). +// - Range ticks (i.e. if the range provides ticks). +// - Generating continuous ticks based on minimum spacing and canvas width. func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { if len(ya.Ticks) > 0 { return ya.Ticks