Skip to content

Commit 6314a31

Browse files
committed
strategy: bollmaker: dynamic spread by weighted Bollinger width ratio
1 parent f17249b commit 6314a31

File tree

3 files changed

+224
-63
lines changed

3 files changed

+224
-63
lines changed

config/bollmaker.yaml

+52-21
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,59 @@ exchangeStrategies:
6060
# Dynamic spread is an experimental feature. Use at your own risk!
6161
#
6262
# dynamicSpread enables the automatic adjustment to bid and ask spread.
63+
# Choose one of the scaling strategy to enable dynamicSpread:
64+
# - amplitude: scales by K-line amplitude
65+
# - weightedBollWidth: scales by weighted Bollinger band width (explained below)
6366
# dynamicSpread:
64-
# enabled: true
65-
# # window is the window of the SMAs of spreads
66-
# window: 1
67-
# askSpreadScale:
68-
# byPercentage:
69-
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
70-
# exp:
71-
# # from down to up
72-
# domain: [ 0.0001, 0.005 ]
73-
# # when in down band, holds 1.0 by maximum
74-
# # when in up band, holds 0.05 by maximum
75-
# range: [ 0.001, 0.002 ]
76-
# bidSpreadScale:
77-
# byPercentage:
78-
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
79-
# exp:
80-
# # from down to up
81-
# domain: [ 0.0001, 0.005 ]
82-
# # when in down band, holds 1.0 by maximum
83-
# # when in up band, holds 0.05 by maximum
84-
# range: [ 0.001, 0.002 ]
67+
# amplitude: # delete other scaling strategy if this is defined
68+
# # window is the window of the SMAs of spreads
69+
# window: 1
70+
# askSpreadScale:
71+
# byPercentage:
72+
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
73+
# exp:
74+
# # from down to up
75+
# domain: [ 0.0001, 0.005 ]
76+
# # when in down band, holds 1.0 by maximum
77+
# # when in up band, holds 0.05 by maximum
78+
# range: [ 0.001, 0.002 ]
79+
# bidSpreadScale:
80+
# byPercentage:
81+
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
82+
# exp:
83+
# # from down to up
84+
# domain: [ 0.0001, 0.005 ]
85+
# # when in down band, holds 1.0 by maximum
86+
# # when in up band, holds 0.05 by maximum
87+
# range: [ 0.001, 0.002 ]
88+
# weightedBollWidth: # delete other scaling strategy if this is defined
89+
# # Scale spread base on weighted Bollinger band width ratio between default and neutral bands.
90+
# # Given the default band: moving average bd_mid, band from bd_lower to bd_upper.
91+
# # And the neutral band: from bn_lower to bn_upper
92+
# # Set the sigmoid weighting function:
93+
# # - to ask spread, the weighting density function d_weight(x) is sigmoid((x - bd_mid) / (bd_upper - bd_lower))
94+
# # - to bid spread, the weighting density function d_weight(x) is sigmoid((bd_mid - x) / (bd_upper - bd_lower))
95+
# # Then calculate the weighted band width ratio by taking integral of d_weight(x) from bx_lower to bx_upper:
96+
# # - weighted_ratio = integral(d_weight from bn_lower to bn_upper) / integral(d_weight from bd_lower to bd_upper)
97+
# # - The wider neutral band get greater ratio
98+
# # - To ask spread, the higher neutral band get greater ratio
99+
# # - To bid spread, the lower neutral band get greater ratio
100+
# # The weighted ratio always positive, and may be greater than 1 if neutral band is wider than default band.
101+
# askSpreadScale:
102+
# byPercentage:
103+
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
104+
# linear:
105+
# # from down to up
106+
# domain: [ 0.1, 0.5 ]
107+
# range: [ 0.001, 0.002 ]
108+
# bidSpreadScale:
109+
# byPercentage:
110+
# # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale
111+
# linear:
112+
# # from down to up
113+
# domain: [ 0.1, 0.5 ]
114+
# range: [ 0.001, 0.002 ]
115+
85116

86117
# maxExposurePosition is the maximum position you can hold
87118
# +10 means you can hold 10 ETH long position by maximum

pkg/strategy/bollmaker/dynamic_spread.go

+166-36
Original file line numberDiff line numberDiff line change
@@ -2,69 +2,125 @@ package bollmaker
22

33
import (
44
"github.com/pkg/errors"
5+
"math"
56

67
"github.com/c9s/bbgo/pkg/bbgo"
78
"github.com/c9s/bbgo/pkg/indicator"
89
"github.com/c9s/bbgo/pkg/types"
910
)
1011

