diff --git a/config/linregmaker.yaml b/config/linregmaker.yaml new file mode 100644 index 000000000..57b89ec8a --- /dev/null +++ b/config/linregmaker.yaml @@ -0,0 +1,169 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: BTCUSDT + +backtest: + sessions: [binance] + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-05-01" + endTime: "2022-10-31" + symbols: + - BTCUSDT + accounts: + binance: + makerCommission: 10 # 0.15% + takerCommission: 15 # 0.15% + balances: + BTC: 2.0 + USDT: 10000.0 + +exchangeStrategies: +- on: binance + linregmaker: + symbol: BTCUSDT + + # interval is how long do you want to update your order price and quantity + interval: 1m + + # leverage uses the account net value to calculate the allowed margin + leverage: 1 + + # reverseEMA is used to determine the long-term trend. + # Above the ReverseEMA is the long trend and vise versa. + # All the opposite trend position will be closed upon the trend change + reverseEMA: + interval: 1d + window: 60 + + # reverseInterval is the interval to check trend reverse against ReverseEMA. Close price of this interval crossing + # the ReverseEMA triggers main trend change. + reverseInterval: 4h + + # fastLinReg is to determine the short-term trend. + # Buy/sell orders are placed if the FastLinReg and the ReverseEMA trend are in the same direction, and only orders + # that reduce position are placed if the FastLinReg and the ReverseEMA trend are in different directions. + fastLinReg: + interval: 1m + window: 30 + + # slowLinReg is to determine the midterm trend. + # When the SlowLinReg and the ReverseEMA trend are in different directions, creation of opposite position is + # allowed. + slowLinReg: + interval: 1m + window: 120 + + # allowOppositePosition if true, the creation of opposite position is allowed when both fast and slow LinReg are in + # the opposite direction to main trend + allowOppositePosition: true + + # fasterDecreaseRatio the quantity of decreasing position orders are multiplied by this ratio when both fast and + # slow LinReg are in the opposite direction to main trend + fasterDecreaseRatio: 2 + + # neutralBollinger is the smaller range of the bollinger band + # If price is in this band, it usually means the price is oscillating. + # If price goes out of this band, we tend to not place sell orders or buy orders + neutralBollinger: + interval: "15m" + window: 21 + bandWidth: 2.0 + + # tradeInBand: when tradeInBand is set, you will only place orders in the bollinger band. + tradeInBand: true + + # spread is the price spread from the middle price. + # For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + # For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + # Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + spread: 0.1% + # dynamicSpread enables the automatic adjustment to bid and ask spread. + # Overrides Spread, BidSpread, and AskSpread + dynamicSpread: + amplitude: # delete other scaling strategy if this is defined + # window is the window of the SMAs of spreads + window: 1 + interval: "1m" + askSpreadScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from down to up + domain: [ 0.0001, 0.005 ] + # the spread range + range: [ 0.001, 0.002 ] + bidSpreadScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from down to up + domain: [ 0.0001, 0.005 ] + # the spread range + range: [ 0.001, 0.002 ] + + # maxExposurePosition is the maximum position you can hold + # 10 means you can hold 10 ETH long/short position by maximum + #maxExposurePosition: 10 + # dynamicExposure is used to define the exposure position range with the given percentage. + # When DynamicExposure is set, your MaxExposurePosition will be calculated dynamically + dynamicExposure: + bollBandExposure: + interval: "1h" + window: 21 + bandWidth: 2.0 + dynamicExposurePositionScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from lower band -100% (-1) to upper band 100% (+1) + domain: [ -1, 1 ] + # when in down band, holds 0.1 by maximum + # when in up band, holds 1 by maximum + range: [ 0.1, 1 ] + + # quantity is the base order quantity for your buy/sell order. + quantity: 0.001 + # amount: fixed amount instead of qty + #amount: 10 + # useDynamicQuantityAsAmount calculates amount instead of quantity + useDynamicQuantityAsAmount: false + # dynamicQuantityIncrease calculates the increase position order quantity dynamically + dynamicQuantityIncrease: + - linRegDynamicQuantity: + quantityLinReg: + interval: 1m + window: 20 + dynamicQuantityLinRegScale: + byPercentage: + linear: + domain: [ -0.0001, 0.00005 ] + range: [ 0, 0.02 ] + # dynamicQuantityDecrease calculates the decrease position order quantity dynamically + dynamicQuantityDecrease: + - linRegDynamicQuantity: + quantityLinReg: + interval: 1m + window: 20 + dynamicQuantityLinRegScale: + byPercentage: + linear: + domain: [ -0.00005, 0.0001 ] + range: [ 0.02, 0 ] + + exits: + # roiStopLoss is the stop loss percentage of the position ROI (currently the price change) + - roiStopLoss: + percentage: 30% diff --git a/pkg/bbgo/standard_indicator_set.go b/pkg/bbgo/standard_indicator_set.go index 52ecdddce..e291cccc6 100644 --- a/pkg/bbgo/standard_indicator_set.go +++ b/pkg/bbgo/standard_indicator_set.go @@ -140,7 +140,7 @@ func (s *StandardIndicatorSet) BOLL(iw types.IntervalWindow, bandWidth float64) iwb := types.IntervalWindowBandWidth{IntervalWindow: iw, BandWidth: bandWidth} inc, ok := s.iwbIndicators[iwb] if !ok { - inc = &indicator.BOLL{IntervalWindow: iw, K: bandWidth} + inc = &indicator.BOLL{IntervalWindow: iw, K: bandWidth, SMA: &indicator.SMA{IntervalWindow: iw}} s.initAndBind(inc, iw.Interval) if debugBOLL { diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 93334d9f0..0b4cab10f 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -21,6 +21,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/harmonic" _ "github.com/c9s/bbgo/pkg/strategy/irr" _ "github.com/c9s/bbgo/pkg/strategy/kline" + _ "github.com/c9s/bbgo/pkg/strategy/linregmaker" _ "github.com/c9s/bbgo/pkg/strategy/marketcap" _ "github.com/c9s/bbgo/pkg/strategy/pivotshort" _ "github.com/c9s/bbgo/pkg/strategy/pricealert" diff --git a/pkg/indicator/linreg.go b/pkg/indicator/linreg.go new file mode 100644 index 000000000..3bb4606ed --- /dev/null +++ b/pkg/indicator/linreg.go @@ -0,0 +1,125 @@ +package indicator + +import ( + "github.com/sirupsen/logrus" + "time" + + "github.com/c9s/bbgo/pkg/datatype/floats" + "github.com/c9s/bbgo/pkg/types" +) + +var logLinReg = logrus.WithField("indicator", "LinReg") + +// LinReg is Linear Regression baseline +//go:generate callbackgen -type LinReg +type LinReg struct { + types.SeriesBase + types.IntervalWindow + + // Values are the slopes of linear regression baseline + Values floats.Slice + // ValueRatios are the ratio of slope to the price + ValueRatios floats.Slice + + klines types.KLineWindow + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +// Last slope of linear regression baseline +func (lr *LinReg) Last() float64 { + if lr.Values.Length() == 0 { + return 0.0 + } + return lr.Values.Last() +} + +// LastRatio of slope to price +func (lr *LinReg) LastRatio() float64 { + if lr.ValueRatios.Length() == 0 { + return 0.0 + } + return lr.ValueRatios.Last() +} + +// Index returns the slope of specified index +func (lr *LinReg) Index(i int) float64 { + if i >= lr.Values.Length() { + return 0.0 + } + + return lr.Values.Index(i) +} + +// IndexRatio returns the slope ratio +func (lr *LinReg) IndexRatio(i int) float64 { + if i >= lr.ValueRatios.Length() { + return 0.0 + } + + return lr.ValueRatios.Index(i) +} + +// Length of the slope values +func (lr *LinReg) Length() int { + return lr.Values.Length() +} + +// LengthRatio of the slope ratio values +func (lr *LinReg) LengthRatio() int { + return lr.ValueRatios.Length() +} + +var _ types.SeriesExtend = &LinReg{} + +// Update Linear Regression baseline slope +func (lr *LinReg) Update(kline types.KLine) { + lr.klines.Add(kline) + lr.klines.Truncate(lr.Window) + if len(lr.klines) < lr.Window { + lr.Values.Push(0) + lr.ValueRatios.Push(0) + return + } + + var sumX, sumY, sumXSqr, sumXY float64 = 0, 0, 0, 0 + end := len(lr.klines) - 1 // The last kline + for i := end; i >= end-lr.Window+1; i-- { + val := lr.klines[i].GetClose().Float64() + per := float64(end - i + 1) + sumX += per + sumY += val + sumXSqr += per * per + sumXY += val * per + } + length := float64(lr.Window) + slope := (length*sumXY - sumX*sumY) / (length*sumXSqr - sumX*sumX) + average := sumY / length + endPrice := average - slope*sumX/length + slope + startPrice := endPrice + slope*(length-1) + lr.Values.Push((endPrice - startPrice) / (length - 1)) + lr.ValueRatios.Push(lr.Values.Last() / kline.GetClose().Float64()) + + logLinReg.Debugf("linear regression baseline slope: %f", lr.Last()) +} + +func (lr *LinReg) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, lr.PushK)) +} + +func (lr *LinReg) PushK(k types.KLine) { + var zeroTime = time.Time{} + if lr.EndTime != zeroTime && k.EndTime.Before(lr.EndTime) { + return + } + + lr.Update(k) + lr.EndTime = k.EndTime.Time() +} + +func (lr *LinReg) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + lr.PushK(k) + } +} diff --git a/pkg/indicator/linreg_callbacks.go b/pkg/indicator/linreg_callbacks.go new file mode 100644 index 000000000..c719c8f51 --- /dev/null +++ b/pkg/indicator/linreg_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type LinReg"; DO NOT EDIT. + +package indicator + +import () + +func (lr *LinReg) OnUpdate(cb func(value float64)) { + lr.UpdateCallbacks = append(lr.UpdateCallbacks, cb) +} + +func (lr *LinReg) EmitUpdate(value float64) { + for _, cb := range lr.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/risk/dynamicrisk/dynamic_exposure.go b/pkg/risk/dynamicrisk/dynamic_exposure.go new file mode 100644 index 000000000..71eb12155 --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_exposure.go @@ -0,0 +1,86 @@ +package dynamicrisk + +import ( + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "math" +) + +type DynamicExposure struct { + // BollBandExposure calculates the max exposure with the Bollinger Band + BollBandExposure *DynamicExposureBollBand `json:"bollBandExposure"` +} + +// Initialize dynamic exposure +func (d *DynamicExposure) Initialize(symbol string, session *bbgo.ExchangeSession) { + switch { + case d.BollBandExposure != nil: + d.BollBandExposure.initialize(symbol, session) + } +} + +func (d *DynamicExposure) IsEnabled() bool { + return d.BollBandExposure != nil +} + +// GetMaxExposure returns the max exposure +func (d *DynamicExposure) GetMaxExposure(price float64, trend types.Direction) (maxExposure fixedpoint.Value, err error) { + switch { + case d.BollBandExposure != nil: + return d.BollBandExposure.getMaxExposure(price, trend) + default: + return fixedpoint.Zero, errors.New("dynamic exposure is not enabled") + } +} + +// DynamicExposureBollBand calculates the max exposure with the Bollinger Band +type DynamicExposureBollBand struct { + // DynamicExposureBollBandScale is used to define the exposure range with the given percentage. + DynamicExposureBollBandScale *bbgo.PercentageScale `json:"dynamicExposurePositionScale"` + + types.IntervalWindowBandWidth + + dynamicExposureBollBand *indicator.BOLL +} + +// initialize dynamic exposure with Bollinger Band +func (d *DynamicExposureBollBand) initialize(symbol string, session *bbgo.ExchangeSession) { + d.dynamicExposureBollBand = session.StandardIndicatorSet(symbol).BOLL(d.IntervalWindow, d.BandWidth) + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: d.dynamicExposureBollBand.Interval, + }) +} + +// getMaxExposure returns the max exposure +func (d *DynamicExposureBollBand) getMaxExposure(price float64, trend types.Direction) (fixedpoint.Value, error) { + downBand := d.dynamicExposureBollBand.DownBand.Last() + upBand := d.dynamicExposureBollBand.UpBand.Last() + sma := d.dynamicExposureBollBand.SMA.Last() + log.Infof("dynamicExposureBollBand bollinger band: up %f sma %f down %f", upBand, sma, downBand) + + bandPercentage := 0.0 + if price < sma { + // should be negative percentage + bandPercentage = (price - sma) / math.Abs(sma-downBand) + } else if price > sma { + // should be positive percentage + bandPercentage = (price - sma) / math.Abs(upBand-sma) + } + + // Reverse if downtrend + if trend == types.DirectionDown { + bandPercentage = 0 - bandPercentage + } + + v, err := d.DynamicExposureBollBandScale.Scale(bandPercentage) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil +} diff --git a/pkg/risk/dynamicrisk/dynamic_quantity.go b/pkg/risk/dynamicrisk/dynamic_quantity.go new file mode 100644 index 000000000..d2d1e8c0e --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_quantity.go @@ -0,0 +1,100 @@ +package dynamicrisk + +import ( + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" + "github.com/pkg/errors" +) + +// DynamicQuantitySet uses multiple dynamic quantity rules to calculate the total quantity +type DynamicQuantitySet []DynamicQuantity + +// Initialize dynamic quantity set +func (d *DynamicQuantitySet) Initialize(symbol string, session *bbgo.ExchangeSession) { + for i := range *d { + (*d)[i].Initialize(symbol, session) + } +} + +// GetQuantity returns the quantity +func (d *DynamicQuantitySet) GetQuantity(reverse bool) (fixedpoint.Value, error) { + quantity := fixedpoint.Zero + for i := range *d { + v, err := (*d)[i].getQuantity(reverse) + if err != nil { + return fixedpoint.Zero, err + } + quantity = quantity.Add(v) + } + + return quantity, nil +} + +type DynamicQuantity struct { + // LinRegQty calculates quantity based on LinReg slope + LinRegDynamicQuantity *DynamicQuantityLinReg `json:"linRegDynamicQuantity"` +} + +// Initialize dynamic quantity +func (d *DynamicQuantity) Initialize(symbol string, session *bbgo.ExchangeSession) { + switch { + case d.LinRegDynamicQuantity != nil: + d.LinRegDynamicQuantity.initialize(symbol, session) + } +} + +func (d *DynamicQuantity) IsEnabled() bool { + return d.LinRegDynamicQuantity != nil +} + +// getQuantity returns quantity +func (d *DynamicQuantity) getQuantity(reverse bool) (fixedpoint.Value, error) { + switch { + case d.LinRegDynamicQuantity != nil: + return d.LinRegDynamicQuantity.getQuantity(reverse) + default: + return fixedpoint.Zero, errors.New("dynamic quantity is not enabled") + } +} + +// DynamicQuantityLinReg uses LinReg slope to calculate quantity +type DynamicQuantityLinReg struct { + // DynamicQuantityLinRegScale is used to define the quantity range with the given parameters. + DynamicQuantityLinRegScale *bbgo.PercentageScale `json:"dynamicQuantityLinRegScale"` + + // QuantityLinReg to define the interval and window of the LinReg + QuantityLinReg *indicator.LinReg `json:"quantityLinReg"` +} + +// initialize LinReg dynamic quantity +func (d *DynamicQuantityLinReg) initialize(symbol string, session *bbgo.ExchangeSession) { + // Subscribe for LinReg + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: d.QuantityLinReg.Interval, + }) + + // Initialize LinReg + kLineStore, _ := session.MarketDataStore(symbol) + d.QuantityLinReg.BindK(session.MarketDataStream, symbol, d.QuantityLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(d.QuantityLinReg.Interval); ok { + d.QuantityLinReg.LoadK((*klines)[0:]) + } +} + +// getQuantity returns quantity +// If reverse is true, the LinReg slope ratio is reversed, ie -0.01 becomes 0.01. This is for short orders. +func (d *DynamicQuantityLinReg) getQuantity(reverse bool) (fixedpoint.Value, error) { + var linregRatio float64 + if reverse { + linregRatio = -d.QuantityLinReg.LastRatio() + } else { + linregRatio = d.QuantityLinReg.LastRatio() + } + v, err := d.DynamicQuantityLinRegScale.Scale(linregRatio) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil +} diff --git a/pkg/risk/dynamicrisk/dynamic_spread.go b/pkg/risk/dynamicrisk/dynamic_spread.go new file mode 100644 index 000000000..2b5cbbeb8 --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_spread.go @@ -0,0 +1,247 @@ +package dynamicrisk + +import ( + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "math" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +type DynamicSpread struct { + // AmpSpread calculates spreads based on kline amplitude + AmpSpread *DynamicAmpSpread `json:"amplitude"` + + // WeightedBollWidthRatioSpread calculates spreads based on two Bollinger Bands + WeightedBollWidthRatioSpread *DynamicSpreadBollWidthRatio `json:"weightedBollWidth"` +} + +// Initialize dynamic spread +func (ds *DynamicSpread) Initialize(symbol string, session *bbgo.ExchangeSession) { + switch { + case ds.AmpSpread != nil: + ds.AmpSpread.initialize(symbol, session) + case ds.WeightedBollWidthRatioSpread != nil: + ds.WeightedBollWidthRatioSpread.initialize(symbol, session) + } +} + +func (ds *DynamicSpread) IsEnabled() bool { + return ds.AmpSpread != nil || ds.WeightedBollWidthRatioSpread != nil +} + +// GetAskSpread returns current ask spread +func (ds *DynamicSpread) GetAskSpread() (askSpread float64, err error) { + switch { + case ds.AmpSpread != nil: + return ds.AmpSpread.getAskSpread() + case ds.WeightedBollWidthRatioSpread != nil: + return ds.WeightedBollWidthRatioSpread.getAskSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// GetBidSpread returns current dynamic bid spread +func (ds *DynamicSpread) GetBidSpread() (bidSpread float64, err error) { + switch { + case ds.AmpSpread != nil: + return ds.AmpSpread.getBidSpread() + case ds.WeightedBollWidthRatioSpread != nil: + return ds.WeightedBollWidthRatioSpread.getBidSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// DynamicSpreadAmp uses kline amplitude to calculate spreads +type DynamicAmpSpread struct { + types.IntervalWindow + + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"` + + dynamicAskSpread *indicator.SMA + dynamicBidSpread *indicator.SMA +} + +// initialize amplitude dynamic spread and preload SMAs +func (ds *DynamicAmpSpread) initialize(symbol string, session *bbgo.ExchangeSession) { + ds.dynamicBidSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + ds.dynamicAskSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.Interval, + }) + + // Update on kline closed + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, ds.Interval, func(kline types.KLine) { + ds.update(kline) + })) + + // Preload + kLineStore, _ := session.MarketDataStore(symbol) + if klines, ok := kLineStore.KLinesOfInterval(ds.Interval); ok { + for i := 0; i < len(*klines); i++ { + ds.update((*klines)[i]) + } + } +} + +// update amplitude dynamic spread with kline +func (ds *DynamicAmpSpread) update(kline types.KLine) { + // ampl is the amplitude of kline + ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64() + + switch kline.Direction() { + case types.DirectionUp: + ds.dynamicAskSpread.Update(ampl) + ds.dynamicBidSpread.Update(0) + case types.DirectionDown: + ds.dynamicBidSpread.Update(ampl) + ds.dynamicAskSpread.Update(0) + default: + ds.dynamicAskSpread.Update(0) + ds.dynamicBidSpread.Update(0) + } +} + +func (ds *DynamicAmpSpread) getAskSpread() (askSpread float64, err error) { + if ds.AskSpreadScale != nil && ds.dynamicAskSpread.Length() >= ds.Window { + askSpread, err = ds.AskSpreadScale.Scale(ds.dynamicAskSpread.Last()) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +func (ds *DynamicAmpSpread) getBidSpread() (bidSpread float64, err error) { + if ds.BidSpreadScale != nil && ds.dynamicBidSpread.Length() >= ds.Window { + bidSpread, err = ds.BidSpreadScale.Scale(ds.dynamicBidSpread.Last()) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicBidSpread") + return 0, err + } + + return bidSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +type DynamicSpreadBollWidthRatio struct { + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"` + + // Sensitivity factor of the weighting function: 1 / (1 + exp(-(x - mid) * sensitivity / width)) + // A positive number. The greater factor, the sharper weighting function. Default set to 1.0 . + Sensitivity float64 `json:"sensitivity"` + + DefaultBollinger types.IntervalWindowBandWidth `json:"defaultBollinger"` + NeutralBollinger types.IntervalWindowBandWidth `json:"neutralBollinger"` + + neutralBoll *indicator.BOLL + defaultBoll *indicator.BOLL +} + +func (ds *DynamicSpreadBollWidthRatio) initialize(symbol string, session *bbgo.ExchangeSession) { + ds.neutralBoll = session.StandardIndicatorSet(symbol).BOLL(ds.NeutralBollinger.IntervalWindow, ds.NeutralBollinger.BandWidth) + ds.defaultBoll = session.StandardIndicatorSet(symbol).BOLL(ds.DefaultBollinger.IntervalWindow, ds.DefaultBollinger.BandWidth) + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.NeutralBollinger.Interval, + }) + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.DefaultBollinger.Interval, + }) + + if ds.Sensitivity <= 0. { + ds.Sensitivity = 1. + } +} + +func (ds *DynamicSpreadBollWidthRatio) getAskSpread() (askSpread float64, err error) { + askSpread, err = ds.AskSpreadScale.Scale(ds.getWeightedBBWidthRatio(true)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatio) getBidSpread() (bidSpread float64, err error) { + bidSpread, err = ds.BidSpreadScale.Scale(ds.getWeightedBBWidthRatio(false)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return bidSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatio) getWeightedBBWidthRatio(positiveSigmoid bool) float64 { + // Weight the width of Boll bands with sigmoid function and calculate the ratio after integral. + // + // Given the default band: moving average default_BB_mid, band from default_BB_lower to default_BB_upper. + // And the neutral band: from neutral_BB_lower to neutral_BB_upper. + // And a sensitivity factor alpha, which is a positive constant. + // + // width of default BB w = default_BB_upper - default_BB_lower + // + // 1 x - default_BB_mid + // sigmoid weighting function f(y) = ------------- where y = -------------------- + // 1 + exp(-y) w / alpha + // Set the sigmoid weighting function: + // - To ask spread, the weighting density function d_weight(x) is sigmoid((x - default_BB_mid) / (w / alpha)) + // - To bid spread, the weighting density function d_weight(x) is sigmoid((default_BB_mid - x) / (w / alpha)) + // - The higher sensitivity factor alpha, the sharper weighting function. + // + // Then calculate the weighted bandwidth ratio by taking integral of d_weight(x) from neutral_BB_lower to neutral_BB_upper: + // infinite integral of ask spread sigmoid weighting density function F(x) = (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // infinite integral of bid spread sigmoid weighting density function F(x) = x - (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // Note that we've rescaled the sigmoid function to fit default BB, + // the weighted default BB width is always calculated by integral(f of x from default_BB_lower to default_BB_upper) + // F(neutral_BB_upper) - F(neutral_BB_lower) + // weighted ratio = ------------------------------------------- + // F(default_BB_upper) - F(default_BB_lower) + // - The wider neutral band get greater ratio + // - To ask spread, the higher neutral band get greater ratio + // - To bid spread, the lower neutral band get greater ratio + + defaultMid := ds.defaultBoll.SMA.Last() + defaultUpper := ds.defaultBoll.UpBand.Last() + defaultLower := ds.defaultBoll.DownBand.Last() + defaultWidth := defaultUpper - defaultLower + neutralUpper := ds.neutralBoll.UpBand.Last() + neutralLower := ds.neutralBoll.DownBand.Last() + factor := defaultWidth / ds.Sensitivity + var weightedUpper, weightedLower, weightedDivUpper, weightedDivLower float64 + if positiveSigmoid { + weightedUpper = factor * math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = factor * math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = factor * math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = factor * math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } else { + weightedUpper = neutralUpper - factor*math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = neutralLower - factor*math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = defaultUpper - factor*math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = defaultLower - factor*math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } + return (weightedUpper - weightedLower) / (weightedDivUpper - weightedDivLower) +} diff --git a/pkg/strategy/linregmaker/doc.go b/pkg/strategy/linregmaker/doc.go new file mode 100644 index 000000000..a4bddf911 --- /dev/null +++ b/pkg/strategy/linregmaker/doc.go @@ -0,0 +1,6 @@ +// Linregmaker is a maker strategy depends on the linear regression baseline slopes +// +// Linregmaker uses two linear regression baseline slopes for trading: +// 1) The fast linReg is to determine the short-term trend. It controls whether placing buy/sell orders or not. +// 2) The slow linReg is to determine the mid-term trend. It controls whether the creation of opposite direction position is allowed. +package linregmaker diff --git a/pkg/strategy/linregmaker/strategy.go b/pkg/strategy/linregmaker/strategy.go new file mode 100644 index 000000000..a0d2f2730 --- /dev/null +++ b/pkg/strategy/linregmaker/strategy.go @@ -0,0 +1,786 @@ +package linregmaker + +import ( + "context" + "fmt" + "github.com/c9s/bbgo/pkg/risk/dynamicrisk" + "sync" + + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/util" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +// TODO: Docs + +const ID = "linregmaker" + +var notionModifier = fixedpoint.NewFromFloat(1.1) +var two = fixedpoint.NewFromInt(2) + +var log = logrus.WithField("strategy", ID) + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + // Leverage uses the account net value to calculate the allowed margin + Leverage fixedpoint.Value `json:"leverage"` + + types.IntervalWindow + + // ReverseEMA is used to determine the long-term trend. + // Above the ReverseEMA is the long trend and vise versa. + // All the opposite trend position will be closed upon the trend change + ReverseEMA *indicator.EWMA `json:"reverseEMA"` + + // ReverseInterval is the interval to check trend reverse against ReverseEMA. Close price of this interval crossing + // the ReverseEMA triggers main trend change. + ReverseInterval types.Interval `json:"reverseInterval"` + + // mainTrendCurrent is the current long-term trend + mainTrendCurrent types.Direction + // mainTrendPrevious is the long-term trend of previous kline + mainTrendPrevious types.Direction + + // FastLinReg is to determine the short-term trend. + // Buy/sell orders are placed if the FastLinReg and the ReverseEMA trend are in the same direction, and only orders + // that reduce position are placed if the FastLinReg and the ReverseEMA trend are in different directions. + FastLinReg *indicator.LinReg `json:"fastLinReg"` + + // SlowLinReg is to determine the midterm trend. + // When the SlowLinReg and the ReverseEMA trend are in different directions, creation of opposite position is + // allowed. + SlowLinReg *indicator.LinReg `json:"slowLinReg"` + + // AllowOppositePosition if true, the creation of opposite position is allowed when both fast and slow LinReg are in + // the opposite direction to main trend + AllowOppositePosition bool `json:"allowOppositePosition"` + + // FasterDecreaseRatio the quantity of decreasing position orders are multiplied by this ratio when both fast and + // slow LinReg are in the opposite direction to main trend + FasterDecreaseRatio fixedpoint.Value `json:"fasterDecreaseRatio,omitempty"` + + // NeutralBollinger is the smaller range of the bollinger band + // If price is in this band, it usually means the price is oscillating. + // If price goes out of this band, we tend to not place sell orders or buy orders + NeutralBollinger types.IntervalWindowBandWidth `json:"neutralBollinger"` + + // neutralBoll is the neutral price section for TradeInBand + neutralBoll *indicator.BOLL + + // TradeInBand + // When this is on, places orders only when the current price is in the bollinger band. + TradeInBand bool `json:"tradeInBand"` + + // Spread is the price spread from the middle price. + // For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + // For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + // Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + Spread fixedpoint.Value `json:"spread"` + + // BidSpread overrides the spread setting, this spread will be used for the buy order + BidSpread fixedpoint.Value `json:"bidSpread,omitempty"` + + // AskSpread overrides the spread setting, this spread will be used for the sell order + AskSpread fixedpoint.Value `json:"askSpread,omitempty"` + + // DynamicSpread enables the automatic adjustment to bid and ask spread. + // Overrides Spread, BidSpread, and AskSpread + DynamicSpread dynamicrisk.DynamicSpread `json:"dynamicSpread,omitempty"` + + // MaxExposurePosition is the maximum position you can hold + // 10 means you can hold 10 ETH long/short position by maximum + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + // DynamicExposure is used to define the exposure position range with the given percentage. + // When DynamicExposure is set, your MaxExposurePosition will be calculated dynamically + DynamicExposure dynamicrisk.DynamicExposure `json:"dynamicExposure"` + + bbgo.QuantityOrAmount + + // DynamicQuantityIncrease calculates the increase position order quantity dynamically + DynamicQuantityIncrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityIncrease"` + + // DynamicQuantityDecrease calculates the decrease position order quantity dynamically + DynamicQuantityDecrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityDecrease"` + + // UseDynamicQuantityAsAmount calculates amount instead of quantity + UseDynamicQuantityAsAmount bool `json:"useDynamicQuantityAsAmount"` + + // ExitMethods are various TP/SL methods + ExitMethods bbgo.ExitMethodSet `json:"exits"` + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + Environment *bbgo.Environment + StandardIndicatorSet *bbgo.StandardIndicatorSet + Market types.Market + ctx context.Context + + session *bbgo.ExchangeSession + + orderExecutor *bbgo.GeneralOrderExecutor + + groupID uint32 + + // StrategyController + bbgo.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +// Validate basic config parameters +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + if len(s.Interval) == 0 { + return errors.New("interval is required") + } + + if s.ReverseEMA == nil { + return errors.New("reverseEMA must be set") + } + + if s.FastLinReg == nil { + return errors.New("fastLinReg must be set") + } + + if s.SlowLinReg == nil { + return errors.New("slowLinReg must be set") + } + + return nil +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + // Subscribe for ReverseEMA + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.ReverseEMA.Interval, + }) + // Initialize ReverseEMA + s.ReverseEMA = s.StandardIndicatorSet.EWMA(s.ReverseEMA.IntervalWindow) + + // Subscribe for ReverseInterval. Use interval of ReverseEMA if ReverseInterval is omitted + if s.ReverseInterval == "" { + s.ReverseInterval = s.ReverseEMA.Interval + } + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.ReverseInterval, + }) + + // Subscribe for LinRegs + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.FastLinReg.Interval, + }) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.SlowLinReg.Interval, + }) + // Initialize LinRegs + kLineStore, _ := session.MarketDataStore(s.Symbol) + s.FastLinReg.BindK(session.MarketDataStream, s.Symbol, s.FastLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.FastLinReg.Interval); ok { + s.FastLinReg.LoadK((*klines)[0:]) + } + s.SlowLinReg.BindK(session.MarketDataStream, s.Symbol, s.SlowLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.SlowLinReg.Interval); ok { + s.SlowLinReg.LoadK((*klines)[0:]) + } + + // Subscribe for BBs + if s.NeutralBollinger.Interval != "" { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.NeutralBollinger.Interval, + }) + } + // Initialize BBs + s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) + + // Setup Exits + s.ExitMethods.SetAndSubscribe(session, s) + + // Setup dynamic spread + if s.DynamicSpread.IsEnabled() { + s.DynamicSpread.Initialize(s.Symbol, session) + } + + // Setup dynamic exposure + if s.DynamicExposure.IsEnabled() { + s.DynamicExposure.Initialize(s.Symbol, session) + } + + // Setup dynamic quantities + if len(s.DynamicQuantityIncrease) > 0 { + s.DynamicQuantityIncrease.Initialize(s.Symbol, session) + } + if len(s.DynamicQuantityDecrease) > 0 { + s.DynamicQuantityDecrease.Initialize(s.Symbol, session) + } +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +// isAllowOppositePosition returns if opening opposite position is allowed +func (s *Strategy) isAllowOppositePosition() bool { + if !s.AllowOppositePosition { + return false + } + + if (s.mainTrendCurrent == types.DirectionUp && s.FastLinReg.Last() < 0 && s.SlowLinReg.Last() < 0) || + (s.mainTrendCurrent == types.DirectionDown && s.FastLinReg.Last() > 0 && s.SlowLinReg.Last() > 0) { + log.Infof("%s allow opposite position is enabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(), s.SlowLinReg.Last()) + return true + } + log.Infof("%s allow opposite position is disabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(), s.SlowLinReg.Last()) + + return false +} + +// updateSpread for ask and bid price +func (s *Strategy) updateSpread() { + // Update spreads with dynamic spread + if s.DynamicSpread.IsEnabled() { + dynamicBidSpread, err := s.DynamicSpread.GetBidSpread() + if err == nil && dynamicBidSpread > 0 { + s.BidSpread = fixedpoint.NewFromFloat(dynamicBidSpread) + log.Infof("%s dynamic bid spread updated: %s", s.Symbol, s.BidSpread.Percentage()) + } + dynamicAskSpread, err := s.DynamicSpread.GetAskSpread() + if err == nil && dynamicAskSpread > 0 { + s.AskSpread = fixedpoint.NewFromFloat(dynamicAskSpread) + log.Infof("%s dynamic ask spread updated: %s", s.Symbol, s.AskSpread.Percentage()) + } + } + + if s.BidSpread.Sign() <= 0 { + s.BidSpread = s.Spread + } + + if s.BidSpread.Sign() <= 0 { + s.AskSpread = s.Spread + } +} + +// updateMaxExposure with dynamic exposure +func (s *Strategy) updateMaxExposure(midPrice fixedpoint.Value) { + // Calculate max exposure + if s.DynamicExposure.IsEnabled() { + var err error + maxExposurePosition, err := s.DynamicExposure.GetMaxExposure(midPrice.Float64(), s.mainTrendCurrent) + if err != nil { + log.WithError(err).Errorf("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol) + bbgo.Notify("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol) + } else { + s.MaxExposurePosition = maxExposurePosition + } + log.Infof("calculated %s max exposure position: %v", s.Symbol, s.MaxExposurePosition) + } +} + +// getOrderPrices returns ask and bid prices +func (s *Strategy) getOrderPrices(midPrice fixedpoint.Value) (askPrice fixedpoint.Value, bidPrice fixedpoint.Value) { + askPrice = midPrice.Mul(fixedpoint.One.Add(s.AskSpread)) + bidPrice = midPrice.Mul(fixedpoint.One.Sub(s.BidSpread)) + log.Infof("%s mid price:%v ask:%v bid: %v", s.Symbol, midPrice, askPrice, bidPrice) + + return askPrice, bidPrice +} + +// adjustQuantity to meet the min notional and qty requirement +func (s *Strategy) adjustQuantity(quantity, price fixedpoint.Value) fixedpoint.Value { + adjustedQty := quantity + if quantity.Mul(price).Compare(s.Market.MinNotional) < 0 { + adjustedQty = bbgo.AdjustFloatQuantityByMinAmount(quantity, price, s.Market.MinNotional.Mul(notionModifier)) + } + + if adjustedQty.Compare(s.Market.MinQuantity) < 0 { + adjustedQty = fixedpoint.Max(adjustedQty, s.Market.MinQuantity) + } + + return adjustedQty +} + +// getOrderQuantities returns sell and buy qty +func (s *Strategy) getOrderQuantities(askPrice fixedpoint.Value, bidPrice fixedpoint.Value) (sellQuantity fixedpoint.Value, buyQuantity fixedpoint.Value) { + // Default + sellQuantity = s.QuantityOrAmount.CalculateQuantity(askPrice) + buyQuantity = s.QuantityOrAmount.CalculateQuantity(bidPrice) + + // Dynamic qty + switch { + case s.mainTrendCurrent == types.DirectionUp: + if len(s.DynamicQuantityIncrease) > 0 { + qty, err := s.DynamicQuantityIncrease.GetQuantity(false) + if err == nil { + buyQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + bbgo.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + } + } + if len(s.DynamicQuantityDecrease) > 0 { + qty, err := s.DynamicQuantityDecrease.GetQuantity(false) + if err == nil { + sellQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + bbgo.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + } + } + case s.mainTrendCurrent == types.DirectionDown: + if len(s.DynamicQuantityIncrease) > 0 { + qty, err := s.DynamicQuantityIncrease.GetQuantity(true) + if err == nil { + sellQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + bbgo.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + } + } + if len(s.DynamicQuantityDecrease) > 0 { + qty, err := s.DynamicQuantityDecrease.GetQuantity(true) + if err == nil { + buyQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + bbgo.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + } + } + } + if s.UseDynamicQuantityAsAmount { + log.Infof("caculated %s buy amount %v, sell amount %v", s.Symbol, buyQuantity, sellQuantity) + qtyAmount := bbgo.QuantityOrAmount{Amount: buyQuantity} + buyQuantity = qtyAmount.CalculateQuantity(bidPrice) + qtyAmount.Amount = sellQuantity + sellQuantity = qtyAmount.CalculateQuantity(askPrice) + log.Infof("convert %s amount to buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity) + } else { + log.Infof("caculated %s buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity) + } + + // Faster position decrease + if s.mainTrendCurrent == types.DirectionUp && s.SlowLinReg.Last() < 0 { + sellQuantity = sellQuantity.Mul(s.FasterDecreaseRatio) + log.Infof("faster %s position decrease: sell qty %v", s.Symbol, sellQuantity) + } else if s.mainTrendCurrent == types.DirectionDown && s.SlowLinReg.Last() > 0 { + buyQuantity = buyQuantity.Mul(s.FasterDecreaseRatio) + log.Infof("faster %s position decrease: buy qty %v", s.Symbol, buyQuantity) + } + + // Reduce order qty to fit current position + if !s.isAllowOppositePosition() { + if s.Position.IsLong() && s.Position.Base.Abs().Compare(sellQuantity) < 0 { + sellQuantity = s.Position.Base.Abs() + } else if s.Position.IsShort() && s.Position.Base.Abs().Compare(buyQuantity) < 0 { + buyQuantity = s.Position.Base.Abs() + } + } + + if buyQuantity.Compare(fixedpoint.Zero) > 0 { + buyQuantity = s.adjustQuantity(buyQuantity, bidPrice) + } + if sellQuantity.Compare(fixedpoint.Zero) > 0 { + sellQuantity = s.adjustQuantity(sellQuantity, askPrice) + } + + log.Infof("adjusted sell qty:%v buy qty: %v", sellQuantity, buyQuantity) + + return sellQuantity, buyQuantity +} + +// getAllowedBalance returns the allowed qty of orders +func (s *Strategy) getAllowedBalance() (baseQty, quoteQty fixedpoint.Value) { + // Default + baseQty = fixedpoint.PosInf + quoteQty = fixedpoint.PosInf + + balances := s.session.GetAccount().Balances() + baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency] + quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency] + lastPrice, _ := s.session.LastPrice(s.Symbol) + + if bbgo.IsBackTesting { + if !hasQuoteBalance { + baseQty = fixedpoint.Zero + quoteQty = fixedpoint.Zero + } else { + baseQty = quoteBalance.Available.Div(lastPrice) + quoteQty = quoteBalance.Available + } + } else if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { + quoteQ, err := bbgo.CalculateQuoteQuantity(s.ctx, s.session, s.Market.QuoteCurrency, s.Leverage) + if err != nil { + quoteQ = fixedpoint.Zero + } + quoteQty = quoteQ + baseQty = quoteQ.Div(lastPrice) + } else { + if !hasBaseBalance { + baseQty = fixedpoint.Zero + } else { + baseQty = baseBalance.Available + } + if !hasQuoteBalance { + quoteQty = fixedpoint.Zero + } else { + quoteQty = quoteBalance.Available + } + } + + return baseQty, quoteQty +} + +// getCanBuySell returns the buy sell switches +func (s *Strategy) getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice fixedpoint.Value) (canBuy bool, canSell bool) { + // By default, both buy and sell are on, which means we will place buy and sell orders + canBuy = true + canSell = true + + // Check if current position > maxExposurePosition + if s.Position.GetBase().Abs().Compare(s.MaxExposurePosition) > 0 { + if s.mainTrendCurrent == types.DirectionUp { + canBuy = false + } else if s.mainTrendCurrent == types.DirectionDown { + canSell = false + } + log.Infof("current position %v larger than max exposure %v, skip increase position", s.Position.GetBase().Abs(), s.MaxExposurePosition) + } + + // Check TradeInBand + if s.TradeInBand { + // Price too high + if bidPrice.Float64() > s.neutralBoll.UpBand.Last() { + canBuy = false + log.Infof("tradeInBand is set, skip buy when the price is higher than the neutralBB") + } + // Price too low in uptrend + if askPrice.Float64() < s.neutralBoll.DownBand.Last() { + canSell = false + log.Infof("tradeInBand is set, skip sell when the price is lower than the neutralBB") + } + } + + // Stop decrease when position closed unless both LinRegs are in the opposite direction to the main trend + if !s.isAllowOppositePosition() { + if s.mainTrendCurrent == types.DirectionUp && (s.Position.IsClosed() || s.Position.IsDust(askPrice)) { + canSell = false + } else if s.mainTrendCurrent == types.DirectionDown && (s.Position.IsClosed() || s.Position.IsDust(bidPrice)) { + canBuy = false + } + } + + // Check against account balance + baseQty, quoteQty := s.getAllowedBalance() + if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { // Leveraged + if quoteQty.Compare(fixedpoint.Zero) <= 0 { + if s.Position.IsLong() { + canBuy = false + } else if s.Position.IsShort() { + canSell = false + } + } + } else { + if buyQuantity.Compare(quoteQty.Div(bidPrice)) > 0 { // Spot + canBuy = false + } + if sellQuantity.Compare(baseQty) > 0 { + canSell = false + } + } + + log.Infof("canBuy %t, canSell %t", canBuy, canSell) + return canBuy, canSell +} + +// getOrderForms returns buy and sell order form for submission +func (s *Strategy) getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice fixedpoint.Value) (buyOrder types.SubmitOrder, sellOrder types.SubmitOrder) { + sellOrder = types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: sellQuantity, + Price: askPrice, + Market: s.Market, + GroupID: s.groupID, + } + buyOrder = types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: buyQuantity, + Price: bidPrice, + Market: s.Market, + GroupID: s.groupID, + } + + isMargin := s.session.Margin || s.session.IsolatedMargin + isFutures := s.session.Futures || s.session.IsolatedFutures + + if s.Position.IsClosed() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + buyOrder.ReduceOnly = false + sellOrder.ReduceOnly = false + } + } else if s.Position.IsLong() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + sellOrder.MarginSideEffect = types.SideEffectTypeAutoRepay + } else if isFutures { + buyOrder.ReduceOnly = false + sellOrder.ReduceOnly = true + } + + if s.Position.Base.Abs().Compare(sellOrder.Quantity) < 0 { + if isMargin { + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + sellOrder.ReduceOnly = false + } + } + } else if s.Position.IsShort() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeAutoRepay + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + buyOrder.ReduceOnly = true + sellOrder.ReduceOnly = false + } + + if s.Position.Base.Abs().Compare(buyOrder.Quantity) < 0 { + if isMargin { + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + sellOrder.ReduceOnly = false + } + } + } + + return buyOrder, sellOrder +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + // initial required information + s.session = session + s.ctx = ctx + + // Calculate group id for orders + instanceID := s.InstanceID() + s.groupID = util.FNV32(instanceID) + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = s.InstanceID() + + // Profit stats + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } + + s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.Bind() + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + bbgo.Sync(ctx, s) + }) + s.ExitMethods.Bind(session, s.orderExecutor) + + // Default spread + if s.Spread == fixedpoint.Zero { + s.Spread = fixedpoint.NewFromFloat(0.001) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + s.OnSuspend(func() { + _ = s.orderExecutor.GracefulCancel(ctx) + bbgo.Sync(ctx, s) + }) + s.OnEmergencyStop(func() { + // Close whole position + _ = s.ClosePosition(ctx, fixedpoint.NewFromFloat(1.0)) + }) + + // Initial trend + session.UserDataStream.OnStart(func() { + var closePrice fixedpoint.Value + if !bbgo.IsBackTesting { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + closePrice = ticker.Buy.Add(ticker.Sell).Div(two) + } else { + if price, ok := session.LastPrice(s.Symbol); ok { + closePrice = price + } + } + priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last()) + + // Main trend by ReverseEMA + if closePrice.Compare(priceReverseEMA) > 0 { + s.mainTrendCurrent = types.DirectionUp + } else if closePrice.Compare(priceReverseEMA) < 0 { + s.mainTrendCurrent = types.DirectionDown + } + }) + + // Check trend reversal + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.ReverseInterval, func(kline types.KLine) { + // closePrice is the close price of current kline + closePrice := kline.GetClose() + // priceReverseEMA is the current ReverseEMA price + priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last()) + + // Main trend by ReverseEMA + s.mainTrendPrevious = s.mainTrendCurrent + if closePrice.Compare(priceReverseEMA) > 0 { + s.mainTrendCurrent = types.DirectionUp + } else if closePrice.Compare(priceReverseEMA) < 0 { + s.mainTrendCurrent = types.DirectionDown + } + log.Infof("%s current trend is %v", s.Symbol, s.mainTrendCurrent) + + // Trend reversal + if s.mainTrendCurrent != s.mainTrendPrevious { + log.Infof("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent) + bbgo.Notify("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent) + // Close on-hand position that is not in the same direction as the new trend + if !s.Position.IsDust(closePrice) && + ((s.Position.IsLong() && s.mainTrendCurrent == types.DirectionDown) || + (s.Position.IsShort() && s.mainTrendCurrent == types.DirectionUp)) { + log.Infof("%s closing on-hand position due to trend reverse", s.Symbol) + bbgo.Notify("%s closing on-hand position due to trend reverse", s.Symbol) + if err := s.ClosePosition(ctx, fixedpoint.One); err != nil { + log.WithError(err).Errorf("cannot close on-hand position of %s", s.Symbol) + bbgo.Notify("cannot close on-hand position of %s", s.Symbol) + } + } + } + })) + + // Main interval + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + _ = s.orderExecutor.GracefulCancel(ctx) + + // closePrice is the close price of current kline + closePrice := kline.GetClose() + + // midPrice for ask and bid prices + var midPrice fixedpoint.Value + if !bbgo.IsBackTesting { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + midPrice = ticker.Buy.Add(ticker.Sell).Div(two) + log.Infof("using ticker price: bid %v / ask %v, mid price %v", ticker.Buy, ticker.Sell, midPrice) + } else { + midPrice = closePrice + } + + // Update price spread + s.updateSpread() + + // Update max exposure + s.updateMaxExposure(midPrice) + + // Current position status + log.Infof("position: %s", s.Position) + if !s.Position.IsClosed() && !s.Position.IsDust(midPrice) { + log.Infof("current %s unrealized profit: %f %s", s.Symbol, s.Position.UnrealizedProfit(midPrice).Float64(), s.Market.QuoteCurrency) + } + + // Order prices + askPrice, bidPrice := s.getOrderPrices(midPrice) + + // Order qty + sellQuantity, buyQuantity := s.getOrderQuantities(askPrice, bidPrice) + + buyOrder, sellOrder := s.getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice) + + canBuy, canSell := s.getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice) + + // Submit orders + var submitOrders []types.SubmitOrder + if canSell && sellOrder.Quantity.Compare(fixedpoint.Zero) > 0 { + submitOrders = append(submitOrders, sellOrder) + } + if canBuy && buyOrder.Quantity.Compare(fixedpoint.Zero) > 0 { + submitOrders = append(submitOrders, buyOrder) + } + + if len(submitOrders) == 0 { + return + } + log.Infof("submitting order(s): %v", submitOrders) + _, _ = s.orderExecutor.SubmitOrders(ctx, submitOrders...) + })) + + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +}