new trade stats

This commit is contained in:
Sven Woldt 2023-11-10 23:07:40 +01:00
commit 5316bc5e3a
No known key found for this signature in database
GPG Key ID: D08249FCD4B52446
85 changed files with 4399 additions and 342 deletions

13
.env.local.example Normal file
View File

@ -0,0 +1,13 @@
SLACK_TOKEN=YOUR_TOKEN
SLACK_CHANNEL=CHANNEL_NAME
# DB_DRIVER="sqlite3"
# DB_DSN="bbgo.sqlite3"
DB_DRIVER=mysql
DB_DSN=root@tcp(127.0.0.1:3306)/bbgo
MAX_API_KEY=YOUR_API_KEY
MAX_API_SECRET=YOUR_API_SECRET
BINANCE_API_KEY=YOUR_API_KEY
BINANCE_API_SECRET=YOUR_API_SECRET

View File

@ -0,0 +1,54 @@
sessions:
max:
exchange: max
envVarPrefix: max
makerFeeRate: 0%
takerFeeRate: 0.025%
#services:
# googleSpreadSheet:
# jsonTokenFile: ".credentials/google-cloud/service-account-json-token.json"
# spreadSheetId: "YOUR_SPREADSHEET_ID"
exchangeStrategies:
- on: max
liquiditymaker:
symbol: &symbol USDTTWD
## adjustmentUpdateInterval is the interval for adjusting position
adjustmentUpdateInterval: 1m
## liquidityUpdateInterval is the interval for updating liquidity orders
liquidityUpdateInterval: 1h
numOfLiquidityLayers: 30
askLiquidityAmount: 20_000.0
bidLiquidityAmount: 20_000.0
liquidityPriceRange: 2%
useLastTradePrice: true
spread: 1.1%
liquidityScale:
exp:
domain: [1, 30]
range: [1, 4]
## maxExposure controls how much balance should be used for placing the maker orders
maxExposure: 200_000
minProfit: 0.01%
backtest:
sessions:
- max
startTime: "2023-05-20"
endTime: "2023-06-01"
symbols:
- *symbol
account:
max:
makerFeeRate: 0.0%
takerFeeRate: 0.025%
balances:
USDT: 5000
TWD: 150_000

View File

