Skip to content

Commit 2df62e0

Browse files
committed
xpremium: add EngulfingTakeProfit
1 parent d47b527 commit 2df62e0

File tree

1 file changed

+234
-5
lines changed

1 file changed

+234
-5
lines changed

pkg/strategy/xpremium/strategy.go

Lines changed: 234 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type backtestBidAsk struct {
3939
premiumAsk, premiumBid fixedpoint.Value
4040
}
4141

42+
type EngulfingTakeProfitConfig struct {
43+
Enabled bool `json:"enabled"`
44+
Interval types.Interval `json:"interval"`
45+
BodyMultiple fixedpoint.Value `json:"bodyMultiple"`
46+
BottomShadowMaxRatio fixedpoint.Value `json:"bottomShadowMaxRatio"`
47+
}
48+
4249
type Strategy struct {
4350
*common.Strategy
4451

@@ -80,6 +87,9 @@ type Strategy struct {
8087
// For long: stop = prevLow * (1 - ratio); for short: stop = prevHigh * (1 + ratio)
8188
StopLossSafetyRatio fixedpoint.Value `json:"stopLossSafetyRatio"`
8289

90+
// EngulfingTakeProfit is an optional take-profit rule triggered by 1h Engulfing pattern
91+
EngulfingTakeProfit *EngulfingTakeProfitConfig `json:"engulfingTakeProfit,omitempty"`
92+
8393
BacktestConfig *BacktestConfig `json:"backtest,omitempty"`
8494

8595
logger logrus.FieldLogger
@@ -177,6 +187,31 @@ func (s *Strategy) Defaults() error {
177187
s.StopLossSafetyRatio = fixedpoint.NewFromFloat(0.01)
178188
}
179189

190+
// defaults for engulfing take profit
191+
if s.EngulfingTakeProfit == nil {
192+
// initialize with sensible defaults; remains disabled unless explicitly enabled
193+
s.EngulfingTakeProfit = &EngulfingTakeProfitConfig{
194+
Enabled: false,
195+
Interval: types.Interval1h,
196+
BodyMultiple: fixedpoint.NewFromFloat(1.0),
197+
BottomShadowMaxRatio: fixedpoint.Zero, // disabled by default
198+
}
199+
} else {
200+
if s.EngulfingTakeProfit.Interval == "" {
201+
s.EngulfingTakeProfit.Interval = types.Interval1h
202+
}
203+
204+
if s.EngulfingTakeProfit.BodyMultiple.IsZero() {
205+
// default: at least the same size as previous body
206+
s.EngulfingTakeProfit.BodyMultiple = fixedpoint.NewFromFloat(1.0)
207+
}
208+
209+
// default bottom shadow max ratio to 0 (disabled) if not provided or negative
210+
if s.EngulfingTakeProfit.BottomShadowMaxRatio.Sign() < 0 {
211+
s.EngulfingTakeProfit.BottomShadowMaxRatio = fixedpoint.Zero
212+
}
213+
}
214+
180215
return nil
181216
}
182217

@@ -211,11 +246,30 @@ func (s *Strategy) Validate() error {
211246
if s.StopLossSafetyRatio.Sign() < 0 {
212247
return fmt.Errorf("stopLossSafetyRatio must be >= 0")
213248
}
249+
250+
if s.EngulfingTakeProfit != nil {
251+
if s.EngulfingTakeProfit.BodyMultiple.Sign() < 0 {
252+
return fmt.Errorf("engulfingTakeProfit.bodyMultiple must be >= 0")
253+
}
254+
if s.EngulfingTakeProfit.BottomShadowMaxRatio.Sign() < 0 {
255+
return fmt.Errorf("engulfingTakeProfit.bottomShadowMaxRatio must be >= 0")
256+
}
257+
}
214258
return nil
215259
}
216260

217261
func (s *Strategy) CrossSubscribe(sessions map[string]*bbgo.ExchangeSession) {
262+
// subscribe klines for engulfing take-profit detection if enabled
263+
if s.EngulfingTakeProfit != nil && s.EngulfingTakeProfit.Enabled {
264+
interval := s.EngulfingTakeProfit.Interval
265+
if interval == "" {
266+
interval = types.Interval1h
267+
}
218268

269+
if session, ok := sessions[s.PremiumSession]; ok {
270+
session.Subscribe(types.KLineChannel, s.PremiumSymbol, types.SubscribeOptions{Interval: interval})
271+
}
272+
}
219273
}
220274

221275
func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, sessions map[string]*bbgo.ExchangeSession) error {
@@ -265,6 +319,14 @@ func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, se
265319
}
266320
}
267321

