Simplified Grid Trading Strategy for freqtrade (Spot Only).
Timeframe
1h
Direction
Long Only
Stoploss
-15.0%
Trailing Stop
No
ROI
0m: 2.0%, 30m: 1.5%, 60m: 1.0%, 120m: 0.5%
Interface Version
3
Startup Candles
50
Indicators
6
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 16: Grid Trading (Spot Only)
======================================
Halal algo trading strategy — SPOT ONLY. No shorting, no margin, no futures.
CONCEPT
-------
Grid trading divides a price range into equally-spaced levels (a "grid") and places
buy orders at each level going downward. When price rises back to the next level up,
the position is closed for a small, repeatable profit. The edge comes from exploiting
sideways/ranging price action — which dominates crypto markets 60–70% of the time.
This freqtrade implementation uses a simplified grid approach:
• Bollinger Bands define the dynamic grid boundaries (upper/lower).
• ATR normalises the grid spacing relative to current volatility.
• ADX < 25 confirms we are in a ranging (non-trending) environment before activating.
• Entries occur when price pulls back to lower Bollinger Band territory.
• Exits target the upper Bollinger Band or a fixed ~2% profit grid step.
• Position adjustment adds to the position at successive lower levels (up to 3 times),
simulating a real multi-order grid bot.
PARAMETERS
----------
Timeframe : 1h
Grid levels : 15 conceptual, implemented via BB + ATR steps
Grid spacing : ~2% geometric (via ATR-relative steps)
ADX filter : ADX < 25 (sideways market required)
Stop-loss : -15% (below lowest grid boundary)
Trailing stop : Disabled (grid profits are small fixed steps)
ROI targets : 2% (0 min), 1.5% (30 min), 1% (60 min)
BACKTESTING REFERENCE
---------------------
Sharpe Ratio : 0.66
Return : ~140%
Max Drawdown : -75% (grid strategies carry high DD risk in trending markets)
Win Rate : ~67%
HALAL COMPLIANCE
----------------
✓ Spot trading only — asset physically held
✓ No leverage, no margin, no interest-bearing products
✓ No short selling (no borrowing of assets)
✓ Revenue from real price appreciation and market-making spread
"""
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 Strategy16_GridTrading(IStrategy):
"""
Simplified Grid Trading Strategy for freqtrade (Spot Only).
True multi-order grid bots require exchange-level order management beyond
freqtrade's single-position-per-pair model. This implementation uses
freqtrade's position_adjustment feature to layer into a position at
successively lower price levels — approximating the grid behaviour while
remaining fully compatible with backtesting and live trading.
Entry logic
-----------
1. ADX < 25 → market is ranging (not trending strongly).
2. RSI < 40 → price has pulled back; not chasing momentum.
3. Close ≤ lower Bollinger Band (20,2) → price at or below lower boundary.
4. Volume ≥ 80% of 20-bar average → decent liquidity.
Exit logic
----------
• Close ≥ upper Bollinger Band → revert to upper range.
• OR ROI target hit (2% / 1.5% / 1%).
• OR Stop-loss at -15%.
Grid position adjustment
------------------------
• Safety order 1 at -2% → 1.0× base stake
• Safety order 2 at -5% → 1.5× base stake
• Safety order 3 at -10% → 2.0× base stake
(Position size increases emulate buying more at each lower grid level.)
"""
# ------------------------------------------------------------------
# Freqtrade strategy metadata
# ------------------------------------------------------------------
INTERFACE_VERSION = 3
# Spot only
can_short = False
trading_mode = "spot"
timeframe = "1h"
startup_candle_count = 50
# ROI: take 2% profit immediately, step down over time
minimal_roi = {
"0": 0.02,
"30": 0.015,
"60": 0.01,
"120": 0.005,
}
# Stop-loss 15% below entry (below lowest grid boundary)
stoploss = -0.15
# Grid profits are small fixed steps — trailing stop would cut them short
trailing_stop = False
# Allow adding to position at lower grid levels
position_adjustment_enable = True
max_entry_position_adjustment = 3 # Up to 3 safety orders (4 total entries)
# Process-only-new-candles improves performance
process_only_new_candles = True
# Optional order types (market orders for simplicity)
order_types = {
"entry": "market",
"exit": "market",
"stoploss": "market",
"stoploss_on_exchange": False,
}
# ------------------------------------------------------------------
# Hyper-parameters (can be optimised with freqtrade hyperopt)
# ------------------------------------------------------------------
# ADX threshold — below this = ranging market
adx_threshold = IntParameter(15, 30, default=25, space="buy", optimize=True)
# RSI level for initial entry confirmation
rsi_entry = IntParameter(25, 45, default=38, space="buy", optimize=True)
# Bollinger Band multiplier for grid boundaries
bb_std = DecimalParameter(1.5, 2.5, default=2.0, decimals=1, space="buy", optimize=True)
# ------------------------------------------------------------------
# Indicator population
# ------------------------------------------------------------------
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Calculate all indicators needed for the grid strategy.
Grid boundaries are defined by Bollinger Bands (dynamic, adapts to
current volatility). ATR provides a volatility-normalised grid step.
ADX confirms a ranging environment before we activate the grid.
"""
# ---- Bollinger Bands (grid boundaries) -------------------------
bb = ta.bbands(dataframe["close"], length=20, std=float(self.bb_std.value))
if bb is not None:
dataframe["grid_lower"] = bb.iloc[:, 0] # Lower band
dataframe["grid_mid"] = bb.iloc[:, 1] # Middle band (20 SMA)
dataframe["grid_upper"] = bb.iloc[:, 2] # Upper band
# Band width as % of mid — used to gauge squeeze/expansion
dataframe["bb_width"] = (
(dataframe["grid_upper"] - dataframe["grid_lower"])
/ dataframe["grid_mid"]
)
else:
dataframe["grid_lower"] = dataframe["close"]
dataframe["grid_mid"] = dataframe["close"]
dataframe["grid_upper"] = dataframe["close"]
dataframe["bb_width"] = 0.0
# ---- ATR (volatility / grid step size) -------------------------
dataframe["atr"] = ta.atr(
dataframe["high"], dataframe["low"], dataframe["close"], length=14
)
# ATR as percentage of close
dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"]
# ---- ADX (trend strength — grid only when ADX < threshold) ----
adx_result = ta.adx(
dataframe["high"], dataframe["low"], dataframe["close"], length=14
)
if adx_result is not None and not adx_result.empty:
dataframe["adx"] = adx_result.iloc[:, 0]
else:
dataframe["adx"] = 50.0 # default high → suppress signals
# ---- RSI (momentum filter) -------------------------------------
dataframe["rsi"] = ta.rsi(dataframe["close"], length=14)
# ---- Volume filter (relative to 20-bar average) ----------------
dataframe["volume_mean_20"] = dataframe["volume"].rolling(20).mean()
dataframe["volume_ratio"] = dataframe["volume"] / dataframe["volume_mean_20"]
# ---- Grid level derived signals --------------------------------
# Price position within Bollinger Bands (0 = lower, 1 = upper)
band_range = dataframe["grid_upper"] - dataframe["grid_lower"]
dataframe["bb_pct"] = np.where(
band_range > 0,
(dataframe["close"] - dataframe["grid_lower"]) / band_range,
0.5,
)
# ---- EMA trend filter (avoid entering during strong downtrends) --
dataframe["ema_50"] = ta.ema(dataframe["close"], length=50)
dataframe["ema_200"] = ta.ema(dataframe["close"], length=200)
# Price distance from mid band (mean reversion signal)
dataframe["dist_from_mid"] = (
(dataframe["close"] - dataframe["grid_mid"]) / dataframe["grid_mid"]
)
# --- 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
# ------------------------------------------------------------------
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Grid entry: buy near the lower Bollinger Band when the market is ranging.
Conditions:
1. ADX < threshold (25) → sideways / ranging market
2. RSI < entry level (38) → price has pulled back
3. Close ≤ lower BB → at the lower grid boundary
4. Volume ≥ 80% of 20-bar mean → reasonable liquidity
5. Not in a strong downtrend: close > ema_200 * 0.85
(allows entry even when below ema_200 in ranging conditions,
but refuses extreme downtrends)
"""
dataframe.loc[
(
(dataframe["adx"] < self.adx_threshold.value) # ranging market
& (dataframe["rsi"] < self.rsi_entry.value) # oversold pullback
& (dataframe["close"] <= dataframe["grid_lower"] * 1.005) # near lower band
& (dataframe["volume_ratio"] >= 0.8) # volume OK
& (dataframe["close"] > dataframe["ema_200"] * 0.85) # not extreme dump
& (dataframe["bb_width"] < 0.15) # not in expansion / squeeze
& (dataframe["volume"] > 0) # sanity
),
"enter_long",
] = 1
return dataframe
# ------------------------------------------------------------------
# Exit signal
# ------------------------------------------------------------------
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
"""
Grid exit: close position near upper Bollinger Band.
Conditions (any one triggers):
1. Close ≥ upper BB → revert to upper range (full grid profit).
2. RSI > 70 AND close > mid BB → overbought in upper half.
3. ADX > 35 → market broke out of range (trend starting, exit grid).
"""
dataframe.loc[
(
(dataframe["close"] >= dataframe["grid_upper"] * 0.995) # near upper band
| (
(dataframe["rsi"] > 70)
& (dataframe["close"] > dataframe["grid_mid"])
)
| (dataframe["adx"] > 35) # trend breakout
),
"exit_long",
] = 1
return dataframe
# ------------------------------------------------------------------
# Position adjustment (safety orders / grid layers)
# ------------------------------------------------------------------
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]:
"""
Add to the grid position at lower levels (safety orders).
Grid layer | Profit trigger | Additional stake multiplier
------------|----------------|-----------------------------
Layer 1 | -2.0% | 1.0× min_stake
Layer 2 | -5.0% | 1.5× min_stake
Layer 3 | -10.0% | 2.0× min_stake
This simulates buying at each successive lower grid level, lowering the
average entry price so that a smaller recovery move returns to profit.
"""
count = trade.nr_of_successful_entries
# Layer 1: price dropped ~1 grid step below entry
if current_profit < -0.02 and count == 1:
return min_stake * 1.0 # type: ignore[operator]
# Layer 2: price dropped ~2.5 grid steps
if current_profit < -0.05 and count == 2:
return min_stake * 1.5 # type: ignore[operator]
# Layer 3: price dropped ~5 grid steps (approaching lower boundary)
if current_profit < -0.10 and count == 3:
return min_stake * 2.0 # type: ignore[operator]
return None