@@ -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+
4249type 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
217261func (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
221275func (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+
617830func (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