@ -58,4 +58,4 @@ bbgo [flags]
* [bbgo userdatastream](bbgo_userdatastream.md) - Listen to session events (orderUpdate, tradeUpdate, balanceUpdate, balanceSnapshot)
* [bbgo version](bbgo_version.md) - show version name
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo account [--session SESSION] [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -50,4 +50,4 @@ bbgo backtest [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -40,4 +40,4 @@ bbgo balances [--session SESSION] [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -39,4 +39,4 @@ bbgo build [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -49,4 +49,4 @@ bbgo cancel-order [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo deposits [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -48,4 +48,4 @@ bbgo execute-order --session SESSION --symbol SYMBOL --side SIDE --target-quanti
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo get-order --session SESSION --order-id ORDER_ID [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -45,4 +45,4 @@ bbgo hoptimize [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo kline [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo list-orders open|closed --session SESSION --symbol SYMBOL [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -38,4 +38,4 @@ margin related history
* [bbgo margin loans](bbgo_margin_loans.md) - query loans history
* [bbgo margin repays](bbgo_margin_repays.md) - query repay history
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo margin interests --session=SESSION_NAME --asset=ASSET [flags]
* [bbgo margin](bbgo_margin.md) - margin related history
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo margin loans --session=SESSION_NAME --asset=ASSET [flags]
* [bbgo margin](bbgo_margin.md) - margin related history
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -41,4 +41,4 @@ bbgo margin repays --session=SESSION_NAME --asset=ASSET [flags]
* [bbgo margin](bbgo_margin.md) - margin related history
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -40,4 +40,4 @@ bbgo market [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -44,4 +44,4 @@ bbgo optimize [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo orderbook --session=[exchange_name] --symbol=[pair_name] [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -40,4 +40,4 @@ bbgo orderupdate [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -49,4 +49,4 @@ bbgo pnl [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -51,4 +51,4 @@ bbgo run [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -46,4 +46,4 @@ bbgo submit-order --session SESSION --symbol SYMBOL --side SIDE --quantity QUANT
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo sync [--session=[exchange_name]] [--symbol=[pair_name]] [[--since=yyyy/mm/d
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo trades --session=[exchange_name] --symbol=[pair_name] [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -40,4 +40,4 @@ bbgo tradeupdate --session=[exchange_name] [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -42,4 +42,4 @@ bbgo transfer-history [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -40,4 +40,4 @@ bbgo userdatastream [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

View File

@ -39,4 +39,4 @@ bbgo version [flags]
* [bbgo](bbgo.md) - bbgo is a crypto trading bot
###### Auto generated by spf13/cobra on 2-Oct-2023
###### Auto generated by spf13/cobra on 9-Nov-2023

65
doc/release/v1.53.0.md Normal file
View File

@ -0,0 +1,65 @@
[Full Changelog](https://github.com/c9s/bbgo/compare/v1.52.0...main)
- [#1401](https://github.com/c9s/bbgo/pull/1401): STRATEGY: add liquidity maker
- [#1403](https://github.com/c9s/bbgo/pull/1403): FEATURE: [bybit] add assertion for API response
- [#1394](https://github.com/c9s/bbgo/pull/1394): FEATURE: [bitget] support query closed orders
- [#1392](https://github.com/c9s/bbgo/pull/1392): FEATURE: [bitget] add query open orders
- [#1396](https://github.com/c9s/bbgo/pull/1396): FEATURE: add ttl for position/grid2.profit stats persistence
- [#1395](https://github.com/c9s/bbgo/pull/1395): FIX: fix skip syncing active order
- [#1398](https://github.com/c9s/bbgo/pull/1398): FIX: [bybit] rm retry and add fee recover
- [#1397](https://github.com/c9s/bbgo/pull/1397): FEATURE: [bybit] to periodically fetch the fee rate
- [#1391](https://github.com/c9s/bbgo/pull/1391): FIX: [grid2] respect BaseGridNum and add a failing test case
- [#1390](https://github.com/c9s/bbgo/pull/1390): FIX: [rebalance] fix buy quantity
- [#1380](https://github.com/c9s/bbgo/pull/1380): FEATURE: [bitget] support kline subscription on stream
- [#1385](https://github.com/c9s/bbgo/pull/1385): FEATURE: [bitget] add query tickers api
- [#1376](https://github.com/c9s/bbgo/pull/1376): FEATURE: query trades from db page by page
- [#1386](https://github.com/c9s/bbgo/pull/1386): REFACTOR: [wall] refactor wall strategy with common.Strategy
- [#1382](https://github.com/c9s/bbgo/pull/1382): REFACTOR: [bitget] add rate limiter for account, ticker
- [#1384](https://github.com/c9s/bbgo/pull/1384): CHORE: minor improvements on backtest cmd
- [#1381](https://github.com/c9s/bbgo/pull/1381): DOC: grammatical errors in the README.md
- [#1377](https://github.com/c9s/bbgo/pull/1377): REFACTOR: [rebalance] submit one order at a time
- [#1378](https://github.com/c9s/bbgo/pull/1378): REFACTOR: [bitget] get symbol api
- [#1375](https://github.com/c9s/bbgo/pull/1375): DOC: grammatical error in the code_of_conduct file
- [#1374](https://github.com/c9s/bbgo/pull/1374): FIX: retry to get open orders only for 5 times and do not sync orders…
- [#1368](https://github.com/c9s/bbgo/pull/1368): FEATURE: merge grid recover and active orders recover logic
- [#1367](https://github.com/c9s/bbgo/pull/1367): DOC: fix typos in doc/development
- [#1372](https://github.com/c9s/bbgo/pull/1372): FIX: [bybit][kucoin] fix negative volume, price precision
- [#1373](https://github.com/c9s/bbgo/pull/1373): FEATURE: [xalign] adjust quantity by max amount
- [#1363](https://github.com/c9s/bbgo/pull/1363): FEATURE: [bitget] support ping/pong
- [#1370](https://github.com/c9s/bbgo/pull/1370): REFACTOR: [stream] move ping into stream level
- [#1361](https://github.com/c9s/bbgo/pull/1361): FEATURE: prepare query trades funtion for new recover
- [#1365](https://github.com/c9s/bbgo/pull/1365): FEATURE: [batch] add jumpIfEmpty opts to closed order batch query
- [#1364](https://github.com/c9s/bbgo/pull/1364): FEATURE: [batch] add a jumpIfEmpty to batch trade option
- [#1362](https://github.com/c9s/bbgo/pull/1362): DOC: Modified README.md file's language.
- [#1360](https://github.com/c9s/bbgo/pull/1360): DOC: Update CONTRIBUTING.md
- [#1351](https://github.com/c9s/bbgo/pull/1351): DOC: Update README.md
- [#1355](https://github.com/c9s/bbgo/pull/1355): REFACTOR: rename file and variable
- [#1358](https://github.com/c9s/bbgo/pull/1358): MINOR: [indicator] remove zero padding from RMA
- [#1357](https://github.com/c9s/bbgo/pull/1357): FIX: Fix duplicate RMA values and add test cases
- [#1356](https://github.com/c9s/bbgo/pull/1356): FIX: fix rma zero value issue
- [#1350](https://github.com/c9s/bbgo/pull/1350): FEATURE: [grid2] twin orderbook
- [#1353](https://github.com/c9s/bbgo/pull/1353): CHORE: go: update requestgen to v1.3.5
- [#1349](https://github.com/c9s/bbgo/pull/1349): MINOR: remove profit entries from profit stats
- [#1352](https://github.com/c9s/bbgo/pull/1352): DOC: Fixed a typo in README.md
- [#1347](https://github.com/c9s/bbgo/pull/1347): FEATURE: [bitget] support market trade stream
- [#1344](https://github.com/c9s/bbgo/pull/1344): FEATURE: [bitget] support book stream on bitget
- [#1280](https://github.com/c9s/bbgo/pull/1280): FEATURE: [bitget] integrate QueryMarkets, QueryTicker and QueryAccount api
- [#1346](https://github.com/c9s/bbgo/pull/1346): FIX: [xnav] skip public only session
- [#1345](https://github.com/c9s/bbgo/pull/1345): FIX: [bbgo] check symbol length for injection
- [#1343](https://github.com/c9s/bbgo/pull/1343): FIX: [max] remove outdated margin fields
- [#1328](https://github.com/c9s/bbgo/pull/1328): FEATURE: recover active orders with open orders periodically
- [#1341](https://github.com/c9s/bbgo/pull/1341): REFACTOR: [random] remove adjustQuantity from config
- [#1342](https://github.com/c9s/bbgo/pull/1342): CHORE: make rightWindow possible to be set as zero
- [#1339](https://github.com/c9s/bbgo/pull/1339): FEATURE: [BYBIT] support order book depth 200 on bybit
- [#1340](https://github.com/c9s/bbgo/pull/1340): CHORE: update xfixedmaker config for backtest
- [#1335](https://github.com/c9s/bbgo/pull/1335): FEATURE: add custom private channel support to max
- [#1338](https://github.com/c9s/bbgo/pull/1338): FIX: [grid2] set max retries to 5
- [#1337](https://github.com/c9s/bbgo/pull/1337): REFACTOR: rename randomtrader to random
- [#1327](https://github.com/c9s/bbgo/pull/1327): FIX: Fix duplicate orders caused by position risk control
- [#1331](https://github.com/c9s/bbgo/pull/1331): FEATURE: add xfixedmaker strategy
- [#1336](https://github.com/c9s/bbgo/pull/1336): FEATURE: add randomtrader strategy
- [#1332](https://github.com/c9s/bbgo/pull/1332): FEATURE: add supported interval for okex
- [#1232](https://github.com/c9s/bbgo/pull/1232): FEATURE: add forceOrder api for binance to show liquid info
- [#1334](https://github.com/c9s/bbgo/pull/1334): CHORE: [maxapi] change default http transport settings
- [#1330](https://github.com/c9s/bbgo/pull/1330): REFACTOR: Make fixedmaker simpler
- [#1312](https://github.com/c9s/bbgo/pull/1312): FEATURE: add QueryClosedOrders() and QueryTrades() for okex

2
go.mod
View File

@ -9,7 +9,7 @@ require (
github.com/Masterminds/squirrel v1.5.3
github.com/adshao/go-binance/v2 v2.4.2
github.com/c-bata/goptuna v0.8.1
github.com/c9s/requestgen v1.3.5
github.com/c9s/requestgen v1.3.6
github.com/c9s/rockhopper v1.2.2-0.20220617053729-ffdc87df194b
github.com/cenkalti/backoff/v4 v4.2.0
github.com/cheggaaa/pb/v3 v3.0.8

2
go.sum
View File

@ -86,6 +86,8 @@ github.com/c9s/requestgen v1.3.4 h1:kK2rIO3OAt9JoY5gT0OSkSpq0dy/+JeuI22FwSKpUrY=
github.com/c9s/requestgen v1.3.4/go.mod h1:wp4saiPdh0zLF5AkopGCqPQfy9Q5xvRh+TQBOA1l1r4=
github.com/c9s/requestgen v1.3.5 h1:iGYAP0rWQW3JOo+Z3S0SoenSt581IQ9mupJxRFCrCJs=
github.com/c9s/requestgen v1.3.5/go.mod h1:QwkZudcv84kJ8g9+E0RDTj+13btFXbTvv2aI+zbuLbc=
github.com/c9s/requestgen v1.3.6 h1:ul7dZ2uwGYjNBjreooRfSY10WTXvQmQSjZsHebz6QfE=
github.com/c9s/requestgen v1.3.6/go.mod h1:QwkZudcv84kJ8g9+E0RDTj+13btFXbTvv2aI+zbuLbc=
github.com/c9s/rockhopper v1.2.2-0.20220617053729-ffdc87df194b h1:wT8c03PHLv7+nZUIGqxAzRvIfYHNxMCNVWwvdGkOXTs=
github.com/c9s/rockhopper v1.2.2-0.20220617053729-ffdc87df194b/go.mod h1:EKObf66Cp7erWxym2de+07qNN5T1N9PXxHdh97N44EQ=
github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=

View File

@ -25,6 +25,7 @@ import (
_ "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/liquiditymaker"
_ "github.com/c9s/bbgo/pkg/strategy/marketcap"
_ "github.com/c9s/bbgo/pkg/strategy/pivotshort"
_ "github.com/c9s/bbgo/pkg/strategy/pricealert"

View File

@ -0,0 +1,29 @@
package bitgetapi
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data
import (
"github.com/c9s/requestgen"
"github.com/c9s/bbgo/pkg/types"
)
type CancelOrder struct {
// OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172
OrderId types.StrInt64 `json:"orderId"`
ClientOrderId string `json:"clientOid"`
}
//go:generate PostRequest -url "/api/v2/spot/trade/cancel-order" -type CancelOrderRequest -responseDataType .CancelOrder
type CancelOrderRequest struct {
client requestgen.AuthenticatedAPIClient
symbol string `param:"symbol"`
orderId *string `param:"orderId"`
clientOrderId *string `param:"clientOid"`
}
func (c *Client) NewCancelOrderRequest() *CancelOrderRequest {
return &CancelOrderRequest{client: c.Client}
}

View File

@ -0,0 +1,196 @@
// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/cancel-order -type CancelOrderRequest -responseDataType .CancelOrder"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (c *CancelOrderRequest) Symbol(symbol string) *CancelOrderRequest {
c.symbol = symbol
return c
}
func (c *CancelOrderRequest) OrderId(orderId string) *CancelOrderRequest {
c.orderId = &orderId
return c
}
func (c *CancelOrderRequest) ClientOrderId(clientOrderId string) *CancelOrderRequest {
c.clientOrderId = &clientOrderId
return c
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
symbol := c.symbol
// assign parameter of symbol
params["symbol"] = symbol
// check orderId field -> json key orderId
if c.orderId != nil {
orderId := *c.orderId
// assign parameter of orderId
params["orderId"] = orderId
} else {
}
// check clientOrderId field -> json key clientOid
if c.clientOrderId != nil {
clientOrderId := *c.clientOrderId
// assign parameter of clientOrderId
params["clientOid"] = clientOrderId
} else {
}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := c.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if c.isVarSlice(_v) {
c.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) {
params, err := c.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := c.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
// GetPath returns the request path of the API
func (c *CancelOrderRequest) GetPath() string {
return "/api/v2/spot/trade/cancel-order"
}
// Do generates the request object and send the request object to the API endpoint
func (c *CancelOrderRequest) Do(ctx context.Context) (*CancelOrder, error) {
params, err := c.GetParameters()
if err != nil {
return nil, err
}
query := url.Values{}
var apiURL string
apiURL = c.GetPath()
req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := c.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse bitgetapi.APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data CancelOrder
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return &data, nil
}

View File

@ -0,0 +1,17 @@
package bitgetapi
import (
"github.com/c9s/requestgen"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
)
type APIResponse = bitgetapi.APIResponse
type Client struct {
Client requestgen.AuthenticatedAPIClient
}
func NewClient(client *bitgetapi.RestClient) *Client {
return &Client{Client: client}
}

View File

@ -0,0 +1,81 @@
package bitgetapi
import (
"context"
"os"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"github.com/c9s/bbgo/pkg/testutil"
)
func getTestClientOrSkip(t *testing.T) *Client {
if b, _ := strconv.ParseBool(os.Getenv("CI")); b {
t.Skip("skip test for CI")
}
key, secret, ok := testutil.IntegrationTestConfigured(t, "BITGET")
if !ok {
t.Skip("BITGET_* env vars are not configured")
return nil
}
client := bitgetapi.NewClient()
client.Auth(key, secret, os.Getenv("BITGET_API_PASSPHRASE"))
return NewClient(client)
}
func TestClient(t *testing.T) {
client := getTestClientOrSkip(t)
ctx := context.Background()
t.Run("GetUnfilledOrdersRequest", func(t *testing.T) {
req := client.NewGetUnfilledOrdersRequest().StartTime(1)
resp, err := req.Do(ctx)
assert.NoError(t, err)
t.Logf("resp: %+v", resp)
})
t.Run("GetHistoryOrdersRequest", func(t *testing.T) {
// market buy
req, err := client.NewGetHistoryOrdersRequest().Symbol("APEUSDT").Do(ctx)
assert.NoError(t, err)
t.Logf("place order resp: %+v", req)
})
t.Run("PlaceOrderRequest", func(t *testing.T) {
req, err := client.NewPlaceOrderRequest().Symbol("APEUSDT").OrderType(OrderTypeLimit).
Side(SideTypeSell).
Price("2").
Size("5").
Force(OrderForceGTC).
Do(context.Background())
assert.NoError(t, err)
t.Logf("place order resp: %+v", req)
})
t.Run("GetTradeFillsRequest", func(t *testing.T) {
req, err := client.NewGetTradeFillsRequest().Symbol("APEUSDT").Do(ctx)
assert.NoError(t, err)
t.Logf("get trade fills resp: %+v", req)
})
t.Run("CancelOrderRequest", func(t *testing.T) {
req, err := client.NewPlaceOrderRequest().Symbol("APEUSDT").OrderType(OrderTypeLimit).
Side(SideTypeSell).
Price("2").
Size("5").
Force(OrderForceGTC).
Do(context.Background())
assert.NoError(t, err)
resp, err := client.NewCancelOrderRequest().Symbol("APEUSDT").OrderId(req.OrderId).Do(ctx)
t.Logf("cancel order resp: %+v", resp)
})
}

View File

@ -0,0 +1,102 @@
package bitgetapi
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data
import (
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
"github.com/c9s/requestgen"
)
type FeeDetail struct {
// NewFees should have a value because when I was integrating, it already prompted,
// "If there is no 'newFees' field, this data represents earlier historical data."
NewFees struct {
// Amount deducted by coupons, unitcurrency obtained from the transaction.
DeductedByCoupon fixedpoint.Value `json:"c"`
// Amount deducted in BGB (Bitget Coin), unitBGB
DeductedInBGB fixedpoint.Value `json:"d"`
// If the BGB balance is insufficient to cover the fees, the remaining amount is deducted from the
//currency obtained from the transaction.
DeductedFromCurrency fixedpoint.Value `json:"r"`
// The total fee amount to be paid, unit currency obtained from the transaction.
ToBePaid fixedpoint.Value `json:"t"`
// ignored
Deduction bool `json:"deduction"`
// ignored
TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"`
} `json:"newFees"`
}
type OrderDetail struct {
UserId types.StrInt64 `json:"userId"`
Symbol string `json:"symbol"`
// OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172
OrderId types.StrInt64 `json:"orderId"`
ClientOrderId string `json:"clientOid"`
Price fixedpoint.Value `json:"price"`
// Size is base coin when orderType=limit; quote coin when orderType=market
Size fixedpoint.Value `json:"size"`
OrderType OrderType `json:"orderType"`
Side SideType `json:"side"`
Status OrderStatus `json:"status"`
PriceAvg fixedpoint.Value `json:"priceAvg"`
BaseVolume fixedpoint.Value `json:"baseVolume"`
QuoteVolume fixedpoint.Value `json:"quoteVolume"`
EnterPointSource string `json:"enterPointSource"`
// The value is json string, so we unmarshal it after unmarshal OrderDetail
FeeDetailRaw string `json:"feeDetail"`
OrderSource string `json:"orderSource"`
CTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"`
FeeDetail FeeDetail
}
func (o *OrderDetail) UnmarshalJSON(data []byte) error {
if o == nil {
return fmt.Errorf("failed to unmarshal json from nil pointer order detail")
}
// define new type to avoid loop reference
type AuxOrderDetail OrderDetail
var aux AuxOrderDetail
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*o = OrderDetail(aux)
if len(aux.FeeDetailRaw) == 0 {
return nil
}
var feeDetail FeeDetail
if err := json.Unmarshal([]byte(aux.FeeDetailRaw), &feeDetail); err != nil {
return fmt.Errorf("unexpected fee detail raw: %s, err: %w", aux.FeeDetailRaw, err)
}
o.FeeDetail = feeDetail
return nil
}
//go:generate GetRequest -url "/api/v2/spot/trade/history-orders" -type GetHistoryOrdersRequest -responseDataType []OrderDetail
type GetHistoryOrdersRequest struct {
client requestgen.AuthenticatedAPIClient
symbol *string `param:"symbol,query"`
// Limit number default 100 max 100
limit *string `param:"limit,query"`
// idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface.
idLessThan *string `param:"idLessThan,query"`
startTime *int64 `param:"startTime,query"`
endTime *int64 `param:"endTime,query"`
orderId *string `param:"orderId,query"`
}
func (c *Client) NewGetHistoryOrdersRequest() *GetHistoryOrdersRequest {
return &GetHistoryOrdersRequest{client: c.Client}
}

View File

@ -0,0 +1,222 @@
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/history-orders -type GetHistoryOrdersRequest -responseDataType []OrderDetail"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (g *GetHistoryOrdersRequest) Symbol(symbol string) *GetHistoryOrdersRequest {
g.symbol = &symbol
return g
}
func (g *GetHistoryOrdersRequest) Limit(limit string) *GetHistoryOrdersRequest {
g.limit = &limit
return g
}
func (g *GetHistoryOrdersRequest) IdLessThan(idLessThan string) *GetHistoryOrdersRequest {
g.idLessThan = &idLessThan
return g
}
func (g *GetHistoryOrdersRequest) StartTime(startTime int64) *GetHistoryOrdersRequest {
g.startTime = &startTime
return g
}
func (g *GetHistoryOrdersRequest) EndTime(endTime int64) *GetHistoryOrdersRequest {
g.endTime = &endTime
return g
}
func (g *GetHistoryOrdersRequest) OrderId(orderId string) *GetHistoryOrdersRequest {
g.orderId = &orderId
return g
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (g *GetHistoryOrdersRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
if g.symbol != nil {
symbol := *g.symbol
// assign parameter of symbol
params["symbol"] = symbol
} else {
}
// check limit field -> json key limit
if g.limit != nil {
limit := *g.limit
// assign parameter of limit
params["limit"] = limit
} else {
}
// check idLessThan field -> json key idLessThan
if g.idLessThan != nil {
idLessThan := *g.idLessThan
// assign parameter of idLessThan
params["idLessThan"] = idLessThan
} else {
}
// check startTime field -> json key startTime
if g.startTime != nil {
startTime := *g.startTime
// assign parameter of startTime
params["startTime"] = startTime
} else {
}
// check endTime field -> json key endTime
if g.endTime != nil {
endTime := *g.endTime
// assign parameter of endTime
params["endTime"] = endTime
} else {
}
// check orderId field -> json key orderId
if g.orderId != nil {
orderId := *g.orderId
// assign parameter of orderId
params["orderId"] = orderId
} else {
}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (g *GetHistoryOrdersRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (g *GetHistoryOrdersRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := g.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if g.isVarSlice(_v) {
g.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (g *GetHistoryOrdersRequest) GetParametersJSON() ([]byte, error) {
params, err := g.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (g *GetHistoryOrdersRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (g *GetHistoryOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (g *GetHistoryOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (g *GetHistoryOrdersRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (g *GetHistoryOrdersRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := g.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (g *GetHistoryOrdersRequest) Do(ctx context.Context) ([]OrderDetail, error) {
// no body params
var params interface{}
query, err := g.GetQueryParameters()
if err != nil {
return nil, err
}
apiURL := "/api/v2/spot/trade/history-orders"
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := g.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse bitgetapi.APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data []OrderDetail
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return data, nil
}

View File

@ -0,0 +1,121 @@
package bitgetapi
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
func TestOrderDetail_UnmarshalJSON(t *testing.T) {
var (
assert = assert.New(t)
)
t.Run("empty fee", func(t *testing.T) {
input := `{
"userId":"8672173294",
"symbol":"APEUSDT",
"orderId":"1104342023170068480",
"clientOid":"f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1",
"price":"1.2000000000000000",
"size":"5.0000000000000000",
"orderType":"limit",
"side":"buy",
"status":"cancelled",
"priceAvg":"0",
"baseVolume":"0.0000000000000000",
"quoteVolume":"0.0000000000000000",
"enterPointSource":"API",
"feeDetail":"",
"orderSource":"normal",
"cTime":"1699021576683",
"uTime":"1699021649099"
}`
var od OrderDetail
err := json.Unmarshal([]byte(input), &od)
assert.NoError(err)
assert.Equal(OrderDetail{
UserId: types.StrInt64(8672173294),
Symbol: "APEUSDT",
OrderId: types.StrInt64(1104342023170068480),
ClientOrderId: "f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1",
Price: fixedpoint.NewFromFloat(1.2),
Size: fixedpoint.NewFromFloat(5),
OrderType: OrderTypeLimit,
Side: SideTypeBuy,
Status: OrderStatusCancelled,
PriceAvg: fixedpoint.Zero,
BaseVolume: fixedpoint.Zero,
QuoteVolume: fixedpoint.Zero,
EnterPointSource: "API",
FeeDetailRaw: "",
OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1699021576683),
UTime: types.NewMillisecondTimestampFromInt(1699021649099),
FeeDetail: FeeDetail{},
}, od)
})
t.Run("fee", func(t *testing.T) {
input := `{
"userId":"8672173294",
"symbol":"APEUSDT",
"orderId":"1104337778433757184",
"clientOid":"8afea7bd-d873-44fe-aff8-6a1fae3cc765",
"price":"1.4000000000000000",
"size":"5.0000000000000000",
"orderType":"limit",
"side":"sell",
"status":"filled",
"priceAvg":"1.4001000000000000",
"baseVolume":"5.0000000000000000",
"quoteVolume":"7.0005000000000000",
"enterPointSource":"API",
"feeDetail":"{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}",
"orderSource":"normal",
"cTime":"1699020564659",
"uTime":"1699020564688"
}`
var od OrderDetail
err := json.Unmarshal([]byte(input), &od)
assert.NoError(err)
assert.Equal(OrderDetail{
UserId: types.StrInt64(8672173294),
Symbol: "APEUSDT",
OrderId: types.StrInt64(1104337778433757184),
ClientOrderId: "8afea7bd-d873-44fe-aff8-6a1fae3cc765",
Price: fixedpoint.NewFromFloat(1.4),
Size: fixedpoint.NewFromFloat(5),
OrderType: OrderTypeLimit,
Side: SideTypeSell,
Status: OrderStatusFilled,
PriceAvg: fixedpoint.NewFromFloat(1.4001),
BaseVolume: fixedpoint.NewFromFloat(5),
QuoteVolume: fixedpoint.NewFromFloat(7.0005),
EnterPointSource: "API",
FeeDetailRaw: `{"newFees":{"c":0,"d":0,"deduction":false,"r":-0.0070005,"t":-0.0070005,"totalDeductionFee":0},"USDT":{"deduction":false,"feeCoinCode":"USDT","totalDeductionFee":0,"totalFee":-0.007000500000}}`,
OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1699020564659),
UTime: types.NewMillisecondTimestampFromInt(1699020564688),
FeeDetail: FeeDetail{
NewFees: struct {
DeductedByCoupon fixedpoint.Value `json:"c"`
DeductedInBGB fixedpoint.Value `json:"d"`
DeductedFromCurrency fixedpoint.Value `json:"r"`
ToBePaid fixedpoint.Value `json:"t"`
Deduction bool `json:"deduction"`
TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"`
}{DeductedByCoupon: fixedpoint.NewFromFloat(0),
DeductedInBGB: fixedpoint.NewFromFloat(0),
DeductedFromCurrency: fixedpoint.NewFromFloat(-0.0070005),
ToBePaid: fixedpoint.NewFromFloat(-0.0070005),
Deduction: false,
TotalDeductionFee: fixedpoint.Zero,
},
},
}, od)
})
}

View File

@ -0,0 +1,70 @@
package bitgetapi
import (
"github.com/c9s/requestgen"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data
type TradeScope string
const (
TradeMaker TradeScope = "maker"
TradeTaker TradeScope = "taker"
)
type DiscountStatus string
const (
DiscountYes DiscountStatus = "yes"
DiscountNo DiscountStatus = "no"
)
type TradeFee struct {
// Discount or not
Deduction DiscountStatus `json:"deduction"`
// Transaction fee coin
FeeCoin string `json:"feeCoin"`
// Total transaction fee discount
TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"`
// Total transaction fee
TotalFee fixedpoint.Value `json:"totalFee"`
}
type Trade struct {
UserId types.StrInt64 `json:"userId"`
Symbol string `json:"symbol"`
OrderId types.StrInt64 `json:"orderId"`
TradeId types.StrInt64 `json:"tradeId"`
OrderType OrderType `json:"orderType"`
Side SideType `json:"side"`
PriceAvg fixedpoint.Value `json:"priceAvg"`
Size fixedpoint.Value `json:"size"`
Amount fixedpoint.Value `json:"amount"`
FeeDetail TradeFee `json:"feeDetail"`
TradeScope TradeScope `json:"tradeScope"`
CTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"`
}
//go:generate GetRequest -url "/api/v2/spot/trade/fills" -type GetTradeFillsRequest -responseDataType []Trade
type GetTradeFillsRequest struct {
client requestgen.AuthenticatedAPIClient
symbol string `param:"symbol,query"`
// Limit number default 100 max 100
limit *string `param:"limit,query"`
// idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface.
idLessThan *string `param:"idLessThan,query"`
startTime *int64 `param:"startTime,query"`
endTime *int64 `param:"endTime,query"`
orderId *string `param:"orderId,query"`
}
func (s *Client) NewGetTradeFillsRequest() *GetTradeFillsRequest {
return &GetTradeFillsRequest{client: s.Client}
}

View File

@ -0,0 +1,219 @@
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/fills -type GetTradeFillsRequest -responseDataType []Trade"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (s *GetTradeFillsRequest) Symbol(symbol string) *GetTradeFillsRequest {
s.symbol = symbol
return s
}
func (s *GetTradeFillsRequest) Limit(limit string) *GetTradeFillsRequest {
s.limit = &limit
return s
}
func (s *GetTradeFillsRequest) IdLessThan(idLessThan string) *GetTradeFillsRequest {
s.idLessThan = &idLessThan
return s
}
func (s *GetTradeFillsRequest) StartTime(startTime int64) *GetTradeFillsRequest {
s.startTime = &startTime
return s
}
func (s *GetTradeFillsRequest) EndTime(endTime int64) *GetTradeFillsRequest {
s.endTime = &endTime
return s
}
func (s *GetTradeFillsRequest) OrderId(orderId string) *GetTradeFillsRequest {
s.orderId = &orderId
return s
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (s *GetTradeFillsRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
symbol := s.symbol
// assign parameter of symbol
params["symbol"] = symbol
// check limit field -> json key limit
if s.limit != nil {
limit := *s.limit
// assign parameter of limit
params["limit"] = limit
} else {
}
// check idLessThan field -> json key idLessThan
if s.idLessThan != nil {
idLessThan := *s.idLessThan
// assign parameter of idLessThan
params["idLessThan"] = idLessThan
} else {
}
// check startTime field -> json key startTime
if s.startTime != nil {
startTime := *s.startTime
// assign parameter of startTime
params["startTime"] = startTime
} else {
}
// check endTime field -> json key endTime
if s.endTime != nil {
endTime := *s.endTime
// assign parameter of endTime
params["endTime"] = endTime
} else {
}
// check orderId field -> json key orderId
if s.orderId != nil {
orderId := *s.orderId
// assign parameter of orderId
params["orderId"] = orderId
} else {
}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (s *GetTradeFillsRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (s *GetTradeFillsRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := s.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if s.isVarSlice(_v) {
s.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (s *GetTradeFillsRequest) GetParametersJSON() ([]byte, error) {
params, err := s.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (s *GetTradeFillsRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (s *GetTradeFillsRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (s *GetTradeFillsRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (s *GetTradeFillsRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (s *GetTradeFillsRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := s.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (s *GetTradeFillsRequest) Do(ctx context.Context) ([]Trade, error) {
// no body params
var params interface{}
query, err := s.GetQueryParameters()
if err != nil {
return nil, err
}
apiURL := "/api/v2/spot/trade/fills"
req, err := s.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := s.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse bitgetapi.APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data []Trade
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return data, nil
}

View File

@ -0,0 +1,50 @@
package bitgetapi
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data
import (
"github.com/c9s/requestgen"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
type UnfilledOrder struct {
UserId types.StrInt64 `json:"userId"`
Symbol string `json:"symbol"`
// OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172
OrderId types.StrInt64 `json:"orderId"`
ClientOrderId string `json:"clientOid"`
PriceAvg fixedpoint.Value `json:"priceAvg"`
// Size is base coin when orderType=limit; quote coin when orderType=market
Size fixedpoint.Value `json:"size"`
OrderType OrderType `json:"orderType"`
Side SideType `json:"side"`
Status OrderStatus `json:"status"`
BasePrice fixedpoint.Value `json:"basePrice"`
BaseVolume fixedpoint.Value `json:"baseVolume"`
QuoteVolume fixedpoint.Value `json:"quoteVolume"`
EnterPointSource string `json:"enterPointSource"`
OrderSource string `json:"orderSource"`
CTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"`
}
//go:generate GetRequest -url "/api/v2/spot/trade/unfilled-orders" -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder
type GetUnfilledOrdersRequest struct {
client requestgen.AuthenticatedAPIClient
symbol *string `param:"symbol,query"`
// Limit number default 100 max 100
limit *string `param:"limit,query"`
// idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface.
idLessThan *string `param:"idLessThan,query"`
startTime *int64 `param:"startTime,query"`
endTime *int64 `param:"endTime,query"`
orderId *string `param:"orderId,query"`
}
func (c *Client) NewGetUnfilledOrdersRequest() *GetUnfilledOrdersRequest {
return &GetUnfilledOrdersRequest{client: c.Client}
}

View File

@ -0,0 +1,221 @@
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/unfilled-orders -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (g *GetUnfilledOrdersRequest) Symbol(symbol string) *GetUnfilledOrdersRequest {
g.symbol = &symbol
return g
}
func (g *GetUnfilledOrdersRequest) Limit(limit string) *GetUnfilledOrdersRequest {
g.limit = &limit
return g
}
func (g *GetUnfilledOrdersRequest) IdLessThan(idLessThan string) *GetUnfilledOrdersRequest {
g.idLessThan = &idLessThan
return g
}
func (g *GetUnfilledOrdersRequest) StartTime(startTime int64) *GetUnfilledOrdersRequest {
g.startTime = &startTime
return g
}
func (g *GetUnfilledOrdersRequest) EndTime(endTime int64) *GetUnfilledOrdersRequest {
g.endTime = &endTime
return g
}
func (g *GetUnfilledOrdersRequest) OrderId(orderId string) *GetUnfilledOrdersRequest {
g.orderId = &orderId
return g
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (g *GetUnfilledOrdersRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
if g.symbol != nil {
symbol := *g.symbol
// assign parameter of symbol
params["symbol"] = symbol
} else {
}
// check limit field -> json key limit
if g.limit != nil {
limit := *g.limit
// assign parameter of limit
params["limit"] = limit
} else {
}
// check idLessThan field -> json key idLessThan
if g.idLessThan != nil {
idLessThan := *g.idLessThan
// assign parameter of idLessThan
params["idLessThan"] = idLessThan
} else {
}
// check startTime field -> json key startTime
if g.startTime != nil {
startTime := *g.startTime
// assign parameter of startTime
params["startTime"] = startTime
} else {
}
// check endTime field -> json key endTime
if g.endTime != nil {
endTime := *g.endTime
// assign parameter of endTime
params["endTime"] = endTime
} else {
}
// check orderId field -> json key orderId
if g.orderId != nil {
orderId := *g.orderId
// assign parameter of orderId
params["orderId"] = orderId
} else {
}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (g *GetUnfilledOrdersRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (g *GetUnfilledOrdersRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := g.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if g.isVarSlice(_v) {
g.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (g *GetUnfilledOrdersRequest) GetParametersJSON() ([]byte, error) {
params, err := g.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (g *GetUnfilledOrdersRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (g *GetUnfilledOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (g *GetUnfilledOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (g *GetUnfilledOrdersRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (g *GetUnfilledOrdersRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := g.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (g *GetUnfilledOrdersRequest) Do(ctx context.Context) ([]UnfilledOrder, error) {
// no body params
var params interface{}
query, err := g.GetQueryParameters()
if err != nil {
return nil, err
}
apiURL := "/api/v2/spot/trade/unfilled-orders"
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := g.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse bitgetapi.APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data []UnfilledOrder
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return data, nil
}

View File

@ -0,0 +1,29 @@
package bitgetapi
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data
import (
"github.com/c9s/requestgen"
)
type PlaceOrderResponse struct {
OrderId string `json:"orderId"`
ClientOrderId string `json:"clientOrderId"`
}
//go:generate PostRequest -url "/api/v2/spot/trade/place-order" -type PlaceOrderRequest -responseDataType .PlaceOrderResponse
type PlaceOrderRequest struct {
client requestgen.AuthenticatedAPIClient
symbol string `param:"symbol"`
orderType OrderType `param:"orderType"`
side SideType `param:"side"`
force OrderForce `param:"force"`
price *string `param:"price"`
size string `param:"size"`
clientOrderId *string `param:"clientOid"`
}
func (c *Client) NewPlaceOrderRequest() *PlaceOrderRequest {
return &PlaceOrderRequest{client: c.Client}
}

View File

@ -0,0 +1,251 @@
// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/place-order -type PlaceOrderRequest -responseDataType .PlaceOrderResponse"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (p *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest {
p.symbol = symbol
return p
}
func (p *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest {
p.orderType = orderType
return p
}
func (p *PlaceOrderRequest) Side(side SideType) *PlaceOrderRequest {
p.side = side
return p
}
func (p *PlaceOrderRequest) Force(force OrderForce) *PlaceOrderRequest {
p.force = force
return p
}
func (p *PlaceOrderRequest) Price(price string) *PlaceOrderRequest {
p.price = &price
return p
}
func (p *PlaceOrderRequest) Size(size string) *PlaceOrderRequest {
p.size = size
return p
}
func (p *PlaceOrderRequest) ClientOrderId(clientOrderId string) *PlaceOrderRequest {
p.clientOrderId = &clientOrderId
return p
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (p *PlaceOrderRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (p *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
symbol := p.symbol
// assign parameter of symbol
params["symbol"] = symbol
// check orderType field -> json key orderType
orderType := p.orderType
// TEMPLATE check-valid-values
switch orderType {
case OrderTypeLimit, OrderTypeMarket:
params["orderType"] = orderType
default:
return nil, fmt.Errorf("orderType value %v is invalid", orderType)
}
// END TEMPLATE check-valid-values
// assign parameter of orderType
params["orderType"] = orderType
// check side field -> json key side
side := p.side
// TEMPLATE check-valid-values
switch side {
case SideTypeBuy, SideTypeSell:
params["side"] = side
default:
return nil, fmt.Errorf("side value %v is invalid", side)
}
// END TEMPLATE check-valid-values
// assign parameter of side
params["side"] = side
// check force field -> json key force
force := p.force
// TEMPLATE check-valid-values
switch force {
case OrderForceGTC, OrderForcePostOnly, OrderForceFOK, OrderForceIOC:
params["force"] = force
default:
return nil, fmt.Errorf("force value %v is invalid", force)
}
// END TEMPLATE check-valid-values
// assign parameter of force
params["force"] = force
// check price field -> json key price
if p.price != nil {
price := *p.price
// assign parameter of price
params["price"] = price
} else {
}
// check size field -> json key size
size := p.size
// assign parameter of size
params["size"] = size
// check clientOrderId field -> json key clientOid
if p.clientOrderId != nil {
clientOrderId := *p.clientOrderId
// assign parameter of clientOrderId
params["clientOid"] = clientOrderId
} else {
}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (p *PlaceOrderRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := p.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if p.isVarSlice(_v) {
p.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (p *PlaceOrderRequest) GetParametersJSON() ([]byte, error) {
params, err := p.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (p *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (p *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (p *PlaceOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (p *PlaceOrderRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := p.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error) {
params, err := p.GetParameters()
if err != nil {
return nil, err
}
query := url.Values{}
apiURL := "/api/v2/spot/trade/place-order"
req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := p.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse bitgetapi.APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data PlaceOrderResponse
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return &data, nil
}

View File

@ -0,0 +1,42 @@
package bitgetapi
type SideType string
const (
SideTypeBuy SideType = "buy"
SideTypeSell SideType = "sell"
)
type OrderType string
const (
OrderTypeLimit OrderType = "limit"
OrderTypeMarket OrderType = "market"
)
type OrderForce string
const (
OrderForceGTC OrderForce = "gtc"
OrderForcePostOnly OrderForce = "post_only"
OrderForceFOK OrderForce = "fok"
OrderForceIOC OrderForce = "ioc"
)
type OrderStatus string
const (
OrderStatusInit OrderStatus = "init"
OrderStatusNew OrderStatus = "new"
OrderStatusLive OrderStatus = "live"
OrderStatusPartialFilled OrderStatus = "partially_filled"
OrderStatusFilled OrderStatus = "filled"
OrderStatusCancelled OrderStatus = "cancelled"
)
func (o OrderStatus) IsWorking() bool {
return o == OrderStatusInit ||
o == OrderStatusNew ||
o == OrderStatusLive ||
o == OrderStatusPartialFilled
}

View File

@ -1,10 +1,14 @@
package bitget
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
v2 "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi/v2"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
@ -59,3 +63,269 @@ func toGlobalTicker(ticker bitgetapi.Ticker) types.Ticker {
Sell: ticker.SellOne,
}
}
func toGlobalSideType(side v2.SideType) (types.SideType, error) {
switch side {
case v2.SideTypeBuy:
return types.SideTypeBuy, nil
case v2.SideTypeSell:
return types.SideTypeSell, nil
default:
return types.SideType(side), fmt.Errorf("unexpected side: %s", side)
}
}
func toGlobalOrderType(s v2.OrderType) (types.OrderType, error) {
switch s {
case v2.OrderTypeMarket:
return types.OrderTypeMarket, nil
case v2.OrderTypeLimit:
return types.OrderTypeLimit, nil
default:
return types.OrderType(s), fmt.Errorf("unexpected order type: %s", s)
}
}
func toGlobalOrderStatus(status v2.OrderStatus) (types.OrderStatus, error) {
switch status {
case v2.OrderStatusInit, v2.OrderStatusNew, v2.OrderStatusLive:
return types.OrderStatusNew, nil
case v2.OrderStatusPartialFilled:
return types.OrderStatusPartiallyFilled, nil
case v2.OrderStatusFilled:
return types.OrderStatusFilled, nil
case v2.OrderStatusCancelled:
return types.OrderStatusCanceled, nil
default:
return types.OrderStatus(status), fmt.Errorf("unexpected order status: %s", status)
}
}
func isMaker(s v2.TradeScope) (bool, error) {
switch s {
case v2.TradeMaker:
return true, nil
case v2.TradeTaker:
return false, nil
default:
return false, fmt.Errorf("unexpected trade scope: %s", s)
}
}
func isFeeDiscount(s v2.DiscountStatus) (bool, error) {
switch s {
case v2.DiscountYes:
return true, nil
case v2.DiscountNo:
return false, nil
default:
return false, fmt.Errorf("unexpected discount status: %s", s)
}
}
func toGlobalTrade(trade v2.Trade) (*types.Trade, error) {
side, err := toGlobalSideType(trade.Side)
if err != nil {
return nil, err
}
isMaker, err := isMaker(trade.TradeScope)
if err != nil {
return nil, err
}
isDiscount, err := isFeeDiscount(trade.FeeDetail.Deduction)
if err != nil {
return nil, err
}
return &types.Trade{
ID: uint64(trade.TradeId),
OrderID: uint64(trade.OrderId),
Exchange: types.ExchangeBitget,
Price: trade.PriceAvg,
Quantity: trade.Size,
QuoteQuantity: trade.Amount,
Symbol: trade.Symbol,
Side: side,
IsBuyer: side == types.SideTypeBuy,
IsMaker: isMaker,
Time: types.Time(trade.CTime),
Fee: trade.FeeDetail.TotalFee.Abs(),
FeeCurrency: trade.FeeDetail.FeeCoin,
FeeDiscounted: isDiscount,
}, nil
}
// unfilledOrderToGlobalOrder convert the local order to global.
//
// Note that the quantity unit, according official document: Base coin when orderType=limit; Quote coin when orderType=market
// https://bitgetlimited.github.io/apidoc/zh/spot/#19671a1099
func unfilledOrderToGlobalOrder(order v2.UnfilledOrder) (*types.Order, error) {
side, err := toGlobalSideType(order.Side)
if err != nil {
return nil, err
}
orderType, err := toGlobalOrderType(order.OrderType)
if err != nil {
return nil, err
}
status, err := toGlobalOrderStatus(order.Status)
if err != nil {
return nil, err
}
qty := order.Size
price := order.PriceAvg
// The market order will be executed immediately, so this check is used to handle corner cases.
if orderType == types.OrderTypeMarket {
qty = order.BaseVolume
log.Warnf("!!! The price(%f) and quantity(%f) are not verified for market orders, because we only receive limit orders in the test environment !!!", price.Float64(), qty.Float64())
}
return &types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: order.ClientOrderId,
Symbol: order.Symbol,
Side: side,
Type: orderType,
Quantity: qty,
Price: price,
// Bitget does not include the "time-in-force" field in its API response for spot trading, so we set GTC.
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(order.OrderId),
UUID: strconv.FormatInt(int64(order.OrderId), 10),
Status: status,
ExecutedQuantity: order.BaseVolume,
IsWorking: order.Status.IsWorking(),
CreationTime: types.Time(order.CTime.Time()),
UpdateTime: types.Time(order.UTime.Time()),
}, nil
}
func toGlobalOrder(order v2.OrderDetail) (*types.Order, error) {
side, err := toGlobalSideType(order.Side)
if err != nil {
return nil, err
}
orderType, err := toGlobalOrderType(order.OrderType)
if err != nil {
return nil, err
}
status, err := toGlobalOrderStatus(order.Status)
if err != nil {
return nil, err
}
qty := order.Size
price := order.Price
if orderType == types.OrderTypeMarket {
price = order.PriceAvg
if side == types.SideTypeBuy {
qty, err = processMarketBuyQuantity(order.BaseVolume, order.QuoteVolume, order.PriceAvg, order.Size, order.Status)
if err != nil {
return nil, err
}
}
}
return &types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: order.ClientOrderId,
Symbol: order.Symbol,
Side: side,
Type: orderType,
Quantity: qty,
Price: price,
// Bitget does not include the "time-in-force" field in its API response for spot trading, so we set GTC.
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(order.OrderId),
UUID: strconv.FormatInt(int64(order.OrderId), 10),
Status: status,
ExecutedQuantity: order.BaseVolume,
IsWorking: order.Status.IsWorking(),
CreationTime: types.Time(order.CTime.Time()),
UpdateTime: types.Time(order.UTime.Time()),
}, nil
}
// processMarketBuyQuantity returns the estimated base quantity or real. The order size will be 'quote quantity' when side is buy and
// type is market, so we need to convert that. This is because the unit of types.Order.Quantity is base coin.
//
// If the order status is PartialFilled, return estimated base coin quantity.
// If the order status is Filled, return the filled base quantity instead of the buy quantity, because a market order on the buy side
// cannot execute all.
// Otherwise, return zero.
func processMarketBuyQuantity(filledQty, filledPrice, priceAvg, buyQty fixedpoint.Value, orderStatus v2.OrderStatus) (fixedpoint.Value, error) {
switch orderStatus {
case v2.OrderStatusInit, v2.OrderStatusNew, v2.OrderStatusLive, v2.OrderStatusCancelled:
return fixedpoint.Zero, nil
case v2.OrderStatusPartialFilled:
// sanity check for avoid divide 0
if priceAvg.IsZero() {
return fixedpoint.Zero, errors.New("priceAvg for a partialFilled should not be zero")
}
// calculate the remaining quote coin quantity.
remainPrice := buyQty.Sub(filledPrice)
// calculate the remaining base coin quantity.
remainBaseCoinQty := remainPrice.Div(priceAvg)
// Estimated quantity that may be purchased.
return filledQty.Add(remainBaseCoinQty), nil
case v2.OrderStatusFilled:
// Market buy orders may not purchase the entire quantity, hence the use of filledQty here.
return filledQty, nil
default:
return fixedpoint.Zero, fmt.Errorf("failed to execute market buy quantity due to unexpected order status %s ", orderStatus)
}
}
func toLocalOrderType(orderType types.OrderType) (v2.OrderType, error) {
switch orderType {
case types.OrderTypeLimit:
return v2.OrderTypeLimit, nil
case types.OrderTypeMarket:
return v2.OrderTypeMarket, nil
default:
return "", fmt.Errorf("order type %s not supported", orderType)
}
}
func toLocalSide(side types.SideType) (v2.SideType, error) {
switch side {
case types.SideTypeSell:
return v2.SideTypeSell, nil
case types.SideTypeBuy:
return v2.SideTypeBuy, nil
default:
return "", fmt.Errorf("side type %s not supported", side)
}
}

View File

@ -1,11 +1,13 @@
package bitget
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
v2 "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi/v2"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
@ -143,3 +145,437 @@ func Test_toGlobalTicker(t *testing.T) {
Sell: fixedpoint.NewFromFloat(24014.06),
}, toGlobalTicker(ticker))
}
func Test_toGlobalSideType(t *testing.T) {
side, err := toGlobalSideType(v2.SideTypeBuy)
assert.NoError(t, err)
assert.Equal(t, types.SideTypeBuy, side)
side, err = toGlobalSideType(v2.SideTypeSell)
assert.NoError(t, err)
assert.Equal(t, types.SideTypeSell, side)
_, err = toGlobalSideType("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_toGlobalOrderType(t *testing.T) {
orderType, err := toGlobalOrderType(v2.OrderTypeMarket)
assert.NoError(t, err)
assert.Equal(t, types.OrderTypeMarket, orderType)
orderType, err = toGlobalOrderType(v2.OrderTypeLimit)
assert.NoError(t, err)
assert.Equal(t, types.OrderTypeLimit, orderType)
_, err = toGlobalOrderType("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_toGlobalOrderStatus(t *testing.T) {
status, err := toGlobalOrderStatus(v2.OrderStatusInit)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusNew, status)
status, err = toGlobalOrderStatus(v2.OrderStatusNew)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusNew, status)
status, err = toGlobalOrderStatus(v2.OrderStatusLive)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusNew, status)
status, err = toGlobalOrderStatus(v2.OrderStatusFilled)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusFilled, status)
status, err = toGlobalOrderStatus(v2.OrderStatusPartialFilled)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusPartiallyFilled, status)
status, err = toGlobalOrderStatus(v2.OrderStatusCancelled)
assert.NoError(t, err)
assert.Equal(t, types.OrderStatusCanceled, status)
_, err = toGlobalOrderStatus("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_unfilledOrderToGlobalOrder(t *testing.T) {
var (
assert = assert.New(t)
orderId = 1105087175647989764
unfilledOrder = v2.UnfilledOrder{
Symbol: "BTCUSDT",
OrderId: types.StrInt64(orderId),
ClientOrderId: "74b86af3-6098-479c-acac-bfb074c067f3",
PriceAvg: fixedpoint.NewFromFloat(1.2),
Size: fixedpoint.NewFromFloat(5),
OrderType: v2.OrderTypeLimit,
Side: v2.SideTypeBuy,
Status: v2.OrderStatusLive,
BasePrice: fixedpoint.NewFromFloat(0),
BaseVolume: fixedpoint.NewFromFloat(0),
QuoteVolume: fixedpoint.NewFromFloat(0),
EnterPointSource: "API",
OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1660704288118),
UTime: types.NewMillisecondTimestampFromInt(1660704288118),
}
)
t.Run("succeeds", func(t *testing.T) {
order, err := unfilledOrderToGlobalOrder(unfilledOrder)
assert.NoError(err)
assert.Equal(&types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3",
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: fixedpoint.NewFromFloat(5),
Price: fixedpoint.NewFromFloat(1.2),
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(orderId),
UUID: strconv.FormatInt(int64(orderId), 10),
Status: types.OrderStatusNew,
ExecutedQuantity: fixedpoint.NewFromFloat(0),
IsWorking: true,
CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
}, order)
})
t.Run("failed to convert side", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.Side = "xxx"
_, err := unfilledOrderToGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
t.Run("failed to convert oder type", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.OrderType = "xxx"
_, err := unfilledOrderToGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
t.Run("failed to convert oder status", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.Status = "xxx"
_, err := unfilledOrderToGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
}
func Test_toGlobalOrder(t *testing.T) {
var (
assert = assert.New(t)
orderId = 1105087175647989764
unfilledOrder = v2.OrderDetail{
UserId: 123456,
Symbol: "BTCUSDT",
OrderId: types.StrInt64(orderId),
ClientOrderId: "74b86af3-6098-479c-acac-bfb074c067f3",
Price: fixedpoint.NewFromFloat(1.2),
Size: fixedpoint.NewFromFloat(5),
OrderType: v2.OrderTypeLimit,
Side: v2.SideTypeBuy,
Status: v2.OrderStatusFilled,
PriceAvg: fixedpoint.NewFromFloat(1.4),
BaseVolume: fixedpoint.NewFromFloat(5),
QuoteVolume: fixedpoint.NewFromFloat(7.0005),
EnterPointSource: "API",
FeeDetailRaw: `{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}`,
OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1660704288118),
UTime: types.NewMillisecondTimestampFromInt(1660704288118),
}
expOrder = &types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3",
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: fixedpoint.NewFromFloat(5),
Price: fixedpoint.NewFromFloat(1.2),
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(orderId),
UUID: strconv.FormatInt(int64(orderId), 10),
Status: types.OrderStatusFilled,
ExecutedQuantity: fixedpoint.NewFromFloat(5),
IsWorking: false,
CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
}
)
t.Run("succeeds with limit buy", func(t *testing.T) {
order, err := toGlobalOrder(unfilledOrder)
assert.NoError(err)
assert.Equal(expOrder, order)
})
t.Run("succeeds with limit sell", func(t *testing.T) {
newUnfilledOrder := unfilledOrder
newUnfilledOrder.Side = v2.SideTypeSell
newExpOrder := *expOrder
newExpOrder.Side = types.SideTypeSell
order, err := toGlobalOrder(newUnfilledOrder)
assert.NoError(err)
assert.Equal(&newExpOrder, order)
})
t.Run("succeeds with market sell", func(t *testing.T) {
newUnfilledOrder := unfilledOrder
newUnfilledOrder.Side = v2.SideTypeSell
newUnfilledOrder.OrderType = v2.OrderTypeMarket
newExpOrder := *expOrder
newExpOrder.Side = types.SideTypeSell
newExpOrder.Type = types.OrderTypeMarket
newExpOrder.Price = newUnfilledOrder.PriceAvg
order, err := toGlobalOrder(newUnfilledOrder)
assert.NoError(err)
assert.Equal(&newExpOrder, order)
})
t.Run("succeeds with market buy", func(t *testing.T) {
newUnfilledOrder := unfilledOrder
newUnfilledOrder.Side = v2.SideTypeBuy
newUnfilledOrder.OrderType = v2.OrderTypeMarket
newExpOrder := *expOrder
newExpOrder.Side = types.SideTypeBuy
newExpOrder.Type = types.OrderTypeMarket
newExpOrder.Price = newUnfilledOrder.PriceAvg
newExpOrder.Quantity = newUnfilledOrder.BaseVolume
order, err := toGlobalOrder(newUnfilledOrder)
assert.NoError(err)
assert.Equal(&newExpOrder, order)
})
t.Run("succeeds with limit buy", func(t *testing.T) {
order, err := toGlobalOrder(unfilledOrder)
assert.NoError(err)
assert.Equal(&types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3",
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: fixedpoint.NewFromFloat(5),
Price: fixedpoint.NewFromFloat(1.2),
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(orderId),
UUID: strconv.FormatInt(int64(orderId), 10),
Status: types.OrderStatusFilled,
ExecutedQuantity: fixedpoint.NewFromFloat(5),
IsWorking: false,
CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()),
}, order)
})
t.Run("failed to convert side", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.Side = "xxx"
_, err := toGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
t.Run("failed to convert oder type", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.OrderType = "xxx"
_, err := toGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
t.Run("failed to convert oder status", func(t *testing.T) {
newOrder := unfilledOrder
newOrder.Status = "xxx"
_, err := toGlobalOrder(newOrder)
assert.ErrorContains(err, "xxx")
})
}
func Test_processMarketBuyQuantity(t *testing.T) {
var (
assert = assert.New(t)
filledBaseCoinQty = fixedpoint.NewFromFloat(3.5648)
filledPrice = fixedpoint.NewFromFloat(4.99998848)
priceAvg = fixedpoint.NewFromFloat(1.4026)
buyQty = fixedpoint.NewFromFloat(5)
)
t.Run("zero quantity on Init/New/Live/Cancelled", func(t *testing.T) {
qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusInit)
assert.NoError(err)
assert.Equal(fixedpoint.Zero, qty)
qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusNew)
assert.NoError(err)
assert.Equal(fixedpoint.Zero, qty)
qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusLive)
assert.NoError(err)
assert.Equal(fixedpoint.Zero, qty)
qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusCancelled)
assert.NoError(err)
assert.Equal(fixedpoint.Zero, qty)
})
t.Run("5 on PartialFilled", func(t *testing.T) {
priceAvg := fixedpoint.NewFromFloat(2)
buyQty := fixedpoint.NewFromFloat(10)
filledPrice := fixedpoint.NewFromFloat(4)
filledBaseCoinQty := fixedpoint.NewFromFloat(2)
qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusPartialFilled)
assert.NoError(err)
assert.Equal(fixedpoint.NewFromFloat(5), qty)
})
t.Run("3.5648 on Filled", func(t *testing.T) {
qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusFilled)
assert.NoError(err)
assert.Equal(fixedpoint.NewFromFloat(3.5648), qty)
})
t.Run("unexpected order status", func(t *testing.T) {
_, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, "xxx")
assert.ErrorContains(err, "xxx")
})
}
func Test_toLocalOrderType(t *testing.T) {
orderType, err := toLocalOrderType(types.OrderTypeLimit)
assert.NoError(t, err)
assert.Equal(t, v2.OrderTypeLimit, orderType)
orderType, err = toLocalOrderType(types.OrderTypeMarket)
assert.NoError(t, err)
assert.Equal(t, v2.OrderTypeMarket, orderType)
_, err = toLocalOrderType("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_toLocalSide(t *testing.T) {
orderType, err := toLocalSide(types.SideTypeSell)
assert.NoError(t, err)
assert.Equal(t, v2.SideTypeSell, orderType)
orderType, err = toLocalSide(types.SideTypeBuy)
assert.NoError(t, err)
assert.Equal(t, v2.SideTypeBuy, orderType)
_, err = toLocalOrderType("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_isMaker(t *testing.T) {
isM, err := isMaker(v2.TradeTaker)
assert.NoError(t, err)
assert.False(t, isM)
isM, err = isMaker(v2.TradeMaker)
assert.NoError(t, err)
assert.True(t, isM)
_, err = isMaker("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_isFeeDiscount(t *testing.T) {
isDiscount, err := isFeeDiscount(v2.DiscountNo)
assert.NoError(t, err)
assert.False(t, isDiscount)
isDiscount, err = isFeeDiscount(v2.DiscountYes)
assert.NoError(t, err)
assert.True(t, isDiscount)
_, err = isFeeDiscount("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_toGlobalTrade(t *testing.T) {
// {
// "userId":"8672173294",
// "symbol":"APEUSDT",
// "orderId":"1104337778433757184",
// "tradeId":"1104337778504044545",
// "orderType":"limit",
// "side":"sell",
// "priceAvg":"1.4001",
// "size":"5",
// "amount":"7.0005",
// "feeDetail":{
// "deduction":"no",
// "feeCoin":"USDT",
// "totalDeductionFee":"",
// "totalFee":"-0.0070005"
// },
// "tradeScope":"taker",
// "cTime":"1699020564676",
// "uTime":"1699020564687"
//}
trade := v2.Trade{
UserId: types.StrInt64(8672173294),
Symbol: "APEUSDT",
OrderId: types.StrInt64(1104337778433757184),
TradeId: types.StrInt64(1104337778504044545),
OrderType: v2.OrderTypeLimit,
Side: v2.SideTypeSell,
PriceAvg: fixedpoint.NewFromFloat(1.4001),
Size: fixedpoint.NewFromFloat(5),
Amount: fixedpoint.NewFromFloat(7.0005),
FeeDetail: v2.TradeFee{
Deduction: "no",
FeeCoin: "USDT",
TotalDeductionFee: fixedpoint.Zero,
TotalFee: fixedpoint.NewFromFloat(-0.0070005),
},
TradeScope: v2.TradeTaker,
CTime: types.NewMillisecondTimestampFromInt(1699020564676),
UTime: types.NewMillisecondTimestampFromInt(1699020564687),
}
res, err := toGlobalTrade(trade)
assert.NoError(t, err)
assert.Equal(t, &types.Trade{
ID: uint64(1104337778504044545),
OrderID: uint64(1104337778433757184),
Exchange: types.ExchangeBitget,
Price: fixedpoint.NewFromFloat(1.4001),
Quantity: fixedpoint.NewFromFloat(5),
QuoteQuantity: fixedpoint.NewFromFloat(7.0005),
Symbol: "APEUSDT",
Side: types.SideTypeSell,
IsBuyer: false,
IsMaker: false,
Time: types.Time(types.NewMillisecondTimestampFromInt(1699020564676)),
Fee: fixedpoint.NewFromFloat(0.0070005),
FeeCurrency: "USDT",
FeeDiscounted: false,
}, res)
}

View File

@ -2,19 +2,29 @@ package bitget
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/sirupsen/logrus"
"go.uber.org/multierr"
"golang.org/x/time/rate"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
v2 "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi/v2"
"github.com/c9s/bbgo/pkg/types"
)
const ID = "bitget"
const (
ID = "bitget"
const PlatformToken = "BGB"
PlatformToken = "BGB"
queryLimit = 100
maxOrderIdLen = 36
queryMaxDuration = 90 * 24 * time.Hour
)
var log = logrus.WithFields(logrus.Fields{
"exchange": ID,
@ -29,12 +39,23 @@ var (
queryTickerRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5)
// queryTickersRateLimiter has its own rate limit. https://bitgetlimited.github.io/apidoc/en/spot/#get-all-tickers
queryTickersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5)
// queryOpenOrdersRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Get-Unfilled-Orders
queryOpenOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5)
// closedQueryOrdersRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/trade/Get-History-Orders
closedQueryOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/15), 5)
// submitOrdersRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Place-Order
submitOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5)
// queryTradeRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Get-Fills
queryTradeRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5)
// cancelOrderRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/trade/Cancel-Order
cancelOrderRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5)
)
type Exchange struct {
key, secret, passphrase string
client *bitgetapi.RestClient
v2Client *v2.Client
}
func New(key, secret, passphrase string) *Exchange {
@ -49,6 +70,7 @@ func New(key, secret, passphrase string) *Exchange {
secret: secret,
passphrase: passphrase,
client: client,
v2Client: v2.NewClient(client),
}
}
@ -168,17 +190,321 @@ func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap,
return bals, nil
}
// SubmitOrder submits an order.
//
// Remark:
// 1. We support only GTC for time-in-force, because the response from queryOrder does not include time-in-force information.
// 2. For market buy orders, the size unit is quote currency, whereas the unit for order.Quantity is in base currency.
// Therefore, we need to calculate the equivalent quote currency amount based on the ticker data.
//
// Note that there is a bug in Bitget where you can place a market order with the 'post_only' option successfully,
// which should not be possible. The issue has been reported.
func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) {
// TODO implement me
panic("implement me")
if len(order.Market.Symbol) == 0 {
return nil, fmt.Errorf("order.Market.Symbol is required: %+v", order)
}
req := e.v2Client.NewPlaceOrderRequest()
req.Symbol(order.Market.Symbol)
// set order type
orderType, err := toLocalOrderType(order.Type)
if err != nil {
return nil, err
}
req.OrderType(orderType)
// set side
side, err := toLocalSide(order.Side)
if err != nil {
return nil, err
}
req.Side(side)
// set quantity
qty := order.Quantity
// if the order is market buy, the quantity is quote coin, instead of base coin. so we need to convert it.
if order.Type == types.OrderTypeMarket && order.Side == types.SideTypeBuy {
ticker, err := e.QueryTicker(ctx, order.Market.Symbol)
if err != nil {
return nil, err
}
qty = order.Quantity.Mul(ticker.Buy)
}
req.Size(order.Market.FormatQuantity(qty))
// we support only GTC/PostOnly, this is because:
// 1. We support only SPOT trading.
// 2. The query oepn/closed order does not including the `force` in SPOT.
// If we support FOK/IOC, but you can't query them, that would be unreasonable.
// The other case to consider is 'PostOnly', which is a trade-off because we want to support 'xmaker'.
if order.TimeInForce != types.TimeInForceGTC {
return nil, fmt.Errorf("time-in-force %s not supported", order.TimeInForce)
}
req.Force(v2.OrderForceGTC)
// set price
if order.Type == types.OrderTypeLimit || order.Type == types.OrderTypeLimitMaker {
req.Price(order.Market.FormatPrice(order.Price))
if order.Type == types.OrderTypeLimitMaker {
req.Force(v2.OrderForcePostOnly)
}
}
// set client order id
if len(order.ClientOrderID) > maxOrderIdLen {
return nil, fmt.Errorf("unexpected length of order id, got: %d", len(order.ClientOrderID))
}
if len(order.ClientOrderID) > 0 {
req.ClientOrderId(order.ClientOrderID)
}
if err := submitOrdersRateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("place order rate limiter wait error: %w", err)
}
res, err := req.Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to place order, order: %#v, err: %w", order, err)
}
if len(res.OrderId) == 0 || (len(order.ClientOrderID) != 0 && res.ClientOrderId != order.ClientOrderID) {
return nil, fmt.Errorf("unexpected order id, resp: %#v, order: %#v", res, order)
}
orderId := res.OrderId
ordersResp, err := e.v2Client.NewGetUnfilledOrdersRequest().OrderId(orderId).Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query open order by order id: %s, err: %w", orderId, err)
}
switch len(ordersResp) {
case 0:
// The market order will be executed immediately, so we cannot retrieve it through the NewGetUnfilledOrdersRequest API.
// Try to get the order from the NewGetHistoryOrdersRequest API.
ordersResp, err := e.v2Client.NewGetHistoryOrdersRequest().OrderId(orderId).Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query history order by order id: %s, err: %w", orderId, err)
}
if len(ordersResp) != 1 {
return nil, fmt.Errorf("unexpected order length, order id: %s", orderId)
}
return toGlobalOrder(ordersResp[0])
case 1:
return unfilledOrderToGlobalOrder(ordersResp[0])
default:
return nil, fmt.Errorf("unexpected order length, order id: %s", orderId)
}
}
func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) {
// TODO implement me
panic("implement me")
var nextCursor types.StrInt64
for {
if err := queryOpenOrdersRateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("open order rate limiter wait error: %w", err)
}
func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error {
// TODO implement me
panic("implement me")
req := e.v2Client.NewGetUnfilledOrdersRequest().
Symbol(symbol).
Limit(strconv.FormatInt(queryLimit, 10))
if nextCursor != 0 {
req.IdLessThan(strconv.FormatInt(int64(nextCursor), 10))
}
openOrders, err := req.Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query open orders: %w", err)
}
for _, o := range openOrders {
order, err := unfilledOrderToGlobalOrder(o)
if err != nil {
return nil, fmt.Errorf("failed to convert order, err: %v", err)
}
orders = append(orders, *order)
}
orderLen := len(openOrders)
// a defensive programming to ensure the length of order response is expected.
if orderLen > queryLimit {
return nil, fmt.Errorf("unexpected open orders length %d", orderLen)
}
if orderLen < queryLimit {
break
}
nextCursor = openOrders[orderLen-1].OrderId
}
return orders, nil
}
// QueryClosedOrders queries closed order by time range(`CTime`) and id. The order of the response is in descending order.
// If you need to retrieve all data, please utilize the function pkg/exchange/batch.ClosedOrderBatchQuery.
//
// ** Since is inclusive, Until is exclusive. If you use a time range to query, you must provide both a start time and an end time. **
// ** Since and Until cannot exceed 90 days. **
// ** Since from the last 90 days can be queried. **
func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []types.Order, err error) {
if time.Since(since) > queryMaxDuration {
return nil, fmt.Errorf("start time from the last 90 days can be queried, got: %s", since)
}
if until.Before(since) {
return nil, fmt.Errorf("end time %s before start %s", until, since)
}
if until.Sub(since) > queryMaxDuration {
return nil, fmt.Errorf("the start time %s and end time %s cannot exceed 90 days", since, until)
}
if lastOrderID != 0 {
log.Warn("!!!BITGET EXCHANGE API NOTICE!!! The order of response is in descending order, so the last order id not supported.")
}
if err := closedQueryOrdersRateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("query closed order rate limiter wait error: %w", err)
}
res, err := e.v2Client.NewGetHistoryOrdersRequest().
Symbol(symbol).
Limit(strconv.Itoa(queryLimit)).
StartTime(since.UnixMilli()).
EndTime(until.UnixMilli()).
Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to call get order histories error: %w", err)
}
for _, order := range res {
o, err2 := toGlobalOrder(order)
if err2 != nil {
err = multierr.Append(err, err2)
continue
}
if o.Status.Closed() {
orders = append(orders, *o)
}
}
if err != nil {
return nil, err
}
return types.SortOrdersAscending(orders), nil
}
func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (errs error) {
if len(orders) == 0 {
return nil
}
for _, order := range orders {
req := e.client.NewCancelOrderRequest()
reqId := ""
switch {
// use the OrderID first, then the ClientOrderID
case order.OrderID > 0:
req.OrderId(strconv.FormatUint(order.OrderID, 10))
reqId = strconv.FormatUint(order.OrderID, 10)
case len(order.ClientOrderID) != 0:
req.ClientOrderId(order.ClientOrderID)
reqId = order.ClientOrderID
default:
errs = multierr.Append(
errs,
fmt.Errorf("the order uuid and client order id are empty, order: %#v", order),
)
continue
}
req.Symbol(order.Market.Symbol)
if err := cancelOrderRateLimiter.Wait(ctx); err != nil {
errs = multierr.Append(errs, fmt.Errorf("cancel order rate limiter wait, order id: %s, error: %w", order.ClientOrderID, err))
continue
}
res, err := req.Do(ctx)
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to cancel order id: %s, err: %w", order.ClientOrderID, err))
continue
}
// sanity check
if res.OrderId != reqId && res.ClientOrderId != reqId {
errs = multierr.Append(errs, fmt.Errorf("order id mismatch, exp: %s, respOrderId: %s, respClientOrderId: %s", reqId, res.OrderId, res.ClientOrderId))
continue
}
}
return errs
}
// QueryTrades queries fill trades. The trade of the response is in descending order. The time-based query are typically
// using (`CTime`) as the search criteria.
// If you need to retrieve all data, please utilize the function pkg/exchange/batch.TradeBatchQuery.
//
// ** StartTime is inclusive, EndTime is exclusive. If you use the EndTime, the StartTime is required. **
// ** StartTime and EndTime cannot exceed 90 days. **
func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) {
if options.LastTradeID != 0 {
log.Warn("!!!BITGET EXCHANGE API NOTICE!!! The trade of response is in descending order, so the last trade id not supported.")
}
req := e.v2Client.NewGetTradeFillsRequest()
req.Symbol(symbol)
if options.StartTime != nil {
if time.Since(*options.StartTime) > queryMaxDuration {
return nil, fmt.Errorf("start time from the last 90 days can be queried, got: %s", options.StartTime)
}
req.StartTime(options.StartTime.UnixMilli())
}
if options.EndTime != nil {
if options.StartTime == nil {
return nil, errors.New("start time is required for query trades if you take end time")
}
if options.EndTime.Before(*options.StartTime) {
return nil, fmt.Errorf("end time %s before start %s", *options.EndTime, *options.StartTime)
}
if options.EndTime.Sub(*options.StartTime) > queryMaxDuration {
return nil, fmt.Errorf("start time %s and end time %s cannot greater than 90 days", options.StartTime, options.EndTime)
}
req.EndTime(options.EndTime.UnixMilli())
}
limit := options.Limit
if limit > queryLimit || limit <= 0 {
log.Debugf("limtit is exceeded or zero, update to %d, got: %d", queryLimit, options.Limit)
limit = queryLimit
}
req.Limit(strconv.FormatInt(limit, 10))
if err := queryTradeRateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("trade rate limiter wait error: %w", err)
}
response, err := req.Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query trades, err: %w", err)
}
var errs error
for _, trade := range response {
res, err := toGlobalTrade(trade)
if err != nil {
errs = multierr.Append(errs, err)
continue
}
trades = append(trades, *res)
}
if errs != nil {
return nil, errs
}
return trades, nil
}

View File

@ -187,6 +187,12 @@ func (p *CancelOrderRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (p *CancelOrderRequest) GetPath() string {
return "/v5/order/cancel"
}
// Do generates the request object and send the request object to the API endpoint
func (p *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, error) {
params, err := p.GetParameters()
@ -195,7 +201,9 @@ func (p *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, erro
}
query := url.Values{}
apiURL := "/v5/order/cancel"
var apiURL string
apiURL = p.GetPath()
req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params)
if err != nil {
@ -211,6 +219,16 @@ func (p *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, erro
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data CancelOrderResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -162,10 +162,25 @@ sample:
*/
type APIResponse struct {
// Success/Error code
RetCode uint `json:"retCode"`
// Success/Error msg. OK, success, SUCCESS indicate a successful response
RetMsg string `json:"retMsg"`
// Business data result
Result json.RawMessage `json:"result"`
// Extend info. Most of the time, it is {}
RetExtInfo json.RawMessage `json:"retExtInfo"`
// Time is current timestamp (ms)
Time types.MillisecondTimestamp `json:"time"`
}
func (a APIResponse) Validate() error {
if a.RetCode != 0 {
return a.Error()
}
return nil
}
func (a APIResponse) Error() error {
return fmt.Errorf("retCode: %d, retMsg: %s, retExtInfo: %q, time: %s", a.RetCode, a.RetMsg, a.RetExtInfo, a.Time)
}

View File

@ -109,13 +109,21 @@ func (g *GetAccountInfoRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetAccountInfoRequest) GetPath() string {
return "/v5/account/info"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetAccountInfoRequest) Do(ctx context.Context) (*AccountInfo, error) {
// no body params
var params interface{}
query := url.Values{}
apiURL := "/v5/account/info"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -131,6 +139,16 @@ func (g *GetAccountInfoRequest) Do(ctx context.Context) (*AccountInfo, error) {
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data AccountInfo
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -156,6 +156,12 @@ func (g *GetFeeRatesRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetFeeRatesRequest) GetPath() string {
return "/v5/account/fee-rate"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetFeeRatesRequest) Do(ctx context.Context) (*FeeRates, error) {
// no body params
@ -165,7 +171,9 @@ func (g *GetFeeRatesRequest) Do(ctx context.Context) (*FeeRates, error) {
return nil, err
}
apiURL := "/v5/account/fee-rate"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -181,6 +189,16 @@ func (g *GetFeeRatesRequest) Do(ctx context.Context) (*FeeRates, error) {
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data FeeRates
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -169,6 +169,12 @@ func (g *GetInstrumentsInfoRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetInstrumentsInfoRequest) GetPath() string {
return "/v5/market/instruments-info"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) (*InstrumentsInfo, error) {
// no body params
@ -178,7 +184,9 @@ func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) (*InstrumentsInfo, e
return nil, err
}
apiURL := "/v5/market/instruments-info"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -194,6 +202,16 @@ func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) (*InstrumentsInfo, e
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data InstrumentsInfo
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -204,6 +204,12 @@ func (g *GetKLinesRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetKLinesRequest) GetPath() string {
return "/v5/market/kline"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetKLinesRequest) Do(ctx context.Context) (*KLinesResponse, error) {
// no body params
@ -213,7 +219,9 @@ func (g *GetKLinesRequest) Do(ctx context.Context) (*KLinesResponse, error) {
return nil, err
}
apiURL := "/v5/market/kline"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -229,6 +237,16 @@ func (g *GetKLinesRequest) Do(ctx context.Context) (*KLinesResponse, error) {
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data KLinesResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -258,6 +258,12 @@ func (g *GetOpenOrdersRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetOpenOrdersRequest) GetPath() string {
return "/v5/order/realtime"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetOpenOrdersRequest) Do(ctx context.Context) (*OrdersResponse, error) {
// no body params
@ -267,7 +273,9 @@ func (g *GetOpenOrdersRequest) Do(ctx context.Context) (*OrdersResponse, error)
return nil, err
}
apiURL := "/v5/order/realtime"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -283,6 +291,16 @@ func (g *GetOpenOrdersRequest) Do(ctx context.Context) (*OrdersResponse, error)
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data OrdersResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -262,6 +262,12 @@ func (g *GetOrderHistoriesRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetOrderHistoriesRequest) GetPath() string {
return "/v5/order/history"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetOrderHistoriesRequest) Do(ctx context.Context) (*OrdersResponse, error) {
// no body params
@ -271,7 +277,9 @@ func (g *GetOrderHistoriesRequest) Do(ctx context.Context) (*OrdersResponse, err
return nil, err
}
apiURL := "/v5/order/history"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -287,6 +295,16 @@ func (g *GetOrderHistoriesRequest) Do(ctx context.Context) (*OrdersResponse, err
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data OrdersResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -143,6 +143,12 @@ func (g *GetTickersRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetTickersRequest) GetPath() string {
return "/v5/market/tickers"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetTickersRequest) Do(ctx context.Context) (*APIResponse, error) {
// no body params
@ -152,7 +158,9 @@ func (g *GetTickersRequest) Do(ctx context.Context) (*APIResponse, error) {
return nil, err
}
apiURL := "/v5/market/tickers"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -168,5 +176,15 @@ func (g *GetTickersRequest) Do(ctx context.Context) (*APIResponse, error) {
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
return &apiResponse, nil
}

View File

@ -143,6 +143,12 @@ func (g *GetWalletBalancesRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetWalletBalancesRequest) GetPath() string {
return "/v5/account/wallet-balance"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetWalletBalancesRequest) Do(ctx context.Context) (*WalletBalancesResponse, error) {
// no body params
@ -152,7 +158,9 @@ func (g *GetWalletBalancesRequest) Do(ctx context.Context) (*WalletBalancesRespo
return nil, err
}
apiURL := "/v5/account/wallet-balance"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -168,6 +176,16 @@ func (g *GetWalletBalancesRequest) Do(ctx context.Context) (*WalletBalancesRespo
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data WalletBalancesResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -496,6 +496,12 @@ func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (p *PlaceOrderRequest) GetPath() string {
return "/v5/order/create"
}
// Do generates the request object and send the request object to the API endpoint
func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error) {
params, err := p.GetParameters()
@ -504,7 +510,9 @@ func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error)
}
query := url.Values{}
apiURL := "/v5/order/create"
var apiURL string
apiURL = p.GetPath()
req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params)
if err != nil {
@ -520,6 +528,16 @@ func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error)
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data PlaceOrderResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -205,6 +205,12 @@ func (g *GetTradesRequest) GetSlugsMap() (map[string]string, error) {
return slugs, nil
}
// GetPath returns the request path of the API
func (g *GetTradesRequest) GetPath() string {
return "/spot/v3/private/my-trades"
}
// Do generates the request object and send the request object to the API endpoint
func (g *GetTradesRequest) Do(ctx context.Context) (*TradesResponse, error) {
// no body params
@ -214,7 +220,9 @@ func (g *GetTradesRequest) Do(ctx context.Context) (*TradesResponse, error) {
return nil, err
}
apiURL := "/spot/v3/private/my-trades"
var apiURL string
apiURL = g.GetPath()
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
@ -230,6 +238,16 @@ func (g *GetTradesRequest) Do(ctx context.Context) (*TradesResponse, error) {
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
type responseValidator interface {
Validate() error
}
validator, ok := interface{}(apiResponse).(responseValidator)
if ok {
if err := validator.Validate(); err != nil {
return nil, err
}
}
var data TradesResponse
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err

View File

@ -0,0 +1,132 @@
package bybit
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/c9s/bbgo/pkg/exchange/bybit/bybitapi"
)
const (
// To maintain aligned fee rates, it's important to update fees frequently.
feeRatePollingPeriod = time.Minute
)
type symbolFeeDetail struct {
bybitapi.FeeRate
BaseCoin string
QuoteCoin string
}
// feeRatePoller pulls the specified market data from bbgo QueryMarkets.
type feeRatePoller struct {
mu sync.Mutex
once sync.Once
client MarketInfoProvider
symbolFeeDetail map[string]symbolFeeDetail
}
func newFeeRatePoller(marketInfoProvider MarketInfoProvider) *feeRatePoller {
return &feeRatePoller{
client: marketInfoProvider,
symbolFeeDetail: map[string]symbolFeeDetail{},
}
}
func (p *feeRatePoller) Start(ctx context.Context) {
p.once.Do(func() {
p.startLoop(ctx)
})
}
func (p *feeRatePoller) startLoop(ctx context.Context) {
err := p.poll(ctx)
if err != nil {
log.WithError(err).Warn("failed to initialize the fee rate, the ticker is scheduled to update it subsequently")
}
ticker := time.NewTicker(feeRatePollingPeriod)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if err := ctx.Err(); !errors.Is(err, context.Canceled) {
log.WithError(err).Error("context done with error")
}
return
case <-ticker.C:
if err := p.poll(ctx); err != nil {
log.WithError(err).Warn("failed to update fee rate")
}
}
}
}
func (p *feeRatePoller) poll(ctx context.Context) error {
symbolFeeRate, err := p.getAllFeeRates(ctx)
if err != nil {
return err
}
p.mu.Lock()
p.symbolFeeDetail = symbolFeeRate
p.mu.Unlock()
return nil
}
func (p *feeRatePoller) Get(symbol string) (symbolFeeDetail, bool) {
p.mu.Lock()
defer p.mu.Unlock()
fee, found := p.symbolFeeDetail[symbol]
return fee, found
}
func (e *feeRatePoller) getAllFeeRates(ctx context.Context) (map[string]symbolFeeDetail, error) {
feeRates, err := e.client.GetAllFeeRates(ctx)
if err != nil {
return nil, fmt.Errorf("failed to call get fee rates: %w", err)
}
symbolMap := map[string]symbolFeeDetail{}
for _, f := range feeRates.List {
if _, found := symbolMap[f.Symbol]; !found {
symbolMap[f.Symbol] = symbolFeeDetail{FeeRate: f}
}
}
mkts, err := e.client.QueryMarkets(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get markets: %w", err)
}
// update base coin, quote coin into symbolFeeDetail
for _, mkt := range mkts {
feeRate, found := symbolMap[mkt.Symbol]
if !found {
continue
}
feeRate.BaseCoin = mkt.BaseCurrency
feeRate.QuoteCoin = mkt.QuoteCurrency
symbolMap[mkt.Symbol] = feeRate
}
// remove trading pairs that are not present in spot market.
for k, v := range symbolMap {
if len(v.BaseCoin) == 0 || len(v.QuoteCoin) == 0 {
log.Debugf("related market not found: %s, skipping the associated trade", k)
delete(symbolMap, k)
}
}
return symbolMap, nil
}

View File

@ -0,0 +1,173 @@
package bybit
import (
"context"
"fmt"
"testing"
"github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/exchange/bybit/bybitapi"
"github.com/c9s/bbgo/pkg/exchange/bybit/mocks"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
func TestFeeRatePoller_getAllFeeRates(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
unknownErr := errors.New("unknown err")
t.Run("succeeds", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &feeRatePoller{
client: mockMarketProvider,
}
ctx := context.Background()
feeRates := bybitapi.FeeRates{
List: []bybitapi.FeeRate{
{
Symbol: "BTCUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "ETHUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "OPTIONCOIN",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
},
}
mkts := types.MarketMap{
"BTCUSDT": types.Market{
Symbol: "BTCUSDT",
QuoteCurrency: "USDT",
BaseCurrency: "BTC",
},
"ETHUSDT": types.Market{
Symbol: "ETHUSDT",
QuoteCurrency: "USDT",
BaseCurrency: "ETH",
},
}
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1)
mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(mkts, nil).Times(1)
expFeeRates := map[string]symbolFeeDetail{
"BTCUSDT": {
FeeRate: feeRates.List[0],
BaseCoin: "BTC",
QuoteCoin: "USDT",
},
"ETHUSDT": {
FeeRate: feeRates.List[1],
BaseCoin: "ETH",
QuoteCoin: "USDT",
},
}
symbolFeeDetails, err := s.getAllFeeRates(ctx)
assert.NoError(t, err)
assert.Equal(t, expFeeRates, symbolFeeDetails)
})
t.Run("failed to query markets", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &feeRatePoller{
client: mockMarketProvider,
}
ctx := context.Background()
feeRates := bybitapi.FeeRates{
List: []bybitapi.FeeRate{
{
Symbol: "BTCUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "ETHUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "OPTIONCOIN",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
},
}
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1)
mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(nil, unknownErr).Times(1)
symbolFeeDetails, err := s.getAllFeeRates(ctx)
assert.Equal(t, fmt.Errorf("failed to get markets: %w", unknownErr), err)
assert.Equal(t, map[string]symbolFeeDetail(nil), symbolFeeDetails)
})
t.Run("failed to get fee rates", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &feeRatePoller{
client: mockMarketProvider,
}
ctx := context.Background()
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(bybitapi.FeeRates{}, unknownErr).Times(1)
symbolFeeDetails, err := s.getAllFeeRates(ctx)
assert.Equal(t, fmt.Errorf("failed to call get fee rates: %w", unknownErr), err)
assert.Equal(t, map[string]symbolFeeDetail(nil), symbolFeeDetails)
})
}
func Test_feeRatePoller_Get(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
t.Run("found", func(t *testing.T) {
symbol := "BTCUSDT"
expFeeDetail := symbolFeeDetail{
FeeRate: bybitapi.FeeRate{
Symbol: symbol,
TakerFeeRate: fixedpoint.NewFromFloat(0.1),
MakerFeeRate: fixedpoint.NewFromFloat(0.2),
},
BaseCoin: "BTC",
QuoteCoin: "USDT",
}
s := &feeRatePoller{
client: mockMarketProvider,
symbolFeeDetail: map[string]symbolFeeDetail{
symbol: expFeeDetail,
},
}
res, found := s.Get(symbol)
assert.True(t, found)
assert.Equal(t, expFeeDetail, res)
})
t.Run("not found", func(t *testing.T) {
symbol := "BTCUSDT"
s := &feeRatePoller{
client: mockMarketProvider,
symbolFeeDetail: map[string]symbolFeeDetail{},
}
_, found := s.Get(symbol)
assert.False(t, found)
})
}

View File

@ -10,6 +10,7 @@ import (
"github.com/gorilla/websocket"
"github.com/c9s/bbgo/pkg/exchange/bybit/bybitapi"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
"github.com/c9s/bbgo/pkg/util"
)
@ -22,6 +23,11 @@ const (
var (
// wsAuthRequest specifies the duration for which a websocket request's authentication is valid.
wsAuthRequest = 10 * time.Second
// The default taker/maker fees can help us in estimating trading fees in the SPOT market, because trade fees are not
// provided for traditional accounts on Bybit.
// https://www.bybit.com/en-US/help-center/article/Trading-Fee-Structure
defaultTakerFee = fixedpoint.NewFromFloat(0.001)
defaultMakerFee = fixedpoint.NewFromFloat(0.001)
)
// MarketInfoProvider calculates trade fees since trading fees are not supported by streaming.
@ -47,8 +53,8 @@ type Stream struct {
key, secret string
streamDataProvider StreamDataProvider
// TODO: update the fee rate at 7:00 am UTC; rotation required.
symbolFeeDetails map[string]*symbolFeeDetail
feeRateProvider *feeRatePoller
marketsInfo types.MarketMap
bookEventCallbacks []func(e BookEvent)
marketTradeEventCallbacks []func(e []MarketTradeEvent)
@ -65,13 +71,23 @@ func NewStream(key, secret string, userDataProvider StreamDataProvider) *Stream
key: key,
secret: secret,
streamDataProvider: userDataProvider,
feeRateProvider: newFeeRatePoller(userDataProvider),
}
stream.SetEndpointCreator(stream.createEndpoint)
stream.SetParser(stream.parseWebSocketEvent)
stream.SetDispatcher(stream.dispatchEvent)
stream.SetHeartBeat(stream.ping)
stream.SetBeforeConnect(stream.getAllFeeRates)
stream.SetBeforeConnect(func(ctx context.Context) (err error) {
go stream.feeRateProvider.Start(ctx)
stream.marketsInfo, err = stream.streamDataProvider.QueryMarkets(ctx)
if err != nil {
log.WithError(err).Error("failed to query market info before to connect stream")
return err
}
return nil
})
stream.OnConnect(stream.handlerConnect)
stream.OnAuth(stream.handleAuthEvent)
@ -403,13 +419,34 @@ func (s *Stream) handleKLineEvent(klineEvent KLineEvent) {
func (s *Stream) handleTradeEvent(events []TradeEvent) {
for _, event := range events {
feeRate, found := s.symbolFeeDetails[event.Symbol]
feeRate, found := s.feeRateProvider.Get(event.Symbol)
if !found {
log.Warnf("unexpected symbol found, fee rate not supported, symbol: %s", event.Symbol)
continue
feeRate = symbolFeeDetail{
FeeRate: bybitapi.FeeRate{
Symbol: event.Symbol,
TakerFeeRate: defaultTakerFee,
MakerFeeRate: defaultMakerFee,
},
BaseCoin: "",
QuoteCoin: "",
}
gTrade, err := event.toGlobalTrade(*feeRate)
if market, ok := s.marketsInfo[event.Symbol]; ok {
feeRate.BaseCoin = market.BaseCurrency
feeRate.QuoteCoin = market.QuoteCurrency
}
// The error log level was utilized due to a detected discrepancy in the fee calculations.
log.Errorf("failed to get %s fee rate, use default taker fee %f, maker fee %f, base coin: %s, quote coin: %s",
event.Symbol,
feeRate.TakerFeeRate.Float64(),
feeRate.MakerFeeRate.Float64(),
feeRate.BaseCoin,
feeRate.QuoteCoin,
)
}
gTrade, err := event.toGlobalTrade(feeRate)
if err != nil {
log.WithError(err).Errorf("unable to convert: %+v", event)
continue
@ -417,53 +454,3 @@ func (s *Stream) handleTradeEvent(events []TradeEvent) {
s.StandardStream.EmitTradeUpdate(*gTrade)
}
}
type symbolFeeDetail struct {
bybitapi.FeeRate
BaseCoin string
QuoteCoin string
}
// getAllFeeRates retrieves all fee rates from the Bybit API and then fetches markets to ensure the base coin and quote coin
// are correct.
func (e *Stream) getAllFeeRates(ctx context.Context) error {
feeRates, err := e.streamDataProvider.GetAllFeeRates(ctx)
if err != nil {
return fmt.Errorf("failed to call get fee rates: %w", err)
}
symbolMap := map[string]*symbolFeeDetail{}
for _, f := range feeRates.List {
if _, found := symbolMap[f.Symbol]; !found {
symbolMap[f.Symbol] = &symbolFeeDetail{FeeRate: f}
}
}
mkts, err := e.streamDataProvider.QueryMarkets(ctx)
if err != nil {
return fmt.Errorf("failed to get markets: %w", err)
}
// update base coin, quote coin into symbolFeeDetail
for _, mkt := range mkts {
feeRate, found := symbolMap[mkt.Symbol]
if !found {
continue
}
feeRate.BaseCoin = mkt.BaseCurrency
feeRate.QuoteCoin = mkt.QuoteCurrency
}
// remove trading pairs that are not present in spot market.
for k, v := range symbolMap {
if len(v.BaseCoin) == 0 || len(v.QuoteCoin) == 0 {
log.Debugf("related market not found: %s, skipping the associated trade", k)
delete(symbolMap, k)
}
}
e.symbolFeeDetails = symbolMap
return nil
}

View File

@ -9,11 +9,9 @@ import (
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/exchange/bybit/bybitapi"
"github.com/c9s/bbgo/pkg/exchange/bybit/mocks"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/testutil"
"github.com/c9s/bbgo/pkg/types"
@ -36,7 +34,7 @@ func getTestClientOrSkip(t *testing.T) *Stream {
}
func TestStream(t *testing.T) {
t.Skip()
//t.Skip()
s := getTestClientOrSkip(t)
symbols := []string{
@ -70,12 +68,12 @@ func TestStream(t *testing.T) {
err := s.Connect(context.Background())
assert.NoError(t, err)
s.OnBookSnapshot(func(book types.SliceOrderBook) {
t.Log("got snapshot", book)
})
s.OnBookUpdate(func(book types.SliceOrderBook) {
t.Log("got update", book)
})
//s.OnBookSnapshot(func(book types.SliceOrderBook) {
// t.Log("got snapshot", book)
//})
//s.OnBookUpdate(func(book types.SliceOrderBook) {
// t.Log("got update", book)
//})
c := make(chan struct{})
<-c
})
@ -175,7 +173,7 @@ func TestStream(t *testing.T) {
assert.NoError(t, err)
s.OnTradeUpdate(func(trade types.Trade) {
t.Log("got update", trade)
t.Log("got update", trade.Fee, trade.FeeCurrency, trade)
})
c := make(chan struct{})
<-c
@ -467,120 +465,3 @@ func Test_convertSubscription(t *testing.T) {
assert.Equal(t, genTopic(TopicTypeMarketTrade, "BTCUSDT"), res)
})
}
func TestStream_getFeeRate(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
unknownErr := errors.New("unknown err")
t.Run("succeeds", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &Stream{
streamDataProvider: mockMarketProvider,
}
ctx := context.Background()
feeRates := bybitapi.FeeRates{
List: []bybitapi.FeeRate{
{
Symbol: "BTCUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "ETHUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "OPTIONCOIN",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
},
}
mkts := types.MarketMap{
"BTCUSDT": types.Market{
Symbol: "BTCUSDT",
QuoteCurrency: "USDT",
BaseCurrency: "BTC",
},
"ETHUSDT": types.Market{
Symbol: "ETHUSDT",
QuoteCurrency: "USDT",
BaseCurrency: "ETH",
},
}
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1)
mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(mkts, nil).Times(1)
expFeeRates := map[string]*symbolFeeDetail{
"BTCUSDT": {
FeeRate: feeRates.List[0],
BaseCoin: "BTC",
QuoteCoin: "USDT",
},
"ETHUSDT": {
FeeRate: feeRates.List[1],
BaseCoin: "ETH",
QuoteCoin: "USDT",
},
}
err := s.getAllFeeRates(ctx)
assert.NoError(t, err)
assert.Equal(t, expFeeRates, s.symbolFeeDetails)
})
t.Run("failed to query markets", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &Stream{
streamDataProvider: mockMarketProvider,
}
ctx := context.Background()
feeRates := bybitapi.FeeRates{
List: []bybitapi.FeeRate{
{
Symbol: "BTCUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "ETHUSDT",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
{
Symbol: "OPTIONCOIN",
TakerFeeRate: fixedpoint.NewFromFloat(0.001),
MakerFeeRate: fixedpoint.NewFromFloat(0.001),
},
},
}
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1)
mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(nil, unknownErr).Times(1)
err := s.getAllFeeRates(ctx)
assert.Equal(t, fmt.Errorf("failed to get markets: %w", unknownErr), err)
assert.Equal(t, map[string]*symbolFeeDetail(nil), s.symbolFeeDetails)
})
t.Run("failed to get fee rates", func(t *testing.T) {
mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl)
s := &Stream{
streamDataProvider: mockMarketProvider,
}
ctx := context.Background()
mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(bybitapi.FeeRates{}, unknownErr).Times(1)
err := s.getAllFeeRates(ctx)
assert.Equal(t, fmt.Errorf("failed to call get fee rates: %w", unknownErr), err)
assert.Equal(t, map[string]*symbolFeeDetail(nil), s.symbolFeeDetails)
})
}

View File

@ -66,22 +66,25 @@ func (s *Strategy) recoverActiveOrdersPeriodically(ctx context.Context) {
exchange: s.session.Exchange,
}
var lastRecoverTime time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := syncActiveOrders(ctx, opts); err != nil {
log.WithError(err).Errorf("unable to sync active orders")
}
s.recoverC <- struct{}{}
case <-s.recoverC:
if err := syncActiveOrders(ctx, opts); err != nil {
log.WithError(err).Errorf("unable to sync active orders")
if !time.Now().After(lastRecoverTime.Add(10 * time.Minute)) {
continue
}
if err := syncActiveOrders(ctx, opts); err != nil {
log.WithError(err).Errorf("unable to sync active orders")
} else {
lastRecoverTime = time.Now()
}
}
}
}
@ -116,20 +119,18 @@ func syncActiveOrders(ctx context.Context, opts SyncActiveOrdersOpts) error {
// no need to sync active order already in active orderbook, because we only need to know if it filled or not.
delete(openOrdersMap, activeOrder.OrderID)
} else {
opts.logger.Infof("found active order #%d is not in the open orders, updating...", activeOrder.OrderID)
if activeOrder.UpdateTime.After(syncBefore) {
opts.logger.Infof("active order #%d is updated in 3 min, skip updating...", activeOrder.OrderID)
continue
}
opts.logger.Infof("[ActiveOrderRecover] found active order #%d is not in the open orders, updating...", activeOrder.OrderID)
// sleep 100ms to avoid DDOS
time.Sleep(100 * time.Millisecond)
if err := syncActiveOrder(ctx, opts.activeOrderBook, opts.orderQueryService, activeOrder.OrderID); err != nil {
isActiveOrderBookUpdated, err := syncActiveOrder(ctx, opts.activeOrderBook, opts.orderQueryService, activeOrder.OrderID, syncBefore)
if err != nil {
opts.logger.WithError(err).Errorf("[ActiveOrderRecover] unable to query order #%d", activeOrder.OrderID)
errs = multierr.Append(errs, err)
continue
}
if !isActiveOrderBookUpdated {
opts.logger.Infof("[ActiveOrderRecover] active order #%d is updated in 3 min, skip updating...", activeOrder.OrderID)
}
}
}

View File

@ -24,6 +24,9 @@ type GridProfitStats struct {
Market types.Market `json:"market,omitempty"`
Since *time.Time `json:"since,omitempty"`
InitialOrderID uint64 `json:"initialOrderID"`
// ttl is the ttl to keep in persistence
ttl time.Duration
}
func newGridProfitStats(market types.Market) *GridProfitStats {
@ -40,6 +43,17 @@ func newGridProfitStats(market types.Market) *GridProfitStats {
}
}
func (s *GridProfitStats) SetTTL(ttl time.Duration) {
if ttl.Nanoseconds() <= 0 {
return
}
s.ttl = ttl
}
func (s *GridProfitStats) Expiration() time.Duration {
return s.ttl
}
func (s *GridProfitStats) AddTrade(trade types.Trade) {
if s.TotalFee == nil {
s.TotalFee = make(map[string]fixedpoint.Value)

View File

@ -13,6 +13,8 @@ import (
"github.com/pkg/errors"
)
var syncWindow = -3 * time.Minute
/*
Background knowledge
1. active orderbook add orders only when receive new order event or call Add/Update method manually
@ -91,11 +93,13 @@ func (s *Strategy) recover(ctx context.Context) error {
pins := s.getGrid().Pins
syncBefore := time.Now().Add(syncWindow)
activeOrdersInTwinOrderBook, err := buildTwinOrderBook(pins, activeOrders)
openOrdersInTwinOrderBook, err := buildTwinOrderBook(pins, openOrders)
s.logger.Infof("active orders' twin orderbook\n%s", activeOrdersInTwinOrderBook.String())
s.logger.Infof("open orders in twin orderbook\n%s", openOrdersInTwinOrderBook.String())
s.logger.Infof("[Recover] active orders' twin orderbook\n%s", activeOrdersInTwinOrderBook.String())
s.logger.Infof("[Recover] open orders in twin orderbook\n%s", openOrdersInTwinOrderBook.String())
// remove index 0, because twin orderbook's price is from the second one
pins = pins[1:]
@ -127,7 +131,9 @@ func (s *Strategy) recover(ctx context.Context) error {
// case 1
if activeOrderID == 0 {
activeOrderBook.Add(openOrder.GetOrder())
order := openOrder.GetOrder()
s.logger.Infof("[Recover] found open order #%d is not in the active orderbook, adding...", order.OrderID)
activeOrderBook.Add(order)
// also add open orders into active order's twin orderbook, we will use this active orderbook to recover empty price grid
activeOrdersInTwinOrderBook.AddTwinOrder(v, openOrder)
continue
@ -135,7 +141,18 @@ func (s *Strategy) recover(ctx context.Context) error {
// case 2
if openOrderID == 0 {
syncActiveOrder(ctx, activeOrderBook, s.orderQueryService, activeOrder.GetOrder().OrderID)
order := activeOrder.GetOrder()
s.logger.Infof("[Recover] found active order #%d is not in the open orders, updating...", order.OrderID)
isActiveOrderBookUpdated, err := syncActiveOrder(ctx, activeOrderBook, s.orderQueryService, order.OrderID, syncBefore)
if err != nil {
s.logger.WithError(err).Errorf("[Recover] unable to query order #%d", order.OrderID)
continue
}
if !isActiveOrderBookUpdated {
s.logger.Infof("[Recover] active order #%d is updated in 3 min, skip updating...", order.OrderID)
}
continue
}
@ -250,19 +267,24 @@ func buildTwinOrderBook(pins []Pin, orders []types.Order) (*TwinOrderBook, error
return book, nil
}
func syncActiveOrder(ctx context.Context, activeOrderBook *bbgo.ActiveOrderBook, orderQueryService types.ExchangeOrderQueryService, orderID uint64) error {
func syncActiveOrder(ctx context.Context, activeOrderBook *bbgo.ActiveOrderBook, orderQueryService types.ExchangeOrderQueryService, orderID uint64, syncBefore time.Time) (bool, error) {
updatedOrder, err := retry.QueryOrderUntilSuccessful(ctx, orderQueryService, types.OrderQuery{
Symbol: activeOrderBook.Symbol,
OrderID: strconv.FormatUint(orderID, 10),
})
isActiveOrderBookUpdated := false
if err != nil {
return err
return isActiveOrderBookUpdated, err
}
isActiveOrderBookUpdated = updatedOrder.UpdateTime.Before(syncBefore)
if isActiveOrderBookUpdated {
activeOrderBook.Update(*updatedOrder)
}
return nil
return isActiveOrderBookUpdated, nil
}
func queryTradesToUpdateTwinOrderBook(

View File

@ -114,7 +114,8 @@ func TestSyncActiveOrder(t *testing.T) {
OrderID: strconv.FormatUint(order.OrderID, 10),
}).Return(&updatedOrder, nil)
if !assert.NoError(syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID)) {
_, err := syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID, time.Now())
if !assert.NoError(err) {
return
}
@ -144,7 +145,8 @@ func TestSyncActiveOrder(t *testing.T) {
OrderID: strconv.FormatUint(order.OrderID, 10),
}).Return(&updatedOrder, nil)
if !assert.NoError(syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID)) {
_, err := syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID, time.Now())
if !assert.NoError(err) {
return
}

View File

@ -177,6 +177,7 @@ type Strategy struct {
GridProfitStats *GridProfitStats `persistence:"grid_profit_stats"`
Position *types.Position `persistence:"position"`
PersistenceTTL types.Duration `json:"persistenceTTL"`
// ExchangeSession is an injection field
ExchangeSession *bbgo.ExchangeSession
@ -796,6 +797,8 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
if numberOfSellOrders > 0 {
numberOfSellOrders--
}
s.logger.Infof("calculated number of sell orders: %d", numberOfSellOrders)
}
// if the maxBaseQuantity is less than minQuantity, then we need to reduce the number of the sell orders
@ -810,8 +813,12 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
s.Market.MinQuantity)
if baseQuantity.Compare(minBaseQuantity) <= 0 {
s.logger.Infof("base quantity %s is less than min base quantity: %s, adjusting...", baseQuantity.String(), minBaseQuantity.String())
baseQuantity = s.Market.RoundUpQuantityByPrecision(minBaseQuantity)
numberOfSellOrders = int(math.Floor(baseInvestment.Div(baseQuantity).Float64()))
s.logger.Infof("adjusted base quantity to %s", baseQuantity.String())
}
s.logger.Infof("grid base investment sell orders: %d", numberOfSellOrders)
@ -824,7 +831,8 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
// quoteInvestment = (p1 + p2 + p3) * q
// maxBuyQuantity = quoteInvestment / (p1 + p2 + p3)
si := -1
for i := len(pins) - 1 - numberOfSellOrders; i >= 0; i-- {
end := len(pins) - 1
for i := end - numberOfSellOrders - 1; i >= 0; i-- {
pin := pins[i]
price := fixedpoint.Value(pin)
@ -844,6 +852,7 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
// requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice))
totalQuotePrice = totalQuotePrice.Add(nextLowerPrice)
}
} else {
// for orders that buy
if s.ProfitSpread.IsZero() && i+1 == si {
@ -851,7 +860,7 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
}
// should never place a buy order at the upper price
if i == len(pins)-1 {
if i == end {
continue
}
@ -859,8 +868,11 @@ func (s *Strategy) calculateBaseQuoteInvestmentQuantity(
}
}
s.logger.Infof("total quote price: %f", totalQuotePrice.Float64())
if totalQuotePrice.Sign() > 0 && quoteInvestment.Sign() > 0 {
quoteSideQuantity := quoteInvestment.Div(totalQuotePrice)
s.logger.Infof("quote side quantity: %f = %f / %f", quoteSideQuantity.Float64(), quoteInvestment.Float64(), totalQuotePrice.Float64())
if numberOfSellOrders > 0 {
return fixedpoint.Min(quoteSideQuantity, baseQuantity), nil
}
@ -1058,6 +1070,11 @@ func (s *Strategy) openGrid(ctx context.Context, session *bbgo.ExchangeSession)
return err2
}
if s.BaseGridNum > 0 {
sell1 := fixedpoint.Value(s.grid.Pins[len(s.grid.Pins)-1-s.BaseGridNum])
lastPrice = sell1.Sub(s.Market.TickSize)
}
// check if base and quote are enough
var totalBase = fixedpoint.Zero
var totalQuote = fixedpoint.Zero
@ -1432,6 +1449,8 @@ func calculateMinimalQuoteInvestment(market types.Market, grid *Grid) fixedpoint
for i := len(pins) - 2; i >= 0; i-- {
pin := pins[i]
price := fixedpoint.Value(pin)
// TODO: should we round the quote here before adding?
totalQuote = totalQuote.Add(price.Mul(minQuantity))
}
@ -1817,13 +1836,17 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
s.ProfitSpread = s.Market.TruncatePrice(s.ProfitSpread)
}
s.logger.Infof("persistence ttl: %s", s.PersistenceTTL.Duration())
if s.GridProfitStats == nil {
s.GridProfitStats = newGridProfitStats(s.Market)
}
s.GridProfitStats.SetTTL(s.PersistenceTTL.Duration())
if s.Position == nil {
s.Position = types.NewPositionFromMarket(s.Market)
}
s.Position.SetTTL(s.PersistenceTTL.Duration())
// initialize and register prometheus metrics
if s.PrometheusLabels != nil {

View File

@ -204,6 +204,123 @@ func TestStrategy_generateGridOrders(t *testing.T) {
}, orders)
})
t.Run("base and quote with predefined base grid num", func(t *testing.T) {
gridNum := int64(22)
upperPrice := number(35500.000000)
lowerPrice := number(34450.000000)
quoteInvestment := number(18.47)
baseInvestment := number(0.010700)
lastPrice := number(34522.930000)
baseGridNum := int(20)
s := newTestStrategy()
s.GridNum = gridNum
s.BaseGridNum = baseGridNum
s.LowerPrice = lowerPrice
s.UpperPrice = upperPrice
s.grid = NewGrid(lowerPrice, upperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize)
s.grid.CalculateArithmeticPins()
assert.Equal(t, 22, len(s.grid.Pins))
quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins)
assert.NoError(t, err)
assert.Equal(t, "0.000535", quantity.String())
s.QuantityOrAmount.Quantity = quantity
orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice)
assert.NoError(t, err)
if !assert.Equal(t, 21, len(orders)) {
for _, o := range orders {
t.Logf("- %s %s", o.Price.String(), o.Side)
}
}
assertPriceSide(t, []PriceSideAssert{
{number(35500.0), types.SideTypeSell},
{number(35450.0), types.SideTypeSell},
{number(35400.0), types.SideTypeSell},
{number(35350.0), types.SideTypeSell},
{number(35300.0), types.SideTypeSell},
{number(35250.0), types.SideTypeSell},
{number(35200.0), types.SideTypeSell},
{number(35150.0), types.SideTypeSell},
{number(35100.0), types.SideTypeSell},
{number(35050.0), types.SideTypeSell},
{number(35000.0), types.SideTypeSell},
{number(34950.0), types.SideTypeSell},
{number(34900.0), types.SideTypeSell},
{number(34850.0), types.SideTypeSell},
{number(34800.0), types.SideTypeSell},
{number(34750.0), types.SideTypeSell},
{number(34700.0), types.SideTypeSell},
{number(34650.0), types.SideTypeSell},
{number(34600.0), types.SideTypeSell},
{number(34550.0), types.SideTypeSell},
// -- fake trade price at 34549.9
// -- 34500 should be empty
{number(34450.0), types.SideTypeBuy},
}, orders)
})
t.Run("base and quote", func(t *testing.T) {
gridNum := int64(22)
upperPrice := number(35500.000000)
lowerPrice := number(34450.000000)
quoteInvestment := number(20.0)
baseInvestment := number(0.010700)
lastPrice := number(34522.930000)
baseGridNum := int(0)
s := newTestStrategy()
s.GridNum = gridNum
s.BaseGridNum = baseGridNum
s.LowerPrice = lowerPrice
s.UpperPrice = upperPrice
s.grid = NewGrid(lowerPrice, upperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize)
s.grid.CalculateArithmeticPins()
assert.Equal(t, 22, len(s.grid.Pins))
quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins)
assert.NoError(t, err)
assert.Equal(t, "0.00029006", quantity.String())
s.QuantityOrAmount.Quantity = quantity
orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice)
assert.NoError(t, err)
if !assert.Equal(t, 21, len(orders)) {
for _, o := range orders {
t.Logf("- %s %s", o.Price.String(), o.Side)
}
}
assertPriceSide(t, []PriceSideAssert{
{number(35500.0), types.SideTypeSell},
{number(35450.0), types.SideTypeSell},
{number(35400.0), types.SideTypeSell},
{number(35350.0), types.SideTypeSell},
{number(35300.0), types.SideTypeSell},
{number(35250.0), types.SideTypeSell},
{number(35200.0), types.SideTypeSell},
{number(35150.0), types.SideTypeSell},
{number(35100.0), types.SideTypeSell},
{number(35050.0), types.SideTypeSell},
{number(35000.0), types.SideTypeSell},
{number(34950.0), types.SideTypeSell},
{number(34900.0), types.SideTypeSell},
{number(34850.0), types.SideTypeSell},
{number(34800.0), types.SideTypeSell},
{number(34750.0), types.SideTypeSell},
{number(34700.0), types.SideTypeSell},
{number(34650.0), types.SideTypeSell},
{number(34600.0), types.SideTypeSell},
{number(34550.0), types.SideTypeSell},
// -- 34500 should be empty
{number(34450.0), types.SideTypeBuy},
}, orders)
})
t.Run("base and quote with pre-calculated baseGridNumber", func(t *testing.T) {
s := newTestStrategy()
s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize)
@ -519,11 +636,11 @@ func newTestMarket(symbol string) types.Market {
BaseCurrency: "BTC",
QuoteCurrency: "USDT",
TickSize: number(0.01),
StepSize: number(0.00001),
StepSize: number(0.000001),
PricePrecision: 2,
VolumePrecision: 8,
MinNotional: number(10.0),
MinQuantity: number(0.001),
MinNotional: number(8.0),
MinQuantity: number(0.0003),
}
case "ETHUSDT":
return types.Market{
@ -534,7 +651,7 @@ func newTestMarket(symbol string) types.Market {
PricePrecision: 2,
VolumePrecision: 6,
MinNotional: number(8.000),
MinQuantity: number(0.00030),
MinQuantity: number(0.0046),
}
}
@ -577,12 +694,17 @@ func newTestOrder(price, quantity fixedpoint.Value, side types.SideType) types.O
}
}
func newTestStrategy() *Strategy {
market := newTestMarket("BTCUSDT")
func newTestStrategy(va ...string) *Strategy {
symbol := "BTCUSDT"
if len(va) > 0 {
symbol = va[0]
}
market := newTestMarket(symbol)
s := &Strategy{
logger: logrus.NewEntry(logrus.New()),
Symbol: "BTCUSDT",
Symbol: symbol,
Market: market,
GridProfitStats: newGridProfitStats(market),
UpperPrice: number(20_000),
@ -790,7 +912,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder)
return []types.Order{
{SubmitOrder: expectedSubmitOrder},
@ -858,7 +982,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder)
return []types.Order{
{SubmitOrder: expectedSubmitOrder},
@ -946,7 +1072,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
Market: s.Market,
Tag: orderTag,
}
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder)
return []types.Order{
{SubmitOrder: expectedSubmitOrder},
@ -963,7 +1091,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
Market: s.Market,
Tag: orderTag,
}
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder2, order), "%+v is not equal to %+v", order, expectedSubmitOrder2)
return []types.Order{
{SubmitOrder: expectedSubmitOrder2},
@ -1060,7 +1190,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder)
return []types.Order{
{SubmitOrder: expectedSubmitOrder},
@ -1078,7 +1210,9 @@ func TestStrategy_handleOrderFilled(t *testing.T) {
Tag: orderTag,
}
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, order types.SubmitOrder) (types.OrderSlice, error) {
orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func(
ctx context.Context, order types.SubmitOrder,
) (types.OrderSlice, error) {
assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder2, order), "%+v is not equal to %+v", order, expectedSubmitOrder2)
return []types.Order{
{SubmitOrder: expectedSubmitOrder2},
@ -1190,14 +1324,14 @@ func TestStrategy_aggregateOrderQuoteAmountAndFeeRetry(t *testing.T) {
func TestStrategy_checkMinimalQuoteInvestment(t *testing.T) {
t.Run("7 grids", func(t *testing.T) {
s := newTestStrategy()
s := newTestStrategy("ETHUSDT")
s.UpperPrice = number(1660)
s.LowerPrice = number(1630)
s.QuoteInvestment = number(61)
s.GridNum = 7
grid := s.newGrid()
minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid)
assert.InDelta(t, 60.46, minQuoteInvestment.Float64(), 0.01)
assert.InDelta(t, 48.36, minQuoteInvestment.Float64(), 0.01)
err := s.checkMinimalQuoteInvestment(grid)
assert.NoError(t, err)
@ -1207,12 +1341,11 @@ func TestStrategy_checkMinimalQuoteInvestment(t *testing.T) {
s := newTestStrategy()
// 10_000 * 0.001 = 10USDT
// 20_000 * 0.001 = 20USDT
// hence we should have at least: 20USDT * 10 grids
s.QuoteInvestment = number(10_000)
s.GridNum = 10
grid := s.newGrid()
minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid)
assert.InDelta(t, 129.9999, minQuoteInvestment.Float64(), 0.01)
assert.InDelta(t, 103.999, minQuoteInvestment.Float64(), 0.01)
err := s.checkMinimalQuoteInvestment(grid)
assert.NoError(t, err)
@ -1225,11 +1358,11 @@ func TestStrategy_checkMinimalQuoteInvestment(t *testing.T) {
grid := s.newGrid()
minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid)
assert.InDelta(t, 14979.995499, minQuoteInvestment.Float64(), 0.001)
assert.InDelta(t, 11983.996400, minQuoteInvestment.Float64(), 0.001)
err := s.checkMinimalQuoteInvestment(grid)
assert.Error(t, err)
assert.EqualError(t, err, "need at least 14979.995500 USDT for quote investment, 10000.000000 USDT given")
assert.EqualError(t, err, "need at least 11983.996400 USDT for quote investment, 10000.000000 USDT given")
})
}

View File

@ -0,0 +1,96 @@
package liquiditymaker
import (
log "github.com/sirupsen/logrus"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
// input: liquidityOrderGenerator(
//
// totalLiquidityAmount,
// startPrice,
// endPrice,
// numLayers,
// quantityScale)
//
// when side == sell
//
// priceAsk1 * scale(1) * f = amount1
// priceAsk2 * scale(2) * f = amount2
// priceAsk3 * scale(3) * f = amount3
//
// totalLiquidityAmount = priceAsk1 * scale(1) * f + priceAsk2 * scale(2) * f + priceAsk3 * scale(3) * f + ....
// totalLiquidityAmount = f * (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....)
// f = totalLiquidityAmount / (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....)
//
// when side == buy
//
// priceBid1 * scale(1) * f = amount1
type LiquidityOrderGenerator struct {
Symbol string
Market types.Market
logger log.FieldLogger
}
func (g *LiquidityOrderGenerator) Generate(
side types.SideType, totalAmount, startPrice, endPrice fixedpoint.Value, numLayers int, scale bbgo.Scale,
) (orders []types.SubmitOrder) {
if g.logger == nil {
logger := log.New()
logger.SetLevel(log.ErrorLevel)
g.logger = logger
}
layerSpread := endPrice.Sub(startPrice).Div(fixedpoint.NewFromInt(int64(numLayers - 1)))
switch side {
case types.SideTypeSell:
if layerSpread.Compare(g.Market.TickSize) < 0 {
layerSpread = g.Market.TickSize
}
case types.SideTypeBuy:
if layerSpread.Compare(g.Market.TickSize.Neg()) > 0 {
layerSpread = g.Market.TickSize.Neg()
}
}
quantityBase := 0.0
var layerPrices []fixedpoint.Value
var layerScales []float64
for i := 0; i < numLayers; i++ {
fi := fixedpoint.NewFromInt(int64(i))
layerPrice := g.Market.TruncatePrice(startPrice.Add(layerSpread.Mul(fi)))
layerPrices = append(layerPrices, layerPrice)
layerScale := scale.Call(float64(i + 1))
layerScales = append(layerScales, layerScale)
quantityBase += layerPrice.Float64() * layerScale
}
factor := totalAmount.Float64() / quantityBase
g.logger.Infof("liquidity amount base: %f, factor: %f", quantityBase, factor)
for i := 0; i < numLayers; i++ {
price := layerPrices[i]
s := layerScales[i]
quantity := factor * s
orders = append(orders, types.SubmitOrder{
Symbol: g.Symbol,
Price: price,
Type: types.OrderTypeLimitMaker,
Quantity: g.Market.TruncateQuantity(fixedpoint.NewFromFloat(quantity)),
Side: side,
Market: g.Market,
})
}
return orders
}

View File

@ -0,0 +1,114 @@
//go:build !dnum
package liquiditymaker
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/fixedpoint"
. "github.com/c9s/bbgo/pkg/testing/testhelper"
"github.com/c9s/bbgo/pkg/types"
)
func newTestMarket() types.Market {
return types.Market{
BaseCurrency: "XML",
QuoteCurrency: "USDT",
TickSize: Number(0.0001),
StepSize: Number(0.01),
PricePrecision: 4,
VolumePrecision: 8,
MinNotional: Number(8.0),
MinQuantity: Number(40.0),
}
}
func TestLiquidityOrderGenerator(t *testing.T) {
g := &LiquidityOrderGenerator{
Symbol: "XMLUSDT",
Market: newTestMarket(),
}
scale := &bbgo.ExponentialScale{
Domain: [2]float64{1.0, 30.0},
Range: [2]float64{1.0, 4.0},
}
err := scale.Solve()
assert.NoError(t, err)
assert.InDelta(t, 1.0, scale.Call(1.0), 0.00001)
assert.InDelta(t, 4.0, scale.Call(30.0), 0.00001)
totalAmount := Number(20_000.0)
t.Run("ask orders", func(t *testing.T) {
orders := g.Generate(types.SideTypeSell, totalAmount, Number(2.0), Number(2.04), 30, scale)
assert.Len(t, orders, 30)
totalQuoteQuantity := fixedpoint.NewFromInt(0)
for _, o := range orders {
totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price))
}
assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0)
AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{
{Side: types.SideTypeSell, Price: Number("2.0000"), Quantity: Number("151.34")},
{Side: types.SideTypeSell, Price: Number("2.0013"), Quantity: Number("158.75")},
{Side: types.SideTypeSell, Price: Number("2.0027"), Quantity: Number("166.52")},
{Side: types.SideTypeSell, Price: Number("2.0041"), Quantity: Number("174.67")},
{Side: types.SideTypeSell, Price: Number("2.0055"), Quantity: Number("183.23")},
{Side: types.SideTypeSell, Price: Number("2.0068"), Quantity: Number("192.20")},
{Side: types.SideTypeSell, Price: Number("2.0082"), Quantity: Number("201.61")},
{Side: types.SideTypeSell, Price: Number("2.0096"), Quantity: Number("211.48")},
{Side: types.SideTypeSell, Price: Number("2.0110"), Quantity: Number("221.84")},
{Side: types.SideTypeSell, Price: Number("2.0124"), Quantity: Number("232.70")},
{Side: types.SideTypeSell, Price: Number("2.0137"), Quantity: Number("244.09")},
{Side: types.SideTypeSell, Price: Number("2.0151"), Quantity: Number("256.04")},
{Side: types.SideTypeSell, Price: Number("2.0165"), Quantity: Number("268.58")},
{Side: types.SideTypeSell, Price: Number("2.0179"), Quantity: Number("281.73")},
{Side: types.SideTypeSell, Price: Number("2.0193"), Quantity: Number("295.53")},
}, orders[0:15])
AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{
{Side: types.SideTypeSell, Price: Number("2.0386"), Quantity: Number("577.10")},
{Side: types.SideTypeSell, Price: Number("2.0399"), Quantity: Number("605.36")},
}, orders[28:30])
})
t.Run("bid orders", func(t *testing.T) {
orders := g.Generate(types.SideTypeBuy, totalAmount, Number(2.0), Number(1.96), 30, scale)
assert.Len(t, orders, 30)
totalQuoteQuantity := fixedpoint.NewFromInt(0)
for _, o := range orders {
totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price))
}
assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0)
AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{
{Side: types.SideTypeBuy, Price: Number("2.0000"), Quantity: Number("155.13")},
{Side: types.SideTypeBuy, Price: Number("1.9986"), Quantity: Number("162.73")},
{Side: types.SideTypeBuy, Price: Number("1.9972"), Quantity: Number("170.70")},
{Side: types.SideTypeBuy, Price: Number("1.9958"), Quantity: Number("179.05")},
{Side: types.SideTypeBuy, Price: Number("1.9944"), Quantity: Number("187.82")},
{Side: types.SideTypeBuy, Price: Number("1.9931"), Quantity: Number("197.02")},
{Side: types.SideTypeBuy, Price: Number("1.9917"), Quantity: Number("206.67")},
{Side: types.SideTypeBuy, Price: Number("1.9903"), Quantity: Number("216.79")},
{Side: types.SideTypeBuy, Price: Number("1.9889"), Quantity: Number("227.40")},
{Side: types.SideTypeBuy, Price: Number("1.9875"), Quantity: Number("238.54")},
{Side: types.SideTypeBuy, Price: Number("1.9862"), Quantity: Number("250.22")},
{Side: types.SideTypeBuy, Price: Number("1.9848"), Quantity: Number("262.47")},
{Side: types.SideTypeBuy, Price: Number("1.9834"), Quantity: Number("275.32")},
{Side: types.SideTypeBuy, Price: Number("1.9820"), Quantity: Number("288.80")},
{Side: types.SideTypeBuy, Price: Number("1.9806"), Quantity: Number("302.94")},
}, orders[0:15])
AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{
{Side: types.SideTypeBuy, Price: Number("1.9613"), Quantity: Number("591.58")},
{Side: types.SideTypeBuy, Price: Number("1.9600"), Quantity: Number("620.54")},
}, orders[28:30])
})
}

View File

@ -0,0 +1,378 @@
package liquiditymaker
import (
"context"
"fmt"
"sync"
log "github.com/sirupsen/logrus"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/fixedpoint"
. "github.com/c9s/bbgo/pkg/indicator/v2"
"github.com/c9s/bbgo/pkg/strategy/common"
"github.com/c9s/bbgo/pkg/types"
)
const ID = "liquiditymaker"
type advancedOrderCancelApi interface {
CancelAllOrders(ctx context.Context) ([]types.Order, error)
CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error)
}
func init() {
bbgo.RegisterStrategy(ID, &Strategy{})
}
// Strategy is the strategy struct of LiquidityMaker
// liquidity maker does not care about the current price, it tries to place liquidity orders (limit maker orders)
// around the current mid price
// liquidity maker's target:
// - place enough total liquidity amount on the order book, for example, 20k USDT value liquidity on both sell and buy
// - ensure the spread by placing the orders from the mid price (or the last trade price)
type Strategy struct {
*common.Strategy
Environment *bbgo.Environment
Market types.Market
Symbol string `json:"symbol"`
LiquidityUpdateInterval types.Interval `json:"liquidityUpdateInterval"`
AdjustmentUpdateInterval types.Interval `json:"adjustmentUpdateInterval"`
NumOfLiquidityLayers int `json:"numOfLiquidityLayers"`
LiquiditySlideRule *bbgo.SlideRule `json:"liquidityScale"`
LiquidityPriceRange fixedpoint.Value `json:"liquidityPriceRange"`
AskLiquidityAmount fixedpoint.Value `json:"askLiquidityAmount"`
BidLiquidityAmount fixedpoint.Value `json:"bidLiquidityAmount"`
UseLastTradePrice bool `json:"useLastTradePrice"`
Spread fixedpoint.Value `json:"spread"`
MaxPrice fixedpoint.Value `json:"maxPrice"`
MinPrice fixedpoint.Value `json:"minPrice"`
MaxExposure fixedpoint.Value `json:"maxExposure"`
MinProfit fixedpoint.Value `json:"minProfit"`
liquidityOrderBook, adjustmentOrderBook *bbgo.ActiveOrderBook
book *types.StreamOrderBook
liquidityScale bbgo.Scale
orderGenerator *LiquidityOrderGenerator
}
func (s *Strategy) ID() string {
return ID
}
func (s *Strategy) InstanceID() string {
return fmt.Sprintf("%s:%s", ID, s.Symbol)
}
func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {
session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{})
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.AdjustmentUpdateInterval})
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LiquidityUpdateInterval})
}
func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
s.Strategy = &common.Strategy{}
s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID())
s.orderGenerator = &LiquidityOrderGenerator{
Symbol: s.Symbol,
Market: s.Market,
}
s.book = types.NewStreamBook(s.Symbol)
s.book.BindStream(session.MarketDataStream)
s.liquidityOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
s.liquidityOrderBook.BindStream(session.UserDataStream)
s.adjustmentOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
s.adjustmentOrderBook.BindStream(session.UserDataStream)
scale, err := s.LiquiditySlideRule.Scale()
if err != nil {
return err
}
if err := scale.Solve(); err != nil {
return err
}
if cancelApi, ok := session.Exchange.(advancedOrderCancelApi); ok {
_, _ = cancelApi.CancelOrdersBySymbol(ctx, s.Symbol)
}
s.liquidityScale = scale
session.UserDataStream.OnStart(func() {
s.placeLiquidityOrders(ctx)
})
session.MarketDataStream.OnKLineClosed(func(k types.KLine) {
if k.Interval == s.AdjustmentUpdateInterval {
s.placeAdjustmentOrders(ctx)
}
if k.Interval == s.LiquidityUpdateInterval {
s.placeLiquidityOrders(ctx)
}
})
bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
if err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil {
logErr(err, "unable to cancel liquidity orders")
}
if err := s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil {
logErr(err, "unable to cancel adjustment orders")
}
})
return nil
}
func (s *Strategy) placeAdjustmentOrders(ctx context.Context) {
_ = s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange)
if s.Position.IsDust() {
return
}
ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol)
if logErr(err, "unable to query ticker") {
return
}
if _, err := s.Session.UpdateAccount(ctx); err != nil {
logErr(err, "unable to update account")
return
}
baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency)
quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency)
var adjOrders []types.SubmitOrder
posSize := s.Position.Base.Abs()
tickSize := s.Market.TickSize
if s.Position.IsShort() {
price := profitProtectedPrice(types.SideTypeBuy, s.Position.AverageCost, ticker.Sell.Add(tickSize.Neg()), s.Session.MakerFeeRate, s.MinProfit)
quoteQuantity := fixedpoint.Min(price.Mul(posSize), quoteBal.Available)
bidQuantity := quoteQuantity.Div(price)
if s.Market.IsDustQuantity(bidQuantity, price) {
return
}
adjOrders = append(adjOrders, types.SubmitOrder{
Symbol: s.Symbol,
Type: types.OrderTypeLimitMaker,
Side: types.SideTypeBuy,
Price: price,
Quantity: bidQuantity,
Market: s.Market,
TimeInForce: types.TimeInForceGTC,
})
} else if s.Position.IsLong() {
price := profitProtectedPrice(types.SideTypeSell, s.Position.AverageCost, ticker.Buy.Add(tickSize), s.Session.MakerFeeRate, s.MinProfit)
askQuantity := fixedpoint.Min(posSize, baseBal.Available)
if s.Market.IsDustQuantity(askQuantity, price) {
return
}
adjOrders = append(adjOrders, types.SubmitOrder{
Symbol: s.Symbol,
Type: types.OrderTypeLimitMaker,
Side: types.SideTypeSell,
Price: price,
Quantity: askQuantity,
Market: s.Market,
TimeInForce: types.TimeInForceGTC,
})
}
createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, adjOrders...)
if logErr(err, "unable to place liquidity orders") {
return
}
s.adjustmentOrderBook.Add(createdOrders...)
}
func (s *Strategy) placeLiquidityOrders(ctx context.Context) {
err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange)
if logErr(err, "unable to cancel orders") {
return
}
ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol)
if logErr(err, "unable to query ticker") {
return
}
if s.IsHalted(ticker.Time) {
log.Warn("circuitBreakRiskControl: trading halted")
return
}
if _, err := s.Session.UpdateAccount(ctx); err != nil {
logErr(err, "unable to update account")
return
}
baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency)
quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency)
if ticker.Buy.IsZero() && ticker.Sell.IsZero() {
ticker.Sell = ticker.Last.Add(s.Market.TickSize)
ticker.Buy = ticker.Last.Sub(s.Market.TickSize)
} else if ticker.Buy.IsZero() {
ticker.Buy = ticker.Sell.Sub(s.Market.TickSize)
} else if ticker.Sell.IsZero() {
ticker.Sell = ticker.Buy.Add(s.Market.TickSize)
}
log.Infof("ticker: %+v", ticker)
lastTradedPrice := ticker.Last
midPrice := ticker.Sell.Add(ticker.Buy).Div(fixedpoint.Two)
currentSpread := ticker.Sell.Sub(ticker.Buy)
sideSpread := s.Spread.Div(fixedpoint.Two)
if s.UseLastTradePrice {
midPrice = lastTradedPrice
}
log.Infof("current spread: %f lastTradedPrice: %f midPrice: %f", currentSpread.Float64(), lastTradedPrice.Float64(), midPrice.Float64())
ask1Price := midPrice.Mul(fixedpoint.One.Add(sideSpread))
bid1Price := midPrice.Mul(fixedpoint.One.Sub(sideSpread))
askLastPrice := midPrice.Mul(fixedpoint.One.Add(s.LiquidityPriceRange))
bidLastPrice := midPrice.Mul(fixedpoint.One.Sub(s.LiquidityPriceRange))
log.Infof("wanted side spread: %f askRange: %f ~ %f bidRange: %f ~ %f",
sideSpread.Float64(),
ask1Price.Float64(), askLastPrice.Float64(),
bid1Price.Float64(), bidLastPrice.Float64())
availableBase := baseBal.Available
availableQuote := quoteBal.Available
log.Infof("balances before liq orders: %s, %s",
baseBal.String(),
quoteBal.String())
if !s.Position.IsDust() {
if s.Position.IsLong() {
availableBase = availableBase.Sub(s.Position.Base)
availableBase = s.Market.RoundDownQuantityByPrecision(availableBase)
} else if s.Position.IsShort() {
posSizeInQuote := s.Position.Base.Mul(ticker.Sell)
availableQuote = availableQuote.Sub(posSizeInQuote)
}
}
bidOrders := s.orderGenerator.Generate(types.SideTypeBuy,
fixedpoint.Min(s.BidLiquidityAmount, quoteBal.Available),
bid1Price,
bidLastPrice,
s.NumOfLiquidityLayers,
s.liquidityScale)
askOrders := s.orderGenerator.Generate(types.SideTypeSell,
s.AskLiquidityAmount,
ask1Price,
askLastPrice,
s.NumOfLiquidityLayers,
s.liquidityScale)
askOrders = filterAskOrders(askOrders, baseBal.Available)
orderForms := append(bidOrders, askOrders...)
createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orderForms...)
if logErr(err, "unable to place liquidity orders") {
return
}
s.liquidityOrderBook.Add(createdOrders...)
log.Infof("%d liq orders are placed successfully", len(orderForms))
for _, o := range createdOrders {
log.Infof("liq order: %+v", o)
}
}
func profitProtectedPrice(
side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value,
) fixedpoint.Value {
switch side {
case types.SideTypeSell:
minProfitPrice := averageCost.Add(
averageCost.Mul(feeRate.Add(minProfit)))
return fixedpoint.Max(minProfitPrice, price)
case types.SideTypeBuy:
minProfitPrice := averageCost.Sub(
averageCost.Mul(feeRate.Add(minProfit)))
return fixedpoint.Min(minProfitPrice, price)
}
return price
}
func filterAskOrders(askOrders []types.SubmitOrder, available fixedpoint.Value) (out []types.SubmitOrder) {
usedBase := fixedpoint.Zero
for _, askOrder := range askOrders {
if usedBase.Add(askOrder.Quantity).Compare(available) > 0 {
return out
}
usedBase = usedBase.Add(askOrder.Quantity)
out = append(out, askOrder)
}
return out
}
func logErr(err error, msgAndArgs ...interface{}) bool {
if err == nil {
return false
}
if len(msgAndArgs) == 0 {
log.WithError(err).Error(err.Error())
} else if len(msgAndArgs) == 1 {
msg := msgAndArgs[0].(string)
log.WithError(err).Error(msg)
} else if len(msgAndArgs) > 1 {
msg := msgAndArgs[0].(string)
log.WithError(err).Errorf(msg, msgAndArgs[1:]...)
}
return true
}
func preloadKLines(
inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval,
) {
if store, ok := session.MarketDataStore(symbol); ok {
if kLinesData, ok := store.KLinesOfInterval(interval); ok {
for _, k := range *kLinesData {
inc.EmitUpdate(k)
}
}
}
}

View File

@ -40,7 +40,6 @@ type Strategy struct {
DryRun bool `json:"dryRun"`
OnStart bool `json:"onStart"` // rebalance on start
session *bbgo.ExchangeSession
symbols []string
markets map[string]types.Market
activeOrderBook *bbgo.ActiveOrderBook
@ -97,11 +96,9 @@ func (s *Strategy) Validate() error {
func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {}
func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
s.session = session
s.markets = make(map[string]types.Market)
for _, symbol := range s.symbols {
market, ok := s.session.Market(symbol)
market, ok := session.Market(symbol)
if !ok {
return fmt.Errorf("market %s not found", symbol)
}
@ -112,7 +109,7 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
s.MultiMarketStrategy.Initialize(ctx, s.Environment, session, s.markets, ID)
s.activeOrderBook = bbgo.NewActiveOrderBook("")
s.activeOrderBook.BindStream(s.session.UserDataStream)
s.activeOrderBook.BindStream(session.UserDataStream)
session.UserDataStream.OnStart(func() {
if s.OnStart {
@ -137,7 +134,7 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
func (s *Strategy) rebalance(ctx context.Context) {
// cancel active orders before rebalance
if err := s.session.Exchange.CancelOrders(ctx, s.activeOrderBook.Orders()...); err != nil {
if err := s.Session.Exchange.CancelOrders(ctx, s.activeOrderBook.Orders()...); err != nil {
log.WithError(err).Errorf("failed to cancel orders")
}
@ -174,7 +171,7 @@ func (s *Strategy) queryMidPrices(ctx context.Context) (types.ValueMap, error) {
continue
}
ticker, err := s.session.Exchange.QueryTicker(ctx, currency+s.QuoteCurrency)
ticker, err := s.Session.Exchange.QueryTicker(ctx, currency+s.QuoteCurrency)
if err != nil {
return nil, err
}
@ -186,7 +183,7 @@ func (s *Strategy) queryMidPrices(ctx context.Context) (types.ValueMap, error) {
func (s *Strategy) selectBalances() (types.BalanceMap, error) {
m := make(types.BalanceMap)
balances := s.session.GetAccount().Balances()
balances := s.Session.GetAccount().Balances()
for currency := range s.TargetWeights {
balance, ok := balances[currency]
if !ok {
@ -235,28 +232,36 @@ func (s *Strategy) generateOrder(ctx context.Context) (*types.SubmitOrder, error
quantity = quantity.Abs()
}
if s.MaxAmount.Float64() > 0 {
quantity = bbgo.AdjustQuantityByMaxAmount(quantity, midPrice, s.MaxAmount)
log.Infof("adjust quantity %s (%s %s @ %s) by max amount %s",
quantity.String(),
symbol,
side.String(),
midPrice.String(),
s.MaxAmount.String())
ticker, err := s.Session.Exchange.QueryTicker(ctx, symbol)
if err != nil {
return nil, err
}
var price fixedpoint.Value
if side == types.SideTypeBuy {
quantity = fixedpoint.Min(quantity, balances[s.QuoteCurrency].Available.Div(midPrice))
price = ticker.Buy
quantity = fixedpoint.Min(quantity, balances[s.QuoteCurrency].Available.Div(ticker.Sell))
} else if side == types.SideTypeSell {
price = ticker.Sell
quantity = fixedpoint.Min(quantity, balances[market.BaseCurrency].Available)
}
if market.IsDustQuantity(quantity, midPrice) {
if s.MaxAmount.Float64() > 0 {
quantity = bbgo.AdjustQuantityByMaxAmount(quantity, price, s.MaxAmount)
log.Infof("adjusted quantity %s (%s %s @ %s) by max amount %s",
quantity.String(),
symbol,
side.String(),
price.String(),
s.MaxAmount.String())
}
if market.IsDustQuantity(quantity, price) {
log.Infof("quantity %s (%s %s @ %s) is dust quantity, skip",
quantity.String(),
symbol,
side.String(),
midPrice.String())
price.String())
continue
}
@ -265,7 +270,7 @@ func (s *Strategy) generateOrder(ctx context.Context) (*types.SubmitOrder, error
Side: side,
Type: s.OrderType,
Quantity: quantity,
Price: midPrice,
Price: price,
}, nil
}
return nil, nil

View File

@ -5,22 +5,18 @@ import (
"fmt"
"math"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/fixedpoint"
. "github.com/c9s/bbgo/pkg/indicator/v2"
"github.com/c9s/bbgo/pkg/risk/riskcontrol"
"github.com/c9s/bbgo/pkg/strategy/common"
"github.com/c9s/bbgo/pkg/types"
)
const ID = "scmaker"
var ten = fixedpoint.NewFromInt(10)
type advancedOrderCancelApi interface {
CancelAllOrders(ctx context.Context) ([]types.Order, error)
CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error)
@ -62,12 +58,6 @@ type Strategy struct {
MinProfit fixedpoint.Value `json:"minProfit"`
// risk related parameters
PositionHardLimit fixedpoint.Value `json:"positionHardLimit"`
MaxPositionQuantity fixedpoint.Value `json:"maxPositionQuantity"`
CircuitBreakLossThreshold fixedpoint.Value `json:"circuitBreakLossThreshold"`
CircuitBreakEMA types.IntervalWindow `json:"circuitBreakEMA"`
liquidityOrderBook, adjustmentOrderBook *bbgo.ActiveOrderBook
book *types.StreamOrderBook
@ -77,9 +67,6 @@ type Strategy struct {
ewma *EWMAStream
boll *BOLLStream
intensity *IntensityStream
positionRiskControl *riskcontrol.PositionRiskControl
circuitBreakRiskControl *riskcontrol.CircuitBreakRiskControl
}
func (s *Strategy) ID() string {
@ -100,12 +87,12 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {
}
}
func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
s.Strategy = &common.Strategy{}
s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID())
s.book = types.NewStreamBook(s.Symbol)
s.book.BindStream(session.UserDataStream)
s.book.BindStream(session.MarketDataStream)
s.liquidityOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
s.liquidityOrderBook.BindStream(session.UserDataStream)
@ -113,21 +100,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
s.adjustmentOrderBook = bbgo.NewActiveOrderBook(s.Symbol)
s.adjustmentOrderBook.BindStream(session.UserDataStream)
if !s.PositionHardLimit.IsZero() && !s.MaxPositionQuantity.IsZero() {
log.Infof("positionHardLimit and maxPositionQuantity are configured, setting up PositionRiskControl...")
s.positionRiskControl = riskcontrol.NewPositionRiskControl(s.OrderExecutor, s.PositionHardLimit, s.MaxPositionQuantity)
}
if !s.CircuitBreakLossThreshold.IsZero() {
log.Infof("circuitBreakLossThreshold is configured, setting up CircuitBreakRiskControl...")
s.circuitBreakRiskControl = riskcontrol.NewCircuitBreakRiskControl(
s.Position,
session.Indicators(s.Symbol).EWMA(s.CircuitBreakEMA),
s.CircuitBreakLossThreshold,
s.ProfitStats,
24*time.Hour)
}
scale, err := s.LiquiditySlideRule.Scale()
if err != nil {
return err
@ -174,7 +146,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
return nil
}
func (s *Strategy) preloadKLines(inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval) {
func (s *Strategy) preloadKLines(
inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval,
) {
if store, ok := session.MarketDataStore(symbol); ok {
if kLinesData, ok := store.KLinesOfInterval(interval); ok {
for _, k := range *kLinesData {
@ -282,7 +256,7 @@ func (s *Strategy) placeLiquidityOrders(ctx context.Context) {
return
}
if s.circuitBreakRiskControl != nil && s.circuitBreakRiskControl.IsHalted(ticker.Time) {
if s.IsHalted(ticker.Time) {
log.Warn("circuitBreakRiskControl: trading halted")
return
}
@ -476,7 +450,9 @@ func (s *Strategy) placeLiquidityOrders(ctx context.Context) {
log.Infof("%d liq orders are placed successfully", len(liqOrders))
}
func profitProtectedPrice(side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value) fixedpoint.Value {
func profitProtectedPrice(
side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value,
) fixedpoint.Value {
switch side {
case types.SideTypeSell:
minProfitPrice := averageCost.Add(

View File

@ -0,0 +1,58 @@
package testhelper
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
type PriceSideAssert struct {
Price fixedpoint.Value
Side types.SideType
}
// AssertOrdersPriceSide asserts the orders with the given price and side (slice)
func AssertOrdersPriceSide(t *testing.T, asserts []PriceSideAssert, orders []types.SubmitOrder) {
for i, a := range asserts {
assert.Equalf(t, a.Price, orders[i].Price, "order #%d price should be %f", i+1, a.Price.Float64())
assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side)
}
}
type PriceSideQuantityAssert struct {
Price fixedpoint.Value
Side types.SideType
Quantity fixedpoint.Value
}
// AssertOrdersPriceSide asserts the orders with the given price and side (slice)
func AssertOrdersPriceSideQuantity(
t *testing.T, asserts []PriceSideQuantityAssert, orders []types.SubmitOrder,
) {
assert.Equalf(t, len(orders), len(asserts), "expecting %d orders", len(asserts))
var assertPrices, orderPrices fixedpoint.Slice
var assertPricesFloat, orderPricesFloat []float64
for _, a := range asserts {
assertPrices = append(assertPrices, a.Price)
assertPricesFloat = append(assertPricesFloat, a.Price.Float64())
}
for _, o := range orders {
orderPrices = append(orderPrices, o.Price)
orderPricesFloat = append(orderPricesFloat, o.Price.Float64())
}
if !assert.Equalf(t, assertPricesFloat, orderPricesFloat, "assert prices") {
return
}
for i, a := range asserts {
assert.Equalf(t, a.Price.Float64(), orders[i].Price.Float64(), "order #%d price should be %f", i+1, a.Price.Float64())
assert.Equalf(t, a.Quantity.Float64(), orders[i].Quantity.Float64(), "order #%d quantity should be %f", i+1, a.Quantity.Float64())
assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side)
}
}

View File

@ -0,0 +1,18 @@
package testhelper
import "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
}

View File

@ -65,6 +65,20 @@ type Position struct {
// Modify position callbacks
modifyCallbacks []func(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value)
// ttl is the ttl to keep in persistence
ttl time.Duration
}
func (s *Position) SetTTL(ttl time.Duration) {
if ttl.Nanoseconds() <= 0 {
return
}
s.ttl = ttl
}
func (s *Position) Expiration() time.Duration {
return s.ttl
}
func (p *Position) CsvHeader() []string {

View File

@ -3,6 +3,6 @@
package version
const Version = "v1.52.0-2058ce80-dev"
const Version = "v1.53.0-4c701676-dev"
const VersionGitRef = "2058ce80"
const VersionGitRef = "4c701676"

View File

@ -3,6 +3,6 @@
package version
const Version = "v1.52.0-2058ce80"
const Version = "v1.53.0-4c701676"
const VersionGitRef = "2058ce80"
const VersionGitRef = "4c701676"