diff --git a/_examples/candlestick_series/main.go b/_examples/candlestick_series/main.go new file mode 100644 index 0000000..cf233e4 --- /dev/null +++ b/_examples/candlestick_series/main.go @@ -0,0 +1,267 @@ +package main + +import ( + "net/http" + "strings" + "time" + + chart "github.com/wcharczuk/go-chart" + "github.com/wcharczuk/go-chart/seq" + util "github.com/wcharczuk/go-chart/util" +) + +type price struct { + Timestamp string + Price float64 +} + +func (p price) format() (time.Time, float64) { + t, err := time.ParseInLocation("2006-01-02 15:04:05", strings.Split(p.Timestamp, ".")[0], util.Date.Eastern()) + if err != nil { + panic(err) + } + return t, p.Price +} + +func stockData() (times []time.Time, prices []float64) { + rawPrices := []price{ + {"2017-05-12 19:45:04.482809", 238.84}, + {"2017-05-12 19:30:04.349476", 238.92}, + {"2017-05-12 19:15:04.160628", 238.98}, + {"2017-05-12 19:00:03.994135", 239.05}, + {"2017-05-12 18:45:03.947176", 238.87}, + {"2017-05-12 18:30:03.653826", 238.97}, + {"2017-05-12 18:15:03.319783", 239.00}, + {"2017-05-12 18:00:03.293044", 238.87}, + {"2017-05-12 17:45:03.010216", 238.85}, + {"2017-05-12 17:30:07.808406", 238.84}, + {"2017-05-12 17:15:02.814348", 238.93}, + {"2017-05-12 17:00:03.153611", 239.00}, + {"2017-05-12 16:45:02.352906", 238.93}, + {"2017-05-12 16:30:02.339523", 239.00}, + {"2017-05-12 16:15:02.051624", 239.05}, + {"2017-05-12 16:00:01.920557", 239.10}, + {"2017-05-12 15:45:06.866833", 239.00}, + {"2017-05-12 15:30:01.60765", 238.92}, + {"2017-05-12 15:15:01.481936", 238.80}, + {"2017-05-12 15:00:01.298738", 238.85}, + {"2017-05-12 14:45:01.19513", 238.78}, + {"2017-05-12 14:30:01.037173", 239.03}, + {"2017-05-12 14:15:00.835599", 239.02}, + {"2017-05-12 14:00:00.654748", 239.00}, + {"2017-05-12 13:45:00.65331", 239.05}, + {"2017-05-11 19:45:03.940287", 239.26}, + {"2017-05-11 19:30:03.796674", 239.42}, + {"2017-05-11 19:15:03.709477", 239.31}, + {"2017-05-11 19:00:03.533934", 239.37}, + {"2017-05-11 18:45:03.383436", 239.44}, + {"2017-05-11 18:30:03.224035", 239.23}, + {"2017-05-11 18:15:03.127204", 239.31}, + {"2017-05-11 18:00:02.8859", 239.27}, + {"2017-05-11 17:45:02.796172", 239.00}, + {"2017-05-11 17:30:02.767553", 239.12}, + {"2017-05-11 17:15:02.56807", 238.96}, + {"2017-05-11 17:00:02.613708", 239.08}, + {"2017-05-11 16:45:02.348852", 238.99}, + {"2017-05-11 16:30:02.165067", 238.84}, + {"2017-05-11 16:15:02.043199", 238.69}, + {"2017-05-11 16:00:01.831857", 238.67}, + {"2017-05-11 15:45:01.705654", 238.72}, + {"2017-05-11 15:30:01.641864", 238.75}, + {"2017-05-11 15:15:01.387109", 238.58}, + {"2017-05-11 15:00:01.212597", 238.53}, + {"2017-05-11 14:45:01.148888", 238.35}, + {"2017-05-11 14:30:00.915767", 238.59}, + {"2017-05-11 14:15:00.655649", 238.55}, + {"2017-05-11 14:00:00.596624", 239.05}, + {"2017-05-11 13:45:00.565487", 239.36}, + {"2017-05-10 19:45:04.220057", 239.69}, + {"2017-05-10 19:30:04.102519", 239.82}, + {"2017-05-10 19:15:03.950613", 239.77}, + {"2017-05-10 19:00:03.871972", 239.73}, + {"2017-05-10 18:45:03.697585", 239.59}, + {"2017-05-10 18:30:03.490385", 239.72}, + {"2017-05-10 18:15:03.327691", 239.71}, + {"2017-05-10 18:00:03.277211", 239.68}, + {"2017-05-10 17:45:03.270592", 239.76}, + {"2017-05-10 17:30:03.124085", 239.69}, + {"2017-05-10 17:15:02.811264", 239.76}, + {"2017-05-10 17:00:02.696942", 239.71}, + {"2017-05-10 16:45:02.435806", 239.66}, + {"2017-05-10 16:30:02.373372", 239.65}, + {"2017-05-10 16:15:02.130672", 239.66}, + {"2017-05-10 16:00:02.04879", 239.71}, + {"2017-05-10 15:45:01.91206", 239.74}, + {"2017-05-10 15:30:01.710121", 239.57}, + {"2017-05-10 15:15:01.485801", 239.39}, + {"2017-05-10 15:00:01.43788", 239.44}, + {"2017-05-10 14:45:01.184263", 239.25}, + {"2017-05-10 14:30:00.940762", 239.45}, + {"2017-05-10 14:15:00.885742", 239.41}, + {"2017-05-10 14:00:00.640709", 239.38}, + {"2017-05-10 13:45:00.548287", 239.38}, + {"2017-05-09 19:45:04.220057", 239.69}, + {"2017-05-09 19:30:04.102519", 239.82}, + {"2017-05-09 19:15:03.950613", 239.77}, + {"2017-05-09 19:00:03.871972", 239.73}, + {"2017-05-09 18:45:03.697585", 239.59}, + {"2017-05-09 18:30:03.490385", 239.72}, + {"2017-05-09 18:15:03.327691", 239.71}, + {"2017-05-09 18:00:03.277211", 239.68}, + {"2017-05-09 17:45:03.270592", 239.76}, + {"2017-05-09 17:30:03.124085", 239.69}, + {"2017-05-09 17:15:02.811264", 239.76}, + {"2017-05-09 17:00:02.696942", 239.71}, + {"2017-05-09 16:45:02.435806", 239.66}, + {"2017-05-09 16:30:02.373372", 239.65}, + {"2017-05-09 16:15:02.130672", 239.66}, + {"2017-05-09 16:00:02.04879", 239.71}, + {"2017-05-09 15:45:01.91206", 239.74}, + {"2017-05-09 15:30:01.710121", 239.57}, + {"2017-05-09 15:15:01.485801", 239.39}, + {"2017-05-09 15:00:01.43788", 239.44}, + {"2017-05-09 14:45:01.184263", 239.25}, + {"2017-05-09 14:30:00.940762", 239.45}, + {"2017-05-09 14:15:00.885742", 239.41}, + {"2017-05-09 14:00:00.640709", 239.38}, + {"2017-05-09 13:45:00.548287", 239.38}, + {"2017-05-05 19:45:04.220057", 239.69}, + {"2017-05-05 19:30:04.102519", 239.82}, + {"2017-05-05 19:15:03.950613", 239.77}, + {"2017-05-05 19:00:03.871972", 239.73}, + {"2017-05-05 18:45:03.697585", 239.59}, + {"2017-05-05 18:30:03.490385", 239.72}, + {"2017-05-05 18:15:03.327691", 239.71}, + {"2017-05-05 18:00:03.277211", 239.68}, + {"2017-05-05 17:45:03.270592", 239.76}, + {"2017-05-05 17:30:03.124085", 239.69}, + {"2017-05-05 17:15:02.811264", 239.76}, + {"2017-05-05 17:00:02.696942", 239.71}, + {"2017-05-05 16:45:02.435806", 239.66}, + {"2017-05-05 16:30:02.373372", 239.65}, + {"2017-05-05 16:15:02.130672", 239.66}, + {"2017-05-05 16:00:02.04879", 239.71}, + {"2017-05-05 15:45:01.91206", 239.74}, + {"2017-05-05 15:30:01.710121", 239.57}, + {"2017-05-05 15:15:01.485801", 239.39}, + {"2017-05-05 15:00:01.43788", 239.44}, + {"2017-05-05 14:45:01.184263", 239.25}, + {"2017-05-05 14:30:00.940762", 239.45}, + {"2017-05-05 14:15:00.885742", 239.41}, + {"2017-05-05 14:00:00.640709", 239.38}, + {"2017-05-05 13:45:00.548287", 239.38}, + {"2017-05-03 19:45:04.220057", 239.69}, + {"2017-05-03 19:30:04.102519", 239.82}, + {"2017-05-03 19:15:03.950613", 239.77}, + {"2017-05-03 19:00:03.871972", 239.73}, + {"2017-05-03 18:45:03.697585", 239.59}, + {"2017-05-03 18:30:03.490385", 239.72}, + {"2017-05-03 18:15:03.327691", 239.71}, + {"2017-05-03 18:00:03.277211", 239.68}, + {"2017-05-03 17:45:03.270592", 239.76}, + {"2017-05-03 17:30:03.124085", 239.69}, + {"2017-05-03 17:15:02.811264", 239.76}, + {"2017-05-03 17:00:02.696942", 239.71}, + {"2017-05-03 16:45:02.435806", 239.66}, + {"2017-05-03 16:30:02.373372", 239.65}, + {"2017-05-03 16:15:02.130672", 239.66}, + {"2017-05-03 16:00:02.04879", 239.71}, + {"2017-05-03 15:45:01.91206", 239.74}, + {"2017-05-03 15:30:01.710121", 239.57}, + {"2017-05-03 15:15:01.485801", 239.39}, + {"2017-05-03 15:00:01.43788", 239.44}, + {"2017-05-03 14:45:01.184263", 239.25}, + {"2017-05-03 14:30:00.940762", 239.45}, + {"2017-05-03 14:15:00.885742", 239.41}, + {"2017-05-03 14:00:00.640709", 239.38}, + {"2017-05-03 13:45:00.548287", 239.38}, + {"2017-04-28 19:45:04.193877", 238.11}, + {"2017-04-28 19:30:04.078242", 238.20}, + {"2017-04-28 19:15:03.886795", 238.05}, + {"2017-04-28 19:00:03.797682", 238.08}, + {"2017-04-28 18:45:03.691054", 238.05}, + {"2017-04-28 18:30:03.513045", 237.96}, + {"2017-04-28 18:15:03.387037", 238.09}, + {"2017-04-28 18:00:03.303554", 238.11}, + {"2017-04-28 17:45:03.181305", 238.16}, + {"2017-04-28 17:30:08.066927", 238.17}, + {"2017-04-28 17:15:02.708957", 238.23}, + {"2017-04-28 17:00:02.673222", 238.27}, + {"2017-04-28 16:45:02.478988", 238.29}, + {"2017-04-28 16:30:02.299229", 238.24}, + {"2017-04-28 16:15:02.100061", 238.15}, + {"2017-04-28 16:00:01.922355", 238.06}, + {"2017-04-28 15:45:01.666185", 238.25}, + {"2017-04-28 15:30:01.477215", 238.19}, + {"2017-04-28 15:15:01.440657", 238.39}, + {"2017-04-28 15:00:01.222734", 238.39}, + {"2017-04-28 14:45:01.005045", 238.33}, + {"2017-04-28 14:30:00.935222", 238.46}, + {"2017-04-28 14:15:00.84484", 238.53}, + {"2017-04-28 14:00:00.635436", 238.52}, + {"2017-04-27 19:45:04.242597", 238.61}, + } + + var t time.Time + var v float64 + var p price + for i := len(rawPrices) - 1; i >= 0; i-- { + p = rawPrices[i] + t, v = p.format() + times = append(times, t) + prices = append(prices, v) + } + return +} + +func drawChart(res http.ResponseWriter, req *http.Request) { + xv, yv := stockData() + + priceSeries := chart.TimeSeries{ + Name: "SPY", + Style: chart.Style{ + Show: true, + StrokeColor: chart.GetDefaultColor(0), + }, + XValues: xv, + YValues: yv, + } + + candleSeries := chart.CandlestickSeries{ + Name: "SPY", + XValues: xv, + YValues: yv, + } + + graph := chart.Chart{ + XAxis: chart.XAxis{ + Style: chart.Style{Show: true}, + TickPosition: chart.TickPositionBetweenTicks, + Range: &chart.MarketHoursRange{ + Min: seq.Times(xv...).Min().In(util.Date.Eastern()), + Max: seq.Times(xv...).Max().In(util.Date.Eastern()), + MarketOpen: util.NYSEOpen(), + MarketClose: util.NYSEClose(), + HolidayProvider: util.Date.IsNYSEHoliday, + }, + }, + YAxis: chart.YAxis{ + Style: chart.Style{Show: true}, + }, + Series: []chart.Series{ + candleSeries, + priceSeries, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := graph.Render(chart.PNG, res) + if err != nil { + panic(err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/candlestick_series.go b/candlestick_series.go index 315ec6a..3ce29df 100644 --- a/candlestick_series.go +++ b/candlestick_series.go @@ -18,6 +18,11 @@ type CandleValue struct { Close float64 } +// String returns a string value for the candle value. +func (cv CandleValue) String() string { + return fmt.Sprintf("candle %s high: %.2f low: %.2f open: %.2f close: %.2f", cv.Timestamp.Format("2006-01-02"), cv.High, cv.Low, cv.Open, cv.Close) +} + // IsZero returns if the value is zero or not. func (cv CandleValue) IsZero() bool { return cv.Timestamp.IsZero() @@ -26,10 +31,12 @@ func (cv CandleValue) IsZero() bool { // CandlestickSeries is a special type of series that takes a norma value provider // and maps it to day value stats (high, low, open, close). type CandlestickSeries struct { - Name string - Style Style - YAxis YAxisType - InnerSeries ValuesProvider + Name string + Style Style + YAxis YAxisType + + XValues []time.Time + YValues []float64 } // GetName implements Series.GetName. @@ -47,37 +54,45 @@ func (cs CandlestickSeries) GetYAxis() YAxisType { return cs.YAxis } +// Len returns the length of the series. +func (cs CandlestickSeries) Len() int { + return util.Math.MinInt(len(cs.XValues), len(cs.YValues)) +} + +// GetValues returns the values at a given index. +func (cs CandlestickSeries) GetValues(index int) (time.Time, float64) { + return cs.XValues[index], cs.YValues[index] +} + // CandleValues returns the candlestick values for each day represented by the inner series. func (cs CandlestickSeries) CandleValues() []CandleValue { - // for each "day" represented by the inner series - // compute the open (i.e. the first value at or near market open) - // compute the close (i.e. the last value at or near market close) - // compute the high, or the max - // compute the low, or the min - - totalValues := cs.InnerSeries.Len() + totalValues := cs.Len() if totalValues == 0 { return nil } - var value CandleValue var values []CandleValue var lastYear, lastMonth, lastDay int var year, month, day int - var tv float64 var t time.Time var lv, v float64 - tv, v = cs.InnerSeries.GetValues(0) - t = util.Time.FromFloat64(tv) + t, v = cs.GetValues(0) year, month, day = t.Year(), int(t.Month()), t.Day() - value.Timestamp = cs.newTimestamp(year, month, day) - value.Open, value.Low, value.High = v, v, v + + lastYear, lastMonth, lastDay = year, month, day + + value := CandleValue{ + Timestamp: cs.newTimestamp(year, month, day), + Open: v, + Low: v, + High: v, + } + lv = v for i := 1; i < totalValues; i++ { - tv, v = cs.InnerSeries.GetValues(i) - t = util.Time.FromFloat64(tv) + t, v = cs.GetValues(i) year, month, day = t.Year(), int(t.Month()), t.Day() // if we've transitioned to a new day or we're on the last value @@ -87,11 +102,18 @@ func (cs CandlestickSeries) CandleValues() []CandleValue { value = CandleValue{ Timestamp: cs.newTimestamp(year, month, day), + Open: v, + High: v, + Low: v, } - } - value.Low = math.Min(value.Low, v) - value.High = math.Max(value.Low, v) + lastYear = year + lastMonth = month + lastDay = day + } else { + value.Low = math.Min(value.Low, v) + value.High = math.Max(value.High, v) + } lv = v } @@ -104,14 +126,17 @@ func (cs CandlestickSeries) newTimestamp(year, month, day int) time.Time { // Render implements Series.Render. func (cs CandlestickSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - //style := cs.Style.InheritFrom(defaults) - //Draw.CandlestickSeries(r, canvasBox, xrange, yrange, style, cs) + style := cs.Style.InheritFrom(defaults) + Draw.CandlestickSeries(r, canvasBox, xrange, yrange, style, cs) } // Validate validates the series. func (cs CandlestickSeries) Validate() error { - if cs.InnerSeries == nil { - return fmt.Errorf("histogram series requires InnerSeries to be set") + if cs.XValues == nil { + return fmt.Errorf("candlestick series requires `XValues` to be set") + } + if cs.YValues == nil { + return fmt.Errorf("candlestick series requires `YValues` to be set") } return nil } diff --git a/candlestick_series_test.go b/candlestick_series_test.go index aeb8165..204c6b7 100644 --- a/candlestick_series_test.go +++ b/candlestick_series_test.go @@ -10,21 +10,28 @@ import ( ) func generateDummyStockData() (times []time.Time, prices []float64) { - start := util.Date.On(time.Date(2017, 05, 15, 6, 30, 0, 0, util.Date.Eastern()), util.NYSEOpen()) - var cursor time.Time + start := util.Date.On(util.NYSEOpen(), time.Date(2017, 05, 15, 0, 0, 0, 0, util.Date.Eastern())) + cursor := start for day := 0; day < 60; day++ { - cursor = start.AddDate(0, 0, day) + + if util.Date.IsWeekendDay(cursor.Weekday()) { + cursor = start.AddDate(0, 0, day) + continue + } + for hour := 0; hour < 7; hour++ { for minute := 0; minute < 60; minute++ { times = append(times, cursor) prices = append(prices, rand.Float64()*256) - cursor = cursor.Add(time.Minute) } cursor = cursor.Add(time.Hour) } + + cursor = start.AddDate(0, 0, day) } + return } @@ -34,12 +41,10 @@ func TestCandlestickSeriesCandleValues(t *testing.T) { xdata, ydata := generateDummyStockData() candleSeries := CandlestickSeries{ - InnerSeries: TimeSeries{ - XValues: xdata, - YValues: ydata, - }, + XValues: xdata, + YValues: ydata, } values := candleSeries.CandleValues() - assert.NotEmpty(values) + assert.Len(values, 43) // should be 60 days per the generator. } diff --git a/draw.go b/draw.go index ef79dc6..0550a3e 100644 --- a/draw.go +++ b/draw.go @@ -168,6 +168,60 @@ func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s } } +func (d draw) CandlestickSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, cs CandlestickSeries) { + if cs.Len() == 0 { + return + } + + candleValues := cs.CandleValues() + + //calculate bar width? + seriesLength := len(candleValues) + barWidth := int(math.Floor(float64(xrange.GetDomain()) / float64(seriesLength))) + + bw2 := barWidth >> 1 + + cb := canvasBox.Bottom + cl := canvasBox.Left + + var cv CandleValue + for index := 0; index < seriesLength; index++ { + cv = candleValues[index] + + y0 := yrange.Translate(cv.Open) + y1 := yrange.Translate(cv.Close) + + x := cl + xrange.Translate(util.Time.ToFloat64(cv.Timestamp)) + + // draw open / close box. + if cv.Open < cv.Close { + d.Box(r, Box{ + Top: cb - y0, + Left: x - bw2, + Right: x + bw2, + Bottom: cb - y1, + }, style.InheritFrom(Style{FillColor: ColorAlternateGreen})) + } else { + d.Box(r, Box{ + Top: cb - y1, + Left: x - bw2, + Right: x + bw2, + Bottom: cb - y0, + }, style.InheritFrom(Style{FillColor: ColorRed})) + } + + // draw high / low t bars + y0 = yrange.Translate(cv.High) + y1 = yrange.Translate(cv.Low) + + style.InheritFrom(Style{StrokeColor: DefaultStrokeColor}).WriteToRenderer(r) + + r.MoveTo(x, cb-y0) + r.LineTo(x, cb-y1) + r.Stroke() + } +} + // MeasureAnnotation measures how big an annotation would be. func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box { style.WriteToRenderer(r) diff --git a/util/time_test.go b/util/time_test.go new file mode 100644 index 0000000..dff108b --- /dev/null +++ b/util/time_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" +) + +func TestTimeFromFloat64(t *testing.T) { + assert := assert.New(t) + + now := time.Now() + + assert.InTimeDelta(now, Time.FromFloat64(Time.ToFloat64(now)), time.Microsecond) +} diff --git a/value_provider.go b/value_provider.go index e93c30d..c141a07 100644 --- a/value_provider.go +++ b/value_provider.go @@ -5,7 +5,7 @@ import "github.com/wcharczuk/go-chart/drawing" // ValuesProvider is a type that produces values. type ValuesProvider interface { Len() int - GetValues(index int) (float64, float64) + GetValues(index int) (x float64, y float64) } // BoundedValuesProvider allows series to return a range.