1112
type DynamicSpreadSettings struct {
12-
Enabled bool `json:"enabled"`
13+
AmpSpreadSettings *DynamicSpreadAmpSettings `json:"amplitude"`
14+
WeightedBollWidthRatioSpreadSettings *DynamicSpreadBollWidthRatioSettings `json:"weightedBollWidth"`
1315

16+
// deprecated
17+
Enabled *bool `json:"enabled"`
18+
19+
// deprecated
1420
types.IntervalWindow
1521

16-
// AskSpreadScale is used to define the ask spread range with the given percentage.
22+
// deprecated. AskSpreadScale is used to define the ask spread range with the given percentage.
1723
AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"`
1824

19-
// BidSpreadScale is used to define the bid spread range with the given percentage.
25+
// deprecated. BidSpreadScale is used to define the bid spread range with the given percentage.
2026
BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"`
27+
}
2128

22-
DynamicAskSpread *indicator.SMA
23-
DynamicBidSpread *indicator.SMA
29+
// Initialize dynamic spreads and preload SMAs
30+
func (ds *DynamicSpreadSettings) Initialize(symbol string, session *bbgo.ExchangeSession, neutralBoll, defaultBoll *indicator.BOLL) {
31+
switch {
32+
case ds.Enabled != nil && !*ds.Enabled:
33+
// do nothing
34+
case ds.AmpSpreadSettings != nil:
35+
ds.AmpSpreadSettings.initialize(symbol, session)
36+
case ds.WeightedBollWidthRatioSpreadSettings != nil:
37+
ds.WeightedBollWidthRatioSpreadSettings.initialize(neutralBoll, defaultBoll)
38+
}
39+
}
40+
41+
func (ds *DynamicSpreadSettings) IsEnabled() bool {
42+
return ds.AmpSpreadSettings != nil || ds.WeightedBollWidthRatioSpreadSettings != nil
2443
}
2544

2645
// Update dynamic spreads
2746
func (ds *DynamicSpreadSettings) Update(kline types.KLine) {
28-
if !ds.Enabled {
29-
return
47+
switch {
48+
case ds.AmpSpreadSettings != nil:
49+
ds.AmpSpreadSettings.update(kline)
50+
case ds.WeightedBollWidthRatioSpreadSettings != nil:
51+
// Boll bands are updated outside of settings. Do nothing.
52+
default:
53+
// Disabled. Do nothing.
3054
}
55+
}
3156

32-
ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64()
57+
// GetAskSpread returns current ask spread
58+
func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) {
59+
switch {
60+
case ds.AmpSpreadSettings != nil:
61+
return ds.AmpSpreadSettings.getAskSpread()
62+
case ds.WeightedBollWidthRatioSpreadSettings != nil:
63+
return ds.WeightedBollWidthRatioSpreadSettings.getAskSpread()
64+
default:
65+
return 0, errors.New("dynamic spread is not enabled")
66+
}
67+
}
3368

34-
switch kline.Direction() {
35-
case types.DirectionUp:
36-
ds.DynamicAskSpread.Update(ampl)
37-
ds.DynamicBidSpread.Update(0)
38-
case types.DirectionDown:
39-
ds.DynamicBidSpread.Update(ampl)
40-
ds.DynamicAskSpread.Update(0)
69+
// GetBidSpread returns current dynamic bid spread
70+
func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) {
71+
switch {
72+
case ds.AmpSpreadSettings != nil:
73+
return ds.AmpSpreadSettings.getBidSpread()
74+
case ds.WeightedBollWidthRatioSpreadSettings != nil:
75+
return ds.WeightedBollWidthRatioSpreadSettings.getBidSpread()
4176
default:
42-
ds.DynamicAskSpread.Update(0)
43-
ds.DynamicBidSpread.Update(0)
77+
return 0, errors.New("dynamic spread is not enabled")
4478
}
4579
}
4680

47-
// Initialize dynamic spreads and preload SMAs
48-
func (ds *DynamicSpreadSettings) Initialize(symbol string, session *bbgo.ExchangeSession) {
49-
ds.DynamicBidSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}}
50-
ds.DynamicAskSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}}
81+
type DynamicSpreadAmpSettings struct {
82+
types.IntervalWindow
5183

84+
// AskSpreadScale is used to define the ask spread range with the given percentage.
85+
AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"`
86+
87+
// BidSpreadScale is used to define the bid spread range with the given percentage.
88+
BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"`
89+
90+
dynamicAskSpread *indicator.SMA
91+
dynamicBidSpread *indicator.SMA
92+
}
93+
94+
func (ds *DynamicSpreadAmpSettings) initialize(symbol string, session *bbgo.ExchangeSession) {
95+
ds.dynamicBidSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}}
96+
ds.dynamicAskSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}}
5297
kLineStore, _ := session.MarketDataStore(symbol)
5398
if klines, ok := kLineStore.KLinesOfInterval(ds.Interval); ok {
5499
for i := 0; i < len(*klines); i++ {
55-
ds.Update((*klines)[i])
100+
ds.update((*klines)[i])
56101
}
57102
}
58103
}
59104

