mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-26 16:55:15 +00:00
Merge pull request #242 from c9s/feature/execute-order-cmd
add twap order execution and related command
This commit is contained in:
commit
93770a0d9f
|
@ -152,6 +152,10 @@ func (b *LocalActiveOrderBook) WriteOff(order types.Order) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (b *LocalActiveOrderBook) NumOfOrders() int {
|
||||
return b.Asks.Len() + b.Bids.Len()
|
||||
}
|
||||
|
||||
func (b *LocalActiveOrderBook) Orders() types.OrderSlice {
|
||||
return append(b.Asks.Orders(), b.Bids.Orders()...)
|
||||
}
|
||||
|
|
|
@ -162,10 +162,10 @@ func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...
|
|||
|
||||
// Increase the quantity if the amount is not enough,
|
||||
// this is the only increase op, later we will decrease the quantity if it meets the criteria
|
||||
quantity = AdjustQuantityByMinAmount(quantity, price, market.MinAmount*1.01)
|
||||
quantity = AdjustFloatQuantityByMinAmount(quantity, price, market.MinAmount*1.01)
|
||||
|
||||
if c.MaxOrderAmount > 0 {
|
||||
quantity = AdjustQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64())
|
||||
quantity = AdjustFloatQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64())
|
||||
}
|
||||
|
||||
quoteAssetQuota := math.Max(0.0, quoteBalance.Available.Float64()-c.MinQuoteBalance.Float64())
|
||||
|
@ -178,7 +178,7 @@ func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...
|
|||
continue
|
||||
}
|
||||
|
||||
quantity = AdjustQuantityByMaxAmount(quantity, price, quoteAssetQuota)
|
||||
quantity = AdjustFloatQuantityByMaxAmount(quantity, price, quoteAssetQuota)
|
||||
|
||||
// if MaxBaseAssetBalance is enabled, we should check the current base asset balance
|
||||
if baseBalance, hasBaseAsset := balances[market.BaseCurrency]; hasBaseAsset && c.MaxBaseAssetBalance > 0 {
|
||||
|
@ -226,7 +226,7 @@ func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...
|
|||
}
|
||||
|
||||
// if the amount is too small, we should increase it.
|
||||
quantity = AdjustQuantityByMinAmount(quantity, price, market.MinNotional*1.01)
|
||||
quantity = AdjustFloatQuantityByMinAmount(quantity, price, market.MinNotional*1.01)
|
||||
|
||||
// we should not SELL too much
|
||||
quantity = math.Min(quantity, baseAssetBalance.Available.Float64())
|
||||
|
@ -253,7 +253,7 @@ func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...
|
|||
}
|
||||
|
||||
if c.MaxOrderAmount > 0 {
|
||||
quantity = AdjustQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64())
|
||||
quantity = AdjustFloatQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64())
|
||||
}
|
||||
|
||||
notional := quantity * lastPrice
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package bbgo
|
||||
|
||||
import (
|
||||
"github.com/c9s/bbgo/pkg/fixedpoint"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
|
@ -14,7 +15,19 @@ var (
|
|||
)
|
||||
|
||||
// AdjustQuantityByMinAmount adjusts the quantity to make the amount greater than the given minAmount
|
||||
func AdjustQuantityByMinAmount(quantity, currentPrice, minAmount float64) float64 {
|
||||
func AdjustQuantityByMinAmount(quantity, currentPrice, minAmount fixedpoint.Value) fixedpoint.Value {
|
||||
// modify quantity for the min amount
|
||||
amount := currentPrice.Mul(quantity)
|
||||
if amount < minAmount {
|
||||
ratio := minAmount.Div(amount)
|
||||
quantity = quantity.Mul(ratio)
|
||||
}
|
||||
|
||||
return quantity
|
||||
}
|
||||
|
||||
// AdjustFloatQuantityByMinAmount adjusts the quantity to make the amount greater than the given minAmount
|
||||
func AdjustFloatQuantityByMinAmount(quantity, currentPrice, minAmount float64) float64 {
|
||||
// modify quantity for the min amount
|
||||
amount := currentPrice * quantity
|
||||
if amount < minAmount {
|
||||
|
@ -25,7 +38,7 @@ func AdjustQuantityByMinAmount(quantity, currentPrice, minAmount float64) float6
|
|||
return quantity
|
||||
}
|
||||
|
||||
func AdjustQuantityByMaxAmount(quantity float64, price float64, maxAmount float64) float64 {
|
||||
func AdjustFloatQuantityByMaxAmount(quantity float64, price float64, maxAmount float64) float64 {
|
||||
amount := price * quantity
|
||||
if amount > maxAmount {
|
||||
ratio := maxAmount / amount
|
||||
|
|
|
@ -36,7 +36,7 @@ func TestAdjustQuantityByMinAmount(t *testing.T) {
|
|||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
q := AdjustQuantityByMinAmount(test.args.quantity, test.args.price, test.args.minAmount)
|
||||
q := AdjustFloatQuantityByMinAmount(test.args.quantity, test.args.price, test.args.minAmount)
|
||||
assert.Equal(t, test.wanted, q)
|
||||
})
|
||||
}
|
||||
|
|
472
pkg/bbgo/twap_order_executor.go
Normal file
472
pkg/bbgo/twap_order_executor.go
Normal file
|
@ -0,0 +1,472 @@
|
|||
package bbgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/c9s/bbgo/pkg/fixedpoint"
|
||||
"github.com/c9s/bbgo/pkg/types"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type TwapExecution struct {
|
||||
Session *ExchangeSession
|
||||
Symbol string
|
||||
Side types.SideType
|
||||
TargetQuantity fixedpoint.Value
|
||||
SliceQuantity fixedpoint.Value
|
||||
StopPrice fixedpoint.Value
|
||||
NumOfTicks int
|
||||
UpdateInterval time.Duration
|
||||
|
||||
market types.Market
|
||||
marketDataStream types.Stream
|
||||
|
||||
userDataStream types.Stream
|
||||
userDataStreamCtx context.Context
|
||||
cancelUserDataStream context.CancelFunc
|
||||
|
||||
orderBook *types.StreamOrderBook
|
||||
currentPrice fixedpoint.Value
|
||||
activePosition fixedpoint.Value
|
||||
|
||||
activeMakerOrders *LocalActiveOrderBook
|
||||
orderStore *OrderStore
|
||||
position *Position
|
||||
|
||||
executionCtx context.Context
|
||||
cancelExecution context.CancelFunc
|
||||
|
||||
stoppedC chan struct{}
|
||||
|
||||
state int
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (e *TwapExecution) connectMarketData(ctx context.Context) {
|
||||
log.Infof("connecting market data stream...")
|
||||
if err := e.marketDataStream.Connect(ctx); err != nil {
|
||||
log.WithError(err).Errorf("market data stream connect error")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *TwapExecution) connectUserData(ctx context.Context) {
|
||||
log.Infof("connecting user data stream...")
|
||||
if err := e.userDataStream.Connect(ctx); err != nil {
|
||||
log.WithError(err).Errorf("user data stream connect error")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *TwapExecution) getSideBook() (pvs types.PriceVolumeSlice, err error) {
|
||||
book := e.orderBook.Get()
|
||||
|
||||
switch e.Side {
|
||||
case types.SideTypeSell:
|
||||
pvs = book.Asks
|
||||
|
||||
case types.SideTypeBuy:
|
||||
pvs = book.Bids
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("invalid side type: %+v", e.Side)
|
||||
}
|
||||
|
||||
return pvs, err
|
||||
}
|
||||
|
||||
func (e *TwapExecution) newBestPriceMakerOrder() (orderForm types.SubmitOrder, err error) {
|
||||
book := e.orderBook.Get()
|
||||
|
||||
sideBook, err := e.getSideBook()
|
||||
if err != nil {
|
||||
return orderForm, err
|
||||
}
|
||||
|
||||
first, ok := sideBook.First()
|
||||
if !ok {
|
||||
return orderForm, fmt.Errorf("empty %s %s side book", e.Symbol, e.Side)
|
||||
}
|
||||
|
||||
newPrice := first.Price
|
||||
spread, ok := book.Spread()
|
||||
if !ok {
|
||||
return orderForm, errors.New("can not calculate spread, neither bid price or ask price exists")
|
||||
}
|
||||
|
||||
// for example, we have tickSize = 0.01, and spread is 28.02 - 28.00 = 0.02
|
||||
// assign tickSpread = min(spread - tickSize, tickSpread)
|
||||
//
|
||||
// if number of ticks = 0, than the tickSpread is 0
|
||||
// tickSpread = min(0.02 - 0.01, 0)
|
||||
// price = first bid price 28.00 + tickSpread (0.00) = 28.00
|
||||
//
|
||||
// if number of ticks = 1, than the tickSpread is 0.01
|
||||
// tickSpread = min(0.02 - 0.01, 0.01)
|
||||
// price = first bid price 28.00 + tickSpread (0.01) = 28.01
|
||||
//
|
||||
// if number of ticks = 2, than the tickSpread is 0.02
|
||||
// tickSpread = min(0.02 - 0.01, 0.02)
|
||||
// price = first bid price 28.00 + tickSpread (0.01) = 28.01
|
||||
tickSize := fixedpoint.NewFromFloat(e.market.TickSize)
|
||||
tickSpread := tickSize.MulInt(e.NumOfTicks)
|
||||
if spread > tickSize {
|
||||
// there is a gap in the spread
|
||||
tickSpread = fixedpoint.Min(tickSpread, spread-tickSize)
|
||||
switch e.Side {
|
||||
case types.SideTypeSell:
|
||||
newPrice -= tickSpread
|
||||
case types.SideTypeBuy:
|
||||
newPrice += tickSpread
|
||||
}
|
||||
}
|
||||
|
||||
if e.StopPrice > 0 {
|
||||
switch e.Side {
|
||||
case types.SideTypeSell:
|
||||
if newPrice < e.StopPrice {
|
||||
log.Infof("%s order price %f is lower than the stop sell price %f, setting order price to the stop sell price %f",
|
||||
e.Symbol,
|
||||
newPrice.Float64(),
|
||||
e.StopPrice.Float64(),
|
||||
e.StopPrice.Float64())
|
||||
newPrice = e.StopPrice
|
||||
}
|
||||
|
||||
case types.SideTypeBuy:
|
||||
if newPrice > e.StopPrice {
|
||||
log.Infof("%s order price %f is higher than the stop buy price %f, setting order price to the stop buy price %f",
|
||||
e.Symbol,
|
||||
newPrice.Float64(),
|
||||
e.StopPrice.Float64(),
|
||||
e.StopPrice.Float64())
|
||||
newPrice = e.StopPrice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
minQuantity := fixedpoint.NewFromFloat(e.market.MinQuantity)
|
||||
restQuantity := e.TargetQuantity - fixedpoint.Abs(e.position.Base)
|
||||
if restQuantity < minQuantity {
|
||||
return orderForm, fmt.Errorf("can not continue placing orders, rest quantity %f is less than the min quantity %f", restQuantity.Float64(), minQuantity.Float64())
|
||||
}
|
||||
|
||||
// if the rest quantity in the next round is not enough, we should merge the rest quantity into this round
|
||||
orderQuantity := e.SliceQuantity
|
||||
nextRestQuantity := restQuantity - e.SliceQuantity
|
||||
if nextRestQuantity < minQuantity {
|
||||
orderQuantity = restQuantity
|
||||
}
|
||||
|
||||
minNotional := fixedpoint.NewFromFloat(e.market.MinNotional)
|
||||
orderAmount := newPrice.Mul(orderQuantity)
|
||||
if orderAmount <= minNotional {
|
||||
orderQuantity = AdjustQuantityByMinAmount(orderQuantity, newPrice, minNotional)
|
||||
}
|
||||
|
||||
orderForm = types.SubmitOrder{
|
||||
// ClientOrderID: "",
|
||||
Symbol: e.Symbol,
|
||||
Side: e.Side,
|
||||
Type: types.OrderTypeLimitMaker,
|
||||
Quantity: orderQuantity.Float64(),
|
||||
Price: newPrice.Float64(),
|
||||
Market: e.market,
|
||||
TimeInForce: "GTC",
|
||||
}
|
||||
return orderForm, err
|
||||
}
|
||||
|
||||
func (e *TwapExecution) updateOrder(ctx context.Context) error {
|
||||
|
||||
sideBook, err := e.getSideBook()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
first, ok := sideBook.First()
|
||||
if !ok {
|
||||
return fmt.Errorf("empty %s %s side book", e.Symbol, e.Side)
|
||||
}
|
||||
|
||||
tickSize := fixedpoint.NewFromFloat(e.market.TickSize)
|
||||
|
||||
// check and see if we need to cancel the existing active orders
|
||||
for e.activeMakerOrders.NumOfOrders() > 0 {
|
||||
orders := e.activeMakerOrders.Orders()
|
||||
|
||||
if len(orders) > 1 {
|
||||
log.Warnf("more than 1 %s open orders in the strategy...", e.Symbol)
|
||||
}
|
||||
|
||||
// get the first order
|
||||
order := orders[0]
|
||||
price := fixedpoint.NewFromFloat(order.Price)
|
||||
quantity := fixedpoint.NewFromFloat(order.Quantity)
|
||||
|
||||
remainingQuantity := order.Quantity - order.ExecutedQuantity
|
||||
if remainingQuantity <= e.market.MinQuantity {
|
||||
log.Infof("order remaining quantity %f is less than the market minimal quantity %f, skip updating order", remainingQuantity, e.market.MinQuantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.StopPrice > 0 {
|
||||
switch e.Side {
|
||||
case types.SideTypeBuy:
|
||||
if first.Price > e.StopPrice {
|
||||
log.Infof("%s first bid price %f is higher than the stop price %f, skip updating order", e.Symbol, first.Price.Float64(), e.StopPrice.Float64())
|
||||
return nil
|
||||
}
|
||||
|
||||
case types.SideTypeSell:
|
||||
if first.Price < e.StopPrice {
|
||||
log.Infof("%s first ask price %f is lower than the stop price %f, skip updating order", e.Symbol, first.Price.Float64(), e.StopPrice.Float64())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// if the first bid price or first ask price is the same to the current active order
|
||||
// we should skip updating the order
|
||||
if first.Price == price {
|
||||
// there are other orders in the same price, it means if we cancel ours, the price is still the best price.
|
||||
if first.Volume > quantity {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if there is no gap between the first price entry and the second price entry
|
||||
second, ok := sideBook.Second()
|
||||
if !ok {
|
||||
return fmt.Errorf("no secoond price on the %s order book %s, can not update", e.Symbol, e.Side)
|
||||
}
|
||||
|
||||
// if there is no gap
|
||||
gap := fixedpoint.Abs(first.Price - second.Price)
|
||||
if gap > tickSize.MulInt(e.NumOfTicks) {
|
||||
// found gap, we should update our price
|
||||
} else {
|
||||
log.Infof("no gap between the second price %f and the first price %f (tick size = %f), skip updating",
|
||||
first.Price.Float64(),
|
||||
second.Price.Float64(),
|
||||
tickSize.Float64())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
e.cancelActiveOrders(ctx)
|
||||
}
|
||||
|
||||
orderForm, err := e.newBestPriceMakerOrder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createdOrders, err := e.Session.OrderExecutor.SubmitOrders(ctx, orderForm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.activeMakerOrders.Add(createdOrders...)
|
||||
e.orderStore.Add(createdOrders...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *TwapExecution) cancelActiveOrders(ctx context.Context) {
|
||||
didCancel := false
|
||||
for e.activeMakerOrders.NumOfOrders() > 0 {
|
||||
didCancel = true
|
||||
|
||||
orders := e.activeMakerOrders.Orders()
|
||||
log.Infof("canceling %d open orders:", len(orders))
|
||||
e.activeMakerOrders.Print()
|
||||
|
||||
if err := e.Session.Exchange.CancelOrders(ctx, orders...); err != nil {
|
||||
log.WithError(err).Errorf("can not cancel %s orders", e.Symbol)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
if didCancel {
|
||||
log.Infof("orders are canceled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *TwapExecution) orderUpdater(ctx context.Context) {
|
||||
rateLimiter := rate.NewLimiter(rate.Every(time.Minute), 15)
|
||||
ticker := time.NewTimer(e.UpdateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// we should stop updater and clean up our open orders, if
|
||||
// 1. the given context is canceled.
|
||||
// 2. the base quantity equals to or greater than the target quantity
|
||||
defer func() {
|
||||
e.cancelActiveOrders(context.Background())
|
||||
e.cancelUserDataStream()
|
||||
e.emitDone()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-e.orderBook.C:
|
||||
if !rateLimiter.Allow() {
|
||||
break
|
||||
}
|
||||
|
||||
if e.cancelContextIfTargetQuantityFilled() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.updateOrder(ctx); err != nil {
|
||||
log.WithError(err).Errorf("order update failed")
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if !rateLimiter.Allow() {
|
||||
break
|
||||
}
|
||||
|
||||
if e.cancelContextIfTargetQuantityFilled() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.updateOrder(ctx); err != nil {
|
||||
log.WithError(err).Errorf("order update failed")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *TwapExecution) cancelContextIfTargetQuantityFilled() bool {
|
||||
if fixedpoint.Abs(e.position.Base) >= e.TargetQuantity {
|
||||
log.Infof("filled target quantity, canceling the order execution context")
|
||||
e.cancelExecution()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *TwapExecution) handleTradeUpdate(trade types.Trade) {
|
||||
// ignore trades that are not in the symbol we interested
|
||||
if trade.Symbol != e.Symbol {
|
||||
return
|
||||
}
|
||||
|
||||
if !e.orderStore.Exists(trade.OrderID) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(trade.String())
|
||||
|
||||
e.position.AddTrade(trade)
|
||||
log.Infof("position updated: %+v", e.position)
|
||||
}
|
||||
|
||||
func (e *TwapExecution) handleFilledOrder(order types.Order) {
|
||||
log.Info(order.String())
|
||||
|
||||
// filled event triggers the order removal from the active order store
|
||||
// we need to ensure we received every order update event before the execution is done.
|
||||
e.cancelContextIfTargetQuantityFilled()
|
||||
}
|
||||
|
||||
func (e *TwapExecution) Run(parentCtx context.Context) error {
|
||||
e.mu.Lock()
|
||||
e.stoppedC = make(chan struct{})
|
||||
e.executionCtx, e.cancelExecution = context.WithCancel(parentCtx)
|
||||
e.userDataStreamCtx, e.cancelUserDataStream = context.WithCancel(context.Background())
|
||||
e.mu.Unlock()
|
||||
|
||||
if e.UpdateInterval == 0 {
|
||||
e.UpdateInterval = 10 * time.Second
|
||||
}
|
||||
|
||||
var ok bool
|
||||
e.market, ok = e.Session.Market(e.Symbol)
|
||||
if !ok {
|
||||
return fmt.Errorf("market %s not found", e.Symbol)
|
||||
}
|
||||
|
||||
e.marketDataStream = e.Session.Exchange.NewStream()
|
||||
e.marketDataStream.SetPublicOnly()
|
||||
e.marketDataStream.Subscribe(types.BookChannel, e.Symbol, types.SubscribeOptions{})
|
||||
|
||||
e.orderBook = types.NewStreamBook(e.Symbol)
|
||||
e.orderBook.BindStream(e.marketDataStream)
|
||||
go e.connectMarketData(e.executionCtx)
|
||||
|
||||
e.userDataStream = e.Session.Exchange.NewStream()
|
||||
e.userDataStream.OnTradeUpdate(e.handleTradeUpdate)
|
||||
e.position = &Position{
|
||||
Symbol: e.Symbol,
|
||||
BaseCurrency: e.market.BaseCurrency,
|
||||
QuoteCurrency: e.market.QuoteCurrency,
|
||||
}
|
||||
|
||||
e.orderStore = NewOrderStore(e.Symbol)
|
||||
e.orderStore.BindStream(e.userDataStream)
|
||||
e.activeMakerOrders = NewLocalActiveOrderBook()
|
||||
e.activeMakerOrders.OnFilled(e.handleFilledOrder)
|
||||
e.activeMakerOrders.BindStream(e.userDataStream)
|
||||
|
||||
go e.connectUserData(e.userDataStreamCtx)
|
||||
go e.orderUpdater(e.executionCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *TwapExecution) emitDone() {
|
||||
e.mu.Lock()
|
||||
if e.stoppedC == nil {
|
||||
e.stoppedC = make(chan struct{})
|
||||
}
|
||||
close(e.stoppedC)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *TwapExecution) Done() (c <-chan struct{}) {
|
||||
e.mu.Lock()
|
||||
// if the channel is not allocated, it means it's not started yet, we need to return a closed channel
|
||||
if e.stoppedC == nil {
|
||||
e.stoppedC = make(chan struct{})
|
||||
close(e.stoppedC)
|
||||
c = e.stoppedC
|
||||
} else {
|
||||
c = e.stoppedC
|
||||
}
|
||||
|
||||
e.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// Shutdown stops the execution
|
||||
// If we call this method, it means the execution is still running,
|
||||
// We need to:
|
||||
// 1. stop the order updater (by using the execution context)
|
||||
// 2. the order updater cancels all open orders and close the user data stream
|
||||
func (e *TwapExecution) Shutdown(shutdownCtx context.Context) {
|
||||
e.mu.Lock()
|
||||
if e.cancelExecution != nil {
|
||||
e.cancelExecution()
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
for {
|
||||
select {
|
||||
|
||||
case <-shutdownCtx.Done():
|
||||
return
|
||||
|
||||
case <-e.Done():
|
||||
return
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,9 +4,13 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/c9s/bbgo/pkg/fixedpoint"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
@ -103,42 +107,14 @@ var listOrdersCmd = &cobra.Command{
|
|||
},
|
||||
}
|
||||
|
||||
// go run ./cmd/bbgo placeorder --session=ftx --symbol=BTC/USDT --side=buy --price=<price> --quantity=<quantity>
|
||||
var placeOrderCmd = &cobra.Command{
|
||||
Use: "placeorder",
|
||||
var executeOrderCmd = &cobra.Command{
|
||||
Use: "execute-order",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
configFile, err := cmd.Flags().GetString("config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(configFile) == 0 {
|
||||
return fmt.Errorf("--config option is required")
|
||||
}
|
||||
|
||||
// if config file exists, use the config loaded from the config file.
|
||||
// otherwise, use a empty config object
|
||||
var userConfig *bbgo.Config
|
||||
if _, err := os.Stat(configFile); err == nil {
|
||||
// load successfully
|
||||
userConfig, err = bbgo.Load(configFile, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
// config file doesn't exist
|
||||
userConfig = &bbgo.Config{}
|
||||
} else {
|
||||
// other error
|
||||
return err
|
||||
}
|
||||
environ := bbgo.NewEnvironment()
|
||||
|
||||
if err := environ.ConfigureExchangeSessions(userConfig); err != nil {
|
||||
return err
|
||||
if userConfig == nil {
|
||||
return errors.New("config file is required")
|
||||
}
|
||||
|
||||
sessionName, err := cmd.Flags().GetString("session")
|
||||
|
@ -146,11 +122,136 @@ var placeOrderCmd = &cobra.Command{
|
|||
return err
|
||||
}
|
||||
|
||||
symbol, err := cmd.Flags().GetString("symbol")
|
||||
if err != nil {
|
||||
return fmt.Errorf("can not get the symbol from flags: %w", err)
|
||||
}
|
||||
|
||||
if symbol == "" {
|
||||
return fmt.Errorf("symbol not found")
|
||||
}
|
||||
|
||||
sideS, err := cmd.Flags().GetString("side")
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't get side: %w", err)
|
||||
}
|
||||
|
||||
side, err := types.StrToSideType(sideS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetQuantityS, err := cmd.Flags().GetString("target-quantity")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targetQuantityS) == 0 {
|
||||
return errors.New("--target-quantity can not be empty")
|
||||
}
|
||||
|
||||
targetQuantity, err := fixedpoint.NewFromString(targetQuantityS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sliceQuantityS, err := cmd.Flags().GetString("slice-quantity")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sliceQuantityS) == 0 {
|
||||
return errors.New("--slice-quantity can not be empty")
|
||||
}
|
||||
|
||||
sliceQuantity, err := fixedpoint.NewFromString(sliceQuantityS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numOfPriceTicks, err := cmd.Flags().GetInt("price-ticks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopPriceS, err := cmd.Flags().GetString("stop-price")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopPrice, err := fixedpoint.NewFromString(stopPriceS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
environ := bbgo.NewEnvironment()
|
||||
if err := environ.ConfigureExchangeSessions(userConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := environ.Init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, ok := environ.Session(sessionName)
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", sessionName)
|
||||
}
|
||||
|
||||
executionCtx, cancelExecution := context.WithCancel(ctx)
|
||||
defer cancelExecution()
|
||||
|
||||
execution := &bbgo.TwapExecution{
|
||||
Session: session,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
TargetQuantity: targetQuantity,
|
||||
SliceQuantity: sliceQuantity,
|
||||
StopPrice: stopPrice,
|
||||
NumOfTicks: numOfPriceTicks,
|
||||
}
|
||||
|
||||
if err := execution.Run(executionCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sigC = make(chan os.Signal, 1)
|
||||
signal.Notify(sigC, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigC)
|
||||
|
||||
select {
|
||||
case sig := <-sigC:
|
||||
log.Warnf("signal %v", sig)
|
||||
log.Infof("shutting down order executor...")
|
||||
shutdownCtx, cancelShutdown := context.WithDeadline(ctx, time.Now().Add(10*time.Second))
|
||||
execution.Shutdown(shutdownCtx)
|
||||
cancelShutdown()
|
||||
|
||||
case <-execution.Done():
|
||||
log.Infof("the order execution is completed")
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// go run ./cmd/bbgo submit-order --session=ftx --symbol=BTC/USDT --side=buy --price=<price> --quantity=<quantity>
|
||||
var submitOrderCmd = &cobra.Command{
|
||||
Use: "submit-order",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if userConfig == nil {
|
||||
return errors.New("config file is required")
|
||||
}
|
||||
|
||||
sessionName, err := cmd.Flags().GetString("session")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
symbol, err := cmd.Flags().GetString("symbol")
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't get the symbol from flags: %w", err)
|
||||
|
@ -174,6 +275,20 @@ var placeOrderCmd = &cobra.Command{
|
|||
return fmt.Errorf("can't get quantity: %w", err)
|
||||
}
|
||||
|
||||
environ := bbgo.NewEnvironment()
|
||||
if err := environ.ConfigureExchangeSessions(userConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := environ.Init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, ok := environ.Session(sessionName)
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", sessionName)
|
||||
}
|
||||
|
||||
so := types.SubmitOrder{
|
||||
ClientOrderID: uuid.New().String(),
|
||||
Symbol: symbol,
|
||||
|
@ -200,12 +315,21 @@ func init() {
|
|||
listOrdersCmd.Flags().String("session", "", "the exchange session name for sync")
|
||||
listOrdersCmd.Flags().String("symbol", "", "the trading pair, like btcusdt")
|
||||
|
||||
placeOrderCmd.Flags().String("session", "", "the exchange session name for sync")
|
||||
placeOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt")
|
||||
placeOrderCmd.Flags().String("side", "", "the trading side: buy or sell")
|
||||
placeOrderCmd.Flags().String("price", "", "the trading price")
|
||||
placeOrderCmd.Flags().String("quantity", "", "the trading quantity")
|
||||
submitOrderCmd.Flags().String("session", "", "the exchange session name for sync")
|
||||
submitOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt")
|
||||
submitOrderCmd.Flags().String("side", "", "the trading side: buy or sell")
|
||||
submitOrderCmd.Flags().String("price", "", "the trading price")
|
||||
submitOrderCmd.Flags().String("quantity", "", "the trading quantity")
|
||||
|
||||
executeOrderCmd.Flags().String("session", "", "the exchange session name for sync")
|
||||
executeOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt")
|
||||
executeOrderCmd.Flags().String("side", "", "the trading side: buy or sell")
|
||||
executeOrderCmd.Flags().String("target-quantity", "", "target quantity")
|
||||
executeOrderCmd.Flags().String("slice-quantity", "", "slice quantity")
|
||||
executeOrderCmd.Flags().String("stop-price", "0", "stop price")
|
||||
executeOrderCmd.Flags().Int("price-ticks", 0, "the number of price tick for the jump spread, default to 0")
|
||||
|
||||
RootCmd.AddCommand(listOrdersCmd)
|
||||
RootCmd.AddCommand(placeOrderCmd)
|
||||
RootCmd.AddCommand(submitOrderCmd)
|
||||
RootCmd.AddCommand(executeOrderCmd)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/c9s/bbgo/pkg/bbgo"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/lestrrat-go/file-rotatelogs"
|
||||
"github.com/pkg/errors"
|
||||
|
@ -18,6 +19,8 @@ import (
|
|||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
var userConfig *bbgo.Config
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "bbgo",
|
||||
Short: "bbgo is a crypto trading bot",
|
||||
|
@ -44,6 +47,31 @@ var RootCmd = &cobra.Command{
|
|||
}
|
||||
}
|
||||
|
||||
configFile, err := cmd.Flags().GetString("config")
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to get the config flag")
|
||||
}
|
||||
|
||||
// load config file nicely
|
||||
if len(configFile) > 0 {
|
||||
// if config file exists, use the config loaded from the config file.
|
||||
// otherwise, use a empty config object
|
||||
if _, err := os.Stat(configFile); err == nil {
|
||||
// load successfully
|
||||
userConfig, err = bbgo.Load(configFile, false)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can not load config file: %s", configFile)
|
||||
}
|
||||
|
||||
} else if os.IsNotExist(err) {
|
||||
// config file doesn't exist, we should use the empty config
|
||||
userConfig = &bbgo.Config{}
|
||||
} else {
|
||||
// other error
|
||||
return errors.Wrapf(err, "config file load error: %s", configFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
|
@ -80,6 +108,7 @@ func init() {
|
|||
RootCmd.PersistentFlags().String("ftx-subaccount-name", "", "subaccount name. Specify it if the credential is for subaccount.")
|
||||
}
|
||||
|
||||
|
||||
func Execute() {
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
|
||||
|
@ -105,12 +134,17 @@ func Execute() {
|
|||
// Once the flags are defined, we can bind config keys with flags.
|
||||
if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil {
|
||||
log.WithError(err).Errorf("failed to bind persistent flags. please check the flag settings.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := viper.BindPFlags(RootCmd.Flags()); err != nil {
|
||||
log.WithError(err).Errorf("failed to bind local flags. please check the flag settings.")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
log.SetFormatter(&prefixed.TextFormatter{})
|
||||
|
||||
logger := log.StandardLogger()
|
||||
|
|
|
@ -127,9 +127,12 @@ func toGlobalOrderType(orderType max.OrderType) types.OrderType {
|
|||
case max.OrderTypeIOCLimit:
|
||||
return types.OrderTypeIOCLimit
|
||||
|
||||
case max.OrderTypePostOnly:
|
||||
return types.OrderTypeLimitMaker
|
||||
|
||||
}
|
||||
|
||||
logger.Errorf("unknown order type: %v", orderType)
|
||||
logger.Errorf("order convert error, unknown order type: %v", orderType)
|
||||
return types.OrderType(orderType)
|
||||
}
|
||||
|
||||
|
|
|
@ -58,6 +58,10 @@ func (v Value) Mul(v2 Value) Value {
|
|||
return NewFromFloat(v.Float64() * v2.Float64())
|
||||
}
|
||||
|
||||
func (v Value) MulInt(v2 int) Value {
|
||||
return NewFromFloat(v.Float64() * float64(v2))
|
||||
}
|
||||
|
||||
func (v Value) MulFloat64(v2 float64) Value {
|
||||
return NewFromFloat(v.Float64() * v2)
|
||||
}
|
||||
|
@ -252,6 +256,18 @@ func NewFromInt64(val int64) Value {
|
|||
return Value(val * DefaultPow)
|
||||
}
|
||||
|
||||
func NumFractionalDigits(a Value) int {
|
||||
numPow := 0
|
||||
for pow := int64(DefaultPow); pow%10 != 1; pow /= 10 {
|
||||
numPow++
|
||||
}
|
||||
numZeros := 0
|
||||
for v := a.Int64(); v%10 == 0; v /= 10 {
|
||||
numZeros++
|
||||
}
|
||||
return numPow - numZeros
|
||||
}
|
||||
|
||||
func Min(a, b Value) Value {
|
||||
if a < b {
|
||||
return a
|
||||
|
@ -268,14 +284,9 @@ func Max(a, b Value) Value {
|
|||
return b
|
||||
}
|
||||
|
||||
func NumFractionalDigits(a Value) int {
|
||||
numPow := 0
|
||||
for pow := int64(DefaultPow); pow%10 != 1; pow /= 10 {
|
||||
numPow++
|
||||
func Abs(a Value) Value {
|
||||
if a < 0 {
|
||||
return -a
|
||||
}
|
||||
numZeros := 0
|
||||
for v := a.Int64(); v%10 == 0; v /= 10 {
|
||||
numZeros++
|
||||
}
|
||||
return numPow - numZeros
|
||||
return a
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ type Notifier struct {
|
|||
|
||||
type NotifyOption func(notifier *Notifier)
|
||||
|
||||
|
||||
// New
|
||||
// TODO: register interaction with channel, so that we can route message to the specific telegram bot
|
||||
func New(interaction *Interaction, options ...NotifyOption) *Notifier {
|
||||
|
@ -38,12 +37,15 @@ func filterPlaintextMessages(args []interface{}) (texts []string, pureArgs []int
|
|||
|
||||
case types.PlainText:
|
||||
texts = append(texts, a.PlainText())
|
||||
if textArgsOffset == -1 {
|
||||
textArgsOffset = idx
|
||||
}
|
||||
|
||||
case types.Stringer:
|
||||
texts = append(texts, a.String())
|
||||
if textArgsOffset == -1 {
|
||||
textArgsOffset = idx
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -157,7 +157,7 @@ func (s *Strategy) generateGridBuyOrders(session *bbgo.ExchangeSession) ([]types
|
|||
continue
|
||||
}
|
||||
// adjust buy quantity using current quote balance
|
||||
quantity := bbgo.AdjustQuantityByMaxAmount(s.Quantity, price, quoteBalance.Float64())
|
||||
quantity := bbgo.AdjustFloatQuantityByMaxAmount(s.Quantity, price, quoteBalance.Float64())
|
||||
order := types.SubmitOrder{
|
||||
Symbol: s.Symbol,
|
||||
Side: types.SideTypeBuy,
|
||||
|
|
|
@ -124,7 +124,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
|||
|
||||
if minNotional > b.Available.Float64() {
|
||||
log.Warnf("modifying quantity %f according to the min quote balance %f %s", quantity, b.Available.Float64(), market.QuoteCurrency)
|
||||
quantity = bbgo.AdjustQuantityByMaxAmount(quantity, closePrice, b.Available.Float64())
|
||||
quantity = bbgo.AdjustFloatQuantityByMaxAmount(quantity, closePrice, b.Available.Float64())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -324,7 +324,7 @@ func (s *Strategy) updateQuote(ctx context.Context, orderExecutionRouter bbgo.Or
|
|||
}
|
||||
accumulativeAskQuantity += askQuantity
|
||||
|
||||
askPrice := aggregatePrice(sourceBook.Asks, accumulativeBidQuantity)
|
||||
askPrice := aggregatePrice(sourceBook.Asks, accumulativeAskQuantity)
|
||||
askPrice = askPrice.MulFloat64(1.0 + s.AskMargin.Float64())
|
||||
if i > 0 && s.Pips > 0 {
|
||||
askPrice += fixedpoint.NewFromFloat(s.makerMarket.TickSize * float64(s.Pips))
|
||||
|
@ -417,7 +417,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) {
|
|||
// check quote quantity
|
||||
if quote, ok := account.Balance(s.sourceMarket.QuoteCurrency); ok {
|
||||
if quote.Available < notional {
|
||||
// qf := bbgo.AdjustQuantityByMaxAmount(quantity.Float64(), lastPrice, quote.Available.Float64())
|
||||
// qf := bbgo.AdjustFloatQuantityByMaxAmount(quantity.Float64(), lastPrice, quote.Available.Float64())
|
||||
// quantity = fixedpoint.NewFromFloat(qf)
|
||||
}
|
||||
}
|
||||
|
@ -675,14 +675,9 @@ func (s *Strategy) CrossRun(ctx context.Context, orderExecutionRouter bbgo.Order
|
|||
|
||||
time.Sleep(s.UpdateInterval.Duration())
|
||||
|
||||
for {
|
||||
for s.activeMakerOrders.NumOfOrders() > 0 {
|
||||
orders := s.activeMakerOrders.Orders()
|
||||
if len(orders) == 0 {
|
||||
log.Info("all orders are cancelled successfully")
|
||||
break
|
||||
}
|
||||
|
||||
log.Warnf("%d orders are not cancelled yet...", len(orders))
|
||||
log.Warnf("%d orders are not cancelled yet:", len(orders))
|
||||
s.activeMakerOrders.Print()
|
||||
|
||||
if err := s.makerSession.Exchange.CancelOrders(ctx, s.activeMakerOrders.Orders()...); err != nil {
|
||||
|
@ -692,6 +687,7 @@ func (s *Strategy) CrossRun(ctx context.Context, orderExecutionRouter bbgo.Order
|
|||
log.Warnf("waiting for orders to be cancelled...")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
log.Info("all orders are cancelled successfully")
|
||||
|
||||
if err := s.Persistence.Save(s.state, ID, s.Symbol, stateKey); err != nil {
|
||||
log.WithError(err).Errorf("can not save state: %+v", s.state)
|
||||
|
|
|
@ -169,7 +169,7 @@ func (o Order) Backup() SubmitOrder {
|
|||
}
|
||||
|
||||
func (o Order) String() string {
|
||||
return fmt.Sprintf("order %s %s %f/%f at %f -> %s", o.Symbol, o.Side, o.ExecutedQuantity, o.Quantity, o.Price, o.Status)
|
||||
return fmt.Sprintf("ORDER %s %s %s %f/%f @ %f -> %s", o.Exchange, o.Symbol, o.Side, o.ExecutedQuantity, o.Quantity, o.Price, o.Status)
|
||||
}
|
||||
|
||||
func (o Order) PlainText() string {
|
||||
|
|
|
@ -44,6 +44,13 @@ func (slice PriceVolumeSlice) Copy() PriceVolumeSlice {
|
|||
return s
|
||||
}
|
||||
|
||||
func (slice PriceVolumeSlice) Second() (PriceVolume, bool) {
|
||||
if len(slice) > 1 {
|
||||
return slice[1], true
|
||||
}
|
||||
return PriceVolume{}, false
|
||||
}
|
||||
|
||||
func (slice PriceVolumeSlice) First() (PriceVolume, bool) {
|
||||
if len(slice) > 0 {
|
||||
return slice[0], true
|
||||
|
@ -127,6 +134,20 @@ type OrderBook struct {
|
|||
asksChangeCallbacks []func(pvs PriceVolumeSlice)
|
||||
}
|
||||
|
||||
func (b *OrderBook) Spread() (fixedpoint.Value, bool) {
|
||||
bestBid, ok := b.BestBid()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
bestAsk, ok := b.BestAsk()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return bestAsk.Price - bestBid.Price, true
|
||||
}
|
||||
|
||||
func (b *OrderBook) BestBid() (PriceVolume, bool) {
|
||||
if len(b.Bids) == 0 {
|
||||
return PriceVolume{}, false
|
||||
|
|
|
@ -21,30 +21,39 @@ const (
|
|||
|
||||
var ErrInvalidSideType = errors.New("invalid side type")
|
||||
|
||||
func (side *SideType) UnmarshalJSON(data []byte) (err error) {
|
||||
func StrToSideType(s string) (side SideType, err error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "buy":
|
||||
side = SideTypeBuy
|
||||
|
||||
case "sell":
|
||||
side = SideTypeSell
|
||||
|
||||
case "both":
|
||||
side = SideTypeBoth
|
||||
|
||||
default:
|
||||
err = ErrInvalidSideType
|
||||
return side, err
|
||||
|
||||
}
|
||||
|
||||
return side, err
|
||||
}
|
||||
|
||||
func (side *SideType) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
err = json.Unmarshal(data, &s)
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ss, err := StrToSideType(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch strings.ToLower(s) {
|
||||
case "buy":
|
||||
*side = SideTypeBuy
|
||||
|
||||
case "sell":
|
||||
*side = SideTypeSell
|
||||
|
||||
case "both":
|
||||
*side = SideTypeBoth
|
||||
|
||||
default:
|
||||
err = ErrInvalidSideType
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
return err
|
||||
*side = ss
|
||||
return nil
|
||||
}
|
||||
|
||||
func (side SideType) Reverse() SideType {
|
||||
|
@ -59,6 +68,10 @@ func (side SideType) Reverse() SideType {
|
|||
return side
|
||||
}
|
||||
|
||||
func (side SideType) String() string {
|
||||
return string(side)
|
||||
}
|
||||
|
||||
func (side SideType) Color() string {
|
||||
if side == SideTypeBuy {
|
||||
return Green
|
||||
|
|
|
@ -71,16 +71,20 @@ type Trade struct {
|
|||
PnL sql.NullFloat64 `json:"pnl" db:"pnl"`
|
||||
}
|
||||
|
||||
func (trade Trade) PlainText() string {
|
||||
return fmt.Sprintf("%s Trade %s %s price %s, quantity %s, amount %s",
|
||||
func (trade Trade) String() string {
|
||||
return fmt.Sprintf("TRADE %s %s %s %s @ %s, amount %s",
|
||||
trade.Exchange,
|
||||
trade.Symbol,
|
||||
trade.Side,
|
||||
util.FormatFloat(trade.Price, 2),
|
||||
util.FormatFloat(trade.Quantity, 4),
|
||||
util.FormatFloat(trade.Price, 3),
|
||||
util.FormatFloat(trade.QuoteQuantity, 2))
|
||||
}
|
||||
|
||||
func (trade Trade) PlainText() string {
|
||||
return trade.String()
|
||||
}
|
||||
|
||||
var slackTradeTextTemplate = ":handshake: {{ .Symbol }} {{ .Side }} Trade Execution @ {{ .Price }}"
|
||||
|
||||
func (trade Trade) SlackAttachment() slack.Attachment {
|
||||
|
|
Loading…
Reference in New Issue
Block a user