diff --git a/pkg/bbgo/config.go b/pkg/bbgo/config.go index bec0fe30b0..ce2565cc57 100644 --- a/pkg/bbgo/config.go +++ b/pkg/bbgo/config.go @@ -359,6 +359,10 @@ type EnvironmentConfig struct { SyncBufferPeriod *types.Duration `json:"syncBufferPeriod"` } +type ChartConfig struct { + Kind string `json:"kind" yaml:"kind"` // kline, supertrend, etc + Options bbgochart.PanelOptions `json:"options" yaml:"options"` +} type Config struct { Build *BuildConfig `json:"build,omitempty" yaml:"build,omitempty"` @@ -391,7 +395,7 @@ type Config struct { ProfilingConfig *ProfilingConfig `json:"profiling,omitempty" yaml:"profiling,omitempty"` - ChartConfig *bbgochart.PanelOptions `json:"chart,omitempty" yaml:"chart,omitempty"` + ChartConfig []ChartConfig `json:"chart,omitempty" yaml:"chart,omitempty"` } func (c *Config) Map() (map[string]interface{}, error) { diff --git a/pkg/chart/v1/indicator_series.go b/pkg/chart/v1/indicator_series.go index f0f136ba28..29030e4c7b 100644 --- a/pkg/chart/v1/indicator_series.go +++ b/pkg/chart/v1/indicator_series.go @@ -16,15 +16,15 @@ var ( type LegendKind string var ( - LegendTop = LegendKind("legend_top") - LegendThin = LegendKind("legend_thin") - LegendLeft = LegendKind("legend_left") + LegendTop = LegendKind("top") + LegendThin = LegendKind("thin") + LegendLeft = LegendKind("left") ) type IndicatorSeries interface { chart.Series - SetLegend(kind LegendKind) - GetLegend() *LegendKind + GetTimeRange() (time.Time, time.Time) + GetValueRange() (float64, float64) } type BandSample struct { @@ -35,16 +35,34 @@ type BandIndicatorSeries struct { Name string Options *PanelOptions - samples []BandSample - legendKind *LegendKind + samples []BandSample } -func (bs *BandIndicatorSeries) SetLegend(kind LegendKind) { - bs.legendKind = &kind +func (bs *BandIndicatorSeries) GetTimeRange() (time.Time, time.Time) { + if len(bs.samples) == 0 { + return time.Time{}, time.Time{} + } + return bs.samples[0].Time, bs.samples[len(bs.samples)-1].Time } -func (bs *BandIndicatorSeries) GetLegend() *LegendKind { - return bs.legendKind +func (bs *BandIndicatorSeries) GetValueRange() (float64, float64) { + if len(bs.samples) == 0 { + return 0., 0. + } + minValue, maxValue := 0.0, 0.0 + for _, s := range bs.samples { + if s.Value == nil { + continue + } + if minValue == 0.0 && maxValue == 0.0 { + minValue = *s.Value + maxValue = *s.Value + } else if s.Value != nil { + minValue = min(minValue, *s.Value) + maxValue = max(maxValue, *s.Value) + } + } + return minValue, maxValue } func NewBandIndicatorSeries(name string, samples []BandSample, options *PanelOptions) *BandIndicatorSeries { @@ -135,9 +153,8 @@ type PointSample struct { type LineIndicatorSeries struct { Name string - points []PointSample - options *PanelOptions - legendKind *LegendKind + points []PointSample + options *PanelOptions } func NewLineIndicatorSeries(name string, points []PointSample, options *PanelOptions) *LineIndicatorSeries { @@ -153,12 +170,31 @@ func NewLineIndicatorSeries(name string, points []PointSample, options *PanelOpt } } -func (ls *LineIndicatorSeries) SetLegend(kind LegendKind) { - ls.legendKind = &kind +func (ls *LineIndicatorSeries) GetTimeRange() (time.Time, time.Time) { + if len(ls.points) == 0 { + return time.Time{}, time.Time{} + } + return ls.points[0].Time, ls.points[len(ls.points)-1].Time } -func (ls *LineIndicatorSeries) GetLegend() *LegendKind { - return ls.legendKind +func (ls *LineIndicatorSeries) GetValueRange() (float64, float64) { + if len(ls.points) == 0 { + return 0., 0. + } + minValue, maxValue := 0.0, 0.0 + for _, p := range ls.points { + if p.Value == nil { + continue + } + if minValue == 0.0 && maxValue == 0.0 { + minValue = *p.Value + maxValue = *p.Value + } else if p.Value != nil { + minValue = min(minValue, *p.Value) + maxValue = max(maxValue, *p.Value) + } + } + return minValue, maxValue } func (ls *LineIndicatorSeries) AddPoints(points ...PointSample) { diff --git a/pkg/chart/v1/panel.go b/pkg/chart/v1/panel.go index 571060e3e5..0d82fdc5d9 100644 --- a/pkg/chart/v1/panel.go +++ b/pkg/chart/v1/panel.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "sort" + "time" "github.com/c9s/bbgo/pkg/types" "github.com/wcharczuk/go-chart/v2" @@ -12,14 +13,13 @@ import ( type Panel struct { *chart.Chart - Name string Options *PanelOptions klines []types.KLine indicators []IndicatorSeries } -func NewPanel(name string, options *PanelOptions) *Panel { +func NewPanel(options *PanelOptions) *Panel { if options == nil { options = &PanelOptions{ IncludeVolume: true, @@ -30,9 +30,7 @@ func NewPanel(name string, options *PanelOptions) *Panel { } return &Panel{ - Chart: &chart.Chart{}, - - Name: name, + Chart: &chart.Chart{}, Options: options, } } @@ -48,78 +46,123 @@ func (p *Panel) AddKLines(klines []types.KLine) { } func (p *Panel) draw() { + var xAxis chart.XAxis + var yAxis chart.YAxis candles := ConvertKLinesToCandles(p.klines) - if len(candles) == 0 { - logger.Warn("no candles to plot") - return + + if len(candles) > 0 { + // setup x and y axis based on klines + symbol := p.klines[0].Symbol + interval := p.klines[0].Interval + + // Calculate min and max values for price and volume + minY, maxY := FindPriceRange(candles) + maxY = maxY * (1 + p.Options.RangePadding) + minY = minY * (1 - p.Options.RangePadding) + + minTime := candles[0].Time + maxTime := candles[len(candles)-1].Time + padDuration := interval.Duration() + xAxis = chart.XAxis{ + ValueFormatter: chart.TimeValueFormatterWithFormat("01/02 15:04"), + Range: &chart.ContinuousRange{ + Min: chart.TimeToFloat64(minTime.Add(-padDuration)), + Max: chart.TimeToFloat64(maxTime.Add(padDuration)), + }, + } + yAxis = chart.YAxis{ + Range: &chart.ContinuousRange{ + Min: minY, + Max: maxY, + }, + } + + if p.Options.Title == "" { + p.Title = fmt.Sprintf("%s %s (%s ~ %s)", + symbol, + interval.String(), + minTime.Format("01/02 15:04"), + maxTime.Format("01/02 15:04"), + ) + } else { + p.Title = p.Options.Title + } + } else { + // find time range and value range from indicators + var minTime, maxTime time.Time + var minY, maxY float64 + + for _, ind := range p.indicators { + indMinTime, indMaxTime := ind.GetTimeRange() + indMinValue, indMaxValue := ind.GetValueRange() + if minTime.IsZero() || indMinTime.Before(minTime) { + minTime = indMinTime + } + if maxTime.IsZero() || indMaxTime.After(maxTime) { + maxTime = indMaxTime + } + if minY == 0.0 || indMinValue < minY { + minY = indMinValue + } + if maxY == 0.0 || indMaxValue > maxY { + maxY = indMaxValue + } + } + + p.Title = p.Options.Title + xAxis = chart.XAxis{ + ValueFormatter: chart.TimeValueFormatterWithFormat("01/02 15:04"), + Range: &chart.ContinuousRange{ + Min: chart.TimeToFloat64(minTime) - p.Options.XAxisPadding, + Max: chart.TimeToFloat64(maxTime) + p.Options.XAxisPadding, + }, + } + yAxis = chart.YAxis{ + Range: &chart.ContinuousRange{ + Min: minY * (1 - p.Options.RangePadding), + Max: maxY * (1 + p.Options.RangePadding), + }, + } } - symbol := p.klines[0].Symbol - interval := p.klines[0].Interval - - // Calculate min and max values for price and volume - minY, maxY := FindPriceRange(candles) - maxY = maxY * (1 + p.Options.RangePadding) - minY = minY * (1 - p.Options.RangePadding) - - padDuration := interval.Duration() - startTime := candles[0].Time - endTime := candles[len(candles)-1].Time - p.Title = fmt.Sprintf("%s %s (%s ~ %s)", - symbol, - interval.String(), - startTime.Format("01/02 15:04"), - endTime.Format("01/02 15:04"), - ) p.Width = p.Options.Width p.Height = p.Options.Height - p.XAxis = chart.XAxis{ - ValueFormatter: chart.TimeValueFormatterWithFormat("01/02 15:04"), - Range: &chart.ContinuousRange{ - Min: chart.TimeToFloat64(candles[0].Time.Add(-padDuration)), - Max: chart.TimeToFloat64(candles[len(candles)-1].Time.Add(padDuration)), - }, - } - p.YAxis = chart.YAxis{ - Range: &chart.ContinuousRange{ - Min: minY, - Max: maxY, - }, - } - p.Series = []chart.Series{ - CandlestickSeries{ - Candles: candles, - }, + p.XAxis = xAxis + p.YAxis = yAxis + + // add series + if len(p.klines) > 0 { + p.Series = append(p.Series, CandlestickSeries{Candles: candles}) + if p.Options != nil && p.Options.IncludeVolume { + // draw volume + minVolume, maxVolume := FindVolumeRange(candles) + maxVolume = maxVolume * 3 + + yMinAdj := p.YAxis.Range.GetMin() - p.YAxis.Range.GetDelta()*0.6 + yAxis.Range.SetMin(yMinAdj) + p.YAxisSecondary = chart.YAxis{ + Range: &chart.ContinuousRange{ + Min: minVolume, + Max: maxVolume, + }, + } + p.Series = append(p.Series, VolumeSeries{Candles: candles}) + } } for _, ind := range p.indicators { p.Series = append(p.Series, ind) - legendKind := ind.GetLegend() - if legendKind != nil { - switch *legendKind { - case LegendTop: - p.Elements = append(p.Elements, chart.Legend(p.Chart)) - case LegendLeft: - p.Elements = append(p.Elements, chart.LegendLeft(p.Chart)) - case LegendThin: - p.Elements = append(p.Elements, chart.LegendThin(p.Chart)) - default: - p.Elements = append(p.Elements, chart.Legend(p.Chart)) - logger.Warnf("unknown legend kind %s, use default legend top", *legendKind) - } - } } - - if p.Options != nil && p.Options.IncludeVolume { - minVolume, maxVolume := FindVolumeRange(candles) - maxVolume = maxVolume * 3 - - p.YAxis.Range.SetMin(minY - (maxY-minY)*0.6) - p.Series = append(p.Series, VolumeSeries{Candles: candles}) - p.YAxisSecondary = chart.YAxis{ - Range: &chart.ContinuousRange{ - Min: minVolume, - Max: maxVolume, - }, + if p.Options.Legend != nil { + switch *p.Options.Legend { + case LegendTop: + p.Elements = append(p.Elements, chart.Legend(p.Chart)) + case LegendLeft: + p.Elements = append(p.Elements, chart.LegendLeft(p.Chart)) + case LegendThin: + p.Elements = append(p.Elements, chart.LegendThin(p.Chart)) + default: + p.Elements = append(p.Elements, chart.Legend(p.Chart)) + logger.Warnf("unknown legend kind %s, use default legend top", *p.Options.Legend) } } } @@ -134,15 +177,27 @@ func (p *Panel) Write(w io.Writer) error { } type PanelOptions struct { - IncludeVolume bool `json:"include_volume" yaml:"include_volume"` - RangePadding float64 `json:"range_padding" yaml:"range_padding"` - Width int `json:"width" yaml:"width"` - Height int `json:"height" yaml:"height"` + // general options + Title string `json:"title,omitempty" yaml:"title,omitempty"` + RangePadding float64 `json:"range_padding" yaml:"range_padding"` + XAxisPadding float64 `json:"x_axis_padding" yaml:"x_axis_padding"` + Width int `json:"width" yaml:"width"` + Height int `json:"height" yaml:"height"` + Legend *LegendKind `json:"legend" yaml:"legend"` + + // kline options + IncludeVolume bool `json:"include_volume" yaml:"include_volume"` + + // indicators options + Window int `json:"window" yaml:"window"` // band indicators options UpperBoundColor string `json:"upper_bound_color" yaml:"upper_bound_color"` LowerBoundColor string `json:"lower_bound_color" yaml:"lower_bound_color"` ValueColor string `json:"value_color" yaml:"value_color"` + + // supertrend + Multiplier float64 `json:"multiplier" yaml:"multiplier"` } // ConvertKLinesToCandles converts a slice of KLine to a slice of Candle. diff --git a/pkg/cmd/chart.go b/pkg/cmd/chart.go index a9f98a5006..048a3623cd 100644 --- a/pkg/cmd/chart.go +++ b/pkg/cmd/chart.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "os" + "sort" "time" "github.com/c9s/bbgo/pkg/bbgo" bbgochart "github.com/c9s/bbgo/pkg/chart/v1" + indicatorv2 "github.com/c9s/bbgo/pkg/indicator/v2" "github.com/c9s/bbgo/pkg/types" "github.com/spf13/cobra" ) @@ -97,22 +99,160 @@ func chart(cmd *cobra.Command, args []string) error { EndTime: &endTime, }, ) + // sort klines by start time, old -> new + sort.Slice(klines, func(i, j int) bool { + return klines[i].StartTime.Before(klines[j].StartTime.Time()) + }) + if err != nil { return fmt.Errorf("query klines error: %w", err) } - f, err := os.Create("klines.png") + var errDraw error + for _, chartConfig := range userConfig.ChartConfig { + switch chartConfig.Kind { + case "kline": + errDraw = drawKLineChart(klines, &chartConfig) + case "supertrend": + errDraw = drawSuperTrendChart(klines, &chartConfig) + case "atr": + errDraw = drawAtrChar(klines, &chartConfig) + } + if errDraw != nil { + return errDraw + } + } + return nil +} + +func drawKLineChart(klines []types.KLine, config *bbgo.ChartConfig) error { + panel := bbgochart.NewPanel(&config.Options) + panel.AddKLines(klines) + + var fname string + if config.Options.Title != "" { + fname = config.Options.Title + ".png" + } else { + fname = fmt.Sprintf("%s_%s_%s.png", + klines[0].Symbol, + klines[0].Interval, + time.Now().Format("2006-01-02_150405"), + ) + } + f, err := os.Create(fname) defer func() { if cerr := f.Close(); cerr != nil { fmt.Printf("failed to close file: %v\n", cerr) } }() + if err != nil { + return err + } + return panel.Write(f) +} + +func drawSuperTrendChart(klines []types.KLine, config *bbgo.ChartConfig) error { + klineStream := indicatorv2.KLineStream{} + superTrendStream := indicatorv2.SuperTrend( + &klineStream, + config.Options.Window, + config.Options.Multiplier, + ) + klineStream.BackFill(klines) + + var samples []bbgochart.BandSample + for _, e := range superTrendStream.Entities() { + sample := bbgochart.BandSample{ + Time: e.Time, + Value: nil, + } + v := e.Value() + if e.Direction == 1 { + sample.LowerBound = &v + } else { + sample.UpperBound = &v + } + samples = append(samples, sample) + } + series := bbgochart.NewBandIndicatorSeries( + fmt.Sprintf( + "SuperTrend(w%d, m%.2f)", + config.Options.Window, config.Options.Multiplier), + samples, + &config.Options, + ) + + panel := bbgochart.NewPanel(&config.Options) + panel.AddKLines(klines) + panel.AddIndicator(series) + + var fname string + if config.Options.Title != "" { + fname = config.Options.Title + ".png" + } else { + fname = fmt.Sprintf("%s_%s_%s.png", + klines[0].Symbol, + klines[0].Interval, + time.Now().Format("2006-01-02_150405"), + ) + } + f, err := os.Create(fname) + defer func() { + if cerr := f.Close(); cerr != nil { + fmt.Printf("failed to close file: %v\n", cerr) + } + }() if err != nil { return err } - graph := bbgochart.NewPanel("klines", userConfig.ChartConfig) - graph.AddKLines(klines) - return graph.Write(f) + return panel.Write(f) +} + +func drawAtrChar(klines []types.KLine, config *bbgo.ChartConfig) error { + klineStream := indicatorv2.KLineStream{} + atrStream := indicatorv2.ATR2( + &klineStream, config.Options.Window, + ) + klineStream.BackFill(klines) + + var points []bbgochart.PointSample + for i, v := range atrStream.Slice { + startTime := klines[i].StartTime + points = append(points, bbgochart.PointSample{ + Time: startTime.Time(), + Value: &v, + }) + } + series := bbgochart.NewLineIndicatorSeries( + fmt.Sprintf("ATR(w%d)", config.Options.Window), + points, + &config.Options, + ) + + panel := bbgochart.NewPanel(&config.Options) + panel.AddIndicator(series) + + var fname string + if config.Options.Title != "" { + fname = config.Options.Title + ".png" + } else { + fname = fmt.Sprintf("ATR_%s_%s_w%d_%s.png", + klines[0].Symbol, + klines[0].Interval, + config.Options.Window, + time.Now().Format("2006-01-02_150405"), + ) + } + f, err := os.Create(fname) + defer func() { + if cerr := f.Close(); cerr != nil { + fmt.Printf("failed to close file: %v\n", cerr) + } + }() + if err != nil { + return err + } + return panel.Write(f) } diff --git a/pkg/indicator/v2/supertrend.go b/pkg/indicator/v2/supertrend.go index be48fd6af1..05bae982b5 100644 --- a/pkg/indicator/v2/supertrend.go +++ b/pkg/indicator/v2/supertrend.go @@ -94,6 +94,10 @@ func (st *SuperTrendStream) update(kline types.KLine) { st.PushAndEmit(e.Value()) } +func (st *SuperTrendStream) Entities() []*SuperTrendBand { + return st.entities +} + func SuperTrend(source KLineSubscription, window int, multiplier float64) *SuperTrendStream { atr := ATR2(source, window) st := &SuperTrendStream{