Stochastic Oscillator Reversal — 4h SPOT
Timeframe
4h
Direction
Long Only
Stoploss
-3.0%
Trailing Stop
No
ROI
0m: 4.0%, 30m: 3.0%, 60m: 2.0%
Interface Version
3
Startup Candles
80
Indicators
4
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 11: Stochastic Oscillator Reversal
============================================
Type : Mean Reversion / Momentum (SPOT ONLY — Halal, no shorting)
Timeframe : 4h
Author : Crypto Halal Algo Trading Suite
DESCRIPTION
-----------
The Slow Stochastic Oscillator measures the position of the closing price
relative to the high-low range over a given period. This strategy uses the
%K/%D crossover in oversold territory to identify potential reversal points
in assets that are in a broader uptrend.
Unlike RSI which looks at price changes, Stochastic provides a normalized
score (0–100) of WHERE price closed relative to its recent range. Readings
below 20 indicate price closed near the bottom of its recent range (oversold);
readings above 80 indicate price closed near the top (overbought).
CORE LOGIC
----------
1. Trend Filter : Price must be ABOVE the 50-period EMA. This ensures we
only buy dips in an established uptrend. Stochastic
oversold signals in a downtrend are value traps.
2. Entry Trigger : %K crosses ABOVE %D while BOTH are below 20 (in oversold
zone). This is the classic Stochastic oversold crossover.
We add the additional requirement that %K must EXIT the
oversold zone (%K > 20) — confirming the reversal has
begun rather than just a brief crossing in deeply oversold
conditions.
3. Exit Signal : %K crosses BELOW %D while BOTH are in overbought zone
(above 80). This signals that upward momentum is exhausting.
4. EMA 50 as Bull/Bear Filter:
On each candle, if price is above EMA50, we're in bullish
territory and the oversold threshold (20) applies.
The strategy does not enter if price is below EMA50 regardless
of Stochastic readings.
PARAMETERS
----------
stoch_k : 14 (Fast %K period)
stoch_d : 3 (Signal %D smoothing)
stoch_smooth_k : 3 (Slow %K smoothing — creates Slow Stochastic)
ema_period : 50 (Trend filter)
oversold_level : 20
overbought_level: 80
STOCHASTIC FORMULA
------------------
Fast %K = (Close − Lowest Low_14) / (Highest High_14 − Lowest Low_14) × 100
Slow %K = SMA(Fast %K, smooth_k=3)
%D = SMA(Slow %K, d=3)
BACKTEST REFERENCE
------------------
Sharpe : 0.71
Return : 183%
Max DD : -79%
Win Rate : 54%
RISK WARNINGS
-------------
- Max drawdown of 79% indicates this strategy experiences severe losing
streaks. Position sizing must be conservative (1-2% risk per trade).
- Win rate of 54% means nearly half of trades are losers — winners must
be larger than losers on average for positive expectancy.
- In strong downtrends, Stochastic can remain oversold (below 20) for
extended periods. The EMA50 filter is essential to avoid this.
- Do not use this strategy on assets with low liquidity where Stochastic
gives many false signals.
HALAL COMPLIANCE
----------------
SPOT trading only. No leverage, no margin, no shorting, no futures/options.
"""
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
from pandas import DataFrame
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def crossed_above(s1: pd.Series, s2: pd.Series) -> pd.Series:
"""Returns True on the candle 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:
"""Returns True on the candle where s1 crosses below s2."""
return (s1 < s2) & (s1.shift(1) >= s2.shift(1))
# ---------------------------------------------------------------------------
# Strategy class
# ---------------------------------------------------------------------------
class Strategy11_Stochastic(IStrategy):
"""
Stochastic Oscillator Reversal — 4h SPOT
Entry : Stochastic %K crosses above %D in oversold zone (<20),
then %K exits oversold zone (> 20), price above 50 EMA.
Exit : Stochastic %K crosses below %D in overbought zone (>80).
Stop : -3% hard stop.
See module docstring for full details and risk warnings.
"""
INTERFACE_VERSION = 3
# ------------------------------------------------------------------
# Metadata
# ------------------------------------------------------------------
timeframe = "4h"
can_short = False # HALAL: long-only, SPOT only
# ------------------------------------------------------------------
# ROI — profit targets
# ------------------------------------------------------------------
minimal_roi = {
"0": 0.04, # 4% — take profit quickly
"30": 0.03, # 3% after 30 min (1 candle offset in minutes)
"60": 0.02, # 2% after 60 min
}
# ------------------------------------------------------------------
# Stoploss
# ------------------------------------------------------------------
stoploss = -0.03 # 3% tight stop
trailing_stop = False # No trailing; stochastic has clear exit signal
# ------------------------------------------------------------------
# Order settings
# ------------------------------------------------------------------
process_only_new_candles = True
startup_candle_count = 80 # EMA50 + Stochastic(14) + warmup
# ------------------------------------------------------------------
# Strategy parameters
# ------------------------------------------------------------------
stoch_k = 14
stoch_d = 3
stoch_smooth_k = 3
ema_period = 50
oversold_level = 20.0
overbought_level = 80.0
# ------------------------------------------------------------------
# Indicators
# ------------------------------------------------------------------
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Compute Slow Stochastic (%K, %D) and 50-period EMA.
Stochastic columns
------------------
slowk : Slow %K (smoothed with smooth_k=3)
slowd : %D signal line (SMA of slowk, period=3)
Trend column
------------
ema50 : 50-period EMA — bull/bear filter
Derived state columns
---------------------
stoch_was_oversold : %K and %D were both below 20 on the previous candle
This detects when we're "coming out of" oversold territory
stoch_crossover : %K crossed above %D (on any candle)
"""
# --- Slow Stochastic Oscillator ---
# pandas_ta.stoch returns a DataFrame with columns:
# STOCHk_{k}_{d}_{smooth_k} and STOCHd_{k}_{d}_{smooth_k}
stoch = ta.stoch(
dataframe["high"],
dataframe["low"],
dataframe["close"],
k=self.stoch_k,
d=self.stoch_d,
smooth_k=self.stoch_smooth_k,
)
if stoch is not None and not stoch.empty:
# Column names: STOCHk_14_3_3, STOCHd_14_3_3
dataframe["slowk"] = stoch.iloc[:, 0] # %K (slow)
dataframe["slowd"] = stoch.iloc[:, 1] # %D signal
else:
dataframe["slowk"] = np.nan
dataframe["slowd"] = np.nan
# --- 50 EMA (trend filter) ---
dataframe["ema50"] = ta.ema(dataframe["close"], length=self.ema_period)
# --- Derived state: were we in oversold territory last candle? ---
# Used to confirm the %K/%D crossover happened INSIDE oversold zone
dataframe["prev_k_oversold"] = dataframe["slowk"].shift(1) < self.oversold_level
dataframe["prev_d_oversold"] = dataframe["slowd"].shift(1) < self.oversold_level
# --- Stochastic %K crosses above %D ---
dataframe["k_cross_above_d"] = crossed_above(dataframe["slowk"], dataframe["slowd"])
# --- Stochastic %K crosses below %D ---
dataframe["k_cross_below_d"] = crossed_below(dataframe["slowk"], dataframe["slowd"])
# --- 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 logic
# ------------------------------------------------------------------
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Entry conditions — ALL must be true:
1. Price above EMA50 → bullish trend / uptrend (mandatory)
2. %K crossed above %D → momentum crossover signal
3. Both %K and %D were < 20 → crossover occurred inside oversold zone
on the previous candle → not just any crossover
4. Current %K > 20 → %K has now EXITED the oversold zone,
confirming the reversal is underway
5. Volume > 0 → liquid candle
Rationale for condition 4 (exit from oversold zone):
Without this condition we might enter as %K and %D first cross inside
the oversold zone, only for price to continue dropping. Waiting for
%K to rise above 20 adds one-candle confirmation at the cost of slightly
later entry.
"""
conditions = (
(dataframe["close"] > dataframe["ema50"]) & # 1. Uptrend
dataframe["k_cross_above_d"] & # 2. Crossover
dataframe["prev_k_oversold"] & # 3a. Was oversold
dataframe["prev_d_oversold"] & # 3b. Both lines oversold
(dataframe["slowk"] > self.oversold_level) & # 4. Exited oversold
(dataframe["volume"] > 0) # 5. Liquid
)
dataframe.loc[conditions, "enter_long"] = 1
return dataframe
# ------------------------------------------------------------------
# Exit logic
# ------------------------------------------------------------------
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Exit conditions:
Primary: %K crosses BELOW %D while BOTH are in overbought zone (> 80).
This is the classic overbought exhaustion signal.
The ROI table provides time-based profit targets and the stoploss
(-3%) handles failed reversals.
"""
# Both lines must be in overbought territory when the cross occurs
both_overbought = (
(dataframe["slowk"] > self.overbought_level) &
(dataframe["slowd"] > self.overbought_level)
)
# Also check that previous candle was overbought (confirmation)
prev_both_overbought = (
(dataframe["slowk"].shift(1) > self.overbought_level) &
(dataframe["slowd"].shift(1) > self.overbought_level)
)
conditions = (
dataframe["k_cross_below_d"] &
(both_overbought | prev_both_overbought)
)
dataframe.loc[conditions, "exit_long"] = 1
return dataframe
# ------------------------------------------------------------------
# Custom exit — safety valve for stalled trades
# ------------------------------------------------------------------
def custom_exit(
self,
pair: str,
trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> Optional[str]:
"""
Time-based safety exit:
If the trade is more than 8 candles old (32 hours on 4h) and
shows less than 0.5% profit, exit. Stochastic reversals that
don't materialise within 8 candles are likely not going to
produce meaningful gains before the next signal.
"""
max_candles = 8
candle_hours = 4
max_hours = max_candles * candle_hours # 32 hours
trade_hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if trade_hours >= max_hours and current_profit < 0.005:
return f"stoch_timeout_{max_candles}c"
return None