""" MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。 正式 Maker / L2 / Edge 采集已迁移到: nautilus_mm/ (NautilusTrader,独立 .venv) 本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。 运行 Nautilus: cd nautilus_mm && ./scripts/run_probe.sh 分析: cd nautilus_mm && ./scripts/analyze.sh """ from __future__ import annotations import logging import time from datetime import datetime, timedelta, timezone from typing import Optional import numpy as np import talib.abstract as ta from pandas import DataFrame from freqtrade.persistence import Trade, Order from freqtrade.strategy import IStrategy from maker_edge_logger import MakerEdgeLogger logger = logging.getLogger(__name__) class MakerEdgeProbe(IStrategy): INTERFACE_VERSION = 3 timeframe = "1m" can_short = True process_only_new_candles = False startup_candle_count = 60 minimal_roi = {"0": 0.01} stoploss = -0.002 trailing_stop = False use_exit_signal = False order_types = { "entry": "limit", "exit": "limit", "stoploss": "market", "stoploss_on_exchange": False, } order_time_in_force = {"entry": "GTC", "exit": "GTC"} tick_size = 0.1 quote_depth_ticks = 1 max_leverage = 1.0 stake_pct = 0.003 max_hold_minutes = 5 edge_exit_pct = 0.0002 adverse_exit_pct = 0.0008 cooldown_minutes = 5 ob_levels = 10 trade_lookback = 100 ema_slope_thr = 0.0002 book_sample_every_sec = 2.0 _logger: MakerEdgeLogger | None = None _last_mid: float | None = None _last_book_sample: float = 0.0 _last_entry_time: Optional[datetime] = None _recent_high: float = 0.0 _recent_low: float = 0.0 _pending_quote_id: Optional[str] = None _fill_by_trade: dict[int, str] = {} def bot_start(self, **kwargs) -> None: self._logger = MakerEdgeLogger(levels=self.ob_levels) self._fill_by_trade = {} logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir) def _get_logger(self) -> MakerEdgeLogger: if self._logger is None: self._logger = MakerEdgeLogger(levels=self.ob_levels) return self._logger def _fetch_trades(self, pair: str) -> list: try: ex = self.dp._exchange if ex is None: return [] api = getattr(ex, "_api", None) or getattr(ex, "api", None) if api is None: return [] return api.fetch_trades(pair, limit=self.trade_lookback) or [] except Exception as e: logger.debug("fetch_trades failed: %s", e) return [] def _inventory(self) -> float: try: inv = 0.0 for t in Trade.get_open_trades(): amt = float(t.amount or 0.0) inv += -amt if t.is_short else amt return inv except Exception: return 0.0 def _market_state(self, pair: str) -> dict: state = { "trend_state": "UNKNOWN", "atr_pct": None, "volatility_regime": "UNKNOWN", "ema_slope": None, } try: df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if df is None or len(df) == 0: return state last = df.iloc[-1] slope = float(last.get("ema_slope") or 0.0) atr_pct = float(last.get("atr_pct") or 0.0) state["ema_slope"] = slope state["atr_pct"] = atr_pct if bool(last.get("trend_block", False)): state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN" else: state["trend_state"] = "RANGE" # 波动分位代理 if "atr_pct" in df.columns: med = float(df["atr_pct"].tail(60).median() or 0) if atr_pct > med * 1.8: state["volatility_regime"] = "HIGH" elif atr_pct < med * 0.7: state["volatility_regime"] = "LOW" else: state["volatility_regime"] = "NORMAL" except Exception: pass return state def _snapshot(self, pair: str): ob = self.dp.orderbook(pair, self.ob_levels) trades = self._fetch_trades(pair) snap = MakerEdgeLogger.snapshot_from_orderbook( ob, levels=self.ob_levels, recent_trades=trades, last_mid=self._last_mid, liq_proxy_low=self._recent_low or None, liq_proxy_high=self._recent_high or None, ) if snap.mid: self._last_mid = snap.mid return snap def bot_loop_start(self, current_time: datetime, **kwargs) -> None: if self.dp.runmode.value not in ("live", "dry_run"): return pair = self.config["exchange"]["pair_whitelist"][0] try: snap = self._snapshot(pair) tick = self.dp.ticker(pair) or {} last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if df is not None and len(df): self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last) self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last) lg = self._get_logger() now = time.time() # 盘口历史(成交前5s恶化检测依赖此) if now - self._last_book_sample >= self.book_sample_every_sec: self._last_book_sample = now lg.record_book(snap, now=now) if last: lg.update_paths(pair, last, now=now) except Exception as e: logger.warning("bot_loop_start probe error: %s", e) def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: df = dataframe close, high, low = df["close"], df["high"], df["low"] volume = df["volume"].astype(float) close_c = close.clip(lower=low, upper=high) hl = (high - low).replace(0, np.nan) buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1) sell_vol = volume * (1.0 - buy_frac) buy_vol = volume * buy_frac df["sell_vol"] = sell_vol df["buy_vol"] = buy_vol df["delta"] = buy_vol - sell_vol vol_ma = volume.rolling(20, min_periods=5).mean() df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0) df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0) drop = (close.shift(3) - low).clip(lower=0) / close.shift(3) up = (high - close.shift(3)).clip(lower=0) / close.shift(3) df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace( [np.inf, -np.inf], np.nan ).fillna(0) df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace( [np.inf, -np.inf], np.nan ).fillna(0) df["ema26"] = ta.EMA(df, timeperiod=26) df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0) df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr df["atr"] = ta.ATR(df, timeperiod=20) df["atr_pct"] = (df["atr"] / close).fillna(0) s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean() df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1)) b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean() df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1)) df["roll_high"] = high.rolling(60, min_periods=10).max() df["roll_low"] = low.rolling(60, min_periods=10).min() return df def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: df = dataframe long_c = ( (~df["trend_block"]) & df["shock_sell"].rolling(5).max().astype(bool) & (df["de_sell"] > 10) & df["sell_exhaust"] ) short_c = ( (~df["trend_block"]) & df["shock_buy"].rolling(5).max().astype(bool) & (df["de_buy"] > 10) & df["buy_exhaust"] ) df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp") df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp") return df def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["exit_long"] = 0 dataframe["exit_short"] = 0 return dataframe 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.quote_depth_ticks * self.tick_size try: snap = self._snapshot(pair) price = snap.best_bid - offset if side == "long" else snap.best_ask + offset state = self._market_state(pair) qid = self._get_logger().create_quote( pair=pair, side="bid" if side == "long" else "ask", quote_price=price, inventory=self._inventory(), snap=snap, reason=entry_tag or "entry", trade_id=trade.id if trade else None, state=state, ) self._pending_quote_id = qid return price except Exception as e: logger.debug("custom_entry_price: %s", e) return proposed_rate - offset if side == "long" else proposed_rate + offset 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._last_entry_time: last = self._last_entry_time if last.tzinfo is None: last = last.replace(tzinfo=timezone.utc) now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) if now - last < timedelta(minutes=self.cooldown_minutes): return False try: df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)): return False snap = self._snapshot(pair) if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7: return False if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7: return False except Exception: pass self._last_entry_time = current_time return True def check_entry_timeout( self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs ) -> bool: """超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。""" try: snap = self._snapshot(pair) self._get_logger().cancel_quote( quote_id=self._pending_quote_id, trade_id=trade.id, reason="entry_timeout", snap=snap, ) except Exception as e: logger.debug("cancel_quote on timeout: %s", e) # False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True return False def order_filled( self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs, ) -> None: try: lg = self._get_logger() # 入场成交 if order.ft_order_side == trade.entry_side: snap = self._snapshot(pair) side = "short" if trade.is_short else "long" # 粗分 fill_reason:time_to_fill 在 logger 内算;这里标 maker_hit # 若成交前5s盘口已恶化 → toxic_passive 候选 det = lg.book_deterioration(side) fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit" if self._pending_quote_id: lg.bind_trade(self._pending_quote_id, trade.id) fill_id = lg.log_fill( pair=pair, side=side, fill_price=float(order.safe_price or trade.open_rate), amount=float(order.safe_filled or order.safe_amount or 0), inventory=self._inventory(), snap=snap, order_type=str(getattr(order, "order_type", None) or "limit"), quote_id=self._pending_quote_id, trade_id=trade.id, fill_reason=fill_reason, state=self._market_state(pair), extra={"entry_tag": trade.enter_tag}, ) self._fill_by_trade[trade.id] = fill_id self._pending_quote_id = None else: # 出场:把 exit_reason 挂到入场 fill,供 H2 fill_id = self._fill_by_trade.get(trade.id) reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit" if fill_id: lg.attach_exit_reason(fill_id, str(reason)) except Exception as e: logger.warning("order_filled log error: %s", e) 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 "probe_time" entry = trade.open_rate edge = ( (current_rate - entry) / entry if not trade.is_short else (entry - current_rate) / entry ) if edge >= self.edge_exit_pct: return "probe_edge_restore" if edge <= -self.adverse_exit_pct: return "probe_adverse" # 趋势切换 → 撤流动性思维 try: st = self._market_state(pair) if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"): # 持仓方向与趋势相反时更危险 if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or ( trade.is_short and st["trend_state"] == "TREND_UP" ): return "probe_trend_cancel" except Exception: pass return None 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: Optional[float], max_stake: float, leverage: float, entry_tag: Optional[str], 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)