""" BTC Maker Micro Scalper v1.0 目标:在 BTCUSDT 永续 1m 级别,用盘口微结构(OBI / Delta / CVD / VWAP) 做 Maker 挂单,捕捉约 0.03%~0.08% 的微小价差。 回测说明: - Freqtrade 标准回测只有 OHLCV,没有真实 L2 / Tick。 - 本策略用 K 线代理重构 OBI / Delta / CVD,使逻辑可回测、可验证。 - 实盘 / Dry-run 下,confirm_trade_entry 会用真实 10 档 orderbook 覆盖 OBI。 不要加入:RSI / MACD / 均线交叉 / 神经网络。 运行示例: freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\ -t 1m --pairs BTC/USDT:USDT --timerange=20260101- freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\ --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\ --timerange=20260101- --fee 0.00016 python user_data/Chan/strategies/mms_stats.py """ 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, DecimalParameter logger = logging.getLogger(__name__) def _safe_div(num, den): return np.where(den != 0, num / den, 0.0) class BTC_Maker_Micro_Scalper(IStrategy): """ Maker Micro Scalping MVP — 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤。 """ INTERFACE_VERSION: int = 3 timeframe: str = "1m" can_short: bool = True process_only_new_candles: bool = True startup_candle_count: int = 120 # 固定小止盈 / 止损(价格百分比,非杠杆后权益) # ROI +0.05%;stoploss -0.03%;时间止损 3 分钟在 custom_exit minimal_roi = {"0": 0.0005} stoploss = -0.0003 trailing_stop = False use_exit_signal = False use_custom_stoploss = False # Maker 限价单 order_types = { "entry": "limit", "exit": "limit", "stoploss": "limit", "stoploss_on_exchange": False, } order_time_in_force = { "entry": "GTC", "exit": "GTC", } # ---- 可调参数(保持与规格一致;后续可 hyperopt)---- maker_fee = 0.00016 # 0.016% atr_fee_mult = 3.0 # ATR > fee * 3 obi_threshold = 0.15 tp_pct = 0.0005 # +0.05% sl_pct = 0.0003 # -0.03% max_hold_minutes = 3 stake_pct = 0.005 # 单次 0.5% 账户资金 max_leverage = 3.0 consecutive_loss_limit = 3 pause_minutes = 30 vwap_band = 0.001 # ±0.1% ob_levels = 10 # 实盘用 10 档 tick_size = 0.1 # BTCUSDT 永续常见最小变动 maker_offset_ticks = 1 # Hyperopt 可选(默认关闭,不改变 v1 逻辑) buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False) # 运行时状态:连续亏损熔断 _loss_streak: int = 0 _pause_until: Optional[datetime] = None _maker_fills: int = 0 _total_fills: int = 0 plot_config = { "main_plot": { "vwap": {"color": "orange"}, }, "subplots": { "OBI": {"obi": {"color": "blue"}}, "Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}}, "CVD": {"cvd": {"color": "purple"}}, "ATR_pct": {"atr_pct": {"color": "red"}}, }, } # ------------------------------------------------------------------ # # 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖) # ------------------------------------------------------------------ # def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: df = dataframe high = df["high"] low = df["low"] close = df["close"] volume = df["volume"].astype(float) # ATR(20) 与相对波动 df["atr"] = ta.ATR(df, timeperiod=20) df["atr_pct"] = _safe_div(df["atr"], close) # 规格:ATR > 单边手续费 × 3(0.016% × 3 = 0.048%) df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult) # ---- Delta / Buy-Sell 分解(蜡烛代理)---- # buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low) # 先把 close 夹到 [low, high],避免脏数据让 OBI 越界 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 # 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta # 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向” df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum() # “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值 df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean() df["delta_accel"] = df["delta"] > df["delta_ma"] # CVD df["cvd"] = df["delta"].cumsum() # 规格:CVD_now > CVD_20s_ago(1m 用 shift(1)) df["cvd_up"] = df["cvd"] > df["cvd"].shift(1) df["cvd_down"] = df["cvd"] < df["cvd"].shift(1) # ---- OBI 代理(无 L2 时)---- # OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1] denom = buy_vol + sell_vol df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0) # ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)---- tp = (high + low + close) / 3.0 window = 60 cum_pv = (tp * volume).rolling(window, min_periods=1).sum() cum_v = volume.rolling(window, min_periods=1).sum() df["vwap"] = _safe_div(cum_pv, cum_v) df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band) df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band) # 辅助:标记是否满足波动过滤 df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult return df def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold long_cond = ( dataframe["vol_ok"] & (dataframe["obi"] > obi_th) & (dataframe["delta_sum"] > 0) & dataframe["delta_accel"] & dataframe["cvd_up"] & dataframe["below_vwap_band"] & (dataframe["volume"] > 0) ) short_cond = ( dataframe["vol_ok"] & (dataframe["obi"] < -obi_th) & (dataframe["delta_sum"] < 0) & (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值) & dataframe["cvd_down"] & dataframe["above_vwap_band"] & (dataframe["volume"] > 0) ) dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi") dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi") return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # 出场交给 ROI / stoploss / custom_exit(时间止损) dataframe["exit_long"] = 0 dataframe["exit_short"] = 0 return dataframe # ------------------------------------------------------------------ # # Maker 报价:Bid+1tick / Ask-1tick # ------------------------------------------------------------------ # def custom_entry_price( self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float, entry_tag: str | None, side: str, **kwargs, ) -> float: tick = self.tick_size offset = self.maker_offset_ticks * tick # 实盘优先用盘口 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 float(bids[0][0]) + offset if side == "short" and asks: return float(asks[0][0]) - offset except Exception as e: logger.debug("custom_entry_price orderbook fallback: %s", e) # 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂) 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: tick = self.tick_size offset = self.maker_offset_ticks * tick 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 trade.is_short and bids: # 空头平仓 = 买入,挂 bid+1tick return float(bids[0][0]) + offset if (not trade.is_short) and asks: # 多头平仓 = 卖出,挂 ask-1tick return float(asks[0][0]) - offset except Exception as e: logger.debug("custom_exit_price orderbook 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: # 单次账户资金 0.5%(作为保证金 stake) try: wallets = self.wallets if wallets: free = 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 as e: logger.debug("custom_stake_amount fallback: %s", e) return proposed_stake * self.stake_pct 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 @staticmethod def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]: bids = (ob.get("bids") or [])[:levels] asks = (ob.get("asks") or [])[:levels] if not bids or not asks: return None bid_vol = sum(float(b[1]) for b in bids) ask_vol = sum(float(a[1]) for a in asks) tot = bid_vol + ask_vol if tot <= 0: return None return (bid_vol - ask_vol) / tot 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): logger.info("Paused until %s — skip entry", self._pause_until) return False # 实盘:用真实 10 档 OBI 复核 try: if self.dp and self.dp.runmode.value in ("live", "dry_run"): ob = self.dp.orderbook(pair, self.ob_levels) obi = self._calc_obi_from_orderbook(ob, self.ob_levels) if obi is None: return False if side == "long" and obi <= self.obi_threshold: logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold) return False if side == "short" and obi >= -self.obi_threshold: logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold) return False except Exception as e: logger.warning("confirm_trade_entry orderbook check failed: %s", e) return True def custom_exit( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ): # 时间止损:持仓 > 3 分钟 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) held = now - open_time if held >= timedelta(minutes=self.max_hold_minutes): return "time_stop_3m" # 双保险:显式 TP / SL(ROI/stoploss 也会触发) if current_profit >= self.tp_pct: return "tp_0.05pct" if current_profit <= -self.sl_pct: return "sl_0.03pct" return None def order_filled( self, pair: str, trade: Trade, order, current_time: datetime, **kwargs, ) -> None: self._total_fills += 1 # limit 单视为 Maker otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None) if otype and str(otype).lower() == "limit": self._maker_fills += 1 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: # 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估) 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) logger.warning( "Loss streak=%d → pause %d min until %s", self._loss_streak, self.pause_minutes, self._pause_until, ) self._loss_streak = 0 else: self._loss_streak = 0 except Exception as e: logger.debug("confirm_trade_exit streak update: %s", e) return True # ------------------------------------------------------------------ # # Protections(回测需 --enable-protections) # ------------------------------------------------------------------ # @property def protections(self): return [ { "method": "StoplossGuard", "lookback_period_candles": 30, "trade_limit": self.consecutive_loss_limit, "stop_duration_candles": self.pause_minutes, "only_per_pair": True, "only_per_side": False, } ]