Freqtrade Strategy

SmoothOperator

5m candlesMean reversionSpot, long only

Tested 1 Jun 2023 – 30 May 2025
Rank 25 of 27 by return

SmoothOperator turned $1,000 into $200 (−80.0%) over 729 days, while holding Bitcoin returned +288.1% and an equal mix of the ten coins +194.8%. Its worst drawdown was a severe 80.2%, deep enough that most people would have switched it off. With a 59% win rate and a profit factor of 0.80, losses outweighed gains.

SmoothOperator account balance compared with holding Bitcoin$0$1,000$2,000$3,000$4,000JulOctJan 2024AprJulOctJan 2025AprSmoothOperator $200BTC hold $3,880
Account balance from $1,000 (closed trades, daily). Dashed line: $1,000 held in BTC over the same days.
Total return−80.0%$200 final balance
Max drawdown80.2%4 Jun 2023 to 6 Apr 2025
Win rate58.9%1,182 wins / 824 losses
Profit factor0.80Gross wins ÷ gross losses
Trades2,0062.8 per day
Average trade−0.20%after fees
Average hold11h 20mwinners 8h 45m, losers 15h 1m
Annualised (CAGR)−55.3%Sharpe -4.16 · Sortino -4.93

How SmoothOperator trades

SmoothOperator looks for the end of a five-candle decline near the middle Bollinger Band with oversold CCI and RSI, plus a deeper-oversold variant. Exits combine a smoothed blend of MFI, RSI and CCI turning down with candle-pattern checks.

One of the more complex strategies in the library. More rules do not automatically mean better results; compare it with simpler mean-reversion systems such as BbandRsi.

Rules at a glance

Indicators
CCI, RSI, MFI, Bollinger Bands, MACD
Take-profit (minimal ROI)
10% from entry
Stoploss
−5%
Trailing stop
Off
Exit signal
Used

Results by month

Profit or loss in USDT for trades closed in each month.

JFMAMJJASOND2023-28-52-148-24+72+77-542024-124-18+12-165+24-66-107-38-41+15+43-22025+7-106-54-24

Results by coin

PairTradesWin rateProfit (USDT)Avg. hold
SOL/USDT22866.7%+48.8910h 29m
DOGE/USDT17366.5%+33.4310h 4m
BTC/USDT25461.8%−72.9812h 8m
BNB/USDT21554.0%−79.5212h 36m
AVAX/USDT14957.0%−84.8211h 48m
DOT/USDT14355.2%−98.0411h 4m
XRP/USDT22758.1%−100.5911h 46m
ETH/USDT26857.8%−111.1811h 8m
LINK/USDT16354.6%−147.3911h 27m
ADA/USDT18654.8%−187.5810h 25m

Why trades closed

Exit reasonTradesWin rateProfit (USDT)
Exit signal171468.6%+2,047.03
Take-profit (ROI)6100.0%+120.21
Stoploss2860.0%−2,967.02

The code

SmoothOperator comes from the community freqtrade-strategies repository (GPL-3.0). These are the parts that decide when it trades; the full file is 304 lines.

Entry rules
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
    dataframe.loc[
        (

            # protection against pump and dump
            #     (dataframe['volume'] < (dataframe['volume'].rolling(window=30).mean().shift(1) * 20))
            #
            #     & (dataframe['macd'] < dataframe['macdsignal'])
            #     & (dataframe['macd'] > 0)

            # # spike below entry band for 3 consecutive ticks
            # & (dataframe['low'] < dataframe['entry_bb_lowerband'])
            # & (dataframe['low'].shift(1) < dataframe['bb_lowerband'].shift(1))
            # & (dataframe['low'].shift(2) < dataframe['bb_lowerband'].shift(2))
            # # pattern recognition
            # & (
            #         (dataframe['close'] > dataframe['open'])
            #         | (dataframe['CDLHAMMER'] == 100)
            #         | (dataframe['CDLINVERTEDHAMMER'] == 100)
            #         | (dataframe['CDLDRAGONFLYDOJI'] == 100)
            # )
            # bottom curve detection
            # & (dataframe['mfi_rsi_cci_smooth'] < 0)
            #
            # |

            (
                # simple v bottom shape (lopsided to the left to increase reactivity)
                # which has to be below a very slow average
                # this pattern only catches a few, but normally very good buy points
                (
                        (dataframe['average'].shift(5) > dataframe['average'].shift(4))
                        & (dataframe['average'].shift(4) > dataframe['average'].shift(3))
                        & (dataframe['average'].shift(3) > dataframe['average'].shift(2))
                        & (dataframe['average'].shift(2) > dataframe['average'].shift(1))
                        & (dataframe['average'].shift(1) < dataframe['average'].shift(0))
                        & (dataframe['low'].shift(1) < dataframe['bb_middleband'])
                        & (dataframe['cci'].shift(1) < -100)
                        & (dataframe['rsi'].shift(1) < 30)

                )
                |
                # buy in very oversold conditions
                (
                        (dataframe['low'] < dataframe['bb_middleband'])
                        & (dataframe['cci'] < -200)
                        & (dataframe['rsi'] < 30)
                        & (dataframe['mfi'] < 30)
                )

                |
                # etc tends to trade like this
                # over very long periods of slowly building up coins
                # does not happen often, but once in a while
                (
                        (dataframe['mfi'] < 10)
                        & (dataframe['cci'] < -150)
                        & (dataframe['rsi'] < dataframe['mfi'])
                )

            )

            &
            # ensure we have an overall uptrend
            (dataframe['close'] > dataframe['close'].shift())
        ),
        'enter_long'] = 1

    return dataframe
