5m cross-sectional meme rotation strategy for a 10-day hackathon. Fast capital recycling, frequent re-entry, aggressive take-profit.
Timeframe
5m
Direction
Long Only
Stoploss
-2.5%
Trailing Stop
No
ROI
0m: 4.0%, 30m: 2.5%, 60m: 1.5%, 90m: 0.8%
Interface Version
N/A
Startup Candles
N/A
Indicators
5
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
================================================================================
MemeRotationAggressive5m — Hackathon Alpha Module
================================================================================
Competition : Roostoo Spot-Only Long, 10-day window
Platform : freqtrade (IStrategy v3) — Roostoo Spot
Timeframe : 5m (primary) + 1h BTC informative
Universe : 7 meme coins (PEPE, BONK, DOGE, TRUMP, SHIB, WIF, FLOKI)
--------------------------------------------------------------------------------
CORE IDEA
--------------------------------------------------------------------------------
Cross-sectional memecoin rotation: at every 5m bar, prefer the coins showing
the strongest recent 1h/4h momentum. Since freqtrade runs one pair at a time,
true cross-pair ranking is approximated via per-coin absolute momentum thresholds
that act as a proxy for "being in the top leaders" of the meme universe.
Entry philosophy:
- Only enter coins with strong 1h + 4h momentum (universe leaders)
- Confirm with 1h breakout + volume surge + RSI + rolling VWAP + anti-chase
- BTC regime scales stake size; only hard risk-off blocks entries entirely
Exit philosophy:
- Lock fast profits: ROI at 4-6% is primary win mechanism
- 2% trailing stop from peak: protect gains after any move
- Custom exit: time stop (90 min no follow-through) and profit protection
- Hard stoploss: -3.5%
--------------------------------------------------------------------------------
APPROXIMATION DISCLAIMER
--------------------------------------------------------------------------------
True cross-sectional ranking (rank PEPE vs BONK vs DOGE in real-time) requires
an external engine (e.g. standalone bot with multi-pair WebSocket subscription).
This implementation uses per-coin momentum thresholds as an approximation:
- ret_1h > MOMENTUM_1H_THRESHOLD → coin is likely "a leader" this hour
- ret_4h > MOMENTUM_4H_THRESHOLD → coin is likely "a leader" this 4h
- momentum_score > MOMENTUM_MIN → composite confirmation
See the bottom of this file for an upgrade path to a true ranking engine.
--------------------------------------------------------------------------------
THREE VARIANT PARAMETER SETS (select via VARIANT class variable)
--------------------------------------------------------------------------------
"HIGH_FREQ" : max trade count, looser entry, tighter TP, fastest exits
"BALANCED" : default — best risk/reward blend for the hackathon
"MAX_RETURN" : fewest filters, aggressive TP ladder, accepts higher drawdown
================================================================================
"""
import numpy as np
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# VARIANT PARAMETER REGISTRY
# Select a variant by setting VARIANT = "BALANCED" | "HIGH_FREQ" | "MAX_RETURN"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
_VARIANTS: dict = {
# ── HIGH_FREQ: maximize trade count ─────────────────────────────────────
# Entry : looser momentum & RSI thresholds, smaller volume requirement
# Exit : very tight trailing (1.5%), quick time-stop at 45 min
# Sizing : up to 3 simultaneous positions, smaller individual stakes
#
# When to use: sideways BTC, want constant activity, fees < 10 bps/side
"HIGH_FREQ": dict(
minimal_roi={"0": 0.04, "30": 0.025, "60": 0.015, "90": 0.008},
stoploss=-0.025,
trailing_stop_pct=0.015, # 1.5% trail — exits fast
rsi_min=50,
rsi_max=76,
vol_surge=1.5,
momentum_1h=0.005, # very loose: any short-term upward drift
momentum_4h=0.008,
momentum_min_score=0.006,
anti_chase=0.06, # allow slightly larger entry candles
breakout_window=6, # 30-min breakout (shorter → more signals)
exit_low_window=4, # 20-min trend low (faster exit)
time_stop_bars=9, # exit at 45 min if no follow-through
rsi_exit_threshold=40,
max_open_trades_hint=3,
),
# ── BALANCED: default recommended for the 10-day competition ─────────────
# Entry : moderate thresholds, targeting 8-15 signals/day across universe
# Exit : 2% trail, 90-min time-stop
# Sizing : 2 simultaneous positions, full stake in bullish BTC regime
"BALANCED": dict(
minimal_roi={"0": 0.06, "30": 0.04, "60": 0.025, "90": 0.015},
stoploss=-0.035,
trailing_stop_pct=0.020,
rsi_min=55,
rsi_max=78,
vol_surge=1.8,
momentum_1h=0.010, # coin must be up 1%+ in last 1h
momentum_4h=0.015, # coin must be up 1.5%+ in last 4h
momentum_min_score=0.012,
anti_chase=0.05,
breakout_window=12, # 1h breakout (12 × 5m)
exit_low_window=6, # 30-min trend low
time_stop_bars=18, # exit at 90 min
rsi_exit_threshold=38,
max_open_trades_hint=2,
),
# ── MAX_RETURN: accept higher drawdown for explosive upside ──────────────
# Entry : strict momentum filter (only true leaders), allows bigger candles
# Exit : loose trailing (2.5%), hold up to 3h for full meme leg
# Sizing : 2 positions but 100% stake even in neutral BTC regime
#
# When to use: strong BTC uptrend, clear meme season in progress
"MAX_RETURN": dict(
minimal_roi={"0": 0.10, "60": 0.06, "120": 0.04, "180": 0.02},
stoploss=-0.040,
trailing_stop_pct=0.025,
rsi_min=58,
rsi_max=82,
vol_surge=2.0,
momentum_1h=0.020, # strict: coin must be up 2%+ in 1h
momentum_4h=0.030, # strict: coin must be up 3%+ in 4h
momentum_min_score=0.024,
anti_chase=0.08, # allow larger candles (big meme runs)
breakout_window=12,
exit_low_window=8, # 40-min trend low
time_stop_bars=36, # hold up to 3h for full meme leg
rsi_exit_threshold=35,
max_open_trades_hint=2,
),
}
class MemeRotationAggressive5m(IStrategy):
"""
5m cross-sectional meme rotation strategy for a 10-day hackathon.
Fast capital recycling, frequent re-entry, aggressive take-profit.
See module docstring for full design documentation.
Change VARIANT below to switch between parameter presets.
"""
INTERFACE_VERSION: int = 3
can_short: bool = False
timeframe = "5m"
# ── Select variant here ──────────────────────────────────────────────────
# Options: "BALANCED" | "HIGH_FREQ" | "MAX_RETURN"
VARIANT: str = "BALANCED"
# ── Static defaults (overridden at runtime from _VARIANTS[VARIANT]) ───────
# These must be class-level attributes so freqtrade initializes correctly.
# The actual values used come from populate_indicators / custom_* methods.
minimal_roi = {"0": 0.06, "30": 0.04, "60": 0.025, "90": 0.015}
stoploss = -0.035
use_custom_stoploss = True
trailing_stop = False # handled in custom_stoploss
startup_candle_count: int = 200 # warmup: 48 bars 4h momentum + BTC SMA50
process_only_new_candles = True
use_exit_signal = True
exit_profit_only = False
ignore_roi_if_entry_signal = False
# ── Shared constants (not variant-specific) ──────────────────────────────
VWAP_WINDOW = 48 # rolling VWAP window: 48 × 5m = 4h
VOL_AVG_WINDOW = 20 # volume baseline: 20 × 5m = 100 min
RSI_PERIOD = 7
BTC_SMA_PERIOD = 50 # BTC SMA on 1h candles = 50h SMA
BTC_RISK_OFF_MARGIN = 0.02 # BTC > 2% below SMA50 → risk-off (regime=0)
plot_config = {
"main_plot": {
"ema9": {"color": "#00CED1"},
"ema21": {"color": "#1E90FF"},
"vwap_roll": {"color": "#FFD700", "type": "line"},
"breakout_high": {"color": "#32CD32", "type": "line"},
},
"subplots": {
"RSI(7)": {"rsi7": {"color": "#9370DB"}},
"Vol Ratio": {"vol_ratio": {"color": "#4682B4", "type": "bar"}},
"Mom Score": {"momentum_score": {"color": "#FF8C00", "type": "line"}},
"Regime": {"btc_regime": {"color": "#DC143C", "type": "line"}},
},
}
# ── Informative pairs ─────────────────────────────────────────────────────
def informative_pairs(self):
# BTC/USD 1h: regime classification (bullish / neutral / risk-off)
return [("BTC/USD", "1h")]
# ── Indicators ────────────────────────────────────────────────────────────
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
p = _VARIANTS.get(self.VARIANT, _VARIANTS["BALANCED"])
# ── EMA fast/slow ─────────────────────────────────────────────────────
if HAS_TALIB:
dataframe["ema9"] = ta.EMA(dataframe, timeperiod=9)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["rsi7"] = ta.RSI(dataframe, timeperiod=self.RSI_PERIOD)
dataframe["atr14"] = ta.ATR(dataframe, timeperiod=14)
else:
dataframe["ema9"] = dataframe["close"].ewm(span=9, adjust=False).mean()
dataframe["ema21"] = dataframe["close"].ewm(span=21, adjust=False).mean()
# RSI (Wilder smoothing)
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.replace(0, np.nan)
dataframe["rsi7"] = 100 - (100 / (1 + rs))
# ATR: rolling mean of True Range
tr = pd.concat([
dataframe["high"] - dataframe["low"],
(dataframe["high"] - dataframe["close"].shift(1)).abs(),
(dataframe["low"] - dataframe["close"].shift(1)).abs(),
], axis=1).max(axis=1)
dataframe["atr14"] = tr.rolling(14).mean()
# ── Rolling VWAP (4h window) ──────────────────────────────────────────
# VWAP = sum(typical_price × volume) / sum(volume) over VWAP_WINDOW bars
# Used as intraday fair-value reference; price above VWAP = demand > supply
tp = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3.0
dataframe["vwap_roll"] = (
(tp * dataframe["volume"]).rolling(self.VWAP_WINDOW).sum()
/ dataframe["volume"].rolling(self.VWAP_WINDOW).sum()
)
# ── Volume ratio ──────────────────────────────────────────────────────
dataframe["vol_avg"] = dataframe["volume"].rolling(self.VOL_AVG_WINDOW).mean()
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_avg"]
# ── Breakout high (1h high, shift-1 = no look-ahead bias) ────────────
bw = p["breakout_window"]
dataframe["breakout_high"] = dataframe["high"].rolling(bw).max().shift(1)
# ── Exit low (short-term trend low) ───────────────────────────────────
el = p["exit_low_window"]
dataframe["exit_low"] = dataframe["low"].rolling(el).min().shift(1)
# ── Anti-chase: absolute candle body as fraction of open ──────────────
# Using body (|close - open|) not total range to avoid flagging wicks
dataframe["bar_body_pct"] = (
(dataframe["close"] - dataframe["open"]).abs() / dataframe["open"]
)
# ── Cross-sectional momentum APPROXIMATION ────────────────────────────
#
# NOTE: True cross-sectional ranking (rank PEPE vs BONK vs WIF at the
# same timestamp) is architecturally impossible inside a standard
# IStrategy — freqtrade calls populate_indicators per-pair in isolation.
#
# APPROXIMATION USED:
# We compute each coin's absolute 1h and 4h return, then apply a
# minimum threshold. A coin that clears both thresholds is — by
# definition — performing strongly in absolute terms, which correlates
# heavily with being in the top 2-3 of a 7-coin meme universe.
#
# ret_1h = close / close_12_bars_ago - 1 (12 × 5m = 60 min)
# ret_4h = close / close_48_bars_ago - 1 (48 × 5m = 240 min)
# score = 0.6 × ret_1h + 0.4 × ret_4h
#
# Threshold calibration: in a meme universe, the top-2 coins typically
# show >1% 1h return and >1.5% 4h return during active momentum.
# See _VARIANTS for per-variant threshold tuning.
#
# For perfect ranking → see UPGRADE PATH at bottom of file.
dataframe["ret_1h"] = dataframe["close"] / dataframe["close"].shift(12) - 1
dataframe["ret_4h"] = dataframe["close"] / dataframe["close"].shift(48) - 1
dataframe["momentum_score"] = (
0.6 * dataframe["ret_1h"] + 0.4 * dataframe["ret_4h"]
)
# ── BTC regime (0=risk-off, 1=neutral, 2=bullish) ─────────────────────
dataframe["btc_regime"] = self._compute_btc_regime(dataframe)
return dataframe
# ─────────────────────────────────────────────────────────────────────────
def _compute_btc_regime(self, dataframe: DataFrame) -> pd.Series:
"""
Pull BTC/USD 1h informative data and classify into 3 regimes:
2 = bullish : BTC close > SMA50 and 1h return ≥ -0.5%
1 = neutral : BTC close near SMA50 (between -2% and 0%)
0 = risk-off : BTC close < SMA50 × (1 - BTC_RISK_OFF_MARGIN)
Returns a Series of ints aligned to the input dataframe's index.
Falls back to regime=1 (neutral) if BTC data is unavailable.
"""
default = pd.Series(1, index=dataframe.index, dtype=int)
try:
btc = self.dp.get_pair_dataframe(pair="BTC/USD", timeframe="1h")
if btc is None or btc.empty:
return default
btc = btc.copy()
btc["sma50"] = btc["close"].rolling(self.BTC_SMA_PERIOD).mean()
btc["btc_ret1h"] = btc["close"].pct_change()
# Default neutral
btc["regime"] = 1
# Bullish: above SMA50 and not in sharp decline
btc.loc[
(btc["close"] > btc["sma50"])
& (btc["btc_ret1h"] >= -0.005),
"regime",
] = 2
# Risk-off: meaningfully below SMA50
btc.loc[
btc["close"] < btc["sma50"] * (1 - self.BTC_RISK_OFF_MARGIN),
"regime",
] = 0
btc_slim = btc[["date", "regime"]].dropna(subset=["regime"])
merged = pd.merge_asof(
dataframe[["date"]].sort_values("date"),
btc_slim.sort_values("date"),
on="date",
direction="backward",
)
# Realign index after merge_asof resets it
merged.index = dataframe.index
return merged["regime"].fillna(1).astype(int)
except Exception:
return default
# ── Entry signal ──────────────────────────────────────────────────────────
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
All conditions must be true simultaneously. Trade executes at NEXT bar open.
A) Momentum leader proxy:
ret_1h > threshold AND ret_4h > threshold AND score > min
→ Approximates "coin is in the top 2-3 meme leaders right now"
B) 1h breakout: close > breakout_high (highest high of last 12 bars)
→ Confirms momentum with a price-level breakout
C) Above EMA21: close > ema21
→ Short-term trend is up
D) Above rolling VWAP: close > vwap_roll
→ Price above fair value = demand exceeds supply
E) Volume surge: vol_ratio > VOL_SURGE_THRESH
→ Real buying, not thin-air noise
F) RSI momentum: rsi_min < rsi7 < rsi_max
→ Entering momentum zone, not overbought yet
G) Anti-chase: bar_body_pct < anti_chase
→ Avoid buying the final euphoric candle of a blowoff
H) BTC regime: btc_regime >= 1
→ Block entries only in hard risk-off (btc_regime == 0)
→ In neutral regime, require 1.5× stronger momentum score
Re-entry: freqtrade allows re-entry by default when a position is closed.
Do NOT set ignore_buying_expired_candle_after to artificially suppress it.
"""
p = _VARIANTS.get(self.VARIANT, _VARIANTS["BALANCED"])
# BTC risk-off → block all entries
btc_ok = dataframe["btc_regime"] >= 1
# In neutral BTC regime, tighten momentum requirement (1.5× threshold)
# In bullish BTC regime, use standard thresholds
regime_momentum_ok = (
(
(dataframe["btc_regime"] == 1) # neutral
& (dataframe["momentum_score"] > p["momentum_min_score"] * 1.5)
)
| (dataframe["btc_regime"] == 2) # bullish
)
conditions = (
# A — Cross-sectional leader proxy (approximation)
(dataframe["ret_1h"] > p["momentum_1h"])
& (dataframe["ret_4h"] > p["momentum_4h"])
& (dataframe["momentum_score"] > p["momentum_min_score"])
# B — Breakout confirmation (price breaks prior 1h high)
& (dataframe["close"] > dataframe["breakout_high"])
# C — Above EMA21 (short-term trend)
& (dataframe["close"] > dataframe["ema21"])
# D — Above rolling VWAP (fair value)
& (dataframe["close"] > dataframe["vwap_roll"])
# E — Volume surge (genuine demand)
& (dataframe["vol_ratio"] > p["vol_surge"])
# F — RSI in momentum zone
& (dataframe["rsi7"] > p["rsi_min"])
& (dataframe["rsi7"] < p["rsi_max"])
# G — Anti-chase: body not too extended
& (dataframe["bar_body_pct"] < p["anti_chase"])
# H — BTC regime filter
& btc_ok
& regime_momentum_ok
# Data quality guards
& dataframe["breakout_high"].notna()
& dataframe["ema21"].notna()
& dataframe["vwap_roll"].notna()
& dataframe["rsi7"].notna()
& dataframe["ret_1h"].notna()
& dataframe["ret_4h"].notna()
& (dataframe["volume"] > 0)
)
dataframe.loc[conditions, "enter_long"] = 1
return dataframe
# ── Exit signal ────────────────────────────────────────────────────────────
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Signal exit on momentum failure (any one condition sufficient):
1. Close breaks below the short-term trend low (exit_low)
→ Trend has reversed; the breakout leg has failed
2. RSI drops below rsi_exit_threshold
→ Momentum has evaporated; no point holding dead weight
3. Price crosses below EMA9 on a bar-by-bar basis
→ Fast trend reversal, exit before it deepens
Additional exits handled by custom_exit (time stop, profit protection)
and by freqtrade internals (minimal_roi, custom_stoploss).
"""
p = _VARIANTS.get(self.VARIANT, _VARIANTS["BALANCED"])
exit_cond = (
(
# 1. Trend low break
(dataframe["close"] < dataframe["exit_low"])
# 2. RSI collapse
| (dataframe["rsi7"] < p["rsi_exit_threshold"])
# 3. EMA9 cross-under (bar-by-bar)
| (
(dataframe["close"] < dataframe["ema9"])
& (dataframe["close"].shift(1) >= dataframe["ema9"].shift(1))
)
)
& dataframe["exit_low"].notna()
& dataframe["ema9"].notna()
& (dataframe["volume"] > 0)
)
dataframe.loc[exit_cond, "exit_long"] = 1
return dataframe
# ── Custom stoploss: trailing from trade peak ─────────────────────────────
def custom_stoploss(
self,
pair: str,
trade,
current_time,
current_rate: float,
current_profit: float,
after_fill: bool,
**kwargs,
) -> float:
"""
Trailing stop below the highest price seen since entry.
Mechanics:
stop = max_rate × (1 - trail_pct)
= open_rate × (1 + max_gain) × (1 - trail_pct) — expressed as %
The stop NEVER moves below the initial hard stoploss floor.
BTC risk-off tightening:
If BTC drops into risk-off (close < SMA50 × 0.98), tighten the
trailing stop by 40% so we exit faster and preserve capital.
"""
p = _VARIANTS.get(self.VARIANT, _VARIANTS["BALANCED"])
trail_pct = p["trailing_stop_pct"]
# Tighten trail in risk-off conditions
try:
btc = self.dp.get_pair_dataframe(pair="BTC/USD", timeframe="1h")
if btc is not None and not btc.empty:
last_close = float(btc["close"].iloc[-1])
last_sma = float(
btc["close"].rolling(self.BTC_SMA_PERIOD).mean().iloc[-1]
)
if last_close < last_sma * (1 - self.BTC_RISK_OFF_MARGIN):
trail_pct = trail_pct * 0.6 # 40% tighter in risk-off
except Exception:
pass
if (
hasattr(trade, "max_rate")
and trade.max_rate > 0
and trade.open_rate > 0
):
max_gain = trade.max_rate / trade.open_rate - 1
stop_from_open = (1 + max_gain) * (1 - trail_pct) - 1
return max(stop_from_open, self.stoploss)
return self.stoploss
# ── Custom exit: time stop + profit protection ────────────────────────────
def custom_exit(
self,
pair: str,
trade,
current_time,
current_rate: float,
current_profit: float,
**kwargs,
):
"""
Two supplementary exit rules not coverable by populate_exit_trend:
1. TIME STOP
If the trade has been open for TIME_STOP_BARS × 5m AND has not
achieved at least 0.5% profit, the catalyst has faded.
Exit and recycle capital into the next leader.
Rationale: meme coin momentum moves happen fast or not at all.
A trade that has not moved after 45-90 min is dead weight.
2. PROFIT PROTECTION
If we ever reached 3%+ unrealised profit but have since fallen back
below 1%, exit immediately — the trailing stop may lag the candle.
This acts as a faster-responding secondary trailing exit.
"""
p = _VARIANTS.get(self.VARIANT, _VARIANTS["BALANCED"])
# Elapsed time in 5m bars
bars_open = int(
(current_time - trade.open_date_utc).total_seconds() / 300
)
# 1. Time stop: stuck trade with no follow-through
if bars_open >= p["time_stop_bars"] and current_profit < 0.005:
return "time_stop"
# 2. Profit protection: winner that reversed hard
if (
hasattr(trade, "max_rate")
and trade.max_rate > 0
and trade.open_rate > 0
):
max_profit = trade.max_rate / trade.open_rate - 1
if max_profit >= 0.03 and current_profit < 0.010:
return "profit_protection"
return None
# ── Custom stake amount: regime-based position sizing ─────────────────────
def custom_stake_amount(
self,
current_time,
current_rate: float,
proposed_stake: float,
min_stake,
max_stake: float,
leverage: float,
entry_tag,
side: str,
**kwargs,
) -> float:
"""
Scale position size based on BTC regime to manage portfolio risk:
regime 2 (bullish) → 100% of proposed_stake
regime 1 (neutral) → 70% of proposed_stake
regime 0 (risk-off) → 50% of proposed_stake
(entries are blocked by entry signal in risk-off,
but this acts as a second line of defence)
The net effect: maximum capital is deployed only when BTC is trending up.
In sideways or weak BTC, smaller bets protect against false breakouts.
"""
scale_map = {2: 1.00, 1: 0.70, 0: 0.50}
try:
btc = self.dp.get_pair_dataframe(pair="BTC/USD", timeframe="1h")
if btc is None or btc.empty:
return proposed_stake
last_close = float(btc["close"].iloc[-1])
last_sma = float(
btc["close"].rolling(self.BTC_SMA_PERIOD).mean().iloc[-1]
)
if last_close < last_sma * (1 - self.BTC_RISK_OFF_MARGIN):
regime = 0
elif last_close > last_sma:
regime = 2
else:
regime = 1
except Exception:
regime = 1
scale = scale_map.get(regime, 0.70)
result = proposed_stake * scale
if min_stake is not None:
result = max(result, min_stake)
return min(result, max_stake)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# UPGRADE PATH: True Cross-Sectional Ranking Engine
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# The fundamental limitation of freqtrade's IStrategy architecture is that
# populate_indicators / populate_entry_trend are called per-pair in isolation.
# There is no shared state between pair calls within a single decision tick.
#
# To implement TRUE cross-sectional ranking (rank PEPE vs BONK vs WIF at the
# same timestamp and only buy top-2), use one of these approaches:
#
# Option A: Shared state file / Redis
# - Run a separate "ranker" process that subscribes to all meme pair streams
# - At each 5m close: compute score for all 7 pairs, write top-2 to a JSON file
# - In custom_stake_amount or custom_entry_price: read the rank file;
# if current pair is not in top-2, return 0 to skip the trade
# - Pros: accurate, fast; Cons: extra process, file locking
#
# Option B: Freqtrade ExternalMessageConsumer (Producer/Consumer mode)
# - Producer bot subscribes to all 7 meme pairs
# - At each bar, rank all pairs and emit signals only for top-N
# - Consumer bot receives signals and places trades
# - Fully supported in freqtrade v2023+
#
# Option C: Standalone ccxt engine (pattern: run_march_competition.py)
# - Pull all 7 pairs via Binance REST at each 5m close
# - Rank in-process, place market orders directly via ccxt
# - Best for production; bypasses freqtrade's per-pair limitation entirely
#
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━