60-
// GetAskSpread returns current ask spread
61-
func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) {
62-
if !ds.Enabled {
63-
return 0, errors.New("dynamic spread is not enabled")
105+
func (ds *DynamicSpreadAmpSettings) update(kline types.KLine) {
106+
ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64()
107+
108+
switch kline.Direction() {
109+
case types.DirectionUp:
110+
ds.dynamicAskSpread.Update(ampl)
111+
ds.dynamicBidSpread.Update(0)
112+
case types.DirectionDown:
113+
ds.dynamicBidSpread.Update(ampl)
114+
ds.dynamicAskSpread.Update(0)
115+
default:
116+
ds.dynamicAskSpread.Update(0)
117+
ds.dynamicBidSpread.Update(0)
64118
}
119+
}
65120

66-
if ds.AskSpreadScale != nil && ds.DynamicAskSpread.Length() >= ds.Window {
67-
askSpread, err = ds.AskSpreadScale.Scale(ds.DynamicAskSpread.Last())
121+
func (ds *DynamicSpreadAmpSettings) getAskSpread() (askSpread float64, err error) {
122+
if ds.AskSpreadScale != nil && ds.dynamicAskSpread.Length() >= ds.Window {
123+
askSpread, err = ds.AskSpreadScale.Scale(ds.dynamicAskSpread.Last())
68124
if err != nil {
69125
log.WithError(err).Errorf("can not calculate dynamicAskSpread")
70126
return 0, err
@@ -76,14 +132,9 @@ func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) {
76132
return 0, errors.New("incomplete dynamic spread settings or not enough data yet")
77133
}
78134

79-
// GetBidSpread returns current dynamic bid spread
80-
func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) {
81-
if !ds.Enabled {
82-
return 0, errors.New("dynamic spread is not enabled")
83-
}
84-
85-
if ds.BidSpreadScale != nil && ds.DynamicBidSpread.Length() >= ds.Window {
86-
bidSpread, err = ds.BidSpreadScale.Scale(ds.DynamicBidSpread.Last())
135+
func (ds *DynamicSpreadAmpSettings) getBidSpread() (bidSpread float64, err error) {
136+
if ds.BidSpreadScale != nil && ds.dynamicBidSpread.Length() >= ds.Window {
137+
bidSpread, err = ds.BidSpreadScale.Scale(ds.dynamicBidSpread.Last())
87138
if err != nil {
88139
log.WithError(err).Errorf("can not calculate dynamicBidSpread")
89140
return 0, err
@@ -94,3 +145,82 @@ func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) {
94145

95146
return 0, errors.New("incomplete dynamic spread settings or not enough data yet")
96147
}
148+
149+
type DynamicSpreadBollWidthRatioSettings struct {
150+
// AskSpreadScale is used to define the ask spread range with the given percentage.
151+
AskSpreadScale *bbgo.PercentageScale `json:"askSpreadScale"`
152+
153+
// BidSpreadScale is used to define the bid spread range with the given percentage.
154+
BidSpreadScale *bbgo.PercentageScale `json:"bidSpreadScale"`
155+
156+
neutralBoll *indicator.BOLL
157+
defaultBoll *indicator.BOLL
158+
}
159+
160+
func (ds *DynamicSpreadBollWidthRatioSettings) initialize(neutralBoll, defaultBoll *indicator.BOLL) {
161+
ds.neutralBoll = neutralBoll
162+
ds.defaultBoll = defaultBoll
163+
}
164+
165+
func (ds *DynamicSpreadBollWidthRatioSettings) getAskSpread() (askSpread float64, err error) {
166+
askSpread, err = ds.AskSpreadScale.Scale(ds.getWeightedBBWidthRatio(true))
167+
if err != nil {
168+
log.WithError(err).Errorf("can not calculate dynamicAskSpread")
169+
return 0, err
170+
}
171+
172+
return askSpread, nil
173+
}
174+
175+
func (ds *DynamicSpreadBollWidthRatioSettings) getBidSpread() (bidSpread float64, err error) {
176+
bidSpread, err = ds.BidSpreadScale.Scale(ds.getWeightedBBWidthRatio(false))
177+
if err != nil {
178+
log.WithError(err).Errorf("can not calculate dynamicAskSpread")
179+
return 0, err
180+
}
181+
182+
return bidSpread, nil
183+
}
184+
185+
func (ds *DynamicSpreadBollWidthRatioSettings) getWeightedBBWidthRatio(positiveSigmoid bool) float64 {
186+
// Weight the width of Boll bands with sigmoid function and calculate the ratio after integral.
187+
//
188+
// Given the default band: moving average default_BB_mid, band from default_BB_lower to default_BB_upper.
189+
// And the neutral band: from neutral_BB_lower to neutral_BB_upper.
190+
//
191+
// 1 x - default_BB_mid
192+
// sigmoid weighting function f(y) = ------------- where y = --------------------
193+
// 1 + exp(-y) default_BB_width
194+
// Set the sigmoid weighting function:
195+
// - to ask spread, the weighting density function d_weight(x) is sigmoid((x - default_BB_mid) / (default_BB_upper - default_BB_lower))
196+
// - to bid spread, the weighting density function d_weight(x) is sigmoid((default_BB_mid - x) / (default_BB_upper - default_BB_lower))
197+
//
198+
// Then calculate the weighted band width ratio by taking integral of d_weight(x) from bx_lower to bx_upper:
199+
// infinite integral of ask spread sigmoid weighting density function F(y) = ln(1 + exp(y))
200+
// infinite integral of bid spread sigmoid weighting density function F(y) = y - ln(1 + exp(y))
201+
// Note that we've rescaled the sigmoid function to fit default BB,
202+
// the weighted default BB width is always calculated by integral(f of y from -1 to 1) = F(1) - F(-1)
203+
// F(y_upper) - F(y_lower) F(y_upper) - F(y_lower)
204+
// weighted ratio = ------------------------- = -------------------------
205+
// F(1) - F(-1) 1
206+
// where y_upper = (neutral_BB_upper - default_BB_mid) / default_BB_width
207+
// y_lower = (neutral_BB_lower - default_BB_mid) / default_BB_width
208+
// - The wider neutral band get greater ratio
209+
// - To ask spread, the higher neutral band get greater ratio
210+
// - To bid spread, the lower neutral band get greater ratio
211+
212+
defaultMid := ds.defaultBoll.SMA.Last()
213+
defaultWidth := ds.defaultBoll.UpBand.Last() - ds.defaultBoll.DownBand.Last()
214+
yUpper := (ds.neutralBoll.UpBand.Last() - defaultMid) / defaultWidth
215+
yLower := (ds.neutralBoll.DownBand.Last() - defaultMid) / defaultWidth
216+
var weightedUpper, weightedLower float64
217+
if positiveSigmoid {
218+
weightedUpper = math.Log(1 + math.Pow(math.E, yUpper))
219+
weightedLower = math.Log(1 + math.Pow(math.E, yLower))
220+
} else {
221+
weightedUpper = yUpper - math.Log(1+math.Pow(math.E, yUpper))
222+
weightedLower = yLower - math.Log(1+math.Pow(math.E, yLower))
223+
}
224+
// The weighted ratio always positive, and may be greater than 1 if neutral band is wider than default band.
225+
return (weightedUpper - weightedLower) / 1.
226+
}

pkg/strategy/bollmaker/strategy.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -435,12 +435,15 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
435435
// StrategyController
436436
s.Status = types.StrategyStatusRunning
437437

438+
s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth)
439+
s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth)
440+
438441
// Setup dynamic spread
439-
if s.DynamicSpread.Enabled {
442+
if s.DynamicSpread.IsEnabled() {
440443
if s.DynamicSpread.Interval == "" {
441444
s.DynamicSpread.Interval = s.Interval
442445
}
443-
s.DynamicSpread.Initialize(s.Symbol, s.session)
446+
s.DynamicSpread.Initialize(s.Symbol, s.session, s.neutralBoll, s.defaultBoll)
444447
}
445448

446449
if s.DisableShort {
@@ -463,9 +466,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
463466
s.ShadowProtectionRatio = fixedpoint.NewFromFloat(0.01)
464467
}
465468

466-
s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth)
467-
s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth)
468-
469469
// calculate group id for orders
470470
instanceID := s.InstanceID()
471471
s.groupID = util.FNV32(instanceID)
@@ -538,7 +538,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
538538
}
539539

540540
// Update spreads with dynamic spread
541-
if s.DynamicSpread.Enabled {
541+
if s.DynamicSpread.IsEnabled() {
542542
s.DynamicSpread.Update(kline)
543543
dynamicBidSpread, err := s.DynamicSpread.GetBidSpread()
544544
if err == nil && dynamicBidSpread > 0 {

0 commit comments

Comments
 (0)