Exit rules
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
    # different strategy used for sell points, due to be able to duplicate it to 100%
    dataframe.loc[
        (
            (
                #   This generates very nice sale points, and mostly sit's one stop behind
                #   the top of the peak
                (
                    (dataframe['mfi_rsi_cci_smooth'] > 100)
                    & (dataframe['mfi_rsi_cci_smooth'].shift(1) > dataframe['mfi_rsi_cci_smooth'])
                    & (dataframe['mfi_rsi_cci_smooth'].shift(2) < dataframe['mfi_rsi_cci_smooth'].shift(1))
                    & (dataframe['mfi_rsi_cci_smooth'].shift(3) < dataframe['mfi_rsi_cci_smooth'].shift(2))
                )
                |
                #   This helps with very long, sideways trends, to get out of a market before
                #   it dumps
                (
                    StrategyHelper.eight_green_candles(dataframe)
                )
                |
                # in case of very overbought market, like some one pumping
                # sell
                (
                    (dataframe['cci'] > 200)
                    & (dataframe['rsi'] > 70)
                )
            )

        ),
        'exit_long'] = 1
    return dataframe
Indicators
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
    ##################################################################################
    # required for entry and exit
    # CCI
    dataframe['cci'] = ta.CCI(dataframe, timeperiod=20)
    dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14)
    dataframe['adx'] = ta.ADX(dataframe)
    dataframe['mfi'] = ta.MFI(dataframe)
    dataframe['mfi_smooth'] = ta.EMA(dataframe, timeperiod=11, price='mfi')
    dataframe['cci_smooth'] = ta.EMA(dataframe, timeperiod=11, price='cci')
    dataframe['rsi_smooth'] = ta.EMA(dataframe, timeperiod=11, price='rsi')

    ##################################################################################
    # required for graphing
    bollinger = qtpylib.bollinger_bands(dataframe['close'], window=20, stds=2)
    dataframe['bb_lowerband'] = bollinger['lower']
    dataframe['bb_upperband'] = bollinger['upper']
    dataframe['bb_middleband'] = bollinger['mid']

    # MACD
    macd = ta.MACD(dataframe)
    dataframe['macd'] = macd['macd']
    dataframe['macdsignal'] = macd['macdsignal']
    dataframe['macdhist'] = macd['macdhist']

    ##################################################################################
    # required for entry
    bollinger = qtpylib.bollinger_bands(dataframe['close'], window=20, stds=1.6)
    dataframe['entry_bb_lowerband'] = bollinger['lower']
    dataframe['entry_bb_upperband'] = bollinger['upper']
    dataframe['entry_bb_middleband'] = bollinger['mid']

    dataframe['bpercent'] = (dataframe['close'] - dataframe['bb_lowerband']) / (
            dataframe['bb_upperband'] - dataframe['bb_lowerband']) * 100

    dataframe['bsharp'] = (dataframe['bb_upperband'] - dataframe['bb_lowerband']) / (
        dataframe['bb_middleband'])

    # these seem to be kind useful to measure when bands widen
    # but than they are directly based on the moving average
    dataframe['bsharp_slow'] = ta.SMA(dataframe, price='bsharp', timeperiod=11)
    dataframe['bsharp_medium'] = ta.SMA(dataframe, price='bsharp', timeperiod=8)
    dataframe['bsharp_fast'] = ta.SMA(dataframe, price='bsharp', timeperiod=5)

    ##################################################################################
    # rsi and mfi are slightly weighted
    dataframe['mfi_rsi_cci_smooth'] = (dataframe['rsi_smooth'] * 1.125 + dataframe['mfi_smooth'] * 1.125 +
                                       dataframe[
                                           'cci_smooth']) / 3

    dataframe['mfi_rsi_cci_smooth'] = ta.TEMA(dataframe, timeperiod=21, price='mfi_rsi_cci_smooth')

    # playgound
    dataframe['candle_size'] = (dataframe['close'] - dataframe['open']) * (
            dataframe['close'] - dataframe['open']) / 2

    # helps with pattern recognition
    dataframe['average'] = (dataframe['close'] + dataframe['open'] + dataframe['high'] + dataframe['low']) / 4
    dataframe['sma_slow'] = ta.SMA(dataframe, timeperiod=200, price='close')
    dataframe['sma_medium'] = ta.SMA(dataframe, timeperiod=100, price='close')
    dataframe['sma_fast'] = ta.SMA(dataframe, timeperiod=50, price='close')

    return dataframe

Reproduce this test

With freqtrade installed (see our Docker guide), download the data and run the same backtest:

freqtrade download-data --exchange binance --pairs ADA/USDT AVAX/USDT BNB/USDT BTC/USDT DOGE/USDT DOT/USDT ETH/USDT LINK/USDT SOL/USDT XRP/USDT --timeframes 5m --timerange 20230501-20250530
freqtrade backtesting --strategy SmoothOperator --timeframe 5m \
  --timerange 20230601-20250530 --dry-run-wallet 1000 --stake-amount 200 --max-open-trades 5 --fee 0.001

Results can differ slightly with a different data source or freqtrade version. See how we test.