diff --git a/config/bollmaker.yaml b/config/bollmaker.yaml index b65211f9f..08fc9fb73 100644 --- a/config/bollmaker.yaml +++ b/config/bollmaker.yaml @@ -60,28 +60,59 @@ exchangeStrategies: # Dynamic spread is an experimental feature. Use at your own risk! # # dynamicSpread enables the automatic adjustment to bid and ask spread. + # Choose one of the scaling strategy to enable dynamicSpread: + # - amplitude: scales by K-line amplitude + # - weightedBollWidth: scales by weighted Bollinger band width (explained below) # dynamicSpread: - # enabled: true - # # window is the window of the SMAs of spreads - # window: 1 - # 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 ] - # # when in down band, holds 1.0 by maximum - # # when in up band, holds 0.05 by maximum - # 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 ] - # # when in down band, holds 1.0 by maximum - # # when in up band, holds 0.05 by maximum - # range: [ 0.001, 0.002 ] + # amplitude: # delete other scaling strategy if this is defined + # # window is the window of the SMAs of spreads + # window: 1 + # 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 ] + # # when in down band, holds 1.0 by maximum + # # when in up band, holds 0.05 by maximum + # 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 ] + # # when in down band, holds 1.0 by maximum + # # when in up band, holds 0.05 by maximum + # range: [ 0.001, 0.002 ] + # weightedBollWidth: # delete other scaling strategy if this is defined + # # Scale spread base on weighted Bollinger band width ratio between default and neutral bands. + # # Given the default band: moving average bd_mid, band from bd_lower to bd_upper. + # # And the neutral band: from bn_lower to bn_upper + # # Set the sigmoid weighting function: + # # - to ask spread, the weighting density function d_weight(x) is sigmoid((x - bd_mid) / (bd_upper - bd_lower)) + # # - to bid spread, the weighting density function d_weight(x) is sigmoid((bd_mid - x) / (bd_upper - bd_lower)) + # # Then calculate the weighted band width ratio by taking integral of d_weight(x) from bx_lower to bx_upper: + # # - weighted_ratio = integral(d_weight from bn_lower to bn_upper) / integral(d_weight from bd_lower to bd_upper) + # # - 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 + # # The weighted ratio always positive, and may be greater than 1 if neutral band is wider than default band. + # askSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # linear: + # # from down to up + # domain: [ 0.1, 0.5 ] + # range: [ 0.001, 0.002 ] + # bidSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # linear: + # # from down to up + # domain: [ 0.1, 0.5 ] + # range: [ 0.001, 0.002 ] + # maxExposurePosition is the maximum position you can hold # +10 means you can hold 10 ETH long position by maximum diff --git a/pkg/strategy/bollmaker/dynamic_spread.go b/pkg/strategy/bollmaker/dynamic_spread.go index a73030791..b3fc7d9a9 100644 --- a/pkg/strategy/bollmaker/dynamic_spread.go +++ b/pkg/strategy/bollmaker/dynamic_spread.go @@ -2,6 +2,7 @@ package bollmaker import ( "github.com/pkg/errors" + "math" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/indicator" @@ -9,8 +10,75 @@ import ( ) type DynamicSpreadSettings struct { - Enabled bool `json:"enabled"` + AmpSpreadSettings *DynamicSpreadAmpSettings `json:"amplitude"` + WeightedBollWidthRatioSpreadSettings *DynamicSpreadBollWidthRatioSettings `json:"weightedBollWidth"` + // deprecated + Enabled *bool `json:"enabled"` + + // deprecated + types.IntervalWindow + + // deprecated. AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"` + + // deprecated. BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"` +} + +// Initialize dynamic spreads and preload SMAs +func (ds *DynamicSpreadSettings) Initialize(symbol string, session *bbgo.ExchangeSession, neutralBoll, defaultBoll *indicator.BOLL) { + switch { + case ds.Enabled != nil && !*ds.Enabled: + // do nothing + case ds.AmpSpreadSettings != nil: + ds.AmpSpreadSettings.initialize(symbol, session) + case ds.WeightedBollWidthRatioSpreadSettings != nil: + ds.WeightedBollWidthRatioSpreadSettings.initialize(neutralBoll, defaultBoll) + } +} + +func (ds *DynamicSpreadSettings) IsEnabled() bool { + return ds.AmpSpreadSettings != nil || ds.WeightedBollWidthRatioSpreadSettings != nil +} + +// Update dynamic spreads +func (ds *DynamicSpreadSettings) Update(kline types.KLine) { + switch { + case ds.AmpSpreadSettings != nil: + ds.AmpSpreadSettings.update(kline) + case ds.WeightedBollWidthRatioSpreadSettings != nil: + // Boll bands are updated outside of settings. Do nothing. + default: + // Disabled. Do nothing. + } +} + +// GetAskSpread returns current ask spread +func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) { + switch { + case ds.AmpSpreadSettings != nil: + return ds.AmpSpreadSettings.getAskSpread() + case ds.WeightedBollWidthRatioSpreadSettings != nil: + return ds.WeightedBollWidthRatioSpreadSettings.getAskSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// GetBidSpread returns current dynamic bid spread +func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) { + switch { + case ds.AmpSpreadSettings != nil: + return ds.AmpSpreadSettings.getBidSpread() + case ds.WeightedBollWidthRatioSpreadSettings != nil: + return ds.WeightedBollWidthRatioSpreadSettings.getBidSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +type DynamicSpreadAmpSettings struct { types.IntervalWindow // AskSpreadScale is used to define the ask spread range with the given percentage. @@ -19,52 +87,40 @@ type DynamicSpreadSettings struct { // BidSpreadScale is used to define the bid spread range with the given percentage. BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"` - DynamicAskSpread *indicator.SMA - DynamicBidSpread *indicator.SMA + dynamicAskSpread *indicator.SMA + dynamicBidSpread *indicator.SMA } -// Update dynamic spreads -func (ds *DynamicSpreadSettings) Update(kline types.KLine) { - if !ds.Enabled { - return - } - - 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) - } -} - -// Initialize dynamic spreads and preload SMAs -func (ds *DynamicSpreadSettings) 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}} - +func (ds *DynamicSpreadAmpSettings) 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}} kLineStore, _ := session.MarketDataStore(symbol) if klines, ok := kLineStore.KLinesOfInterval(ds.Interval); ok { for i := 0; i < len(*klines); i++ { - ds.Update((*klines)[i]) + ds.update((*klines)[i]) } } } -// GetAskSpread returns current ask spread -func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) { - if !ds.Enabled { - return 0, errors.New("dynamic spread is not enabled") - } +func (ds *DynamicSpreadAmpSettings) update(kline types.KLine) { + ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64() - if ds.AskSpreadScale != nil && ds.DynamicAskSpread.Length() >= ds.Window { - askSpread, err = ds.AskSpreadScale.Scale(ds.DynamicAskSpread.Last()) + 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 *DynamicSpreadAmpSettings) 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 @@ -76,14 +132,9 @@ func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) { return 0, errors.New("incomplete dynamic spread settings or not enough data yet") } -// GetBidSpread returns current dynamic bid spread -func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) { - if !ds.Enabled { - return 0, errors.New("dynamic spread is not enabled") - } - - if ds.BidSpreadScale != nil && ds.DynamicBidSpread.Length() >= ds.Window { - bidSpread, err = ds.BidSpreadScale.Scale(ds.DynamicBidSpread.Last()) +func (ds *DynamicSpreadAmpSettings) 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 @@ -94,3 +145,82 @@ func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) { return 0, errors.New("incomplete dynamic spread settings or not enough data yet") } + +type DynamicSpreadBollWidthRatioSettings 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"` + + neutralBoll *indicator.BOLL + defaultBoll *indicator.BOLL +} + +func (ds *DynamicSpreadBollWidthRatioSettings) initialize(neutralBoll, defaultBoll *indicator.BOLL) { + ds.neutralBoll = neutralBoll + ds.defaultBoll = defaultBoll +} + +func (ds *DynamicSpreadBollWidthRatioSettings) 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 *DynamicSpreadBollWidthRatioSettings) 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 *DynamicSpreadBollWidthRatioSettings) 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. + // + // 1 x - default_BB_mid + // sigmoid weighting function f(y) = ------------- where y = -------------------- + // 1 + exp(-y) default_BB_width + // Set the sigmoid weighting function: + // - to ask spread, the weighting density function d_weight(x) is sigmoid((x - default_BB_mid) / (default_BB_upper - default_BB_lower)) + // - to bid spread, the weighting density function d_weight(x) is sigmoid((default_BB_mid - x) / (default_BB_upper - default_BB_lower)) + // + // Then calculate the weighted band width ratio by taking integral of d_weight(x) from bx_lower to bx_upper: + // infinite integral of ask spread sigmoid weighting density function F(y) = ln(1 + exp(y)) + // infinite integral of bid spread sigmoid weighting density function F(y) = y - ln(1 + exp(y)) + // Note that we've rescaled the sigmoid function to fit default BB, + // the weighted default BB width is always calculated by integral(f of y from -1 to 1) = F(1) - F(-1) + // F(y_upper) - F(y_lower) F(y_upper) - F(y_lower) + // weighted ratio = ------------------------- = ------------------------- + // F(1) - F(-1) 1 + // where y_upper = (neutral_BB_upper - default_BB_mid) / default_BB_width + // y_lower = (neutral_BB_lower - default_BB_mid) / default_BB_width + // - 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() + defaultWidth := ds.defaultBoll.UpBand.Last() - ds.defaultBoll.DownBand.Last() + yUpper := (ds.neutralBoll.UpBand.Last() - defaultMid) / defaultWidth + yLower := (ds.neutralBoll.DownBand.Last() - defaultMid) / defaultWidth + var weightedUpper, weightedLower float64 + if positiveSigmoid { + weightedUpper = math.Log(1 + math.Pow(math.E, yUpper)) + weightedLower = math.Log(1 + math.Pow(math.E, yLower)) + } else { + weightedUpper = yUpper - math.Log(1+math.Pow(math.E, yUpper)) + weightedLower = yLower - math.Log(1+math.Pow(math.E, yLower)) + } + // The weighted ratio always positive, and may be greater than 1 if neutral band is wider than default band. + return (weightedUpper - weightedLower) / 1. +} diff --git a/pkg/strategy/bollmaker/strategy.go b/pkg/strategy/bollmaker/strategy.go index 2d202d954..f9b3200bd 100644 --- a/pkg/strategy/bollmaker/strategy.go +++ b/pkg/strategy/bollmaker/strategy.go @@ -435,12 +435,15 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se // StrategyController s.Status = types.StrategyStatusRunning + s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) + s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth) + // Setup dynamic spread - if s.DynamicSpread.Enabled { + if s.DynamicSpread.IsEnabled() { if s.DynamicSpread.Interval == "" { s.DynamicSpread.Interval = s.Interval } - s.DynamicSpread.Initialize(s.Symbol, s.session) + s.DynamicSpread.Initialize(s.Symbol, s.session, s.neutralBoll, s.defaultBoll) } if s.DisableShort { @@ -463,9 +466,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.ShadowProtectionRatio = fixedpoint.NewFromFloat(0.01) } - s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) - s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth) - // calculate group id for orders instanceID := s.InstanceID() s.groupID = util.FNV32(instanceID) @@ -538,7 +538,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } // Update spreads with dynamic spread - if s.DynamicSpread.Enabled { + if s.DynamicSpread.IsEnabled() { s.DynamicSpread.Update(kline) dynamicBidSpread, err := s.DynamicSpread.GetBidSpread() if err == nil && dynamicBidSpread > 0 {