diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 196516304..b18cdf76a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,6 +9,7 @@ on: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: diff --git a/config/grid2-max.yaml b/config/grid2-max.yaml new file mode 100644 index 000000000..fb5416da9 --- /dev/null +++ b/config/grid2-max.yaml @@ -0,0 +1,50 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + +# example command: +# godotenv -f .env.local -- go run ./cmd/bbgo backtest --config config/grid2-max.yaml --base-asset-baseline +backtest: + startTime: "2022-01-01" + endTime: "2022-11-25" + symbols: + - BTCUSDT + sessions: [max] + accounts: + binance: + balances: + BTC: 0.0 + USDT: 10000.0 + +exchangeStrategies: + +- on: max + grid2: + symbol: BTCUSDT + upperPrice: 20_000.0 + lowerPrice: 10_000.0 + gridNumber: 50 + + ## profitSpread is the profit spread of the arbitrage order (sell order) + ## greater the profitSpread, greater the profit you make when the sell order is filled. + ## you can set this instead of the default grid profit spread. + ## by default, profitSpread = (upperPrice - lowerPrice) / gridNumber + ## that is, greater the gridNumber, lesser the profit of each grid. + # profitSpread: 1000.0 + + ## There are 3 kinds of setup + ## NOTICE: you can only choose one, uncomment the config to enable it + ## + ## 1) fixed amount: amount is the quote unit (e.g. USDT in BTCUSDT) + # amount: 10.0 + + ## 2) fixed quantity: it will use your balance to place orders with the fixed quantity. e.g. 0.001 BTC + # quantity: 0.001 + + ## 3) quoteInvestment and baseInvestment: when using quoteInvestment, the strategy will automatically calculate your best quantity for the whole grid. + ## quoteInvestment is required, and baseInvestment is optional (could be zero) + ## if you have existing BTC position and want to reuse it you can set the baseInvestment. + quoteInvestment: 10_000 + baseInvestment: 1.0 diff --git a/config/grid2.yaml b/config/grid2.yaml new file mode 100644 index 000000000..a6aa3d920 --- /dev/null +++ b/config/grid2.yaml @@ -0,0 +1,50 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + +# example command: +# go run ./cmd/bbgo backtest --config config/grid2.yaml --base-asset-baseline +backtest: + startTime: "2022-01-01" + endTime: "2022-11-25" + symbols: + - BTCUSDT + sessions: [binance] + accounts: + binance: + balances: + BTC: 0.0 + USDT: 10000.0 + +exchangeStrategies: + +- on: binance + grid2: + symbol: BTCUSDT + upperPrice: 15_000.0 + lowerPrice: 10_000.0 + gridNumber: 10 + + ## profitSpread is the profit spread of the arbitrage order (sell order) + ## greater the profitSpread, greater the profit you make when the sell order is filled. + ## you can set this instead of the default grid profit spread. + ## by default, profitSpread = (upperPrice - lowerPrice) / gridNumber + ## that is, greater the gridNumber, lesser the profit of each grid. + # profitSpread: 1000.0 + + ## There are 3 kinds of setup + ## NOTICE: you can only choose one, uncomment the config to enable it + ## + ## 1) fixed amount: amount is the quote unit (e.g. USDT in BTCUSDT) + # amount: 10.0 + + ## 2) fixed quantity: it will use your balance to place orders with the fixed quantity. e.g. 0.001 BTC + # quantity: 0.001 + + ## 3) quoteInvestment and baseInvestment: when using quoteInvestment, the strategy will automatically calculate your best quantity for the whole grid. + ## quoteInvestment is required, and baseInvestment is optional (could be zero) + ## if you have existing BTC position and want to reuse it you can set the baseInvestment. + quoteInvestment: 10_000 + baseInvestment: 1.0 diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 1c29ab684..93334d9f0 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -17,6 +17,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/fmaker" _ "github.com/c9s/bbgo/pkg/strategy/funding" _ "github.com/c9s/bbgo/pkg/strategy/grid" + _ "github.com/c9s/bbgo/pkg/strategy/grid2" _ "github.com/c9s/bbgo/pkg/strategy/harmonic" _ "github.com/c9s/bbgo/pkg/strategy/irr" _ "github.com/c9s/bbgo/pkg/strategy/kline" diff --git a/pkg/exchange/binance/binanceapi/client_test.go b/pkg/exchange/binance/binanceapi/client_test.go index e86bba783..2c06f858f 100644 --- a/pkg/exchange/binance/binanceapi/client_test.go +++ b/pkg/exchange/binance/binanceapi/client_test.go @@ -4,6 +4,8 @@ import ( "context" "log" "net/http/httputil" + "os" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -12,6 +14,10 @@ import ( ) func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") if !ok { t.SkipNow() @@ -101,6 +107,10 @@ func TestClient_NewGetMarginInterestRateHistoryRequest(t *testing.T) { } func TestClient_privateCall(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") if !ok { t.SkipNow() @@ -136,6 +146,10 @@ func TestClient_privateCall(t *testing.T) { } func TestClient_setTimeOffsetFromServer(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + client := NewClient("") err := client.SetTimeOffsetFromServer(context.Background()) assert.NoError(t, err) diff --git a/pkg/fixedpoint/dec.go b/pkg/fixedpoint/dec.go index 2b5f3743b..43c52d499 100644 --- a/pkg/fixedpoint/dec.go +++ b/pkg/fixedpoint/dec.go @@ -131,7 +131,7 @@ func NewFromInt(n int64) Value { if n == 0 { return Zero } - //n0 := n + // n0 := n sign := int8(signPos) if n < 0 { n = -n @@ -527,7 +527,7 @@ func NewFromString(s string) (Value, error) { coef *= pow10[p] exp -= p } - //check(coefMin <= coef && coef <= coefMax) + // check(coefMin <= coef && coef <= coefMax) return Value{coef, sign, exp}, nil } @@ -577,7 +577,7 @@ func NewFromBytes(s []byte) (Value, error) { coef *= pow10[p] exp -= p } - //check(coefMin <= coef && coef <= coefMax) + // check(coefMin <= coef && coef <= coefMax) return Value{coef, sign, exp}, nil } @@ -958,6 +958,10 @@ func (dn Value) integer(mode RoundingMode) Value { return Value{i, dn.sign, dn.exp} } +func (dn Value) Floor() Value { + return dn.Round(0, Down) +} + func (dn Value) Round(r int, mode RoundingMode) Value { if dn.sign == 0 || dn.sign == signNegInf || dn.sign == signPosInf || r >= digitsMax { @@ -1171,9 +1175,9 @@ func align(x, y *Value) bool { return false } yshift = e - //check(0 <= yshift && yshift <= 20) + // check(0 <= yshift && yshift <= 20) y.coef = (y.coef + halfpow10[yshift]) / pow10[yshift] - //check(int(y.exp)+yshift == int(x.exp)) + // check(int(y.exp)+yshift == int(x.exp)) return true } @@ -1278,7 +1282,7 @@ func (dn Value) Format(mask string) string { nd := len(digits) di := e - before - //check(di <= 0) + // check(di <= 0) var buf strings.Builder sign := n.Sign() signok := (sign >= 0) diff --git a/pkg/fixedpoint/dec_dnum_test.go b/pkg/fixedpoint/dec_dnum_test.go index d92d49639..051a8f0e6 100644 --- a/pkg/fixedpoint/dec_dnum_test.go +++ b/pkg/fixedpoint/dec_dnum_test.go @@ -3,8 +3,9 @@ package fixedpoint import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func TestDelta(t *testing.T) { @@ -13,6 +14,12 @@ func TestDelta(t *testing.T) { assert.InDelta(t, f1.Mul(f2).Float64(), 41.3, 1e-14) } +func TestFloor(t *testing.T) { + f1 := MustNewFromString("10.333333") + f2 := f1.Floor() + assert.Equal(t, "10", f2.String()) +} + func TestInternal(t *testing.T) { r := &reader{"1.1e-15", 0} c, e := r.getCoef() diff --git a/pkg/service/backtest_test.go b/pkg/service/backtest_test.go index ca863e287..2d7b5bbb2 100644 --- a/pkg/service/backtest_test.go +++ b/pkg/service/backtest_test.go @@ -3,6 +3,8 @@ package service import ( "context" "database/sql" + "os" + "strconv" "testing" "time" @@ -15,6 +17,10 @@ import ( ) func TestBacktestService_FindMissingTimeRanges_EmptyData(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + db, err := prepareDB(t) if err != nil { t.Fatal(err) @@ -40,6 +46,10 @@ func TestBacktestService_FindMissingTimeRanges_EmptyData(t *testing.T) { } func TestBacktestService_QueryExistingDataRange(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + db, err := prepareDB(t) if err != nil { t.Fatal(err) @@ -67,6 +77,10 @@ func TestBacktestService_QueryExistingDataRange(t *testing.T) { } func TestBacktestService_SyncPartial(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + db, err := prepareDB(t) if err != nil { t.Fatal(err) @@ -113,6 +127,10 @@ func TestBacktestService_SyncPartial(t *testing.T) { } func TestBacktestService_FindMissingTimeRanges(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + db, err := prepareDB(t) if err != nil { t.Fatal(err) diff --git a/pkg/strategy/grid2/grid.go b/pkg/strategy/grid2/grid.go new file mode 100644 index 000000000..b2939e223 --- /dev/null +++ b/pkg/strategy/grid2/grid.go @@ -0,0 +1,187 @@ +package grid2 + +import ( + "math" + "sort" + + "github.com/c9s/bbgo/pkg/fixedpoint" +) + +type PinCalculator func() []Pin + +type Grid struct { + UpperPrice fixedpoint.Value `json:"upperPrice"` + LowerPrice fixedpoint.Value `json:"lowerPrice"` + + // Size is the number of total grids + Size fixedpoint.Value `json:"size"` + + // TickSize is the price tick size, this is used for truncating price + TickSize fixedpoint.Value `json:"tickSize"` + + // Spread is a immutable number + Spread fixedpoint.Value `json:"spread"` + + // Pins are the pinned grid prices, from low to high + Pins []Pin `json:"pins"` + + pinsCache map[Pin]struct{} `json:"-"` + + calculator PinCalculator +} + +type Pin fixedpoint.Value + +func calculateArithmeticPins(lower, upper, spread, tickSize fixedpoint.Value) []Pin { + var pins []Pin + var ts = tickSize.Float64() + for p := lower; p.Compare(upper) <= 0; p = p.Add(spread) { + // tickSize here = 0.01 + pp := p.Float64() / ts + pp = math.Trunc(pp) * ts + pin := Pin(fixedpoint.NewFromFloat(pp)) + pins = append(pins, pin) + } + + return pins +} + +func buildPinCache(pins []Pin) map[Pin]struct{} { + cache := make(map[Pin]struct{}, len(pins)) + for _, pin := range pins { + cache[pin] = struct{}{} + } + + return cache +} + +func NewGrid(lower, upper, size, tickSize fixedpoint.Value) *Grid { + height := upper.Sub(lower) + spread := height.Div(size) + + grid := &Grid{ + UpperPrice: upper, + LowerPrice: lower, + Size: size, + TickSize: tickSize, + Spread: spread, + } + + return grid +} + +func (g *Grid) CalculateGeometricPins() { + g.calculator = func() []Pin { + // TODO: implement geometric calculator + // return calculateArithmeticPins(g.LowerPrice, g.UpperPrice, g.Spread, g.TickSize) + return nil + } + + g.addPins(g.calculator()) +} + +func (g *Grid) CalculateArithmeticPins() { + g.calculator = func() []Pin { + return calculateArithmeticPins(g.LowerPrice, g.UpperPrice, g.Spread, g.TickSize) + } + + g.addPins(g.calculator()) +} + +func (g *Grid) Height() fixedpoint.Value { + return g.UpperPrice.Sub(g.LowerPrice) +} + +func (g *Grid) Above(price fixedpoint.Value) bool { + return price.Compare(g.UpperPrice) > 0 +} + +func (g *Grid) Below(price fixedpoint.Value) bool { + return price.Compare(g.LowerPrice) < 0 +} + +func (g *Grid) OutOfRange(price fixedpoint.Value) bool { + return price.Compare(g.LowerPrice) < 0 || price.Compare(g.UpperPrice) > 0 +} + +func (g *Grid) HasPin(pin Pin) (ok bool) { + _, ok = g.pinsCache[pin] + return ok +} + +// NextHigherPin finds the next higher pin +func (g *Grid) NextHigherPin(price fixedpoint.Value) (Pin, bool) { + i := g.SearchPin(price) + if i < len(g.Pins) && fixedpoint.Value(g.Pins[i]).Compare(price) == 0 && i+1 < len(g.Pins) { + return g.Pins[i+1], true + } + + return Pin(fixedpoint.Zero), false +} + +// NextLowerPin finds the next lower pin +func (g *Grid) NextLowerPin(price fixedpoint.Value) (Pin, bool) { + i := g.SearchPin(price) + if i < len(g.Pins) && fixedpoint.Value(g.Pins[i]).Compare(price) == 0 && i-1 >= 0 { + return g.Pins[i-1], true + } + + return Pin(fixedpoint.Zero), false +} + +func (g *Grid) SearchPin(price fixedpoint.Value) int { + i := sort.Search(len(g.Pins), func(i int) bool { + a := fixedpoint.Value(g.Pins[i]) + return a.Compare(price) >= 0 + }) + return i +} + +func (g *Grid) ExtendUpperPrice(upper fixedpoint.Value) (newPins []Pin) { + if upper.Compare(g.UpperPrice) <= 0 { + return nil + } + + newPins = calculateArithmeticPins(g.UpperPrice.Add(g.Spread), upper, g.Spread, g.TickSize) + g.UpperPrice = upper + g.addPins(newPins) + return newPins +} + +func (g *Grid) ExtendLowerPrice(lower fixedpoint.Value) (newPins []Pin) { + if lower.Compare(g.LowerPrice) >= 0 { + return nil + } + + n := g.LowerPrice.Sub(lower).Div(g.Spread).Floor() + lower = g.LowerPrice.Sub(g.Spread.Mul(n)) + newPins = calculateArithmeticPins(lower, g.LowerPrice.Sub(g.Spread), g.Spread, g.TickSize) + + g.LowerPrice = lower + g.addPins(newPins) + return newPins +} + +func (g *Grid) TopPin() Pin { + return g.Pins[len(g.Pins)-1] +} + +func (g *Grid) BottomPin() Pin { + return g.Pins[0] +} + +func (g *Grid) addPins(pins []Pin) { + g.Pins = append(g.Pins, pins...) + + sort.Slice(g.Pins, func(i, j int) bool { + a := fixedpoint.Value(g.Pins[i]) + b := fixedpoint.Value(g.Pins[j]) + return a.Compare(b) < 0 + }) + + g.updatePinsCache() +} + +func (g *Grid) updatePinsCache() { + g.pinsCache = buildPinCache(g.Pins) +} diff --git a/pkg/strategy/grid2/grid_test.go b/pkg/strategy/grid2/grid_test.go new file mode 100644 index 000000000..8edfe59cd --- /dev/null +++ b/pkg/strategy/grid2/grid_test.go @@ -0,0 +1,217 @@ +//go:build !dnum + +package grid2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" +) + +func number(a interface{}) fixedpoint.Value { + switch v := a.(type) { + case string: + return fixedpoint.MustNewFromString(v) + case int: + return fixedpoint.NewFromInt(int64(v)) + case int64: + return fixedpoint.NewFromInt(int64(v)) + case float64: + return fixedpoint.NewFromFloat(v) + } + + return fixedpoint.Zero +} + +func TestNewGrid(t *testing.T) { + upper := fixedpoint.NewFromFloat(500.0) + lower := fixedpoint.NewFromFloat(100.0) + size := fixedpoint.NewFromFloat(100.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.Equal(t, upper, grid.UpperPrice) + assert.Equal(t, lower, grid.LowerPrice) + assert.Equal(t, fixedpoint.NewFromFloat(4), grid.Spread) + if assert.Len(t, grid.Pins, 101) { + assert.Equal(t, Pin(number(100.0)), grid.Pins[0]) + assert.Equal(t, Pin(number(500.0)), grid.Pins[100]) + } +} + +func TestGrid_HasPin(t *testing.T) { + upper := fixedpoint.NewFromFloat(500.0) + lower := fixedpoint.NewFromFloat(100.0) + size := fixedpoint.NewFromFloat(100.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.True(t, grid.HasPin(Pin(number(100.0)))) + assert.True(t, grid.HasPin(Pin(number(500.0)))) + assert.False(t, grid.HasPin(Pin(number(101.0)))) +} + +func TestGrid_ExtendUpperPrice(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(4.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + originalSpread := grid.Spread + + t.Logf("pins: %+v", grid.Pins) + assert.Equal(t, number(100.0), originalSpread) + assert.Len(t, grid.Pins, 5) // (1000-500) / 4 + + newPins := grid.ExtendUpperPrice(number(1000.0)) + assert.Len(t, grid.Pins, 10) + assert.Len(t, newPins, 5) + assert.Equal(t, originalSpread, grid.Spread) + t.Logf("pins: %+v", grid.Pins) +} + +func TestGrid_ExtendLowerPrice(t *testing.T) { + upper := fixedpoint.NewFromFloat(3000.0) + lower := fixedpoint.NewFromFloat(2000.0) + size := fixedpoint.NewFromFloat(10.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.Equal(t, Pin(number(2000.0)), grid.BottomPin(), "bottom pin should be 1000.0") + assert.Equal(t, Pin(number(3000.0)), grid.TopPin(), "top pin should be 3000.0") + assert.Len(t, grid.Pins, 11) + + // spread = (3000 - 2000) / 10.0 + expectedSpread := fixedpoint.NewFromFloat(100.0) + assert.Equal(t, expectedSpread, grid.Spread) + + originalSpread := grid.Spread + newPins := grid.ExtendLowerPrice(fixedpoint.NewFromFloat(1000.0)) + assert.Equal(t, originalSpread, grid.Spread) + + t.Logf("newPins: %+v", newPins) + + // 100 = (2000-1000) / 10 + if assert.Len(t, newPins, 10) { + assert.Equal(t, Pin(number(1000.0)), newPins[0]) + assert.Equal(t, Pin(number(1900.0)), newPins[len(newPins)-1]) + } + + assert.Equal(t, expectedSpread, grid.Spread) + + if assert.Len(t, grid.Pins, 21) { + assert.Equal(t, Pin(number(1000.0)), grid.BottomPin(), "bottom pin should be 1000.0") + assert.Equal(t, Pin(number(3000.0)), grid.TopPin(), "top pin should be 3000.0") + } +} + +func TestGrid_NextLowerPin(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(4.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + t.Logf("pins: %+v", grid.Pins) + + next, ok := grid.NextLowerPin(number(200.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(100.0)), next) + + next, ok = grid.NextLowerPin(number(150.0)) + assert.False(t, ok) + assert.Equal(t, Pin(fixedpoint.Zero), next) +} + +func TestGrid_NextHigherPin(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(4.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + t.Logf("pins: %+v", grid.Pins) + + next, ok := grid.NextHigherPin(number(100.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(200.0)), next) + + next, ok = grid.NextHigherPin(number(400.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(500.0)), next) + + next, ok = grid.NextHigherPin(number(500.0)) + assert.False(t, ok) + assert.Equal(t, Pin(fixedpoint.Zero), next) +} + +func Test_calculateArithmeticPins(t *testing.T) { + type args struct { + lower fixedpoint.Value + upper fixedpoint.Value + size fixedpoint.Value + tickSize fixedpoint.Value + } + tests := []struct { + name string + args args + want []Pin + }{ + { + // (3000-1000)/30 = 66.6666666 + name: "simple", + args: args{ + lower: number(1000.0), + upper: number(3000.0), + size: number(30.0), + tickSize: number(0.01), + }, + want: []Pin{ + Pin(number(1000.0)), + Pin(number(1066.660)), + Pin(number(1133.330)), + Pin(number("1199.99")), + Pin(number(1266.660)), + Pin(number(1333.330)), + Pin(number(1399.990)), + Pin(number(1466.660)), + Pin(number(1533.330)), + Pin(number(1599.990)), + Pin(number(1666.660)), + Pin(number(1733.330)), + Pin(number(1799.990)), + Pin(number(1866.660)), + Pin(number(1933.330)), + Pin(number(1999.990)), + Pin(number(2066.660)), + Pin(number(2133.330)), + Pin(number("2199.99")), + Pin(number(2266.660)), + Pin(number(2333.330)), + Pin(number("2399.99")), + Pin(number(2466.660)), + Pin(number(2533.330)), + Pin(number("2599.99")), + Pin(number(2666.660)), + Pin(number(2733.330)), + Pin(number(2799.990)), + Pin(number(2866.660)), + Pin(number(2933.330)), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spread := tt.args.upper.Sub(tt.args.lower).Div(tt.args.size) + pins := calculateArithmeticPins(tt.args.lower, tt.args.upper, spread, tt.args.tickSize) + for i := 0; i < len(tt.want); i++ { + assert.InDelta(t, fixedpoint.Value(tt.want[i]).Float64(), + fixedpoint.Value(pins[i]).Float64(), + 0.001, + "calculateArithmeticPins(%v, %v, %v, %v)", tt.args.lower, tt.args.upper, tt.args.size, tt.args.tickSize) + } + }) + } +} diff --git a/pkg/strategy/grid2/strategy.go b/pkg/strategy/grid2/strategy.go new file mode 100644 index 000000000..dfefe60d8 --- /dev/null +++ b/pkg/strategy/grid2/strategy.go @@ -0,0 +1,588 @@ +package grid2 + +import ( + "context" + "fmt" + "sync" + + "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" + "github.com/c9s/bbgo/pkg/util" +) + +const ID = "grid2" + +var log = logrus.WithField("strategy", ID) + +func init() { + // Register the pointer of the strategy struct, + // so that bbgo knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the bbgo cmd package. + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type GridProfitStats struct { + TotalProfit fixedpoint.Value `json:"totalProfit"` + FloatProfit fixedpoint.Value `json:"floatProfit"` + GridProfit fixedpoint.Value `json:"gridProfit"` + ArbitrageCount int `json:"arbitrageCount"` + TotalFee fixedpoint.Value `json:"totalFee"` + Volume fixedpoint.Value `json:"volume"` +} + +type Strategy struct { + Environment *bbgo.Environment + + // Market stores the configuration of the market, for example, VolumePrecision, PricePrecision, MinLotSize... etc + // This field will be injected automatically since we defined the Symbol field. + types.Market `json:"-"` + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + + // ProfitSpread is the fixed profit spread you want to submit the sell order + ProfitSpread fixedpoint.Value `json:"profitSpread"` + + // GridNum is the grid number, how many orders you want to post on the orderbook. + GridNum int64 `json:"gridNumber"` + + UpperPrice fixedpoint.Value `json:"upperPrice"` + + LowerPrice fixedpoint.Value `json:"lowerPrice"` + + // QuantityOrAmount embeds the Quantity field and the Amount field + // If you set up the Quantity field or the Amount field, you don't need to set the QuoteInvestment and BaseInvestment + bbgo.QuantityOrAmount + + // If Quantity and Amount is not set, we can use the quote investment to calculate our quantity. + QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` + + // BaseInvestment is the total base quantity you want to place as the sell order. + BaseInvestment fixedpoint.Value `json:"baseInvestment"` + + grid *Grid + + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + Position *types.Position `persistence:"position"` + + // orderStore is used to store all the created orders, so that we can filter the trades. + orderStore *bbgo.OrderStore + + tradeCollector *bbgo.TradeCollector + + orderExecutor *bbgo.GeneralOrderExecutor + + // groupID is the group ID used for the strategy instance for canceling orders + groupID uint32 + + logger *logrus.Entry +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if s.UpperPrice.IsZero() { + return errors.New("upperPrice can not be zero, you forgot to set?") + } + + if s.LowerPrice.IsZero() { + return errors.New("lowerPrice can not be zero, you forgot to set?") + } + + if s.UpperPrice.Compare(s.LowerPrice) <= 0 { + return fmt.Errorf("upperPrice (%s) should not be less than or equal to lowerPrice (%s)", s.UpperPrice.String(), s.LowerPrice.String()) + } + + if s.GridNum == 0 { + return fmt.Errorf("gridNum can not be zero") + } + + if err := s.QuantityOrAmount.Validate(); err != nil { + if s.QuoteInvestment.IsZero() && s.BaseInvestment.IsZero() { + return err + } + } + + if !s.QuantityOrAmount.IsSet() && s.QuoteInvestment.IsZero() && s.BaseInvestment.IsZero() { + return fmt.Errorf("one of quantity, amount, quoteInvestment must be set") + } + + return nil +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +// InstanceID returns the instance identifier from the current grid configuration parameters +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s-%d-%d-%d", ID, s.Symbol, s.GridNum, s.UpperPrice.Int(), s.LowerPrice.Int()) +} + +func (s *Strategy) handleOrderFilled(o types.Order) { + +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + instanceID := s.InstanceID() + + s.logger = log.WithFields(logrus.Fields{ + "symbol": s.Symbol, + }) + + s.groupID = util.FNV32(instanceID) + + s.logger.Infof("using group id %d from fnv(%s)", s.groupID, instanceID) + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.Bind() + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + bbgo.Sync(ctx, s) + }) + + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + bbgo.Sync(ctx, s) + + // now we can cancel the open orders + s.logger.Infof("canceling active orders...") + + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful order cancel error") + } + }) + + session.UserDataStream.OnStart(func() { + if err := s.setupGridOrders(ctx, session); err != nil { + log.WithError(err).Errorf("failed to setup grid orders") + } + }) + + return nil +} + +type InvestmentBudget struct { + baseInvestment fixedpoint.Value + quoteInvestment fixedpoint.Value + baseBalance fixedpoint.Value + quoteBalance fixedpoint.Value +} + +func (s *Strategy) checkRequiredInvestmentByQuantity(baseBalance, quoteBalance, quantity, lastPrice fixedpoint.Value, pins []Pin) (requiredBase, requiredQuote fixedpoint.Value, err error) { + // check more investment budget details + requiredBase = fixedpoint.Zero + requiredQuote = fixedpoint.Zero + + // when we need to place a buy-to-sell conversion order, we need to mark the price + buyPlacedPrice := fixedpoint.Zero + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... + if price.Compare(lastPrice) >= 0 { + // for orders that sell + // if we still have the base balance + if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { + requiredBase = requiredBase.Add(quantity) + } else if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + buyPlacedPrice = nextLowerPrice + } + } else { + // for orders that buy + if !buyPlacedPrice.IsZero() && price.Compare(buyPlacedPrice) == 0 { + continue + } + requiredQuote = requiredQuote.Add(quantity.Mul(price)) + } + } + + if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", + baseBalance.Float64(), s.Market.BaseCurrency, + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredBase.Float64(), + requiredQuote.Float64()) + } + + if requiredBase.Compare(baseBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", + baseBalance.Float64(), s.Market.BaseCurrency, + requiredBase.Float64(), + ) + } + + if requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredQuote.Float64(), + ) + } + + return requiredBase, requiredQuote, nil +} + +func (s *Strategy) checkRequiredInvestmentByAmount(baseBalance, quoteBalance, amount, lastPrice fixedpoint.Value, pins []Pin) (requiredBase, requiredQuote fixedpoint.Value, err error) { + + // check more investment budget details + requiredBase = fixedpoint.Zero + requiredQuote = fixedpoint.Zero + + // when we need to place a buy-to-sell conversion order, we need to mark the price + buyPlacedPrice := fixedpoint.Zero + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... + if price.Compare(lastPrice) >= 0 { + // for orders that sell + // if we still have the base balance + quantity := amount.Div(lastPrice) + if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { + requiredBase = requiredBase.Add(quantity) + } else if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + buyPlacedPrice = nextLowerPrice + } + } else { + // for orders that buy + if !buyPlacedPrice.IsZero() && price.Compare(buyPlacedPrice) == 0 { + continue + } + requiredQuote = requiredQuote.Add(amount) + } + } + + if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", + baseBalance.Float64(), s.Market.BaseCurrency, + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredBase.Float64(), + requiredQuote.Float64()) + } + + if requiredBase.Compare(baseBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", + baseBalance.Float64(), s.Market.BaseCurrency, + requiredBase.Float64(), + ) + } + + if requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredQuote.Float64(), + ) + } + + return requiredBase, requiredQuote, nil +} + +func (s *Strategy) calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice fixedpoint.Value, pins []Pin) (fixedpoint.Value, error) { + buyPlacedPrice := fixedpoint.Zero + + // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... + // => + // quoteInvestment = (p1 + p2 + p3) * q + // q = quoteInvestment / (p1 + p2 + p3) + totalQuotePrice := fixedpoint.Zero + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + if price.Compare(lastPrice) >= 0 { + // for orders that sell + // if we still have the base balance + // quantity := amount.Div(lastPrice) + if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + // requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) + buyPlacedPrice = nextLowerPrice + } + } else { + // for orders that buy + if !buyPlacedPrice.IsZero() && price.Compare(buyPlacedPrice) == 0 { + continue + } + + totalQuotePrice = totalQuotePrice.Add(price) + } + } + + return quoteInvestment.Div(totalQuotePrice), nil +} + +func (s *Strategy) calculateQuoteBaseInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice fixedpoint.Value, pins []Pin) (fixedpoint.Value, error) { + s.logger.Infof("calculating quantity by quote/base investment: %f / %f", baseInvestment.Float64(), quoteInvestment.Float64()) + // q_p1 = q_p2 = q_p3 = q_p4 + // baseInvestment = q_p1 + q_p2 + q_p3 + q_p4 + .... + // baseInvestment = numberOfSellOrders * q + // maxBaseQuantity = baseInvestment / numberOfSellOrders + // if maxBaseQuantity < minQuantity or maxBaseQuantity * priceLowest < minNotional + // then reduce the numberOfSellOrders + numberOfSellOrders := 0 + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + if price.Compare(lastPrice) < 0 { + break + } + numberOfSellOrders++ + } + + // if the maxBaseQuantity is less than minQuantity, then we need to reduce the number of the sell orders + // so that the quantity can be increased. + maxNumberOfSellOrders := numberOfSellOrders + 1 + minBaseQuantity := fixedpoint.Max(s.Market.MinNotional.Div(lastPrice), s.Market.MinQuantity) + maxBaseQuantity := fixedpoint.Zero + for maxBaseQuantity.Compare(s.Market.MinQuantity) <= 0 || maxBaseQuantity.Compare(minBaseQuantity) <= 0 { + maxNumberOfSellOrders-- + maxBaseQuantity = baseInvestment.Div(fixedpoint.NewFromInt(int64(maxNumberOfSellOrders))) + } + s.logger.Infof("grid %s base investment sell orders: %d", s.Symbol, maxNumberOfSellOrders) + if maxNumberOfSellOrders > 0 { + s.logger.Infof("grid %s base investment quantity range: %f <=> %f", s.Symbol, minBaseQuantity.Float64(), maxBaseQuantity.Float64()) + } + + buyPlacedPrice := fixedpoint.Zero + totalQuotePrice := fixedpoint.Zero + // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... + // => + // quoteInvestment = (p1 + p2 + p3) * q + // maxBuyQuantity = quoteInvestment / (p1 + p2 + p3) + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + if price.Compare(lastPrice) >= 0 { + // for orders that sell + // if we still have the base balance + // quantity := amount.Div(lastPrice) + if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + // requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) + buyPlacedPrice = nextLowerPrice + } + } else { + // for orders that buy + if !buyPlacedPrice.IsZero() && price.Compare(buyPlacedPrice) == 0 { + continue + } + + totalQuotePrice = totalQuotePrice.Add(price) + } + } + + quoteSideQuantity := quoteInvestment.Div(totalQuotePrice) + if maxNumberOfSellOrders > 0 { + return fixedpoint.Max(quoteSideQuantity, maxBaseQuantity), nil + } + + return quoteSideQuantity, nil +} + +// setupGridOrders +// 1) if quantity or amount is set, we should use quantity/amount directly instead of using investment amount to calculate. +// 2) if baseInvestment, quoteInvestment is set, then we should calculate the quantity from the given base investment and quote investment. +func (s *Strategy) setupGridOrders(ctx context.Context, session *bbgo.ExchangeSession) error { + lastPrice, err := s.getLastTradePrice(ctx, session) + if err != nil { + return errors.Wrap(err, "failed to get the last trade price") + } + + // check if base and quote are enough + baseBalance, ok := session.Account.Balance(s.Market.BaseCurrency) + if !ok { + return fmt.Errorf("base %s balance not found", s.Market.BaseCurrency) + } + + quoteBalance, ok := session.Account.Balance(s.Market.QuoteCurrency) + if !ok { + return fmt.Errorf("quote %s balance not found", s.Market.QuoteCurrency) + } + + totalBase := baseBalance.Available + totalQuote := quoteBalance.Available + + // shift 1 grid because we will start from the buy order + // if the buy order is filled, then we will submit another sell order at the higher grid. + if s.QuantityOrAmount.IsSet() { + if quantity := s.QuantityOrAmount.Quantity; !quantity.IsZero() { + if _, _, err2 := s.checkRequiredInvestmentByQuantity(totalBase, totalQuote, lastPrice, s.QuantityOrAmount.Quantity, s.grid.Pins); err != nil { + return err2 + } + } + if amount := s.QuantityOrAmount.Amount; !amount.IsZero() { + if _, _, err2 := s.checkRequiredInvestmentByAmount(totalBase, totalQuote, lastPrice, amount, s.grid.Pins); err != nil { + return err2 + } + } + } else { + // calculate the quantity from the investment configuration + if !s.QuoteInvestment.IsZero() && !s.BaseInvestment.IsZero() { + quantity, err2 := s.calculateQuoteBaseInvestmentQuantity(s.QuoteInvestment, s.BaseInvestment, lastPrice, s.grid.Pins) + if err2 != nil { + return err2 + } + s.QuantityOrAmount.Quantity = quantity + + } else if !s.QuoteInvestment.IsZero() { + quantity, err2 := s.calculateQuoteInvestmentQuantity(s.QuoteInvestment, lastPrice, s.grid.Pins) + if err2 != nil { + return err2 + } + s.QuantityOrAmount.Quantity = quantity + } + } + + // if base investment and quote investment is set, when we should check if the + // investment configuration is valid with the current balances + if !s.BaseInvestment.IsZero() && !s.QuoteInvestment.IsZero() { + if s.BaseInvestment.Compare(totalBase) > 0 { + return fmt.Errorf("baseInvestment setup %f is greater than the total base balance %f", s.BaseInvestment.Float64(), totalBase.Float64()) + } + if s.QuoteInvestment.Compare(totalQuote) > 0 { + return fmt.Errorf("quoteInvestment setup %f is greater than the total quote balance %f", s.QuoteInvestment.Float64(), totalQuote.Float64()) + } + + if !s.QuantityOrAmount.IsSet() { + // TODO: calculate and override the quantity here + } + } + + var buyPlacedPrice = fixedpoint.Zero + var pins = s.grid.Pins + var usedBase = fixedpoint.Zero + var usedQuote = fixedpoint.Zero + var submitOrders []types.SubmitOrder + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + quantity := s.QuantityOrAmount.Quantity + if quantity.IsZero() { + quantity = s.QuantityOrAmount.Amount.Div(price) + } + + // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... + if price.Compare(lastPrice) >= 0 { + if usedBase.Add(quantity).Compare(totalBase) < 0 { + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeSell, + Price: price, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: "grid", + }) + usedBase = usedBase.Add(quantity) + } else if i > 0 { + // next price + nextPin := pins[i-1] + nextPrice := fixedpoint.Value(nextPin) + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: nextPrice, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: "grid", + }) + quoteQuantity := quantity.Mul(price) + usedQuote = usedQuote.Add(quoteQuantity) + buyPlacedPrice = nextPrice + } + } else { + if !buyPlacedPrice.IsZero() && price.Compare(buyPlacedPrice) >= 0 { + continue + } + + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: price, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: "grid", + }) + quoteQuantity := quantity.Mul(price) + usedQuote = usedQuote.Add(quoteQuantity) + } + + createdOrders, err2 := s.orderExecutor.SubmitOrders(ctx, submitOrders...) + if err2 != nil { + return err + } + for _, order := range createdOrders { + s.logger.Infof(order.String()) + } + } + + return nil +} + +func (s *Strategy) getLastTradePrice(ctx context.Context, session *bbgo.ExchangeSession) (fixedpoint.Value, error) { + if bbgo.IsBackTesting { + price, ok := session.LastPrice(s.Symbol) + if !ok { + return fixedpoint.Zero, fmt.Errorf("last price of %s not found", s.Symbol) + } + + return price, nil + } + + tickers, err := session.Exchange.QueryTickers(ctx, s.Symbol) + if err != nil { + return fixedpoint.Zero, err + } + + if ticker, ok := tickers[s.Symbol]; ok { + if !ticker.Last.IsZero() { + return ticker.Last, nil + } + + // fallback to buy price + return ticker.Buy, nil + } + + return fixedpoint.Zero, fmt.Errorf("%s ticker price not found", s.Symbol) +} diff --git a/pkg/strategy/grid2/strategy_test.go b/pkg/strategy/grid2/strategy_test.go new file mode 100644 index 000000000..fd7b8493f --- /dev/null +++ b/pkg/strategy/grid2/strategy_test.go @@ -0,0 +1,103 @@ +//go:build !dnum + +package grid2 + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/types" +) + +func TestStrategy_checkRequiredInvestmentByQuantity(t *testing.T) { + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + + Market: types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + } + + t.Run("quote to base balance conversion check", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByQuantity(number(0.0), number(10_000.0), number(0.1), number(13_500.0), []Pin{ + Pin(number(10_000.0)), // 0.1 * 10_000 = 1000 USD (buy) + Pin(number(11_000.0)), // 0.1 * 11_000 = 1100 USD (buy) + Pin(number(12_000.0)), // 0.1 * 12_000 = 1200 USD (buy) + Pin(number(13_000.0)), // 0.1 * 13_000 = 1300 USD (buy) + Pin(number(14_000.0)), // 0.1 * 14_000 = 1400 USD (buy) + Pin(number(15_000.0)), // 0.1 * 15_000 = 1500 USD + }) + assert.NoError(t, err) + assert.Equal(t, number(6000.0), requiredQuote) + }) + + t.Run("quote to base balance conversion not enough", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByQuantity(number(0.0), number(5_000.0), number(0.1), number(13_500.0), []Pin{ + Pin(number(10_000.0)), // 0.1 * 10_000 = 1000 USD (buy) + Pin(number(11_000.0)), // 0.1 * 11_000 = 1100 USD (buy) + Pin(number(12_000.0)), // 0.1 * 12_000 = 1200 USD (buy) + Pin(number(13_000.0)), // 0.1 * 13_000 = 1300 USD (buy) + Pin(number(14_000.0)), // 0.1 * 14_000 = 1400 USD (buy) + Pin(number(15_000.0)), // 0.1 * 15_000 = 1500 USD + }) + assert.EqualError(t, err, "quote balance (5000.000000 USDT) is not enough, required = quote 6000.000000") + assert.Equal(t, number(6000.0), requiredQuote) + }) +} + +func TestStrategy_checkRequiredInvestmentByAmount(t *testing.T) { + s := &Strategy{ + + logger: logrus.NewEntry(logrus.New()), + Market: types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + } + + t.Run("quote to base balance conversion", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByAmount( + number(0.0), number(3_000.0), + number(1000.0), + number(13_500.0), []Pin{ + Pin(number(10_000.0)), + Pin(number(11_000.0)), + Pin(number(12_000.0)), + Pin(number(13_000.0)), + Pin(number(14_000.0)), + Pin(number(15_000.0)), + }) + assert.EqualError(t, err, "quote balance (3000.000000 USDT) is not enough, required = quote 4999.999890") + assert.InDelta(t, 4999.99989, requiredQuote.Float64(), number(0.001).Float64()) + }) +} + +func TestStrategy_calculateQuoteInvestmentQuantity(t *testing.T) { + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + Market: types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + } + + t.Run("calculate quote quantity from quote investment", func(t *testing.T) { + // quoteInvestment = (10,000 + 11,000 + 12,000 + 13,000 + 14,000) * q + // q = quoteInvestment / (10,000 + 11,000 + 12,000 + 13,000 + 14,000) + // q = 12_000 / (10,000 + 11,000 + 12,000 + 13,000 + 14,000) + // q = 0.2 + quantity, err := s.calculateQuoteInvestmentQuantity(number(12_000.0), number(13_500.0), []Pin{ + Pin(number(10_000.0)), // buy + Pin(number(11_000.0)), // buy + Pin(number(12_000.0)), // buy + Pin(number(13_000.0)), // buy + Pin(number(14_000.0)), // buy + Pin(number(15_000.0)), + }) + assert.NoError(t, err) + assert.Equal(t, number(0.2).String(), quantity.String()) + }) +} diff --git a/pkg/strategy/trendtrader/trend.go b/pkg/strategy/trendtrader/trend.go index fb27c8568..be12f575a 100644 --- a/pkg/strategy/trendtrader/trend.go +++ b/pkg/strategy/trendtrader/trend.go @@ -2,6 +2,7 @@ package trendtrader import ( "context" + "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/indicator" @@ -149,8 +150,5 @@ func line(p1, p2, p3 float64) int64 { } func converge(mr, ms float64) bool { - if ms > mr { - return true - } - return false + return ms > mr } diff --git a/pkg/types/market.go b/pkg/types/market.go index 1092b441e..e2b08cd21 100644 --- a/pkg/types/market.go +++ b/pkg/types/market.go @@ -150,10 +150,10 @@ func (m Market) FormatPriceCurrency(val fixedpoint.Value) string { func (m Market) FormatPrice(val fixedpoint.Value) string { // p := math.Pow10(m.PricePrecision) - return formatPrice(val, m.TickSize) + return FormatPrice(val, m.TickSize) } -func formatPrice(price fixedpoint.Value, tickSize fixedpoint.Value) string { +func FormatPrice(price fixedpoint.Value, tickSize fixedpoint.Value) string { prec := int(math.Round(math.Abs(math.Log10(tickSize.Float64())))) return price.FormatString(prec) } diff --git a/pkg/types/market_test.go b/pkg/types/market_test.go index d0544e9ba..809e60b0d 100644 --- a/pkg/types/market_test.go +++ b/pkg/types/market_test.go @@ -26,12 +26,12 @@ func TestFormatQuantity(t *testing.T) { } func TestFormatPrice(t *testing.T) { - price := formatPrice( + price := FormatPrice( s("26.288256"), s("0.0001")) assert.Equal(t, "26.2882", price) - price = formatPrice(s("26.288656"), s("0.001")) + price = FormatPrice(s("26.288656"), s("0.001")) assert.Equal(t, "26.288", price) } @@ -78,7 +78,7 @@ func TestDurationParse(t *testing.T) { } } -func Test_formatPrice(t *testing.T) { +func Test_FormatPrice(t *testing.T) { type args struct { price fixedpoint.Value tickSize fixedpoint.Value @@ -125,9 +125,9 @@ func Test_formatPrice(t *testing.T) { binanceFormatRE := regexp.MustCompile("^([0-9]{1,20})(.[0-9]{1,20})?$") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := formatPrice(tt.args.price, tt.args.tickSize) + got := FormatPrice(tt.args.price, tt.args.tickSize) if got != tt.want { - t.Errorf("formatPrice() = %v, want %v", got, tt.want) + t.Errorf("FormatPrice() = %v, want %v", got, tt.want) } assert.Regexp(t, binanceFormatRE, got)