From b97ec7bb1ed782db2006f4a10a02f7d06960f0b0 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 17:44:37 +0800 Subject: [PATCH 1/9] pivotshort: remove unused struct --- pkg/strategy/pivotshort/strategy.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index cb9830af7..a6e6e1270 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -68,12 +68,6 @@ type Entry struct { MarginSideEffect types.MarginOrderSideEffectType `json:"marginOrderSideEffect"` } -type CumulatedVolume struct { - Enabled bool `json:"enabled"` - MinQuoteVolume fixedpoint.Value `json:"minQuoteVolume"` - Window int `json:"window"` -} - type Strategy struct { *bbgo.Graceful From 2953518af97e3c3b01c2791e5037051355a3aaaa Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 18:17:46 +0800 Subject: [PATCH 2/9] add limit to the order table --- .../components/OrderListTable.tsx | 41 +++++++++++-------- .../components/TimeRangeSlider/index.scss | 2 +- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/backtest-report/components/OrderListTable.tsx b/apps/backtest-report/components/OrderListTable.tsx index efaa0d721..09dee9f34 100644 --- a/apps/backtest-report/components/OrderListTable.tsx +++ b/apps/backtest-report/components/OrderListTable.tsx @@ -1,15 +1,18 @@ -import {Checkbox, Group, Table} from "@mantine/core"; +import {Button, Checkbox, Group, Table} from "@mantine/core"; import React, {useState} from "react"; import {Order} from "../types"; interface OrderListTableProps { orders: Order[]; onClick?: (order: Order) => void; + limit?: number; } const OrderListTable = (props: OrderListTableProps) => { let orders = props.orders; + const [showCanceledOrders, setShowCanceledOrders] = useState(false); + const [limit, setLimit] = useState(props.limit || 5); if (!showCanceledOrders) { orders = orders.filter((order: Order) => { @@ -17,6 +20,10 @@ const OrderListTable = (props: OrderListTableProps) => { }) } + if (orders.length > limit) { + orders = orders.slice(0, limit) + } + const rows = orders.map((order: Order) => ( { props.onClick ? props.onClick(order) : null; @@ -41,23 +48,25 @@ const OrderListTable = (props: OrderListTableProps) => { setShowCanceledOrders(event.currentTarget.checked)}/> - + - - - - - - - - - - - - - {rows} -
Order IDSymbolSideOrder TypePriceQuantityStatusCreation Time
+ + + Order ID + Symbol + Side + Order Type + Price + Quantity + Status + Creation Time + + + {rows} + } diff --git a/apps/backtest-report/components/TimeRangeSlider/index.scss b/apps/backtest-report/components/TimeRangeSlider/index.scss index 6b96f7376..29f08447f 100644 --- a/apps/backtest-report/components/TimeRangeSlider/index.scss +++ b/apps/backtest-report/components/TimeRangeSlider/index.scss @@ -10,7 +10,7 @@ $react-time-range--track--disabled: repeating-linear-gradient( -45deg, transpare .react_time_range__time_range_container { padding: 30px 52px 0 52px; - height: 70px; + height: 90px; // width: 90%; box-sizing: border-box; } From 2784408b8b9b7c09e88719730f5edcf0aabdddc6 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 18:17:57 +0800 Subject: [PATCH 3/9] add submit order tag --- pkg/bbgo/order_executor_general.go | 5 ++++- pkg/strategy/pivotshort/protection_stop.go | 3 ++- pkg/strategy/pivotshort/roi_stop.go | 2 +- pkg/strategy/pivotshort/roi_take_profit.go | 2 +- pkg/strategy/pivotshort/strategy.go | 18 +++++++++++------- pkg/types/order.go | 4 ++++ 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pkg/bbgo/order_executor_general.go b/pkg/bbgo/order_executor_general.go index 054ae4b20..540dce477 100644 --- a/pkg/bbgo/order_executor_general.go +++ b/pkg/bbgo/order_executor_general.go @@ -2,6 +2,7 @@ package bbgo import ( "context" + "strings" log "github.com/sirupsen/logrus" @@ -117,12 +118,14 @@ func (e *GeneralOrderExecutor) GracefulCancel(ctx context.Context) error { return nil } -func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { +func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fixedpoint.Value, tags ...string) error { submitOrder := e.position.NewMarketCloseOrder(percentage) if submitOrder == nil { return nil } + submitOrder.Tag = strings.Join(tags, ",") + _, err := e.SubmitOrders(ctx, *submitOrder) return err } diff --git a/pkg/strategy/pivotshort/protection_stop.go b/pkg/strategy/pivotshort/protection_stop.go index ca7553fd5..8c7f5328f 100644 --- a/pkg/strategy/pivotshort/protection_stop.go +++ b/pkg/strategy/pivotshort/protection_stop.go @@ -58,6 +58,7 @@ func (s *ProtectionStopLoss) placeStopOrder(ctx context.Context, position *types Price: s.stopLossPrice.Mul(one.Add(fixedpoint.NewFromFloat(0.005))), // +0.5% from the trigger price, slippage protection StopPrice: s.stopLossPrice, Market: position.Market, + Tag: "protectionStopLoss", }) if len(createdOrders) > 0 { @@ -174,7 +175,7 @@ func (s *ProtectionStopLoss) checkStopPrice(closePrice fixedpoint.Value, positio if s.shouldStop(closePrice) { log.Infof("[ProtectionStopLoss] protection stop order is triggered at price %f, position = %+v", closePrice.Float64(), position) - if err := s.orderExecutor.ClosePosition(context.Background(), one); err != nil { + if err := s.orderExecutor.ClosePosition(context.Background(), one, "protectionStopLoss"); err != nil { log.WithError(err).Errorf("failed to close position") } } diff --git a/pkg/strategy/pivotshort/roi_stop.go b/pkg/strategy/pivotshort/roi_stop.go index 34cc7d5d0..0c94e876a 100644 --- a/pkg/strategy/pivotshort/roi_stop.go +++ b/pkg/strategy/pivotshort/roi_stop.go @@ -48,7 +48,7 @@ func (s *RoiStopLoss) checkStopPrice(closePrice fixedpoint.Value, position *type if roi.Compare(s.Percentage.Neg()) < 0 { // stop loss bbgo.Notify("[RoiStopLoss] %s stop loss triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Neg().Percentage(), closePrice.Float64()) - _ = s.orderExecutor.ClosePosition(context.Background(), fixedpoint.One) + _ = s.orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "roiStopLoss") return } } diff --git a/pkg/strategy/pivotshort/roi_take_profit.go b/pkg/strategy/pivotshort/roi_take_profit.go index 6c1efce8e..2813f6f9e 100644 --- a/pkg/strategy/pivotshort/roi_take_profit.go +++ b/pkg/strategy/pivotshort/roi_take_profit.go @@ -34,7 +34,7 @@ func (s *RoiTakeProfit) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo. if roi.Compare(s.Percentage) > 0 { // stop loss bbgo.Notify("[RoiTakeProfit] %s take profit is triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Percentage(), kline.Close.Float64()) - _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One) + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "roiTakeProfit") return } }) diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index a6e6e1270..1edb13968 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -282,8 +282,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se return } - isPositionOpened := !s.Position.IsClosed() && !s.Position.IsDust(kline.Close) - if isPositionOpened && s.Position.IsShort() { + if !s.Position.IsClosed() && !s.Position.IsDust(kline.Close) { return } @@ -299,6 +298,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.pivotLowPrices = s.pivotLowPrices[len(s.pivotLowPrices)-10:] } + // stop EMA protection if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() { ema := fixedpoint.NewFromFloat(s.stopEWMA.Last()) if ema.IsZero() { @@ -319,11 +319,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se return } - if !s.Position.IsClosed() && !s.Position.IsDust(kline.Close) { - // s.Notify("skip opening %s position, which is not closed", s.Symbol, s.Position) - return - } - _ = s.orderExecutor.GracefulCancel(ctx) quantity := s.useQuantityOrBaseBalance(s.BreakLow.Quantity) @@ -332,6 +327,8 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.placeMarketSell(ctx, quantity) } else { sellPrice := kline.Close.Mul(fixedpoint.One.Add(s.BreakLow.BounceRatio)) + + bbgo.Notify("%s price %f breaks the previous low %f with ratio %f, submitting limit sell @ %f", s.Symbol, kline.Close.Float64(), previousLow.Float64(), s.BreakLow.Ratio.Float64(), sellPrice.Float64()) s.placeLimitSell(ctx, sellPrice, quantity) } }) @@ -370,6 +367,13 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } }) + if !bbgo.IsBackTesting { + // use market trade to submit short order + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + + }) + } + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { // StrategyController if s.Status != types.StrategyStatusRunning { diff --git a/pkg/types/order.go b/pkg/types/order.go index 60d1760cd..a16de24db 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -132,6 +132,8 @@ type SubmitOrder struct { IsFutures bool `json:"is_futures" db:"is_futures"` ReduceOnly bool `json:"reduceOnly" db:"reduce_only"` ClosePosition bool `json:"closePosition" db:"close_position"` + + Tag string `json:"tag" db:"-"` } func (o *SubmitOrder) String() string { @@ -229,6 +231,7 @@ func (o Order) CsvHeader() []string { "quantity", "creation_time", "update_time", + "tag", } } @@ -244,6 +247,7 @@ func (o Order) CsvRecords() [][]string { o.Quantity.String(), o.CreationTime.Time().Format(time.RFC1123), o.UpdateTime.Time().Format(time.RFC1123), + o.Tag, }, } } From 11dacbc2cd27b093cb5a61ea0af878d943684f65 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 18:20:26 +0800 Subject: [PATCH 4/9] show order tag in the order list --- apps/backtest-report/components/OrderListTable.tsx | 12 ++++++++++-- apps/backtest-report/components/TradingViewChart.tsx | 6 +++++- apps/backtest-report/types/order.ts | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/backtest-report/components/OrderListTable.tsx b/apps/backtest-report/components/OrderListTable.tsx index 09dee9f34..4e075b490 100644 --- a/apps/backtest-report/components/OrderListTable.tsx +++ b/apps/backtest-report/components/OrderListTable.tsx @@ -1,6 +1,7 @@ import {Button, Checkbox, Group, Table} from "@mantine/core"; import React, {useState} from "react"; import {Order} from "../types"; +import moment from "moment"; interface OrderListTableProps { orders: Order[]; @@ -12,7 +13,7 @@ const OrderListTable = (props: OrderListTableProps) => { let orders = props.orders; const [showCanceledOrders, setShowCanceledOrders] = useState(false); - const [limit, setLimit] = useState(props.limit || 5); + const [limit, setLimit] = useState(props.limit || 100); if (!showCanceledOrders) { orders = orders.filter((order: Order) => { @@ -40,7 +41,8 @@ const OrderListTable = (props: OrderListTableProps) => { {order.price} {order.quantity} {order.status} - {order.creation_time.toString()} + {formatDate(order.creation_time)} + {order.tag} )); @@ -63,6 +65,7 @@ const OrderListTable = (props: OrderListTableProps) => { Quantity Status Creation Time + Tag {rows} @@ -70,4 +73,9 @@ const OrderListTable = (props: OrderListTableProps) => { } +const formatDate = (d : Date) : string => { + return moment(d).format("MMM Do YY hh:mm:ss A Z"); +} + + export default OrderListTable; diff --git a/apps/backtest-report/components/TradingViewChart.tsx b/apps/backtest-report/components/TradingViewChart.tsx index 9bc0cbc0a..426286bf0 100644 --- a/apps/backtest-report/components/TradingViewChart.tsx +++ b/apps/backtest-report/components/TradingViewChart.tsx @@ -657,6 +657,10 @@ const createLegendUpdater = (legend: HTMLDivElement, prefix: string) => { } } +const formatDate = (d : Date) : string => { + return moment(d).format("MMM Do YY hh:mm:ss A Z"); +} + const createOHLCLegendUpdater = (legend: HTMLDivElement, prefix: string) => { return (param: any, time : any) => { if (param) { @@ -664,7 +668,7 @@ const createOHLCLegendUpdater = (legend: HTMLDivElement, prefix: string) => { const changePercentage = Math.round((param.close - param.open) / param.close * 10000.0) / 100.0; const ampl = Math.round((param.high - param.low) / param.low * 10000.0) / 100.0; const t = new Date(time * 1000); - const dateStr = moment(t).format("MMM Do YY hh:mm:ss A Z"); + const dateStr = formatDate(t); legend.innerHTML = prefix + ` O: ${param.open} H: ${param.high} L: ${param.low} C: ${param.close} CHG: ${change} (${changePercentage}%) AMP: ${ampl}% T: ${dateStr}`; } else { legend.innerHTML = prefix + ' O: - H: - L: - C: - T: -'; diff --git a/apps/backtest-report/types/order.ts b/apps/backtest-report/types/order.ts index b9045ccd4..1f96cdfa4 100644 --- a/apps/backtest-report/types/order.ts +++ b/apps/backtest-report/types/order.ts @@ -11,4 +11,5 @@ export interface Order { update_time: Date; creation_time: Date; time?: Date; + tag?: string; } From 9631a2e551de1703a1ed0d3e671fc6271014b9d8 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 18:26:26 +0800 Subject: [PATCH 5/9] fix segment control default value Signed-off-by: c9s --- apps/backtest-report/components/TradingViewChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backtest-report/components/TradingViewChart.tsx b/apps/backtest-report/components/TradingViewChart.tsx index 426286bf0..0e14fca40 100644 --- a/apps/backtest-report/components/TradingViewChart.tsx +++ b/apps/backtest-report/components/TradingViewChart.tsx @@ -573,6 +573,7 @@ const TradingViewChart = (props: TradingViewChartProps) => {
{ return {label: interval, value: interval} })} From 10d5a8a4f22ce1f49c65e51c460a7fd48d59d630 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 19:48:14 +0800 Subject: [PATCH 6/9] backtest: fix stop limit order matching Signed-off-by: c9s --- pkg/backtest/matching.go | 79 +++++++++++++++++++-- pkg/backtest/matching_test.go | 103 +++++++++++++++++++++++++--- pkg/strategy/pivotshort/strategy.go | 67 +++++++++--------- pkg/types/order.go | 11 ++- 4 files changed, 209 insertions(+), 51 deletions(-) diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go index a032299ee..47695d52d 100644 --- a/pkg/backtest/matching.go +++ b/pkg/backtest/matching.go @@ -124,7 +124,8 @@ func (m *SimplePriceMatching) CancelOrder(o types.Order) (types.Order, error) { 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 := o.Price @@ -135,7 +136,7 @@ func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (closedOrders *typ } price = m.LastPrice - case types.OrderTypeLimit, types.OrderTypeLimitMaker: + case types.OrderTypeLimit, types.OrderTypeStopLimit, types.OrderTypeLimitMaker: 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) { 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 { switch o.Type { @@ -333,10 +365,13 @@ func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders [ o.Type = types.OrderTypeLimit // 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 { o.Price = m.LastKLine.Low } + o.ExecutedQuantity = o.Quantity o.Status = types.OrderStatusFilled 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()) 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 for _, o := range m.bidOrders { switch o.Type { @@ -402,11 +468,12 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders } 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 { o.Type = types.OrderTypeLimit - if sellPrice.Compare(o.Price) <= 0 { + if o.Price.Compare(sellPrice) <= 0 { if o.Price.Compare(m.LastKLine.High) > 0 { o.Price = m.LastKLine.High } diff --git a/pkg/backtest/matching_test.go b/pkg/backtest/matching_test.go index 3b478bbce..d0e20d65c 100644 --- a/pkg/backtest/matching_test.go +++ b/pkg/backtest/matching_test.go @@ -151,17 +151,7 @@ func newKLine(symbol string, interval types.Interval, startTime time.Time, o, h, } } -func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { - 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)}, - }) - +func getTestMarket() types.Market { market := types.Market{ Symbol: "BTCUSDT", PricePrecision: 8, @@ -172,7 +162,98 @@ func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { MinAmount: fixedpoint.MustNewFromString("10.0"), 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{ Account: account, Market: market, diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index 1edb13968..3daa3a456 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -220,16 +220,38 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.orderExecutor.Bind() store, _ := session.MarketDataStore(s.Symbol) + standardIndicator, _ := session.StandardIndicatorSet(s.Symbol) s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} 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 { s.resistancePivot = &indicator.Pivot{IntervalWindow: s.BounceShort.IntervalWindow} s.resistancePivot.Bind(store) } - standardIndicator, _ := session.StandardIndicatorSet(s.Symbol) if s.BreakLow.StopEMA != nil { 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:] } + 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 if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() { 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)) - 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 } } - 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) 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) { _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) wg.Done() diff --git a/pkg/types/order.go b/pkg/types/order.go index a16de24db..993cd9a5a 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -271,7 +271,7 @@ func (o Order) String() string { 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.CreationTime.Time().Local().Format(time.RFC1123), orderID, @@ -280,8 +280,13 @@ func (o Order) String() string { o.Side, o.ExecutedQuantity.String(), o.Quantity.String(), - o.Price.String(), - o.Status) + o.Price.String()) + + if o.Type == OrderTypeStopLimit { + desc += " Stop @ " + o.StopPrice.String() + } + + return desc + " | " + string(o.Status) } // PlainText is used for telegram-styled messages From dec02296a36742416034288772d7a7235f0e655e Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 19:53:43 +0800 Subject: [PATCH 7/9] backtest-report: show order tag in the mark Signed-off-by: c9s --- apps/backtest-report/components/TradingViewChart.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/backtest-report/components/TradingViewChart.tsx b/apps/backtest-report/components/TradingViewChart.tsx index 0e14fca40..7c1e08046 100644 --- a/apps/backtest-report/components/TradingViewChart.tsx +++ b/apps/backtest-report/components/TradingViewChart.tsx @@ -181,6 +181,11 @@ const ordersToMarkers = (interval: string, orders: Array | void): Array | void): Array | void): Array Date: Mon, 27 Jun 2022 19:54:58 +0800 Subject: [PATCH 8/9] pivotshort: reformat code --- pkg/strategy/pivotshort/exit.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/strategy/pivotshort/exit.go b/pkg/strategy/pivotshort/exit.go index 735814995..7fe7fdc9c 100644 --- a/pkg/strategy/pivotshort/exit.go +++ b/pkg/strategy/pivotshort/exit.go @@ -3,10 +3,10 @@ package pivotshort import "github.com/c9s/bbgo/pkg/bbgo" type ExitMethod struct { - RoiStopLoss *RoiStopLoss `json:"roiStopLoss"` - ProtectionStopLoss *ProtectionStopLoss `json:"protectionStopLoss"` - RoiTakeProfit *RoiTakeProfit `json:"roiTakeProfit"` - LowerShadowTakeProfit *LowerShadowTakeProfit `json:"lowerShadowTakeProfit"` + RoiStopLoss *RoiStopLoss `json:"roiStopLoss"` + ProtectionStopLoss *ProtectionStopLoss `json:"protectionStopLoss"` + RoiTakeProfit *RoiTakeProfit `json:"roiTakeProfit"` + LowerShadowTakeProfit *LowerShadowTakeProfit `json:"lowerShadowTakeProfit"` CumulatedVolumeTakeProfit *CumulatedVolumeTakeProfit `json:"cumulatedVolumeTakeProfit"` } From 09e98eed82834fd7146a65da98e7d9a691fb69f1 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 27 Jun 2022 20:49:55 +0800 Subject: [PATCH 9/9] backtest: handle stop market and add test case Signed-off-by: c9s --- pkg/backtest/matching.go | 30 +++++++++++++++++++++++++++- pkg/backtest/matching_test.go | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go index 47695d52d..dd8f057a8 100644 --- a/pkg/backtest/matching.go +++ b/pkg/backtest/matching.go @@ -309,6 +309,21 @@ func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders [ var bidOrders []types.Order for _, o := range m.bidOrders { switch o.Type { + + case types.OrderTypeStopMarket: + // should we trigger the order + if o.StopPrice.Compare(price) <= 0 { + // not triggering it, put it back + bidOrders = append(bidOrders, o) + break + } + + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = price + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + case types.OrderTypeStopLimit: // should we trigger the order? if price.Compare(o.StopPrice) <= 0 { @@ -426,6 +441,18 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders for _, o := range m.askOrders { switch o.Type { + case types.OrderTypeStopMarket: + // should we trigger the order + if o.StopPrice.Compare(sellPrice) >= 0 { + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = sellPrice + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + askOrders = append(askOrders, o) + } + case types.OrderTypeStopLimit: // if the price is lower than the stop price // we should trigger the stop sell order @@ -458,7 +485,8 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders case types.OrderTypeStopMarket: // should we trigger the order - if sellPrice.Compare(o.StopPrice) <= 0 { + if o.StopPrice.Compare(sellPrice) >= 0 { + o.Type = types.OrderTypeMarket o.ExecutedQuantity = o.Quantity o.Price = sellPrice o.Status = types.OrderStatusFilled diff --git a/pkg/backtest/matching_test.go b/pkg/backtest/matching_test.go index d0e20d65c..269f52563 100644 --- a/pkg/backtest/matching_test.go +++ b/pkg/backtest/matching_test.go @@ -251,6 +251,43 @@ func TestSimplePriceMatching_StopLimitOrderSell(t *testing.T) { assert.Equal(t, stopOrder.Price, trades[0].Price) } +func TestSimplePriceMatching_StopMarketOrderSell(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.OrderTypeStopMarket, + 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.OrderTypeMarket, closedOrders[0].Type) + assert.Equal(t, fixedpoint.NewFromFloat(20990.0), trades[0].Price) +} + func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { account := getTestAccount() market := getTestMarket()