|
1 | 1 | """Mode exchange subclass""" |
2 | 2 |
|
3 | 3 | import logging |
4 | | -from copy import deepcopy |
5 | | -from decimal import Decimal |
| 4 | +from typing import Any |
6 | 5 |
|
7 | 6 | import ccxt |
8 | 7 | from freqtrade.enums.marginmode import MarginMode |
9 | 8 | from freqtrade.enums.tradingmode import TradingMode |
10 | 9 | from freqtrade.exchange import Exchange |
11 | | -from freqtrade.exchange.exchange_types import CcxtBalances, FtHas |
| 10 | +from freqtrade.exchange.exchange_types import FtHas |
12 | 11 | from freqtrade.exchange.common import retrier |
13 | 12 | from freqtrade.exceptions import ( |
14 | 13 | DDosProtection, |
@@ -48,6 +47,194 @@ class Modetrade(Exchange): |
48 | 47 | # "ws_enabled": True, |
49 | 48 | } |
50 | 49 |
|
| 50 | + def _modetrade_price_sanity_cfg(self) -> dict[str, Any]: |
| 51 | + cfg = {} |
| 52 | + if isinstance(self._config, dict): |
| 53 | + # New generic key (preferred) |
| 54 | + cfg = self._config.get("price_sanity_check_settings", {}) or {} |
| 55 | + # Backward-compatible key (older configs) |
| 56 | + if not cfg: |
| 57 | + cfg = self._config.get("modetrade_price_sanity", {}) or {} |
| 58 | + if not isinstance(cfg, dict): |
| 59 | + cfg = {} |
| 60 | + return { |
| 61 | + "enabled": bool(cfg.get("enabled", True)), |
| 62 | + "max_deviation_ratio": float(cfg.get("max_deviation_ratio", 0.03)), |
| 63 | + "log_level": str(cfg.get("log_level", "warning")).lower(), |
| 64 | + } |
| 65 | + |
| 66 | + @staticmethod |
| 67 | + def _rel_deviation(a: float, b: float) -> float: |
| 68 | + """Relative deviation |a-b|/|b| (b as reference).""" |
| 69 | + if b == 0: |
| 70 | + return float("inf") |
| 71 | + return abs(a - b) / abs(b) |
| 72 | + |
| 73 | + def _fetch_last_trade_price(self, pair: str) -> tuple[float | None, str | None]: |
| 74 | + try: |
| 75 | + trades = self._api.fetch_trades(pair, limit=10) |
| 76 | + if not trades: |
| 77 | + return None, None |
| 78 | + # CCXT does not guarantee ordering, so pick the newest by timestamp. |
| 79 | + newest = max( |
| 80 | + (t for t in trades if isinstance(t, dict) and t.get("price") is not None), |
| 81 | + key=lambda t: t.get("timestamp") or 0, |
| 82 | + default=None, |
| 83 | + ) |
| 84 | + if not newest: |
| 85 | + return None, None |
| 86 | + return float(newest["price"]), None |
| 87 | + except Exception as e: |
| 88 | + return None, str(e) |
| 89 | + |
| 90 | + def _fetch_last_ohlcv_close(self, pair: str) -> tuple[float | None, str | None]: |
| 91 | + try: |
| 92 | + ohlcv = self._api.fetch_ohlcv(pair, timeframe="1m", limit=2) |
| 93 | + if not ohlcv: |
| 94 | + return None, None |
| 95 | + last = ohlcv[-1] |
| 96 | + # [timestamp, open, high, low, close, volume] |
| 97 | + if isinstance(last, (list, tuple)) and len(last) >= 5 and last[4] is not None: |
| 98 | + return float(last[4]), None |
| 99 | + return None, None |
| 100 | + except Exception as e: |
| 101 | + return None, str(e) |
| 102 | + |
| 103 | + @staticmethod |
| 104 | + def _ticker_ref(ticker: Any | None) -> tuple[float | None, float | None]: |
| 105 | + """ |
| 106 | + Extract (index_price, mark_price) from a CCXT ticker. |
| 107 | + We store these under ticker["info"]["index_price"/"mark_price"] in our CCXT adapter. |
| 108 | + """ |
| 109 | + if not isinstance(ticker, dict): |
| 110 | + return None, None |
| 111 | + info = ticker.get("info") |
| 112 | + if not isinstance(info, dict): |
| 113 | + return None, None |
| 114 | + idx = info.get("index_price") |
| 115 | + mark = info.get("mark_price") |
| 116 | + try: |
| 117 | + idx = float(idx) if idx is not None else None |
| 118 | + except Exception: |
| 119 | + idx = None |
| 120 | + try: |
| 121 | + mark = float(mark) if mark is not None else None |
| 122 | + except Exception: |
| 123 | + mark = None |
| 124 | + return idx, mark |
| 125 | + |
| 126 | + @staticmethod |
| 127 | + def _ob_top(order_book: Any | None) -> tuple[float | None, float | None]: |
| 128 | + """ |
| 129 | + Extract top-of-book bid/ask (best bid, best ask) from a ccxt-style orderbook dict. |
| 130 | + """ |
| 131 | + if not isinstance(order_book, dict): |
| 132 | + return None, None |
| 133 | + bid = ask = None |
| 134 | + bids = order_book.get("bids") |
| 135 | + asks = order_book.get("asks") |
| 136 | + if isinstance(bids, list) and bids: |
| 137 | + try: |
| 138 | + bid = float(bids[0][0]) |
| 139 | + except Exception: |
| 140 | + bid = None |
| 141 | + if isinstance(asks, list) and asks: |
| 142 | + try: |
| 143 | + ask = float(asks[0][0]) |
| 144 | + except Exception: |
| 145 | + ask = None |
| 146 | + return bid, ask |
| 147 | + |
| 148 | + def get_rate( |
| 149 | + self, |
| 150 | + pair: str, |
| 151 | + refresh: bool, |
| 152 | + side: str, |
| 153 | + is_short: bool, |
| 154 | + order_book: Any | None = None, |
| 155 | + ticker: Any | None = None, |
| 156 | + ) -> float: |
| 157 | + """ |
| 158 | + ModeTrade can occasionally return a spiky/stale orderbook snapshot, which can make |
| 159 | + stoploss/trailing logic and notifications use a wrong `current_rate`. |
| 160 | +
|
| 161 | + Minimal sanity guard: |
| 162 | + - compare `base_rate` vs a forced-refresh rate |
| 163 | + - only if they differ too much, compare to index/mark from ticker |
| 164 | + - last_close is only used as a last resort |
| 165 | + """ |
| 166 | + cfg = self._modetrade_price_sanity_cfg() |
| 167 | + if not cfg["enabled"]: |
| 168 | + return super().get_rate(pair, refresh, side, is_short, order_book=order_book, ticker=ticker) |
| 169 | + |
| 170 | + max_dev = cfg["max_deviation_ratio"] |
| 171 | + log_level = cfg["log_level"] |
| 172 | + |
| 173 | + ob_bid, ob_ask = self._ob_top(order_book) |
| 174 | + base_rate = super().get_rate(pair, refresh, side, is_short, order_book=order_book, ticker=ticker) |
| 175 | + |
| 176 | + refreshed_rate = base_rate |
| 177 | + if not refresh: |
| 178 | + refreshed_rate = super().get_rate(pair, True, side, is_short, order_book=None, ticker=None) |
| 179 | + |
| 180 | + dev_base_refreshed = self._rel_deviation(base_rate, refreshed_rate) |
| 181 | + if dev_base_refreshed <= max_dev: |
| 182 | + return refreshed_rate |
| 183 | + |
| 184 | + action = "fallback_no_ref" |
| 185 | + chosen_rate = float(refreshed_rate) |
| 186 | + |
| 187 | + ref_err: str | None = None |
| 188 | + idx: float | None = None |
| 189 | + mark: float | None = None |
| 190 | + last_close: float | None = None |
| 191 | + ohlcv_err: str | None = None |
| 192 | + try: |
| 193 | + t = ticker if isinstance(ticker, dict) else None |
| 194 | + if t is None: |
| 195 | + t = self._api.fetch_ticker(pair) |
| 196 | + idx, mark = self._ticker_ref(t) |
| 197 | + except Exception as e: |
| 198 | + ref_err = str(e) |
| 199 | + |
| 200 | + ref = idx if isinstance(idx, (int, float)) else mark |
| 201 | + if isinstance(ref, (int, float)): |
| 202 | + dev_refreshed_ref = self._rel_deviation(refreshed_rate, ref) |
| 203 | + dev_base_ref = self._rel_deviation(base_rate, ref) |
| 204 | + if dev_refreshed_ref <= max_dev: |
| 205 | + action = "accept_refreshed" |
| 206 | + chosen_rate = float(refreshed_rate) |
| 207 | + try: |
| 208 | + cache_rate = self._entry_rate_cache if side == "entry" else self._exit_rate_cache |
| 209 | + with self._cache_lock: |
| 210 | + cache_rate[pair] = chosen_rate |
| 211 | + except Exception: |
| 212 | + pass |
| 213 | + elif dev_base_ref <= max_dev: |
| 214 | + action = "accept_base" |
| 215 | + chosen_rate = float(base_rate) |
| 216 | + else: |
| 217 | + action = "use_index" if idx is not None else "use_mark" |
| 218 | + chosen_rate = float(ref) |
| 219 | + else: |
| 220 | + last_close, ohlcv_err = self._fetch_last_ohlcv_close(pair) |
| 221 | + if last_close is not None: |
| 222 | + action = "use_last_close" |
| 223 | + chosen_rate = float(last_close) |
| 224 | + else: |
| 225 | + action = "fallback_failed" |
| 226 | + |
| 227 | + msg = ( |
| 228 | + "ModeTrade price sanity check " |
| 229 | + f"action={action} pair={pair} side={side} short={bool(is_short)} " |
| 230 | + f"base={base_rate:.8f} refreshed={refreshed_rate:.8f} " |
| 231 | + f"index={idx} mark={mark} close={last_close} " |
| 232 | + f"ob_bid={ob_bid} ob_ask={ob_ask} dev_base_refreshed={dev_base_refreshed:.4%} " |
| 233 | + f"max_dev={max_dev} ref_err={ref_err} ohlcv_err={ohlcv_err}" |
| 234 | + ) |
| 235 | + getattr(logger, log_level, logger.warning)(msg) |
| 236 | + return chosen_rate |
| 237 | + |
51 | 238 | # TODO: This is a spoofed with Binance data. |
52 | 239 | # Ask Orderly how to get. |
53 | 240 | def fill_leverage_tiers(self) -> None: |
@@ -100,20 +287,6 @@ def validate_ordertypes(self, order_types: dict) -> None: |
100 | 287 | # raise ConfigurationError(f"Exchange {self.name} does not support market orders.") |
101 | 288 | self.validate_stop_ordertypes(order_types) |
102 | 289 |
|
103 | | - # @retrier |
104 | | - # def get_balances(self) -> CcxtBalances: |
105 | | - # """ |
106 | | - # Override the default get_balances to add values for "free" and "used" |
107 | | - # """ |
108 | | - # balances = super().get_balances() |
109 | | - # new_balances = deepcopy(balances) |
110 | | - # for token, balance in balances.items(): |
111 | | - # if balance["free"] is None: |
112 | | - # new_balances[token]["frozen"] = float(balance["frozen"]) |
113 | | - # new_balances[token]["free"] = float(Decimal(balance["total"]) - Decimal(balance["frozen"])) |
114 | | - # new_balances[token]["used"] = float(balance["frozen"]) |
115 | | - # return new_balances |
116 | | - |
117 | 290 | @retrier |
118 | 291 | def additional_exchange_init(self) -> None: |
119 | 292 | """ |
|
0 commit comments