322+
// register engulfing take-profit handler on kline close
323+
if s.EngulfingTakeProfit != nil && s.EngulfingTakeProfit.Enabled {
324+
interval := s.EngulfingTakeProfit.Interval
325+
s.premiumSession.MarketDataStream.OnKLineClosed(types.KLineWith(s.PremiumSymbol, interval, func(k types.KLine) {
326+
s.maybeEngulfingTakeProfit(ctx, k)
327+
}))
328+
}
329+
268330
// allocate isolated public streams for books and bind StreamBooks
269331
premiumStream := bbgo.NewBookStream(s.premiumSession, s.PremiumSymbol)
270332
baseStream := bbgo.NewBookStream(s.baseSession, s.BaseSymbol)
@@ -578,8 +640,8 @@ func (s *Strategy) executeSignal(ctx context.Context, side types.SideType, now t
578640
return nil
579641
}
580642

581-
// maybeTakeProfit checks if the current position ROI reaches 3% and closes the position
582-
func (s *Strategy) maybeTakeProfit(ctx context.Context, latestPrice fixedpoint.Value) (bool, error) {
643+
// maybeRoiTakeProfit checks if the current position ROI reaches 3% and closes the position
644+
func (s *Strategy) maybeRoiTakeProfit(ctx context.Context, latestPrice fixedpoint.Value) (bool, error) {
583645
if s.Position == nil || s.OrderExecutor == nil {
584646
return false, nil
585647
}
@@ -614,6 +676,157 @@ func (s *Strategy) maybeTakeProfit(ctx context.Context, latestPrice fixedpoint.V
614676
return false, nil
615677
}
616678

679+
// isBearishEngulfing checks if current (c) kline forms a bearish engulfing over previous (p)
680+
// Conditions:
681+
// 1) c is bearish (c.Close < c.Open)
682+
// 2) Body(c) >= Body(p) * cfg.BodyMultiple (if BodyMultiple == 0, skip this check)
683+
// 3) c.Close < p.Low (close below previous low)
684+
// 4) bottom shadow ratio of c <= cfg.BottomShadowMaxRatio (if > 0)
685+
func (s *Strategy) isBearishEngulfing(p, c types.KLine, cfg *EngulfingTakeProfitConfig) bool {
686+
if cfg == nil || !cfg.Enabled {
687+
return false
688+
}
689+
// ensure interval match if provided
690+
if cfg.Interval != "" && c.Interval != cfg.Interval {
691+
return false
692+
}
693+
694+
if !(c.GetClose().Compare(c.GetOpen()) < 0) {
695+
return false
696+
}
697+
698+
bodyPrev := p.GetClose().Sub(p.GetOpen()).Abs()
699+
bodyCurr := c.GetOpen().Sub(c.GetClose()).Abs()
700+
if cfg.BodyMultiple.Sign() > 0 {
701+
if bodyPrev.Sign() == 0 {
702+
return false
703+
}
704+
req := bodyPrev.Mul(cfg.BodyMultiple)
705+
if bodyCurr.Compare(req) < 0 {
706+
return false
707+
}
708+
}
709+
if !(c.GetClose().Compare(p.GetLow()) < 0) {
710+
return false
711+
}
712+
713+
if cfg.BottomShadowMaxRatio.Sign() > 0 {
714+
// bottom shadow ratio ~ (close - low) / close for bearish bar
715+
den := c.GetClose()
716+
if den.Sign() == 0 {
717+
return false
718+
}
719+
shadow := c.GetClose().Sub(c.GetLow()).Div(den)
720+
if shadow.Compare(cfg.BottomShadowMaxRatio) > 0 {
721+
return false
722+
}
723+
}
724+
725+
return true
726+
}
727+
728+
// isBullishEngulfing checks if current (c) kline forms a bullish engulfing over previous (p)
729+
// Conditions:
730+
// 1) c is bullish (c.Close > c.Open)
731+
// 2) Body(c) >= Body(p) * cfg.BodyMultiple (if BodyMultiple == 0, skip this check)
732+
// 3) c.Close > p.High (close above previous high)
733+
// 4) optional upper shadow ratio check using cfg.BottomShadowMaxRatio as cap (if > 0)
734+
func (s *Strategy) isBullishEngulfing(p, c types.KLine, cfg *EngulfingTakeProfitConfig) bool {
735+
if cfg == nil || !cfg.Enabled {
736+
return false
737+
}
738+
if cfg.Interval != "" && c.Interval != cfg.Interval {
739+
return false
740+
}
741+
742+
if !(c.GetClose().Compare(c.GetOpen()) > 0) {
743+
return false
744+
}
745+
746+
bodyPrev := p.GetClose().Sub(p.GetOpen()).Abs()
747+
bodyCurr := c.GetClose().Sub(c.GetOpen()).Abs()
748+
if cfg.BodyMultiple.Sign() > 0 {
749+
if bodyPrev.Sign() == 0 {
750+
return false
751+
}
752+
req := bodyPrev.Mul(cfg.BodyMultiple)
753+
if bodyCurr.Compare(req) < 0 {
754+
return false
755+
}
756+
}
757+
if !(c.GetClose().Compare(p.GetHigh()) > 0) {
758+
return false
759+
}
760+
761+
if cfg.BottomShadowMaxRatio.Sign() > 0 {
762+
// use it as an upper shadow cap for bullish bar: (high - close) / close
763+
den := c.GetClose()
764+
if den.Sign() == 0 {
765+
return false
766+
}
767+
shadow := c.GetHigh().Sub(c.GetClose()).Div(den)
768+
if shadow.Compare(cfg.BottomShadowMaxRatio) > 0 {
769+
return false
770+
}
771+
}
772+
773+
return true
774+
}
775+
776+
// maybeEngulfingTakeProfit evaluates the last two klines and closes position if profitable
777+
func (s *Strategy) maybeEngulfingTakeProfit(ctx context.Context, k types.KLine) {
778+
if s.EngulfingTakeProfit == nil || !s.EngulfingTakeProfit.Enabled {
779+
return
780+
}
781+
782+
if s.Position == nil || s.OrderExecutor == nil || s.Position.GetBase().IsZero() {
783+
return
784+
}
785+
786+
// Only evaluate on configured interval's close
787+
interval := s.EngulfingTakeProfit.Interval
788+
if k.Interval != interval || !k.Closed {
789+
return
790+
}
791+
792+
// query last two klines ending at k.EndTime
793+
end := k.EndTime.Time()
794+
kLines, err := s.premiumSession.Exchange.QueryKLines(ctx, s.PremiumSymbol, interval, types.KLineQueryOptions{EndTime: &end, Limit: 2})
795+
if err != nil || len(kLines) < 2 {
796+
return
797+
}
798+
799+
prev := kLines[0]
800+
curr := kLines[1]
801+
if !curr.Closed {
802+
return
803+
}
804+
805+
matched := false
806+
pattern := ""
807+
if s.Position.IsLong() {
808+
matched = s.isBearishEngulfing(prev, curr, s.EngulfingTakeProfit)
809+
pattern = "bearish engulfing"
810+
} else if s.Position.IsShort() {
811+
matched = s.isBullishEngulfing(prev, curr, s.EngulfingTakeProfit)
812+
pattern = "bullish engulfing"
813+
}
814+
815+
if matched {
816+
latest := curr.GetClose()
817+
if latest.IsZero() {
818+
return
819+
}
820+
821+
roi := s.Position.ROI(latest)
822+
if roi.Sign() > 0 {
823+
s.logger.Infof("%s (%s) detected, ROI %s > 0, closing position to take profit", pattern, interval, roi.FormatPercentage(2))
824+
_ = s.OrderExecutor.GracefulCancel(ctx)
825+
_ = s.OrderExecutor.ClosePosition(ctx, fixedpoint.One, pattern)
826+
}
827+
}
828+
}
829+
617830
func (s *Strategy) premiumWorker(ctx context.Context) {
618831
log := s.logger.WithField("worker", "premium")
619832
var lastLog time.Time
@@ -642,7 +855,7 @@ func (s *Strategy) premiumWorker(ctx context.Context) {
642855
s.lastTPCheck = time.Now()
643856
if ticker, err := s.tradingSession.Exchange.QueryTicker(ctx, s.TradingSymbol); err == nil {
644857
price := ticker.GetValidPrice()
645-
if taken, err := s.maybeTakeProfit(ctx, price); err != nil {
858+
if taken, err := s.maybeRoiTakeProfit(ctx, price); err != nil {
646859
log.WithError(err).Warn("take-profit check error")
647860
} else if taken {
648861
// position closed; skip further processing this tick
@@ -785,6 +998,16 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {
785998
session.Subscribe(types.KLineChannel, s.PremiumSymbol, types.SubscribeOptions{Interval: "15m"})
786999
session.Subscribe(types.KLineChannel, s.PremiumSymbol, types.SubscribeOptions{Interval: "1h"})
7871000
session.Subscribe(types.KLineChannel, s.PremiumSymbol, types.SubscribeOptions{Interval: "1d"})
1001+
1002+
// subscribe klines for engulfing take-profit detection if enabled
1003+
if s.EngulfingTakeProfit != nil && s.EngulfingTakeProfit.Enabled {
1004+
interval := s.EngulfingTakeProfit.Interval
1005+
if interval == "" {
1006+
interval = types.Interval1h
1007+
}
1008+
1009+
session.Subscribe(types.KLineChannel, s.PremiumSymbol, types.SubscribeOptions{Interval: interval})
1010+
}
7881011
}
7891012

7901013
// Run is only used for back-testing with single session
@@ -829,13 +1052,19 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
8291052
tradingInterval = s.BacktestConfig.TradingInterval
8301053
}
8311054

1055+
session.MarketDataStream.OnKLineClosed(types.KLineWith(s.PremiumSymbol, s.EngulfingTakeProfit.Interval, func(k types.KLine) {
1056+
// engulfing take-profit check triggered by kline close events as well
1057+
s.maybeEngulfingTakeProfit(ctx, k)
1058+
}))
1059+
8321060
// subscribe to klines for time alignment; assume 1m unless different backtest interval
8331061
session.MarketDataStream.OnKLineClosed(types.KLineWith(s.TradingSymbol, tradingInterval, func(k types.KLine) {
1062+
8341063
// match kline time with csv time; prefer k.EndTime
8351064
if rec, ok := s.lookupBacktestAt(k.EndTime.Time().Add(time.Millisecond)); ok {
836-
// backtest take-profit using kline close
1065+
// backtest ROI take-profit using kline close
8371066
if s.Position != nil && !s.Position.GetBase().IsZero() {
838-
if taken, err := s.maybeTakeProfit(ctx, k.GetClose()); err != nil {
1067+
if taken, err := s.maybeRoiTakeProfit(ctx, k.GetClose()); err != nil {
8391068
s.logger.WithError(err).Warn("backtest take-profit error")
8401069
} else if taken {
8411070
return

0 commit comments

Comments
 (0)