DCA Bot Strategy using freqtrade's position_adjustment feature.
Timeframe
4h
Direction
Long Only
Stoploss
-35.0%
Trailing Stop
No
ROI
0m: 3.0%, 60m: 2.5%, 120m: 2.0%, 240m: 1.5%
Interface Version
3
Startup Candles
210
Indicators
7
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
# flake8: noqa: F401
"""
Strategy 17: DCA Bot Strategy
==============================
Halal algo trading strategy — SPOT ONLY. No shorting, no margin, no futures.
CONCEPT
-------
Dollar-Cost Averaging (DCA) — also called "martingale-lite" in crypto bot communities —
spreads an entry across multiple price levels. When price falls after the initial buy,
the bot places additional "safety orders" at pre-defined intervals below entry. This
lowers the average cost basis so a smaller price recovery is needed to reach profit.
Unlike raw martingale (which doubles stake indefinitely), this implementation uses
a capped 1.5× geometric scaling and a hard maximum of 4 safety orders, bounding
the maximum position size and protecting against unlimited capital drain.
PARAMETERS
----------
Timeframe : 4h
Base order trigger : RSI < 30 OR price below 50 EMA
Safety orders : 4 maximum
SO trigger levels : -2%, -6%, -14%, -30% from base entry
SO stake scaling : 1× / 1.5× / 2.25× / 3.375× (× base stake per SO)
Take profit : 2–3% above weighted average entry price
Stop-loss : -35% (wide — allows DCA to work before stopping)
Trailing stop : Disabled (let ROI targets close the trade)
SAFETY ORDER MECHANICS
-----------------------
SO1 at -2% → 1.000× base_stake → small top-up, price just pulled back
SO2 at -6% → 1.500× base_stake → moderate top-up, deeper retracement
SO3 at -14% → 2.250× base_stake → significant top-up, strong correction
SO4 at -30% → 3.375× base_stake → final safety net near bear territory
Maximum deployment: 1 + 1 + 1.5 + 2.25 + 3.375 = 9.125× base_stake
At 2% base_stake of portfolio → max exposure ≈ 18–20% of portfolio. ✓
BACKTEST REFERENCE (3Commas DCA community benchmarks)
------------------------------------------------------
Best case (ranging/bull) : Profitable and steady
Bear market caution : -30% SO4 trigger may not fire fast enough; use
conservative base_stake (1–2% of portfolio).
HALAL COMPLIANCE
----------------
✓ Spot trading only — asset physically held at each entry
✓ No leverage, no margin, no interest-bearing financing
✓ No short selling
✓ DCA profit comes from real price appreciation — permissible
✓ Wide stop-loss is patient, not speculative gambling
"""
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import pandas_ta as ta
from freqtrade.strategy import IStrategy, DecimalParameter, IntParameter
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def crossed_above(s1: pd.Series, s2: pd.Series) -> pd.Series:
"""Return True on the bar where s1 crosses above s2."""
return (s1 > s2) & (s1.shift(1) <= s2.shift(1))
def crossed_below(s1: pd.Series, s2: pd.Series) -> pd.Series:
"""Return True on the bar where s1 crosses below s2."""
return (s1 < s2) & (s1.shift(1) >= s2.shift(1))
# ---------------------------------------------------------------------------
# Strategy
# ---------------------------------------------------------------------------
class Strategy17_DCA_Bot(IStrategy):
"""
DCA Bot Strategy using freqtrade's position_adjustment feature.
Design rationale
----------------
The base order entry uses two triggers (RSI oversold OR price below 50 EMA)
to initiate only during pullbacks — not into strength. Safety orders fire
automatically via adjust_trade_position() as price declines further.
Each safety order:
• Lowers the average cost basis.
• Is scaled geometrically (1.5× multiplier) to have meaningful impact.
• Requires the previous SO to have already been placed (checked via
trade.nr_of_successful_entries).
Profit taking
-------------
The ROI table targets 3% at time 0, stepping down to 1.5% at 240 min.
In practice the DCA-lowered average cost means the trade may close at
ROI on a smaller price move than the raw entry price suggests — this is
the DCA edge.
Important configuration notes
-----------------------------
In config.json set:
"max_open_trades": 3 # Limit to 3 concurrent DCA positions
"stake_amount": "unlimited" # Or a fixed amount (e.g. 50 USDT base)
"tradable_balance_ratio": 0.25 # Use max 25% of portfolio per trade
The adjust_trade_position() method uses min_stake as the base unit for
scaling safety orders. Ensure min_stake is set consistently with your
exchange minimums.
"""
# ------------------------------------------------------------------
# Freqtrade strategy metadata
# ------------------------------------------------------------------
INTERFACE_VERSION = 3
# Spot only — no shorting
can_short = False
trading_mode = "spot"
timeframe = "4h"
startup_candle_count = 210
# ROI: target 3% at open, step down for longer holds
minimal_roi = {
"0": 0.03,
"60": 0.025,
"120": 0.02,
"240": 0.015,
}
# Wide stop-loss: DCA needs room to work; -35% is community consensus
# for a 4-SO bot with -30% deepest trigger
stoploss = -0.35
# No trailing stop — let the ROI table handle exits cleanly
trailing_stop = False
# ---- Position adjustment (core DCA mechanism) ----------------------
position_adjustment_enable = True
max_entry_position_adjustment = 4 # 4 safety orders beyond base order
process_only_new_candles = True
order_types = {
"entry": "market",
"exit": "market",
"stoploss": "market",
"stoploss_on_exchange": False,
}
# ------------------------------------------------------------------
# Hyper-parameters
# ------------------------------------------------------------------
# RSI threshold for base order entry
rsi_oversold = IntParameter(20, 35, default=30, space="buy", optimize=True)
# EMA length for trend baseline
ema_length = IntParameter(40, 60, default=50, space="buy", optimize=True)
# RSI for exit confirmation (overbought)
rsi_overbought = IntParameter(65, 80, default=70, space="sell", optimize=True)
# ------------------------------------------------------------------
# Indicator population
# ------------------------------------------------------------------
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Calculate RSI, EMAs, MACD, and volume indicators for DCA entries.
Indicators used
---------------
rsi_14 : Relative Strength Index (14-period) — oversold detection
ema_50 : 50-period EMA — medium-term trend baseline
ema_200 : 200-period EMA — long-term trend filter (avoid bear markets)
macd : MACD histogram — momentum confirmation
volume_ma : 20-bar volume MA — liquidity filter
"""
# ---- RSI -------------------------------------------------------
dataframe["rsi"] = ta.rsi(dataframe["close"], length=14)
# ---- EMAs ------------------------------------------------------
dataframe["ema_50"] = ta.ema(dataframe["close"], length=int(self.ema_length.value))
dataframe["ema_200"] = ta.ema(dataframe["close"], length=200)
dataframe["ema_21"] = ta.ema(dataframe["close"], length=21)
dataframe["ema_9"] = ta.ema(dataframe["close"], length=9)
# ---- MACD ------------------------------------------------------
macd = ta.macd(dataframe["close"], fast=12, slow=26, signal=9)
if macd is not None and not macd.empty:
dataframe["macd"] = macd.iloc[:, 0] # MACD line
dataframe["macd_signal"] = macd.iloc[:, 2] # Signal line
dataframe["macd_hist"] = macd.iloc[:, 1] # Histogram
else:
dataframe["macd"] = 0.0
dataframe["macd_signal"] = 0.0
dataframe["macd_hist"] = 0.0
# ---- Bollinger Bands (additional context) ----------------------
bb = ta.bbands(dataframe["close"], length=20, std=2.0)
if bb is not None and not bb.empty:
dataframe["bb_lower"] = bb.iloc[:, 0]
dataframe["bb_mid"] = bb.iloc[:, 1]
dataframe["bb_upper"] = bb.iloc[:, 2]
else:
dataframe["bb_lower"] = dataframe["close"]
dataframe["bb_mid"] = dataframe["close"]
dataframe["bb_upper"] = dataframe["close"]
# ---- Volume ----------------------------------------------------
dataframe["volume_ma_20"] = dataframe["volume"].rolling(20).mean()
dataframe["volume_ratio"] = dataframe["volume"] / dataframe["volume_ma_20"]
# ---- ATR (for context / future hyperopt use) -------------------
dataframe["atr"] = ta.atr(
dataframe["high"], dataframe["low"], dataframe["close"], length=14
)
# ---- Stochastic RSI (additional momentum filter) ---------------
stoch_rsi = ta.stochrsi(dataframe["close"], length=14, rsi_length=14, k=3, d=3)
if stoch_rsi is not None and not stoch_rsi.empty:
dataframe["stochrsi_k"] = stoch_rsi.iloc[:, 0]
dataframe["stochrsi_d"] = stoch_rsi.iloc[:, 1]
else:
dataframe["stochrsi_k"] = 50.0
dataframe["stochrsi_d"] = 50.0
# --- NaN Safety: convert any None to NaN so pandas comparisons work ---
for col in dataframe.columns:
if col not in ['open', 'high', 'low', 'close', 'volume', 'date']:
try:
dataframe[col] = pd.to_numeric(dataframe[col], errors='coerce')
except (ValueError, TypeError):
pass
return dataframe
# ------------------------------------------------------------------
# Entry signal (base order)
# ------------------------------------------------------------------
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Base order entry trigger — initiate DCA position on pullbacks.
Entry fires when ANY of the following oversold conditions are met,
combined with a long-term trend filter to avoid entering in bear markets:
Condition A: RSI < 30 (significantly oversold on 4h timeframe)
Condition B: Price is below the 50 EMA (medium-term bearish pressure)
with RSI < 40 (some weakness, not extreme)
Condition C: Price below lower Bollinger Band (statistical extreme)
Long-term filter: Close > ema_200 * 0.80
→ Only take DCA bets if price hasn't fallen more than 20% below the
200 EMA. Below that level, DCA safety orders could become trapped
in a prolonged bear market.
Volume filter: volume_ratio >= 0.7
→ Ensure reasonable market participation (not illiquid wicks).
"""
condition_a = dataframe["rsi"] < self.rsi_oversold.value
condition_b = (
(dataframe["close"] < dataframe["ema_50"])
& (dataframe["rsi"] < 40)
)
condition_c = dataframe["close"] < dataframe["bb_lower"]
trend_filter = dataframe["close"] > dataframe["ema_200"] * 0.80
volume_filter = dataframe["volume_ratio"] >= 0.7
dataframe.loc[
(
(condition_a | condition_b | condition_c)
& trend_filter
& volume_filter
& (dataframe["volume"] > 0)
),
"enter_long",
] = 1
return dataframe
# ------------------------------------------------------------------
# Exit signal
# ------------------------------------------------------------------
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Exit signal — close the DCA position when recovery is confirmed.
Exit fires when:
1. RSI > overbought level (70) AND price is above 50 EMA (recovered)
2. OR EMA(9) crosses above EMA(21) from below (momentum turning up)
3. OR Price reaches the upper Bollinger Band (extended)
The ROI table will usually exit before these signals fire, especially
after safety orders have lowered the average cost basis significantly.
These signals act as a backstop for ROI misses.
"""
ema_cross_up = crossed_above(dataframe["ema_9"], dataframe["ema_21"])
dataframe.loc[
(
(
(dataframe["rsi"] > self.rsi_overbought.value)
& (dataframe["close"] > dataframe["ema_50"])
)
| ema_cross_up
| (dataframe["close"] >= dataframe["bb_upper"])
),
"exit_long",
] = 1
return dataframe
# ------------------------------------------------------------------
# Safety order placement (DCA position adjustment)
# ------------------------------------------------------------------
def adjust_trade_position(
self,
trade,
current_time: datetime,
current_rate: float,
current_profit: float,
min_stake: Optional[float],
max_stake: float,
current_entry_rate: float,
current_exit_rate: float,
current_entry_profit: float,
current_exit_profit: float,
**kwargs,
) -> Optional[float]:
"""
DCA safety order placement triggered by declining current_profit.
Safety order schedule
---------------------
SO | Trigger (profit) | Stake multiplier | Cumulative stake
---|------------------|------------------|----------------
1 | < -2% | 1.000× min_stake | 2.00× base
2 | < -6% | 1.500× min_stake | 3.50× base
3 | < -14% | 2.250× min_stake | 5.75× base
4 | < -30% | 3.375× min_stake | 9.125× base
The 1.5× geometric multiplier means each safety order has progressively
more buying power, pulling the average entry price down faster.
Parameters
----------
trade : Current open Trade object
current_profit : Unrealised profit as decimal (e.g. -0.05 = -5%)
min_stake : Minimum stake allowed by the exchange
max_stake : Maximum stake remaining in the wallet
trade.nr_of_successful_entries : How many buys have already been placed
Returns None if no safety order should be placed.
"""
count = trade.nr_of_successful_entries
# Guard: min_stake must be valid
if min_stake is None:
return None
# SO1: -2% trigger, 1.0× stake
if current_profit < -0.02 and count == 1:
so_stake = min_stake * 1.0
return min(so_stake, max_stake)
# SO2: -6% trigger, 1.5× stake
if current_profit < -0.06 and count == 2:
so_stake = min_stake * 1.5
return min(so_stake, max_stake)
# SO3: -14% trigger, 2.25× stake
if current_profit < -0.14 and count == 3:
so_stake = min_stake * 2.25
return min(so_stake, max_stake)
# SO4: -30% trigger, 3.375× stake (final safety net)
if current_profit < -0.30 and count == 4:
so_stake = min_stake * 3.375
return min(so_stake, max_stake)
return None