Merge pull request #1027 from andycheng123/strategy/linregmaker

Strategy: LinReg Maker
This commit is contained in:
Yo-An Lin 2022-12-20 14:55:48 +08:00 committed by GitHub
commit 2b20ff4da9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1536 additions and 1 deletions

169
config/linregmaker.yaml Normal file
View File

@ -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%

View File

@ -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 {

View File

@ -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"

125
pkg/indicator/linreg.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}