mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-22 14:55:16 +00:00
fix: drift bias on long entry position condition, make cancel faster
This commit is contained in:
parent
55704fdd21
commit
f2d37650a5
|
@ -16,6 +16,8 @@ exchangeStrategies:
|
|||
interval: 15m
|
||||
window: 3
|
||||
stoploss: 2%
|
||||
source: hl2
|
||||
predictOffset: 5
|
||||
#exits:
|
||||
#- roiStopLoss:
|
||||
# percentage: 0.8%
|
||||
|
@ -48,7 +50,7 @@ sync:
|
|||
- ETHUSDT
|
||||
|
||||
backtest:
|
||||
startTime: "2022-04-01"
|
||||
startTime: "2022-01-01"
|
||||
endTime: "2022-06-18"
|
||||
symbols:
|
||||
- ETHUSDT
|
||||
|
|
|
@ -114,6 +114,9 @@ func (e *GeneralOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ..
|
|||
|
||||
// GracefulCancelActiveOrderBook cancels the orders from the active orderbook.
|
||||
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 {
|
||||
return fmt.Errorf("graceful cancel order error: %w", err)
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ type Drift struct {
|
|||
types.IntervalWindow
|
||||
chng *types.Queue
|
||||
Values types.Float64Slice
|
||||
SMA *SMA
|
||||
MA types.UpdatableSeriesExtend
|
||||
LastValue float64
|
||||
|
||||
UpdateCallbacks []func(value float64)
|
||||
|
@ -24,7 +24,9 @@ type Drift struct {
|
|||
func (inc *Drift) Update(value float64) {
|
||||
if inc.chng == nil {
|
||||
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.LastValue = value
|
||||
return
|
||||
|
@ -36,11 +38,11 @@ func (inc *Drift) Update(value float64) {
|
|||
chng = math.Log(value / inc.LastValue)
|
||||
inc.LastValue = value
|
||||
}
|
||||
inc.SMA.Update(chng)
|
||||
inc.MA.Update(chng)
|
||||
inc.chng.Update(chng)
|
||||
if inc.chng.Length() >= 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)
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +52,7 @@ func (inc *Drift) Clone() (out *Drift) {
|
|||
IntervalWindow: inc.IntervalWindow,
|
||||
chng: inc.chng.Clone(),
|
||||
Values: inc.Values[:],
|
||||
SMA: inc.SMA.Clone().(*SMA),
|
||||
MA: types.Clone(inc.MA),
|
||||
LastValue: inc.LastValue,
|
||||
}
|
||||
out.SeriesBase.Series = out
|
||||
|
|
|
@ -2,10 +2,14 @@ package drift
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
|
@ -19,11 +23,17 @@ import (
|
|||
const ID = "drift"
|
||||
|
||||
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() {
|
||||
bbgo.RegisterStrategy(ID, &Strategy{})
|
||||
}
|
||||
|
||||
type SourceFunc func(*types.KLine) fixedpoint.Value
|
||||
|
||||
type Strategy struct {
|
||||
Symbol string `json:"symbol"`
|
||||
|
||||
|
@ -41,13 +51,30 @@ type Strategy struct {
|
|||
midPrice fixedpoint.Value
|
||||
lock sync.RWMutex
|
||||
|
||||
Source string `json:"source"`
|
||||
Stoploss fixedpoint.Value `json:"stoploss"`
|
||||
CanvasPath string `json:"canvasPath"`
|
||||
PredictOffset int `json:"predictOffset"`
|
||||
|
||||
ExitMethods bbgo.ExitMethodSet `json:"exits"`
|
||||
Session *bbgo.ExchangeSession
|
||||
*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 {
|
||||
|
@ -72,35 +99,6 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {
|
|||
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) {
|
||||
order := s.Position.NewMarketCloseOrder(fixedpoint.One)
|
||||
if order == nil {
|
||||
|
@ -109,7 +107,7 @@ func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) {
|
|||
order.TimeInForce = ""
|
||||
balances := s.Session.GetAccount().Balances()
|
||||
baseBalance := balances[s.Market.BaseCurrency].Available
|
||||
price := s.GetLastPrice()
|
||||
price := s.getLastPrice()
|
||||
if order.Side == types.SideTypeBuy {
|
||||
quoteAmount := balances[s.Market.QuoteCurrency].Available.Div(price)
|
||||
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 {
|
||||
instanceID := s.InstanceID()
|
||||
if s.Position == nil {
|
||||
|
@ -168,18 +198,15 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
for _, method := range s.ExitMethods {
|
||||
method.Bind(session, s.GeneralOrderExecutor)
|
||||
}
|
||||
s.ActiveOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
|
||||
s.ActiveOrderBook.BindStream(session.UserDataStream)
|
||||
|
||||
store, _ := session.MarketDataStore(s.Symbol)
|
||||
|
||||
getSource := func(kline *types.KLine) fixedpoint.Value {
|
||||
//return kline.High.Add(kline.Low).Div(Two)
|
||||
//return kline.Close
|
||||
return kline.High.Add(kline.Low).Add(kline.Close).Div(Three)
|
||||
}
|
||||
getSource := s.SourceFuncGenerator()
|
||||
|
||||
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}}
|
||||
|
||||
klines, ok := store.KLinesOfInterval(s.Interval)
|
||||
|
@ -187,22 +214,30 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
log.Errorf("klines not exists")
|
||||
return nil
|
||||
}
|
||||
|
||||
dynamicKLine := &types.KLine{}
|
||||
for _, kline := range *klines {
|
||||
source := getSource(&kline).Float64()
|
||||
s.drift.Update(source)
|
||||
s.atr.Update(kline.High.Float64(), kline.Low.Float64(), kline.Close.Float64())
|
||||
}
|
||||
|
||||
session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) {
|
||||
if s.Environment.IsBackTesting() {
|
||||
return
|
||||
s.getLastPrice = func() fixedpoint.Value {
|
||||
lastPrice, ok := s.Session.LastPrice(s.Symbol)
|
||||
if !ok {
|
||||
log.Error("cannot get lastprice")
|
||||
}
|
||||
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(types.Two)
|
||||
s.midPrice = bestAsk.Add(bestBid).Div(Two)
|
||||
} else if !bestAsk.IsZero() {
|
||||
s.midPrice = bestAsk
|
||||
} else {
|
||||
|
@ -211,9 +246,25 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
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)
|
||||
stoploss := s.Stoploss.Float64()
|
||||
|
||||
session.MarketDataStream.OnKLineClosed(func(kline types.KLine) {
|
||||
if s.Status != types.StrategyStatusRunning {
|
||||
|
@ -236,15 +287,13 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
source := getSource(dynamicKLine)
|
||||
sourcef := source.Float64()
|
||||
priceLine.Update(sourcef)
|
||||
dynamicKLine.Closed = false
|
||||
s.drift.Update(sourcef)
|
||||
drift = s.drift.Array(2)
|
||||
driftPred = s.drift.Predict(3)
|
||||
driftPred = s.drift.Predict(s.PredictOffset)
|
||||
atr = s.atr.Last()
|
||||
price := s.GetLastPrice()
|
||||
price := s.getLastPrice()
|
||||
pricef := price.Float64()
|
||||
avg := s.Position.AverageCost.Float64()
|
||||
stoploss := s.Stoploss.Float64()
|
||||
|
||||
shortCondition := (driftPred <= 0 && drift[0] <= 0)
|
||||
longCondition := (driftPred >= 0 && drift[0] >= 0)
|
||||
|
@ -253,31 +302,18 @@ 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) &&
|
||||
(!s.Position.IsClosed() && !s.Position.IsDust(fixedpoint.Min(price, source))) && !shortCondition
|
||||
|
||||
if exitShortCondition {
|
||||
if s.ActiveOrderBook.NumOfOrders() > 0 {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil {
|
||||
if exitShortCondition || exitLongCondition {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
|
||||
log.WithError(err).Errorf("cannot cancel orders")
|
||||
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)
|
||||
}
|
||||
if shortCondition {
|
||||
if s.ActiveOrderBook.NumOfOrders() > 0 {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
|
||||
log.WithError(err).Errorf("cannot cancel orders")
|
||||
return
|
||||
}
|
||||
}
|
||||
baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency)
|
||||
if !ok {
|
||||
log.Errorf("unable to get baseBalance")
|
||||
|
@ -295,7 +331,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
Side: types.SideTypeSell,
|
||||
Type: types.OrderTypeLimitMaker,
|
||||
Price: source,
|
||||
StopPrice: fixedpoint.NewFromFloat(sourcef + atr/2),
|
||||
StopPrice: fixedpoint.NewFromFloat(math.Min(sourcef+atr/2, sourcef*(1.+stoploss))),
|
||||
Quantity: baseBalance.Available,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -304,12 +340,10 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
}
|
||||
}
|
||||
if longCondition {
|
||||
if s.ActiveOrderBook.NumOfOrders() > 0 {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancelActiveOrderBook(ctx, s.ActiveOrderBook); err != nil {
|
||||
if err := s.GeneralOrderExecutor.GracefulCancel(ctx); err != nil {
|
||||
log.WithError(err).Errorf("cannot cancel orders")
|
||||
return
|
||||
}
|
||||
}
|
||||
if source.Compare(price) > 0 {
|
||||
source = price
|
||||
}
|
||||
|
@ -322,15 +356,12 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
quoteBalance.Available.Div(source), source) {
|
||||
return
|
||||
}
|
||||
if !s.Position.IsClosed() && !s.Position.IsDust(source) {
|
||||
return
|
||||
}
|
||||
_, err := s.GeneralOrderExecutor.SubmitOrders(ctx, types.SubmitOrder{
|
||||
Symbol: s.Symbol,
|
||||
Side: types.SideTypeBuy,
|
||||
Type: types.OrderTypeLimitMaker,
|
||||
Price: source,
|
||||
StopPrice: fixedpoint.NewFromFloat(sourcef - atr/2),
|
||||
StopPrice: fixedpoint.NewFromFloat(math.Max(sourcef-atr/2, sourcef*(1.-stoploss))),
|
||||
Quantity: quoteBalance.Available.Div(source),
|
||||
})
|
||||
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) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String())
|
||||
s.Print()
|
||||
canvas := types.NewCanvas(s.InstanceID(), s.Interval)
|
||||
fmt.Println(dynamicKLine.StartTime, dynamicKLine.EndTime)
|
||||
mean := priceLine.Mean(100)
|
||||
highestPrice := priceLine.Minus(mean).Highest(100)
|
||||
highestDrift := s.drift.Highest(100)
|
||||
|
|
|
@ -3,11 +3,11 @@ package types
|
|||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"gonum.org/v1/gonum/stat"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"gonum.org/v1/gonum/stat"
|
||||
)
|
||||
|
||||
// Super basic Series type that simply holds the float64 data
|
||||
|
@ -46,7 +46,7 @@ func (inc *Queue) Length() int {
|
|||
}
|
||||
|
||||
func (inc *Queue) Clone() *Queue {
|
||||
out := &Queue {
|
||||
out := &Queue{
|
||||
arr: inc.arr[:],
|
||||
size: inc.size,
|
||||
}
|
||||
|
@ -213,7 +213,7 @@ func Abs(a Series) SeriesExtend {
|
|||
|
||||
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 {
|
||||
lookback = a.Length()
|
||||
}
|
||||
|
@ -224,7 +224,12 @@ func Predict(a Series, lookback int, offset ...int) float64 {
|
|||
x[i] = float64(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
|
||||
if len(offset) > 0 {
|
||||
o = -float64(offset[0])
|
||||
|
@ -1167,15 +1172,15 @@ type Canvas struct {
|
|||
|
||||
func NewCanvas(title string, interval Interval) *Canvas {
|
||||
valueFormatter := chart.TimeValueFormatter
|
||||
if interval.Minutes() > 24 * 60 {
|
||||
if interval.Minutes() > 24*60 {
|
||||
valueFormatter = chart.TimeDateValueFormatter
|
||||
} else if interval.Minutes() > 60 {
|
||||
valueFormatter = chart.TimeHourValueFormatter
|
||||
} else {
|
||||
valueFormatter = chart.TimeMinuteValueFormatter
|
||||
}
|
||||
out := &Canvas {
|
||||
Chart: chart.Chart {
|
||||
out := &Canvas{
|
||||
Chart: chart.Chart{
|
||||
Title: title,
|
||||
XAxis: chart.XAxis{
|
||||
ValueFormatter: valueFormatter,
|
||||
|
@ -1193,7 +1198,7 @@ func (canvas *Canvas) Plot(tag string, a Series, endTime Time, length int) {
|
|||
var timeline []time.Time
|
||||
e := endTime.Time()
|
||||
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)
|
||||
}
|
||||
canvas.Series = append(canvas.Series, chart.TimeSeries{
|
||||
|
|
Loading…
Reference in New Issue
Block a user