Skip to content

Commit 9fef25b

Browse files
committed
add 0 contracts edge case + modetrade price sanity config and check
1 parent 8a96e1e commit 9fef25b

File tree

2 files changed

+194
-18
lines changed

2 files changed

+194
-18
lines changed

freqtrade/exchange/modetrade.py

Lines changed: 190 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
"""Mode exchange subclass"""
22

33
import logging
4-
from copy import deepcopy
5-
from decimal import Decimal
4+
from typing import Any
65

76
import ccxt
87
from freqtrade.enums.marginmode import MarginMode
98
from freqtrade.enums.tradingmode import TradingMode
109
from freqtrade.exchange import Exchange
11-
from freqtrade.exchange.exchange_types import CcxtBalances, FtHas
10+
from freqtrade.exchange.exchange_types import FtHas
1211
from freqtrade.exchange.common import retrier
1312
from freqtrade.exceptions import (
1413
DDosProtection,
@@ -48,6 +47,194 @@ class Modetrade(Exchange):
4847
# "ws_enabled": True,
4948
}
5049

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+
51238
# TODO: This is a spoofed with Binance data.
52239
# Ask Orderly how to get.
53240
def fill_leverage_tiers(self) -> None:
@@ -100,20 +287,6 @@ def validate_ordertypes(self, order_types: dict) -> None:
100287
# raise ConfigurationError(f"Exchange {self.name} does not support market orders.")
101288
self.validate_stop_ordertypes(order_types)
102289

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-
117290
@retrier
118291
def additional_exchange_init(self) -> None:
119292
"""

freqtrade/freqtradebot.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,10 @@ def _abort_on_unknown_open_positions(self) -> None:
206206
return
207207

208208
db_open_pairs = {t.pair for t in Trade.get_trades_proxy(is_open=True)}
209-
open_position_pairs = set(self.wallets.get_all_positions().keys())
209+
# Only consider *actually open* positions.
210+
# Some exchanges may return "position" entries with 0 contracts but non-zero collateral/metadata.
211+
open_position_pairs = {pair for pair, pos in self.wallets.get_all_positions().items() if pos.position != 0}
212+
210213

211214
unknown_positions = open_position_pairs - db_open_pairs
212215
if unknown_positions:

0 commit comments

Comments
 (0)