Skip to content

Scale-In

Add to an existing position at a better price. replaybt supports two methods: DCA (limit order at a fixed dip) and signal-based (second entry on a new signal).

DCA Scale-In

Place a limit order to buy more if price dips after entry:

from replaybt import Strategy, MarketOrder, LimitOrder, Side, CancelPendingLimitsOrder


class DCAStrategy(Strategy):
    def configure(self, config):
        self._prev_rsi = None

    def on_bar(self, bar, indicators, positions):
        rsi = indicators.get("rsi")
        prev = self._prev_rsi
        self._prev_rsi = rsi
        if rsi is None or prev is None:
            return None

        if not positions and rsi < 25 and prev >= 25:
            return MarketOrder(
                side=Side.LONG,
                take_profit_pct=0.04,
                stop_loss_pct=0.05,
            )
        return None

    def on_fill(self, fill):
        if fill.is_entry:
            # Scale in at -0.5% from entry
            dip_price = fill.price * 0.995
            return LimitOrder(
                side=fill.side,
                limit_price=dip_price,
                merge_position=True,   # merge into existing position
                timeout_bars=120,      # cancel after 120 bars (2 hours)
                size_usd=fill.size_usd * 0.5,  # 50% of main size
            )
        return None

    def on_exit(self, fill, trade):
        # Cancel pending scale-in when position closes
        return CancelPendingLimitsOrder()

How merge_position Works

When merge_position=True, the limit order doesn't open a new position. Instead, it merges into the existing one:

  • Entry price becomes the weighted average of both fills
  • Position size increases
  • SL/TP levels recalculate from the new average entry

Timeout

timeout_bars=120 means the limit order is canceled after 120 bars if not filled. Set timeout_bars=0 for no timeout.

min_positions

Use min_positions=1 to ensure the limit order only fills when a position already exists:

LimitOrder(
    side=fill.side,
    limit_price=dip_price,
    merge_position=True,
    min_positions=1,  # only fill if at least 1 position exists
)

Signal-Based Scale-In

Enter a second position on a new RSI signal in the same direction:

class SignalScaleIn(Strategy):
    def configure(self, config):
        self._prev_rsi = None

    def on_bar(self, bar, indicators, positions):
        rsi = indicators.get("rsi")
        prev = self._prev_rsi
        self._prev_rsi = rsi
        if rsi is None or prev is None:
            return None

        if rsi < 25 and prev >= 25:
            if not positions:
                return MarketOrder(
                    side=Side.LONG,
                    take_profit_pct=0.04,
                    stop_loss_pct=0.05,
                )
            # Already have a position — scale in with another signal
            elif len(positions) == 1 and positions[0].is_long:
                return MarketOrder(
                    side=Side.LONG,
                    take_profit_pct=0.04,
                    stop_loss_pct=0.05,
                    size_usd=5000,  # smaller second entry
                )
        return None

Note

For signal-based scale-in with max_positions=1, you need merge_position=True on a LimitOrder instead, or increase max_positions in the engine config.

Engine Config for Scale-In

config = {
    "initial_equity": 10_000,
    "max_positions": 1,       # merge into single position
    "default_size_usd": 10_000,
}

With max_positions=1 and merge_position=True, the engine merges additional fills into the existing position rather than rejecting them.