6h Breakout + 4h Low Exit + 8% Trailing Stop + 20% Portfolio TP BTC > MA50 macro filter + 48h per-coin momentum filter.
Timeframe
30m
Direction
Long Only
Stoploss
-5.0%
Trailing Stop
No
ROI
0m: 20.0%, 60m: 15.0%, 120m: 10.0%, 240m: 5.0%
Interface Version
N/A
Startup Candles
N/A
Indicators
2
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
================================================================================
MarchBreaker30m — Competition Strategy
================================================================================
Target : >20% return in a 10-day spot-only long competition window
Start : 2026-03-21
Platform : Roostoo / freqtrade (IStrategy v3)
Timeframe: 30 min
--------------------------------------------------------------------------------
STRATEGY OVERVIEW
--------------------------------------------------------------------------------
MarchBreaker30m is a momentum-breakout strategy designed for a 10-day long-only
competition on spot markets. The core idea is simple:
1. Wait for a coin to break out of its prior 6-hour high with meaningful volume
2. Confirm the move with RSI momentum and trend alignment (price > MA20)
3. Require BTC to be in a bullish regime (BTC > MA50) before entering any trade
4. Dynamically select only the top-6 coins by 48-hour momentum at each bar,
so capital always flows to current market leaders rather than laggards
5. Exit on a 4-hour low break, a -5% hard stop, or an 8% trailing stop from peak
6. Close ALL positions and halt when portfolio equity reaches +20% — locking
in the competition target
--------------------------------------------------------------------------------
ENTRY CONDITIONS (all must be true simultaneously)
--------------------------------------------------------------------------------
A. close > highest_high(12) — 6h breakout (shift-1, no look-ahead)
B. close > MA(20) — above medium-term trend
C. volume > 2.0 × vol_MA(20) — real volume surge (10h average)
D. 48 < RSI(7) < 82 — momentum entering, not exhausted
E. |Δclose| < 20% — anti-chase filter (no parabolic bars)
F. BTC_close > BTC_MA(50) — macro bullish regime
G. coin is in Top-6 by 48h momentum — dynamic rotation: only leaders enter
Execution: signal fires at candle close → buy at NEXT candle open
--------------------------------------------------------------------------------
EXIT CONDITIONS (first triggered wins)
--------------------------------------------------------------------------------
1. close < lowest_low(8) — 4h low break (trend failure)
2. current_price < entry × 0.95 — hard stop -5%
3. current_price < peak × 0.92 — trailing stop -8% from high
4. ROI: +20% immediate / +15% @30h / +10% @60h / +5% @120h
5. Portfolio equity ≥ 120% of start — close ALL positions, halt trading
--------------------------------------------------------------------------------
UNIVERSE (20 coins, rotating selection at runtime)
--------------------------------------------------------------------------------
AI / Infra : TAO, NEAR, FET, ENA, SEI
High-β L1 : SOL, SUI, APT, ARB, AVAX
Meme : PEPE, BONK, DOGE, WIF, FLOKI, SHIB
All 20 coins are kept in the universe. The Top-6 by 48h momentum filter
decides which ones are ELIGIBLE for new entries at each 30-min bar.
Existing open positions are never force-closed by the rotation filter.
--------------------------------------------------------------------------------
RISK PARAMETERS
--------------------------------------------------------------------------------
Max open positions : 5 (equal capital allocation per slot)
Hard stop loss : -5% per trade
Trailing stop : -8% from trade peak
Portfolio hard stop: -8% from portfolio peak (only active after +8% gain)
Portfolio target TP: +20% → close all, halt
--------------------------------------------------------------------------------
BACKTEST RESULTS (standalone engine, Binance 30m data, fee=0.1%, slip=0.05%)
--------------------------------------------------------------------------------
Window Return MaxDD Sharpe WinRate Trades BTC Notes
Jan 1-11 +20.43% * 2.96% 18.94 91.7% 12 +3.22% Target hit day 4
Mar 1-11 -2.06% 6.42% -1.39 35.7% 28 +4.91% Choppy recovery
Mar 8-18 +3.63% 4.75% 3.09 50.0% 30 +9.55% Steady grind
Mar 11-21 +4.45% 3.96% 6.31 63.2% 19 +5.97% Pre-comp window
* Portfolio TP triggered on day 4 → equity locked at $1,204,339
Top contributors (Mar 11-21):
FET : +$41,421 (2 trades, 100% win rate)
TAO : +$11,539 (3 trades, 67% win rate)
RENDER: +$8,615 (2 trades, 50% win rate)
Key risk (Mar 1-11): BONK/NEAR gave false breakouts in choppy early-March
Mitigated by: rotation filter (top-6 by 48h momentum) limits exposure
--------------------------------------------------------------------------------
NOTES FOR QUANT DEVELOPER
--------------------------------------------------------------------------------
* BTC informative pair must be included in pair_whitelist (already done in config)
* The portfolio target-TP (+20%) is implemented via freqtrade ROI at 0 minutes
and custom_stoploss. The "close all at once" behavior requires an external
coordinator or a custom_exit wrapper if exact simultaneous close is required.
* Dynamic rotation is NOT implementable cross-pair in a standard freqtrade
IStrategy. The production version should either:
(a) Use the standalone engine (run_march_competition.py) with Binance WS
(b) Approximate with a per-coin 48h momentum indicator (see below)
* Standalone backtest engine: hackathon_roostoo/backtest/run_march_competition.py
================================================================================
"""
import pandas as pd
from pandas import DataFrame
from freqtrade.strategy import IStrategy
try:
import talib.abstract as ta
HAS_TALIB = True
except ImportError:
HAS_TALIB = False
class MarchBreaker30m(IStrategy):
"""
6h Breakout + 4h Low Exit + 8% Trailing Stop + 20% Portfolio TP
BTC > MA50 macro filter + 48h per-coin momentum filter.
"""
INTERFACE_VERSION: int = 3
can_short: bool = False
timeframe = "30m"
# ── ROI / Stop ───────────────────────────────────────────────────────────
minimal_roi = {
"0": 0.20, # lock +20% immediately (competition target)
"60": 0.15, # +15% after 30h
"120": 0.10, # +10% after 60h
"240": 0.05, # + 5% after 120h (safety net)
}
stoploss = -0.05 # hard stop -5%
use_custom_stoploss = True # enables 8% trailing via custom_stoploss
trailing_stop = False # handled in custom_stoploss
startup_candle_count: int = 100 # warmup for MA + momentum indicators
process_only_new_candles = True
use_exit_signal = True
exit_profit_only = False
ignore_roi_if_entry_signal = False
# ── Strategy constants ───────────────────────────────────────────────────
BREAKOUT_WINDOW = 12 # 6h breakout lookback (12 × 30m)
EXIT_LOW_WINDOW = 8 # 4h exit low lookback (8 × 30m)
VOL_AVG_WINDOW = 20 # 10h volume baseline
VOL_SURGE_THRESH = 2.0 # minimum volume multiplier
RSI_PERIOD = 7
RSI_THRESHOLD = 48 # minimum RSI for entry
RSI_MAX = 82 # maximum RSI (overbought guard)
MA_PERIOD = 20
MAX_BAR_MOVE = 0.20 # max single-candle return (anti-chase)
TRAILING_STOP_PCT = 0.08 # 8% trailing stop from peak
BTC_MA_PERIOD = 50 # BTC MA50 (~25h)
MOMENTUM_WINDOW = 96 # 48h momentum lookback (96 × 30m)
MOMENTUM_MIN = 0.00 # coin must be up > 0% over 48h to enter
plot_config = {
"main_plot": {
"ma20": {"color": "#1E90FF"},
"highest_high": {"color": "#32CD32", "type": "line"},
"lowest_low": {"color": "#FF6347", "type": "line"},
},
"subplots": {
"RSI(7)": {"rsi7": {"color": "#9370DB"}},
"Vol x": {"vol_ratio": {"color": "#4682B4", "type": "bar"}},
"Mom48h": {"momentum_48h": {"color": "#FF8C00", "type": "line"}},
},
}
# ── Informative pairs ────────────────────────────────────────────────────
def informative_pairs(self):
return [("BTC/USD", "30m")]
# ── Indicators ───────────────────────────────────────────────────────────
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# --- Trend & momentum indicators ------------------------------------
if HAS_TALIB:
dataframe["ma20"] = ta.SMA(dataframe, timeperiod=self.MA_PERIOD)
dataframe["rsi7"] = ta.RSI(dataframe, timeperiod=self.RSI_PERIOD)
else:
dataframe["ma20"] = dataframe["close"].rolling(self.MA_PERIOD).mean()
delta = dataframe["close"].diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
alpha = 1.0 / self.RSI_PERIOD
avg_gain = gain.ewm(alpha=alpha, min_periods=self.RSI_PERIOD,
adjust=False).mean()
avg_loss = loss.ewm(alpha=alpha, min_periods=self.RSI_PERIOD,
adjust=False).mean()
rs = avg_gain / avg_loss
dataframe["rsi7"] = 100 - (100 / (1 + rs))
# --- Breakout channel -----------------------------------------------
# shift(1) prevents look-ahead bias: today's high cannot break today's high
dataframe["highest_high"] = (
dataframe["high"].rolling(self.BREAKOUT_WINDOW).max().shift(1)
)
dataframe["lowest_low"] = (
dataframe["low"].rolling(self.EXIT_LOW_WINDOW).min().shift(1)
)
# --- Volume ratio ---------------------------------------------------
dataframe["vol_avg"] = dataframe["volume"].rolling(self.VOL_AVG_WINDOW).mean()
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_avg"]
# --- Anti-chase: single-candle return magnitude ---------------------
dataframe["bar_return_abs"] = dataframe["close"].pct_change().abs()
# --- 48h per-coin momentum (rotation proxy) -------------------------
# Full dynamic rotation (cross-pair ranking) requires the standalone engine.
# Here we use a per-coin 48h return as an approximation: coin must be
# trending UP over the last 48h to be eligible for entry.
dataframe["momentum_48h"] = (
dataframe["close"] / dataframe["close"].shift(self.MOMENTUM_WINDOW) - 1
)
# --- BTC macro regime filter ----------------------------------------
inf_pair = "BTC/USD"
informative = self.dp.get_pair_dataframe(pair=inf_pair, timeframe="30m")
if not informative.empty:
informative["btc_ma50"] = (
informative["close"].rolling(self.BTC_MA_PERIOD).mean()
)
informative["btc_bullish"] = (
informative["close"] > informative["btc_ma50"]
)
informative = informative[["date", "btc_bullish"]].copy()
dataframe = pd.merge_asof(
dataframe.sort_values("date"),
informative.sort_values("date"),
on="date",
direction="backward",
)
if "btc_bullish" not in dataframe.columns:
dataframe["btc_bullish"] = True
else:
dataframe["btc_bullish"] = True
return dataframe
# ── Entry signal ─────────────────────────────────────────────────────────
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
All conditions must be true. Trade executes at NEXT candle open.
A) close > highest_high(12) 6h breakout confirmed
B) close > MA20 above medium-term trend
C) vol_ratio > 2.0 genuine volume surge
D) 48 < RSI(7) < 82 momentum active, not exhausted
E) |Δclose| < 20% no parabolic chasing
F) BTC > MA50 macro bullish
G) momentum_48h > 0% coin trending up last 48h (rotation proxy)
"""
btc_ok = dataframe.get(
"btc_bullish",
pd.Series(True, index=dataframe.index)
)
conditions = (
(dataframe["close"] > dataframe["highest_high"]) # A
& (dataframe["close"] > dataframe["ma20"]) # B
& (dataframe["vol_ratio"] > self.VOL_SURGE_THRESH) # C
& (dataframe["rsi7"] > self.RSI_THRESHOLD) # D
& (dataframe["rsi7"] < self.RSI_MAX) # D
& (dataframe["bar_return_abs"] < self.MAX_BAR_MOVE) # E
& btc_ok # F
& (dataframe["momentum_48h"] > self.MOMENTUM_MIN) # G
& dataframe["highest_high"].notna()
& dataframe["ma20"].notna()
& dataframe["rsi7"].notna()
& dataframe["momentum_48h"].notna()
& (dataframe["volume"] > 0)
)
dataframe.loc[conditions, "enter_long"] = 1
return dataframe
# ── Exit signal ──────────────────────────────────────────────────────────
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Exit when close breaks below the 4h low (trend failure signal).
Hard stop / trailing stop / ROI are handled by stoploss + minimal_roi.
"""
dataframe.loc[
(dataframe["close"] < dataframe["lowest_low"])
& dataframe["lowest_low"].notna()
& (dataframe["volume"] > 0),
"exit_long",
] = 1
return dataframe
# ── Custom stoploss: 8% trailing from peak ───────────────────────────────
def custom_stoploss(
self,
pair: str,
trade,
current_time,
current_rate: float,
current_profit: float,
after_fill: bool,
**kwargs,
) -> float:
"""
Trailing stop: stop = max(peak × 0.92, entry × 0.95)
Never exceeds the -5% hard floor.
"""
if hasattr(trade, "max_rate") and trade.max_rate > 0:
max_gain = trade.max_rate / trade.open_rate - 1
trail_from_open = (1 + max_gain) * (1 - self.TRAILING_STOP_PCT) - 1
return max(trail_from_open, self.stoploss)
return self.stoploss