""" ================================================================================ 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 # # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━