fix: drift bias on long entry position condition, make cancel faster

This commit is contained in:
zenix 2022-07-14 12:46:19 +09:00
parent 55704fdd21
commit f2d37650a5
5 changed files with 153 additions and 110 deletions

View File

@ -16,6 +16,8 @@ exchangeStrategies:
interval: 15m interval: 15m
window: 3 window: 3
stoploss: 2% stoploss: 2%
source: hl2
predictOffset: 5
#exits: #exits:
#- roiStopLoss: #- roiStopLoss:
# percentage: 0.8% # percentage: 0.8%
@ -48,7 +50,7 @@ sync:
- ETHUSDT - ETHUSDT
backtest: backtest:
startTime: "2022-04-01" startTime: "2022-01-01"
endTime: "2022-06-18" endTime: "2022-06-18"
symbols: symbols:
- ETHUSDT - ETHUSDT

View File

@ -114,6 +114,9 @@ func (e *GeneralOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ..
// GracefulCancelActiveOrderBook cancels the orders from the active orderbook. // GracefulCancelActiveOrderBook cancels the orders from the active orderbook.
func (e *GeneralOrderExecutor) GracefulCancelActiveOrderBook(ctx context.Context, activeOrders *ActiveOrderBook) error { func (e *GeneralOrderExecutor) GracefulCancelActiveOrderBook(ctx context.Context, activeOrders *ActiveOrderBook) error {
if activeOrders.NumOfOrders() == 0 {
return nil
}
if err := activeOrders.GracefulCancel(ctx, e.session.Exchange); err != nil { if err := activeOrders.GracefulCancel(ctx, e.session.Exchange); err != nil {
return fmt.Errorf("graceful cancel order error: %w", err) return fmt.Errorf("graceful cancel order error: %w", err)
} }

View File

@ -15,7 +15,7 @@ type Drift struct {
types.IntervalWindow types.IntervalWindow
chng *types.Queue chng *types.Queue
Values types.Float64Slice Values types.Float64Slice
SMA *SMA MA types.UpdatableSeriesExtend
LastValue float64 LastValue float64
UpdateCallbacks []func(value float64) UpdateCallbacks []func(value float64)
@ -24,7 +24,9 @@ type Drift struct {
func (inc *Drift) Update(value float64) { func (inc *Drift) Update(value float64) {
if inc.chng == nil { if inc.chng == nil {
inc.SeriesBase.Series = inc inc.SeriesBase.Series = inc
inc.SMA = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window}} if inc.MA == nil {
inc.MA = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window}}
}
inc.chng = types.NewQueue(inc.Window) inc.chng = types.NewQueue(inc.Window)
inc.LastValue = value inc.LastValue = value
return return
@ -36,11 +38,11 @@ func (inc *Drift) Update(value float64) {
chng = math.Log(value / inc.LastValue) chng = math.Log(value / inc.LastValue)
inc.LastValue = value inc.LastValue = value
} }
inc.SMA.Update(chng) inc.MA.Update(chng)
inc.chng.Update(chng) inc.chng.Update(chng)
if inc.chng.Length() >= inc.Window { if inc.chng.Length() >= inc.Window {
stdev := types.Stdev(inc.chng, inc.Window) stdev := types.Stdev(inc.chng, inc.Window)
drift := inc.SMA.Last() - stdev*stdev*0.5 drift := inc.MA.Last() - stdev*stdev*0.5
inc.Values.Push(drift) inc.Values.Push(drift)
} }
} }
@ -50,7 +52,7 @@ func (inc *Drift) Clone() (out *Drift) {
IntervalWindow: inc.IntervalWindow, IntervalWindow: inc.IntervalWindow,
chng: inc.chng.Clone(), chng: inc.chng.Clone(),
Values: inc.Values[:], Values: inc.Values[:],
SMA: inc.SMA.Clone().(*SMA), MA: types.Clone(inc.MA),
LastValue: inc.LastValue, LastValue: inc.LastValue,
} }
out.SeriesBase.Series = out out.SeriesBase.Series = out

View File

@ -2,10 +2,14 @@ package drift
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"math"
"os" "os"
"strings"
"sync" "sync"
"github.com/fatih/color"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
@ -19,11 +23,17 @@ import (
const ID = "drift" const ID = "drift"
var log = logrus.WithField("strategy", ID) var log = logrus.WithField("strategy", ID)
var Four fixedpoint.Value = fixedpoint.NewFromInt(4)
var Three fixedpoint.Value = fixedpoint.NewFromInt(3)
var Two fixedpoint.Value = fixedpoint.NewFromInt(2)
var Delta fixedpoint.Value = fixedpoint.NewFromFloat(0.01)
func init() { func init() {
bbgo.RegisterStrategy(ID, &Strategy{}) bbgo.RegisterStrategy(ID, &Strategy{})
} }
type SourceFunc func(*types.KLine) fixedpoint.Value
type Strategy struct { type Strategy struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
@ -41,13 +51,30 @@ type Strategy struct {
midPrice fixedpoint.Value midPrice fixedpoint.Value
lock sync.RWMutex lock sync.RWMutex
Stoploss fixedpoint.Value `json:"stoploss"` Source string `json:"source"`
CanvasPath string `json:"canvasPath"` Stoploss fixedpoint.Value `json:"stoploss"`
CanvasPath string `json:"canvasPath"`
PredictOffset int `json:"predictOffset"`
ExitMethods bbgo.ExitMethodSet `json:"exits"` ExitMethods bbgo.ExitMethodSet `json:"exits"`
Session *bbgo.ExchangeSession Session *bbgo.ExchangeSession
*bbgo.GeneralOrderExecutor *bbgo.GeneralOrderExecutor
*bbgo.ActiveOrderBook
getLastPrice func() fixedpoint.Value
}
func (s *Strategy) Print() {
b, _ := json.MarshalIndent(s.ExitMethods, " ", " ")
hiyellow := color.New(color.FgHiYellow).FprintfFunc()
hiyellow(os.Stderr, "------ %s Settings ------\n", s.InstanceID())
hiyellow(os.Stderr, "canvasPath: %s\n", s.CanvasPath)
hiyellow(os.Stderr, "source: %s\n", s.Source)
hiyellow(os.Stderr, "stoploss: %v\n", s.Stoploss)
hiyellow(os.Stderr, "predictOffset: %d\n", s.PredictOffset)
hiyellow(os.Stderr, "exits:\n %s\n", string(b))
hiyellow(os.Stderr, "symbol: %s\n", s.Symbol)
hiyellow(os.Stderr, "interval: %s\n", s.Interval)
hiyellow(os.Stderr, "window: %d\n", s.Window)
} }
func (s *Strategy) ID() string { func (s *Strategy) ID() string {
@ -72,35 +99,6 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {
s.ExitMethods.SetAndSubscribe(session, s) s.ExitMethods.SetAndSubscribe(session, s)
} }
var Three fixedpoint.Value = fixedpoint.NewFromInt(3)
var Two fixedpoint.Value = fixedpoint.NewFromInt(2)
func (s *Strategy) GetLastPrice() (lastPrice fixedpoint.Value) {
var ok bool
if s.Environment.IsBackTesting() {
lastPrice, ok = s.Session.LastPrice(s.Symbol)
if !ok {
log.Error("cannot get lastprice")
return lastPrice
}
} else {
s.lock.RLock()
if s.midPrice.IsZero() {
lastPrice, ok = s.Session.LastPrice(s.Symbol)
if !ok {
log.Error("cannot get lastprice")
return lastPrice
}
} else {
lastPrice = s.midPrice
}
s.lock.RUnlock()
}
return lastPrice
}
var Delta fixedpoint.Value = fixedpoint.NewFromFloat(0.01)
func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) { func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) {
order := s.Position.NewMarketCloseOrder(fixedpoint.One) order := s.Position.NewMarketCloseOrder(fixedpoint.One)
if order == nil { if order == nil {
@ -109,7 +107,7 @@ func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) {
order.TimeInForce = "" order.TimeInForce = ""
balances := s.Session.GetAccount().Balances() balances := s.Session.GetAccount().Balances()
baseBalance := balances[s.Market.BaseCurrency].Available baseBalance := balances[s.Market.BaseCurrency].Available
price := s.GetLastPrice() price := s.getLastPrice()
if order.Side == types.SideTypeBuy { if order.Side == types.SideTypeBuy {
quoteAmount := balances[s.Market.QuoteCurrency].Available.Div(price) quoteAmount := balances[s.Market.QuoteCurrency].Available.Div(price)
if order.Quantity.Compare(quoteAmount) > 0 { if order.Quantity.Compare(quoteAmount) > 0 {
@ -131,6 +129,38 @@ func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) {
} }
} }
func (s *Strategy) SourceFuncGenerator() SourceFunc {
switch strings.ToLower(s.Source) {
case "close":
return func(kline *types.KLine) fixedpoint.Value { return kline.Close }
case "high":
return func(kline *types.KLine) fixedpoint.Value { return kline.High }
case "low":
return func(kline *types.KLine) fixedpoint.Value { return kline.Low }
case "hl2":
return func(kline *types.KLine) fixedpoint.Value {
return kline.High.Add(kline.Low).Div(Two)
}
case "hlc3":
return func(kline *types.KLine) fixedpoint.Value {
return kline.High.Add(kline.Low).Add(kline.Close).Div(Three)
}
case "ohlc4":
return func(kline *types.KLine) fixedpoint.Value {
return kline.Open.Add(kline.High).Add(kline.Low).Add(kline.Close).Div(Four)
}
case "open":
return func(kline *types.KLine) fixedpoint.Value { return kline.Open }
case "":
return func(kline *types.KLine) fixedpoint.Value {
log.Infof("source not set, use hl2 by default")
return kline.High.Add(kline.Low).Div(Two)
}
default:
panic(fmt.Sprintf("Unable to parse: %s", s.Source))
}
}
func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
instanceID := s.InstanceID() instanceID := s.InstanceID()
if s.Position == nil { if s.Position == nil {
@ -168,18 +198,15 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
for _, method := range s.ExitMethods { for _, method := range s.ExitMethods {
method.Bind(session, s.GeneralOrderExecutor) method.Bind(session, s.GeneralOrderExecutor)
} }
s.ActiveOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
s.ActiveOrderBook.BindStream(session.UserDataStream)
store, _ := session.MarketDataStore(s.Symbol) store, _ := session.MarketDataStore(s.Symbol)
getSource := func(kline *types.KLine) fixedpoint.Value { getSource := s.SourceFuncGenerator()
//return kline.High.Add(kline.Low).Div(Two)
//return kline.Close
return kline.High.Add(kline.Low).Add(kline.Close).Div(Three)
}
s.drift = &indicator.Drift{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.Window}} s.drift = &indicator.Drift{
MA: &indicator.SMA{IntervalWindow: s.IntervalWindow},
IntervalWindow: s.IntervalWindow,
}
s.atr = &indicator.ATR{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 14}} s.atr = &indicator.ATR{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 14}}
klines, ok := store.KLinesOfInterval(s.Interval) klines, ok := store.KLinesOfInterval(s.Interval)
@ -187,33 +214,57 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
log.Errorf("klines not exists") log.Errorf("klines not exists")
return nil return nil
} }
dynamicKLine := &types.KLine{}
for _, kline := range *klines { for _, kline := range *klines {
source := getSource(&kline).Float64() source := getSource(&kline).Float64()
s.drift.Update(source) s.drift.Update(source)
s.atr.Update(kline.High.Float64(), kline.Low.Float64(), kline.Close.Float64()) s.atr.Update(kline.High.Float64(), kline.Low.Float64(), kline.Close.Float64())
} }
session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) { if s.Environment.IsBackTesting() {
if s.Environment.IsBackTesting() { s.getLastPrice = func() fixedpoint.Value {
return lastPrice, ok := s.Session.LastPrice(s.Symbol)
} if !ok {
bestBid := ticker.Buy log.Error("cannot get lastprice")
bestAsk := ticker.Sell
if util.TryLock(&s.lock) {
if !bestAsk.IsZero() && !bestBid.IsZero() {
s.midPrice = bestAsk.Add(bestBid).Div(types.Two)
} else if !bestAsk.IsZero() {
s.midPrice = bestAsk
} else {
s.midPrice = bestBid
} }
s.lock.Unlock() return lastPrice
} }
}) } else {
session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) {
bestBid := ticker.Buy
bestAsk := ticker.Sell
if util.TryLock(&s.lock) {
if !bestAsk.IsZero() && !bestBid.IsZero() {
s.midPrice = bestAsk.Add(bestBid).Div(Two)
} else if !bestAsk.IsZero() {
s.midPrice = bestAsk
} else {
s.midPrice = bestBid
}
s.lock.Unlock()
}
})
s.getLastPrice = func() (lastPrice fixedpoint.Value) {
var ok bool
s.lock.RLock()
if s.midPrice.IsZero() {
lastPrice, ok = s.Session.LastPrice(s.Symbol)
if !ok {
log.Error("cannot get lastprice")
return lastPrice
}
} else {
lastPrice = s.midPrice
}
s.lock.RUnlock()
return lastPrice
}
}
dynamicKLine := &types.KLine{}
priceLine := types.NewQueue(100) priceLine := types.NewQueue(100)
stoploss := s.Stoploss.Float64()
session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { session.MarketDataStream.OnKLineClosed(func(kline types.KLine) {
if s.Status != types.StrategyStatusRunning { if s.Status != types.StrategyStatusRunning {
@ -236,15 +287,13 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
source := getSource(dynamicKLine) source := getSource(dynamicKLine)
sourcef := source.Float64() sourcef := source.Float64()
priceLine.Update(sourcef) priceLine.Update(sourcef)
dynamicKLine.Closed = false
s.drift.Update(sourcef) s.drift.Update(sourcef)
drift = s.drift.Array(2) drift = s.drift.Array(2)
driftPred = s.drift.Predict(3) driftPred = s.drift.Predict(s.PredictOffset)
atr = s.atr.Last() atr = s.atr.Last()
price := s.GetLastPrice() price := s.getLastPrice()
pricef := price.Float64() pricef := price.Float64()
avg := s.Position.AverageCost.Float64() avg := s.Position.AverageCost.Float64()
stoploss := s.Stoploss.Float64()
shortCondition := (driftPred <= 0 && drift[0] <= 0) shortCondition := (driftPred <= 0 && drift[0] <= 0)
longCondition := (driftPred >= 0 && drift[0] >= 0) longCondition := (driftPred >= 0 && drift[0] >= 0)
@ -253,30 +302,17 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
exitLongCondition := ((drift[1] > 0 && drift[0] < 0) || avg-atr/2 >= pricef || avg*(1.-stoploss) >= pricef) && exitLongCondition := ((drift[1] > 0 && drift[0] < 0) || avg-atr/2 >= pricef || avg*(1.-stoploss) >= pricef) &&
(!s.Position.IsClosed() && !s.Position.IsDust(fixedpoint.Min(price, source))) && !shortCondition (!s.Position.IsClosed() && !s.Position.IsDust(fixedpoint.Min(price, source))) && !shortCondition
if exitShortCondition { if exitShortCondition || exitLongCondition {
if s.ActiveOrderBook.NumOfOrders() > 0 { if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil { log.WithError(err).Errorf("cannot cancel orders")
log.WithError(err).Errorf("cannot cancel orders") return
return
}
}
_, _ = s.ClosePosition(ctx)
}
if exitLongCondition {
if s.ActiveOrderBook.NumOfOrders() > 0 {
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil {
log.WithError(err).Errorf("cannot cancel orders")
return
}
} }
_, _ = s.ClosePosition(ctx) _, _ = s.ClosePosition(ctx)
} }
if shortCondition { if shortCondition {
if s.ActiveOrderBook.NumOfOrders() > 0 { if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil { log.WithError(err).Errorf("cannot cancel orders")
log.WithError(err).Errorf("cannot cancel orders") return
return
}
} }
baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency)
if !ok { if !ok {
@ -295,7 +331,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
Side: types.SideTypeSell, Side: types.SideTypeSell,
Type: types.OrderTypeLimitMaker, Type: types.OrderTypeLimitMaker,
Price: source, Price: source,
StopPrice: fixedpoint.NewFromFloat(sourcef + atr/2), StopPrice: fixedpoint.NewFromFloat(math.Min(sourcef+atr/2, sourcef*(1.+stoploss))),
Quantity: baseBalance.Available, Quantity: baseBalance.Available,
}) })
if err != nil { if err != nil {
@ -304,11 +340,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
} }
} }
if longCondition { if longCondition {
if s.ActiveOrderBook.NumOfOrders() > 0 { if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil { log.WithError(err).Errorf("cannot cancel orders")
log.WithError(err).Errorf("cannot cancel orders") return
return
}
} }
if source.Compare(price) > 0 { if source.Compare(price) > 0 {
source = price source = price
@ -322,15 +356,12 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
quoteBalance.Available.Div(source), source) { quoteBalance.Available.Div(source), source) {
return return
} }
if !s.Position.IsClosed() && !s.Position.IsDust(source) {
return
}
_, err := s.GeneralOrderExecutor.SubmitOrders(ctx, types.SubmitOrder{ _, err := s.GeneralOrderExecutor.SubmitOrders(ctx, types.SubmitOrder{
Symbol: s.Symbol, Symbol: s.Symbol,
Side: types.SideTypeBuy, Side: types.SideTypeBuy,
Type: types.OrderTypeLimitMaker, Type: types.OrderTypeLimitMaker,
Price: source, Price: source,
StopPrice: fixedpoint.NewFromFloat(sourcef - atr/2), StopPrice: fixedpoint.NewFromFloat(math.Max(sourcef-atr/2, sourcef*(1.-stoploss))),
Quantity: quoteBalance.Available.Div(source), Quantity: quoteBalance.Available.Div(source),
}) })
if err != nil { if err != nil {
@ -342,8 +373,8 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) {
_, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String())
s.Print()
canvas := types.NewCanvas(s.InstanceID(), s.Interval) canvas := types.NewCanvas(s.InstanceID(), s.Interval)
fmt.Println(dynamicKLine.StartTime, dynamicKLine.EndTime)
mean := priceLine.Mean(100) mean := priceLine.Mean(100)
highestPrice := priceLine.Minus(mean).Highest(100) highestPrice := priceLine.Minus(mean).Highest(100)
highestDrift := s.drift.Highest(100) highestDrift := s.drift.Highest(100)

View File

@ -3,11 +3,11 @@ package types
import ( import (
"fmt" "fmt"
"math" "math"
"time"
"reflect" "reflect"
"time"
"gonum.org/v1/gonum/stat"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"gonum.org/v1/gonum/stat"
) )
// Super basic Series type that simply holds the float64 data // Super basic Series type that simply holds the float64 data
@ -46,8 +46,8 @@ func (inc *Queue) Length() int {
} }
func (inc *Queue) Clone() *Queue { func (inc *Queue) Clone() *Queue {
out := &Queue { out := &Queue{
arr: inc.arr[:], arr: inc.arr[:],
size: inc.size, size: inc.size,
} }
out.SeriesBase.Series = out out.SeriesBase.Series = out
@ -213,7 +213,7 @@ func Abs(a Series) SeriesExtend {
var _ Series = &AbsResult{} var _ Series = &AbsResult{}
func Predict(a Series, lookback int, offset ...int) float64 { func LinearRegression(a Series, lookback int) (alpha float64, beta float64) {
if a.Length() < lookback { if a.Length() < lookback {
lookback = a.Length() lookback = a.Length()
} }
@ -224,7 +224,12 @@ func Predict(a Series, lookback int, offset ...int) float64 {
x[i] = float64(i) x[i] = float64(i)
y[i] = a.Index(i) y[i] = a.Index(i)
} }
alpha, beta := stat.LinearRegression(x, y, weights, false) alpha, beta = stat.LinearRegression(x, y, weights, false)
return
}
func Predict(a Series, lookback int, offset ...int) float64 {
alpha, beta := LinearRegression(a, lookback)
o := -1.0 o := -1.0
if len(offset) > 0 { if len(offset) > 0 {
o = -float64(offset[0]) o = -float64(offset[0])
@ -1167,15 +1172,15 @@ type Canvas struct {
func NewCanvas(title string, interval Interval) *Canvas { func NewCanvas(title string, interval Interval) *Canvas {
valueFormatter := chart.TimeValueFormatter valueFormatter := chart.TimeValueFormatter
if interval.Minutes() > 24 * 60 { if interval.Minutes() > 24*60 {
valueFormatter = chart.TimeDateValueFormatter valueFormatter = chart.TimeDateValueFormatter
} else if interval.Minutes() > 60 { } else if interval.Minutes() > 60 {
valueFormatter = chart.TimeHourValueFormatter valueFormatter = chart.TimeHourValueFormatter
} else { } else {
valueFormatter = chart.TimeMinuteValueFormatter valueFormatter = chart.TimeMinuteValueFormatter
} }
out := &Canvas { out := &Canvas{
Chart: chart.Chart { Chart: chart.Chart{
Title: title, Title: title,
XAxis: chart.XAxis{ XAxis: chart.XAxis{
ValueFormatter: valueFormatter, ValueFormatter: valueFormatter,
@ -1193,11 +1198,11 @@ func (canvas *Canvas) Plot(tag string, a Series, endTime Time, length int) {
var timeline []time.Time var timeline []time.Time
e := endTime.Time() e := endTime.Time()
for i := length - 1; i >= 0; i-- { for i := length - 1; i >= 0; i-- {
shiftedT := e.Add(-time.Duration(i * canvas.Interval.Minutes()) * time.Minute) shiftedT := e.Add(-time.Duration(i*canvas.Interval.Minutes()) * time.Minute)
timeline = append(timeline, shiftedT) timeline = append(timeline, shiftedT)
} }
canvas.Series = append(canvas.Series, chart.TimeSeries{ canvas.Series = append(canvas.Series, chart.TimeSeries{
Name: tag, Name: tag,
YValues: Reverse(a, length), YValues: Reverse(a, length),
XValues: timeline, XValues: timeline,
}) })