""" BTC Maker Scalper v1.1 — Liquidity Providing 相对 v1.0 的核心变化: - 不再用 OBI/Delta/CVD 预测下一根涨跌(Directional Scalping) - 改为:卖压衰竭 + Bid 吸收 → 提供流动性接单(Liquidity Providing) - 挂单更深:Bid - 0~2 tick(等待被打) - 出场:盘口/价差优势恢复(非固定 0.05% TP) - 禁做市:5m EMA26 斜率过大 或 ATR 异常(单边趋势) 回测限制(仍然存在,但模型目标不同): - OHLCV 无法完美模拟 Maker 成交时点;本版用更严过滤降频到 ~10-30 笔/天量级做压力测试 - 实盘用 orderbook 复核吸收/挂价 运行: freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\ --strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\ --timerange=20260701-20260708 --fee 0.00016 --enable-protections """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Optional import numpy as np import pandas as pd import talib.abstract as ta from pandas import DataFrame from freqtrade.persistence import Trade from freqtrade.strategy import IStrategy logger = logging.getLogger(__name__) def _safe_div(num, den, fill=0.0): out = np.where((den is not None) & (den != 0), num / den, fill) return out class BTC_Maker_Micro_Scalper_v11(IStrategy): """ v1.1 Liquidity Providing:卖压衰竭 + 吸收 → Maker 接单;趋势中禁做市。 """ INTERFACE_VERSION: int = 3 timeframe = "1m" can_short = True process_only_new_candles = True startup_candle_count = 200 # 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复) # 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉 minimal_roi = {"0": 0.01} # 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复 stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察) trailing_stop = False use_exit_signal = True exit_profit_only = False use_custom_stoploss = False order_types = { "entry": "limit", "exit": "limit", "stoploss": "limit", "stoploss_on_exchange": False, } order_time_in_force = {"entry": "GTC", "exit": "GTC"} # ---- 费用 / 风控 ---- maker_fee = 0.00016 stake_pct = 0.005 max_leverage = 2.0 # v1.1 更克制 consecutive_loss_limit = 10 pause_minutes = 30 max_hold_minutes = 5 # ---- 微结构代理窗口(1m 近似 20s/100trades)---- sell_window = 3 # 近端卖量 sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭” absorb_lookback = 5 min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参) tick_size = 0.1 maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70% prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压) # ---- 禁做市(趋势)---- ema_slope_thr = 0.00018 # 更早禁止单边做市 atr_spike_mult = 1.8 min_atr_pct = 0.00035 # 目标退出:相对入场的“优势恢复”幅度(价格) edge_exit_pct = 0.00025 adverse_exit_pct = 0.0006 cooldown_minutes = 8 # 降频到验收带附近 ob_levels = 10 _loss_streak: int = 0 _pause_until: Optional[datetime] = None _last_entry_time: Optional[datetime] = None plot_config = { "main_plot": { "ema26_1m": {"color": "gray"}, }, "subplots": { "SellVol": { "sell_vol": {"color": "red"}, "sell_vol_ma": {"color": "orange"}, }, "Absorb": {"absorb_ratio": {"color": "blue"}}, "TrendBlock": {"trend_block": {"color": "black"}}, }, } def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: df = dataframe.copy() high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float) close_c = close.clip(lower=low, upper=high) hl = (high - low).astype(float) hl_safe = hl.where(hl > 0, np.nan) buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0) sell_frac = 1.0 - buy_frac buy_vol = volume * buy_frac sell_vol = volume * sell_frac df["buy_vol"] = buy_vol df["sell_vol"] = sell_vol df["delta"] = buy_vol - sell_vol # ---- A. 主动卖压衰竭(Long)---- # 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低 df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean() df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean() sell_baseline = sell_vol.rolling(30, min_periods=10).mean() df["sell_exhaust"] = ( (df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult) & (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio) & (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1)) ) # 主动买压衰竭(Short 对称) df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean() df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean() buy_baseline = buy_vol.rolling(30, min_periods=10).mean() df["buy_exhaust"] = ( (df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult) & (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio) & (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1)) ) # ---- B. Bid 吸收:成交卖量 / 价格跌幅 ---- # 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零) px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0) px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan) sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum() # absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大 df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace( [np.inf, -np.inf], np.nan ).fillna(0.0) # 价格几乎不跌但有大量卖出 → 吸收极强:给高分 flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median()) df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[ flat_sell.fillna(False), "absorb_ratio" ].clip(lower=self.min_absorb_ratio * 1.5) # Ask 吸收(Short):买量 / 上涨幅度 px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0) px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan) buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum() df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace( [np.inf, -np.inf], np.nan ).fillna(0.0) flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median()) df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[ flat_buy.fillna(False), "absorb_ratio_ask" ].clip(lower=self.min_absorb_ratio * 1.5) df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio # ---- 波动与 ATR ---- df["atr"] = ta.ATR(df, timeperiod=20) df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0) atr_med = df["atr_pct"].rolling(60, min_periods=20).median() df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult) df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"]) # ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)---- df["ema26_1m"] = ta.EMA(df, timeperiod=26) df["ema26_slope"] = ( (df["ema26_1m"] - df["ema26_1m"].shift(5)) / close ).replace([np.inf, -np.inf], np.nan).fillna(0.0) df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr # 微结构“可做市”综合 df["mm_regime"] = df["atr_ok"] & (~df["trend_block"]) # 中价 / 伪价差 df["mid"] = (high + low) / 2.0 df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0) return df def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: df = dataframe # Long:卖压衰竭 + Bid 吸收 + 非趋势 long_cond = ( df["mm_regime"] & df["sell_exhaust"] & df["bid_absorb"] & (df["volume"] > 0) # 额外:近端 delta 不再恶化(卖压减弱) & (df["delta"] > df["delta"].shift(1)) ) # Short:买压衰竭 + Ask 吸收 + 非趋势 short_cond = ( df["mm_regime"] & df["buy_exhaust"] & df["ask_absorb"] & (df["volume"] > 0) & (df["delta"] < df["delta"].shift(1)) ) df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb") df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb") return df def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """信号层只做趋势禁做市强平;主出场交给 custom_exit。""" df = dataframe df["exit_long"] = 0 df["exit_short"] = 0 df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block") df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block") return df # ------------------------------------------------------------------ # # Maker 报价:Bid - depth ticks / Ask + depth ticks # ------------------------------------------------------------------ # def custom_entry_price( self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float, entry_tag: str | None, side: str, **kwargs, ) -> float: offset = self.maker_depth_ticks * self.tick_size try: if self.dp and self.dp.runmode.value in ("live", "dry_run"): ob = self.dp.orderbook(pair, self.ob_levels) bids = ob.get("bids") or [] asks = ob.get("asks") or [] if side == "long" and bids: return max(float(bids[0][0]) - offset, self.tick_size) if side == "short" and asks: return float(asks[0][0]) + offset except Exception as e: logger.debug("v11 entry price ob fallback: %s", e) # 回测:挂得更深,降低“虚假即时成交”概率(仍不完美) if side == "long": return proposed_rate - offset return proposed_rate + offset def custom_exit_price( self, pair: str, trade: Trade, current_time: datetime, proposed_rate: float, current_profit: float, exit_tag: str | None, **kwargs, ) -> float: offset = 1 * self.tick_size try: if self.dp and self.dp.runmode.value in ("live", "dry_run"): ob = self.dp.orderbook(pair, self.ob_levels) bids = ob.get("bids") or [] asks = ob.get("asks") or [] # 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1 if trade.is_short and bids: return float(bids[0][0]) + offset if (not trade.is_short) and asks: return float(asks[0][0]) - offset except Exception as e: logger.debug("v11 exit price ob fallback: %s", e) if trade.is_short: return proposed_rate - offset return proposed_rate + offset def leverage( self, pair: str, current_time: datetime, current_rate: float, proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str, **kwargs, ) -> float: return min(self.max_leverage, float(max_leverage)) def custom_stake_amount( self, pair: str, current_time: datetime, current_rate: float, proposed_stake: float, min_stake: float | None, max_stake: float, leverage: float, entry_tag: str | None, side: str, **kwargs, ) -> float: try: if self.wallets: free = self.wallets.get_free(self.config["stake_currency"]) stake = free * self.stake_pct if min_stake: stake = max(stake, min_stake) return min(stake, max_stake) except Exception: pass return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake def _paused(self, current_time: datetime) -> bool: if self._pause_until is None: return False now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) until = ( self._pause_until if self._pause_until.tzinfo else self._pause_until.replace(tzinfo=timezone.utc) ) return now < until def _in_cooldown(self, current_time: datetime) -> bool: if self._last_entry_time is None: return False now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) last = ( self._last_entry_time if self._last_entry_time.tzinfo else self._last_entry_time.replace(tzinfo=timezone.utc) ) return (now - last) < timedelta(minutes=self.cooldown_minutes) def confirm_trade_entry( self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, current_time: datetime, entry_tag: str | None, side: str, **kwargs, ) -> bool: if self._paused(current_time) or self._in_cooldown(current_time): return False # 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度) try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is not None and len(dataframe): last = dataframe.iloc[-1] if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))): return False if self.dp.runmode.value in ("live", "dry_run"): ob = self.dp.orderbook(pair, self.ob_levels) bids = ob.get("bids") or [] asks = ob.get("asks") or [] if not bids or not asks: return False # 简单吸收代理:同价位附近挂单厚度 bid_vol = sum(float(b[1]) for b in bids[:3]) ask_vol = sum(float(a[1]) for a in asks[:3]) if side == "long" and bid_vol < ask_vol * 0.8: # Bid 不够厚,吸收叙事弱 return False if side == "short" and ask_vol < bid_vol * 0.8: return False except Exception as e: logger.debug("v11 confirm entry: %s", e) self._last_entry_time = current_time return True def custom_exit( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ): open_time = trade.open_date_utc if open_time.tzinfo is None: open_time = open_time.replace(tzinfo=timezone.utc) now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) if now - open_time >= timedelta(minutes=self.max_hold_minutes): return "time_stop_5m" # 优势恢复出场(替代固定 0.05% TP) # long: 价格相对开仓上涨 edge_exit_pct;short: 下跌 edge_exit_pct # current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳 entry = trade.open_rate if not trade.is_short: edge = (current_rate - entry) / entry if edge >= self.edge_exit_pct: return "spread_edge_restore" if edge <= -self.adverse_exit_pct: return "adverse_move" else: edge = (entry - current_rate) / entry if edge >= self.edge_exit_pct: return "spread_edge_restore" if edge <= -self.adverse_exit_pct: return "adverse_move" # 重新进入趋势禁做市 → 立刻撤流动性 try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is not None and len(dataframe): if bool(dataframe.iloc[-1].get("trend_block", False)): return "trend_block_exit" except Exception: pass return None def confirm_trade_exit( self, pair: str, trade: Trade, order_type: str, amount: float, rate: float, time_in_force: str, exit_reason: str, current_time: datetime, **kwargs, ) -> bool: try: profit = trade.calc_profit_ratio(rate) if profit < 0: self._loss_streak += 1 if self._loss_streak >= self.consecutive_loss_limit: self._pause_until = current_time + timedelta(minutes=self.pause_minutes) self._loss_streak = 0 else: self._loss_streak = 0 except Exception: pass return True @property def protections(self): return [ { "method": "CooldownPeriod", "stop_duration_candles": int(self.cooldown_minutes), }, { "method": "StoplossGuard", "lookback_period_candles": 60, "trade_limit": self.consecutive_loss_limit, "stop_duration_candles": self.pause_minutes, "only_per_pair": True, }, ]