mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-22 23:05:15 +00:00
backtest: fix stop limit order matching
Signed-off-by: c9s <yoanlin93@gmail.com>
This commit is contained in:
parent
9631a2e551
commit
10d5a8a4f2
|
@ -124,7 +124,8 @@ func (m *SimplePriceMatching) CancelOrder(o types.Order) (types.Order, error) {
|
||||||
return o, nil
|
return o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (closedOrders *types.Order, trades *types.Trade, err error) {
|
// PlaceOrder returns the created order object, executed trade (if any) and error
|
||||||
|
func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (*types.Order, *types.Trade, error) {
|
||||||
// price for checking account balance, default price
|
// price for checking account balance, default price
|
||||||
price := o.Price
|
price := o.Price
|
||||||
|
|
||||||
|
@ -135,7 +136,7 @@ func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (closedOrders *typ
|
||||||
}
|
}
|
||||||
|
|
||||||
price = m.LastPrice
|
price = m.LastPrice
|
||||||
case types.OrderTypeLimit, types.OrderTypeLimitMaker:
|
case types.OrderTypeLimit, types.OrderTypeStopLimit, types.OrderTypeLimitMaker:
|
||||||
price = o.Price
|
price = o.Price
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,11 +302,42 @@ func (m *SimplePriceMatching) newTradeFromOrder(order *types.Order, isMaker bool
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuyToPrice means price go up and the limit sell should be triggered
|
||||||
func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders []types.Order, trades []types.Trade) {
|
func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders []types.Order, trades []types.Trade) {
|
||||||
klineMatchingLogger.Debugf("kline buy to price %s", price.String())
|
klineMatchingLogger.Debugf("kline buy to price %s", price.String())
|
||||||
|
|
||||||
var askOrders []types.Order
|
var bidOrders []types.Order
|
||||||
|
for _, o := range m.bidOrders {
|
||||||
|
switch o.Type {
|
||||||
|
case types.OrderTypeStopLimit:
|
||||||
|
// should we trigger the order?
|
||||||
|
if price.Compare(o.StopPrice) <= 0 {
|
||||||
|
bidOrders = append(bidOrders, o)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert this order to limit order
|
||||||
|
// we use value object here, so it's a copy
|
||||||
|
o.Type = types.OrderTypeLimit
|
||||||
|
|
||||||
|
// is it a taker order?
|
||||||
|
// higher than the current price, then it's a taker order
|
||||||
|
if o.Price.Compare(price) >= 0 {
|
||||||
|
// taker order, move it to the closed order
|
||||||
|
o.ExecutedQuantity = o.Quantity
|
||||||
|
o.Status = types.OrderStatusFilled
|
||||||
|
closedOrders = append(closedOrders, o)
|
||||||
|
} else {
|
||||||
|
// keep it as a maker order
|
||||||
|
bidOrders = append(bidOrders, o)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
bidOrders = append(bidOrders, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.bidOrders = bidOrders
|
||||||
|
|
||||||
|
var askOrders []types.Order
|
||||||
for _, o := range m.askOrders {
|
for _, o := range m.askOrders {
|
||||||
switch o.Type {
|
switch o.Type {
|
||||||
|
|
||||||
|
@ -333,10 +365,13 @@ func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders [
|
||||||
o.Type = types.OrderTypeLimit
|
o.Type = types.OrderTypeLimit
|
||||||
|
|
||||||
// is it a taker order?
|
// is it a taker order?
|
||||||
if price.Compare(o.Price) >= 0 {
|
// higher than the current price, then it's a taker order
|
||||||
|
if o.Price.Compare(price) >= 0 {
|
||||||
|
// price protection, added by @zenix
|
||||||
if o.Price.Compare(m.LastKLine.Low) < 0 {
|
if o.Price.Compare(m.LastKLine.Low) < 0 {
|
||||||
o.Price = m.LastKLine.Low
|
o.Price = m.LastKLine.Low
|
||||||
}
|
}
|
||||||
|
|
||||||
o.ExecutedQuantity = o.Quantity
|
o.ExecutedQuantity = o.Quantity
|
||||||
o.Status = types.OrderStatusFilled
|
o.Status = types.OrderStatusFilled
|
||||||
closedOrders = append(closedOrders, o)
|
closedOrders = append(closedOrders, o)
|
||||||
|
@ -386,6 +421,37 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders
|
||||||
klineMatchingLogger.Debugf("kline sell to price %s", price.String())
|
klineMatchingLogger.Debugf("kline sell to price %s", price.String())
|
||||||
|
|
||||||
var sellPrice = price
|
var sellPrice = price
|
||||||
|
|
||||||
|
var askOrders []types.Order
|
||||||
|
for _, o := range m.askOrders {
|
||||||
|
switch o.Type {
|
||||||
|
|
||||||
|
case types.OrderTypeStopLimit:
|
||||||
|
// if the price is lower than the stop price
|
||||||
|
// we should trigger the stop sell order
|
||||||
|
if sellPrice.Compare(o.StopPrice) > 0 {
|
||||||
|
askOrders = append(askOrders, o)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Type = types.OrderTypeLimit
|
||||||
|
|
||||||
|
// if the order price is lower than the current price
|
||||||
|
// it's a taker order
|
||||||
|
if o.Price.Compare(sellPrice) <= 0 {
|
||||||
|
o.ExecutedQuantity = o.Quantity
|
||||||
|
o.Status = types.OrderStatusFilled
|
||||||
|
closedOrders = append(closedOrders, o)
|
||||||
|
} else {
|
||||||
|
askOrders = append(askOrders, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
askOrders = append(askOrders, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.askOrders = askOrders
|
||||||
|
|
||||||
var bidOrders []types.Order
|
var bidOrders []types.Order
|
||||||
for _, o := range m.bidOrders {
|
for _, o := range m.bidOrders {
|
||||||
switch o.Type {
|
switch o.Type {
|
||||||
|
@ -402,11 +468,12 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders
|
||||||
}
|
}
|
||||||
|
|
||||||
case types.OrderTypeStopLimit:
|
case types.OrderTypeStopLimit:
|
||||||
// should we trigger the order
|
// if the price is lower than the stop price
|
||||||
|
// we should trigger the stop order
|
||||||
if sellPrice.Compare(o.StopPrice) <= 0 {
|
if sellPrice.Compare(o.StopPrice) <= 0 {
|
||||||
o.Type = types.OrderTypeLimit
|
o.Type = types.OrderTypeLimit
|
||||||
|
|
||||||
if sellPrice.Compare(o.Price) <= 0 {
|
if o.Price.Compare(sellPrice) <= 0 {
|
||||||
if o.Price.Compare(m.LastKLine.High) > 0 {
|
if o.Price.Compare(m.LastKLine.High) > 0 {
|
||||||
o.Price = m.LastKLine.High
|
o.Price = m.LastKLine.High
|
||||||
}
|
}
|
||||||
|
|
|
@ -151,17 +151,7 @@ func newKLine(symbol string, interval types.Interval, startTime time.Time, o, h,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) {
|
func getTestMarket() types.Market {
|
||||||
account := &types.Account{
|
|
||||||
MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01),
|
|
||||||
TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01),
|
|
||||||
}
|
|
||||||
|
|
||||||
account.UpdateBalances(types.BalanceMap{
|
|
||||||
"USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(1000000.0)},
|
|
||||||
"BTC": {Currency: "BTC", Available: fixedpoint.NewFromFloat(100.0)},
|
|
||||||
})
|
|
||||||
|
|
||||||
market := types.Market{
|
market := types.Market{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
PricePrecision: 8,
|
PricePrecision: 8,
|
||||||
|
@ -172,7 +162,98 @@ func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) {
|
||||||
MinAmount: fixedpoint.MustNewFromString("10.0"),
|
MinAmount: fixedpoint.MustNewFromString("10.0"),
|
||||||
MinQuantity: fixedpoint.MustNewFromString("0.001"),
|
MinQuantity: fixedpoint.MustNewFromString("0.001"),
|
||||||
}
|
}
|
||||||
|
return market
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTestAccount() *types.Account {
|
||||||
|
account := &types.Account{
|
||||||
|
MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01),
|
||||||
|
TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01),
|
||||||
|
}
|
||||||
|
account.UpdateBalances(types.BalanceMap{
|
||||||
|
"USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(1000000.0)},
|
||||||
|
"BTC": {Currency: "BTC", Available: fixedpoint.NewFromFloat(100.0)},
|
||||||
|
})
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimplePriceMatching_StopLimitOrderBuy(t *testing.T) {
|
||||||
|
account := getTestAccount()
|
||||||
|
market := getTestMarket()
|
||||||
|
engine := &SimplePriceMatching{
|
||||||
|
Account: account,
|
||||||
|
Market: market,
|
||||||
|
closedOrders: make(map[uint64]types.Order),
|
||||||
|
LastPrice: fixedpoint.NewFromFloat(19000.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
stopOrder := types.SubmitOrder{
|
||||||
|
Symbol: market.Symbol,
|
||||||
|
Side: types.SideTypeBuy,
|
||||||
|
Type: types.OrderTypeStopLimit,
|
||||||
|
Quantity: fixedpoint.NewFromFloat(0.1),
|
||||||
|
Price: fixedpoint.NewFromFloat(22000.0),
|
||||||
|
StopPrice: fixedpoint.NewFromFloat(21000.0),
|
||||||
|
TimeInForce: types.TimeInForceGTC,
|
||||||
|
}
|
||||||
|
createdOrder, trade, err := engine.PlaceOrder(stopOrder)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, trade, "place stop order should not trigger the stop buy")
|
||||||
|
assert.NotNil(t, createdOrder, "place stop order should not trigger the stop buy")
|
||||||
|
|
||||||
|
closedOrders, trades := engine.BuyToPrice(fixedpoint.NewFromFloat(20000.0))
|
||||||
|
assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy")
|
||||||
|
assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy")
|
||||||
|
|
||||||
|
closedOrders, trades = engine.BuyToPrice(fixedpoint.NewFromFloat(21001.0))
|
||||||
|
assert.Len(t, closedOrders, 1, "should trigger the stop buy order")
|
||||||
|
assert.Len(t, trades, 1, "should have stop order trade executed")
|
||||||
|
|
||||||
|
assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status)
|
||||||
|
assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type)
|
||||||
|
assert.Equal(t, stopOrder.Price, trades[0].Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimplePriceMatching_StopLimitOrderSell(t *testing.T) {
|
||||||
|
account := getTestAccount()
|
||||||
|
market := getTestMarket()
|
||||||
|
engine := &SimplePriceMatching{
|
||||||
|
Account: account,
|
||||||
|
Market: market,
|
||||||
|
closedOrders: make(map[uint64]types.Order),
|
||||||
|
LastPrice: fixedpoint.NewFromFloat(22000.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
stopOrder := types.SubmitOrder{
|
||||||
|
Symbol: market.Symbol,
|
||||||
|
Side: types.SideTypeSell,
|
||||||
|
Type: types.OrderTypeStopLimit,
|
||||||
|
Quantity: fixedpoint.NewFromFloat(0.1),
|
||||||
|
Price: fixedpoint.NewFromFloat(20000.0),
|
||||||
|
StopPrice: fixedpoint.NewFromFloat(21000.0),
|
||||||
|
TimeInForce: types.TimeInForceGTC,
|
||||||
|
}
|
||||||
|
createdOrder, trade, err := engine.PlaceOrder(stopOrder)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, trade, "place stop order should not trigger the stop sell")
|
||||||
|
assert.NotNil(t, createdOrder, "place stop order should not trigger the stop sell")
|
||||||
|
|
||||||
|
closedOrders, trades := engine.SellToPrice(fixedpoint.NewFromFloat(21500.0))
|
||||||
|
assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy")
|
||||||
|
assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy")
|
||||||
|
|
||||||
|
closedOrders, trades = engine.SellToPrice(fixedpoint.NewFromFloat(20990.0))
|
||||||
|
assert.Len(t, closedOrders, 1, "should trigger the stop sell order")
|
||||||
|
assert.Len(t, trades, 1, "should have stop order trade executed")
|
||||||
|
|
||||||
|
assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status)
|
||||||
|
assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type)
|
||||||
|
assert.Equal(t, stopOrder.Price, trades[0].Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) {
|
||||||
|
account := getTestAccount()
|
||||||
|
market := getTestMarket()
|
||||||
engine := &SimplePriceMatching{
|
engine := &SimplePriceMatching{
|
||||||
Account: account,
|
Account: account,
|
||||||
Market: market,
|
Market: market,
|
||||||
|
|
|
@ -220,16 +220,38 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
||||||
s.orderExecutor.Bind()
|
s.orderExecutor.Bind()
|
||||||
|
|
||||||
store, _ := session.MarketDataStore(s.Symbol)
|
store, _ := session.MarketDataStore(s.Symbol)
|
||||||
|
standardIndicator, _ := session.StandardIndicatorSet(s.Symbol)
|
||||||
|
|
||||||
s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow}
|
s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow}
|
||||||
s.pivot.Bind(store)
|
s.pivot.Bind(store)
|
||||||
|
if kLinesP, ok := store.KLinesOfInterval(s.IntervalWindow.Interval); ok {
|
||||||
|
s.pivot.Update(*kLinesP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update pivot low data
|
||||||
|
session.MarketDataStream.OnKLineClosed(func(kline types.KLine) {
|
||||||
|
if kline.Symbol != s.Symbol || kline.Interval != s.Interval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow())
|
||||||
|
if lastLow.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastLow.Compare(s.lastLow) != 0 {
|
||||||
|
log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time())
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastLow = lastLow
|
||||||
|
s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow)
|
||||||
|
})
|
||||||
|
|
||||||
if s.BounceShort != nil && s.BounceShort.Enabled {
|
if s.BounceShort != nil && s.BounceShort.Enabled {
|
||||||
s.resistancePivot = &indicator.Pivot{IntervalWindow: s.BounceShort.IntervalWindow}
|
s.resistancePivot = &indicator.Pivot{IntervalWindow: s.BounceShort.IntervalWindow}
|
||||||
s.resistancePivot.Bind(store)
|
s.resistancePivot.Bind(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
standardIndicator, _ := session.StandardIndicatorSet(s.Symbol)
|
|
||||||
if s.BreakLow.StopEMA != nil {
|
if s.BreakLow.StopEMA != nil {
|
||||||
s.stopEWMA = standardIndicator.EWMA(*s.BreakLow.StopEMA)
|
s.stopEWMA = standardIndicator.EWMA(*s.BreakLow.StopEMA)
|
||||||
}
|
}
|
||||||
|
@ -298,6 +320,17 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
||||||
s.pivotLowPrices = s.pivotLowPrices[len(s.pivotLowPrices)-10:]
|
s.pivotLowPrices = s.pivotLowPrices[len(s.pivotLowPrices)-10:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ratio := fixedpoint.One.Add(s.BreakLow.Ratio)
|
||||||
|
breakPrice := previousLow.Mul(ratio)
|
||||||
|
|
||||||
|
closePrice := kline.Close
|
||||||
|
// if previous low is not break, skip
|
||||||
|
if closePrice.Compare(breakPrice) >= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("%s breakLow signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64())
|
||||||
|
|
||||||
// stop EMA protection
|
// stop EMA protection
|
||||||
if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() {
|
if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() {
|
||||||
ema := fixedpoint.NewFromFloat(s.stopEWMA.Last())
|
ema := fixedpoint.NewFromFloat(s.stopEWMA.Last())
|
||||||
|
@ -306,19 +339,12 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
||||||
}
|
}
|
||||||
|
|
||||||
emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.BreakLow.StopEMARange))
|
emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.BreakLow.StopEMARange))
|
||||||
if kline.Close.Compare(emaStopShortPrice) < 0 {
|
if closePrice.Compare(emaStopShortPrice) < 0 {
|
||||||
|
log.Infof("stopEMA protection: close price %f < EMA(%v) = %f", closePrice.Float64(), s.BreakLow.StopEMA, ema.Float64())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ratio := fixedpoint.One.Add(s.BreakLow.Ratio)
|
|
||||||
breakPrice := previousLow.Mul(ratio)
|
|
||||||
|
|
||||||
// if previous low is not break, skip
|
|
||||||
if kline.Close.Compare(breakPrice) >= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.orderExecutor.GracefulCancel(ctx)
|
_ = s.orderExecutor.GracefulCancel(ctx)
|
||||||
|
|
||||||
quantity := s.useQuantityOrBaseBalance(s.BreakLow.Quantity)
|
quantity := s.useQuantityOrBaseBalance(s.BreakLow.Quantity)
|
||||||
|
@ -374,27 +400,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
session.MarketDataStream.OnKLineClosed(func(kline types.KLine) {
|
|
||||||
// StrategyController
|
|
||||||
if s.Status != types.StrategyStatusRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if kline.Symbol != s.Symbol || kline.Interval != s.Interval {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pivot.LastLow() > 0.0 {
|
|
||||||
lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow())
|
|
||||||
if lastLow.Compare(s.lastLow) != 0 {
|
|
||||||
log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time())
|
|
||||||
}
|
|
||||||
|
|
||||||
s.lastLow = lastLow
|
|
||||||
s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) {
|
s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String())
|
_, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String())
|
||||||
wg.Done()
|
wg.Done()
|
||||||
|
|
|
@ -271,7 +271,7 @@ func (o Order) String() string {
|
||||||
orderID = strconv.FormatUint(o.OrderID, 10)
|
orderID = strconv.FormatUint(o.OrderID, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("ORDER %s | %s | %s | %s | %s %-4s | %s/%s @ %s | %s",
|
desc := fmt.Sprintf("ORDER %s | %s | %s | %s | %s %-4s | %s/%s @ %s",
|
||||||
o.Exchange.String(),
|
o.Exchange.String(),
|
||||||
o.CreationTime.Time().Local().Format(time.RFC1123),
|
o.CreationTime.Time().Local().Format(time.RFC1123),
|
||||||
orderID,
|
orderID,
|
||||||
|
@ -280,8 +280,13 @@ func (o Order) String() string {
|
||||||
o.Side,
|
o.Side,
|
||||||
o.ExecutedQuantity.String(),
|
o.ExecutedQuantity.String(),
|
||||||
o.Quantity.String(),
|
o.Quantity.String(),
|
||||||
o.Price.String(),
|
o.Price.String())
|
||||||
o.Status)
|
|
||||||
|
if o.Type == OrderTypeStopLimit {
|
||||||
|
desc += " Stop @ " + o.StopPrice.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return desc + " | " + string(o.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlainText is used for telegram-styled messages
|
// PlainText is used for telegram-styled messages
|
||||||
|
|
Loading…
Reference in New Issue
Block a user