Skip to content

Commit 21f1fb6

Browse files
HenryHenry
authored andcommitted
fix: harden market data and fast analysis safeguards
1 parent d169c03 commit 21f1fb6

17 files changed

Lines changed: 1055 additions & 221 deletions

backend_api_python/app/data_sources/crypto.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import ccxt
1111

1212
from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS
13+
from app.data_sources.errors import MarketDataFailure, classify_market_data_failure
1314
from app.utils.logger import get_logger
1415
from app.config import CCXTConfig, APIKeys
1516

@@ -154,6 +155,7 @@ def __init__(self):
154155
self._scoped_market_type = "spot"
155156
self._preferred_public_exchange_id = ""
156157
self._markets_load_lock = threading.Lock()
158+
self._failure_local = threading.local()
157159
default_ex = (CCXTConfig.DEFAULT_EXCHANGE or "binance").strip().lower()
158160
if default_ex == "huobi":
159161
default_ex = "htx"
@@ -178,6 +180,7 @@ def for_exchange(cls, exchange_id: str, market_type: str = "swap") -> "CryptoDat
178180
inst._scoped_market_type = mt
179181
inst._preferred_public_exchange_id = ""
180182
inst._markets_load_lock = threading.Lock()
183+
inst._failure_local = threading.local()
181184
inst._init_ccxt_exchange(ccxt_id, options)
182185
_SCOPED_INSTANCES[cache_key] = inst
183186
logger.info(
@@ -227,10 +230,43 @@ def for_public_market(
227230
inst._scoped_market_type = mt
228231
inst._preferred_public_exchange_id = ""
229232
inst._markets_load_lock = threading.Lock()
233+
inst._failure_local = threading.local()
230234
inst._init_ccxt_exchange(ccxt_id, options)
231235
_PUBLIC_MARKET_INSTANCES[cache_key] = inst
232236
return inst
233237

238+
def _clear_last_failure(self) -> None:
239+
local = getattr(self, "_failure_local", None)
240+
if local is None:
241+
local = threading.local()
242+
self._failure_local = local
243+
local.value = None
244+
245+
def _set_last_failure(
246+
self,
247+
error: Any,
248+
*,
249+
symbol: str,
250+
timeframe: str,
251+
) -> MarketDataFailure:
252+
failure = classify_market_data_failure(
253+
error,
254+
exchange_id=getattr(self, "_scoped_exchange_id", "") or getattr(self.exchange, "id", ""),
255+
market_type=getattr(self, "_scoped_market_type", "") or "spot",
256+
symbol=symbol,
257+
timeframe=timeframe,
258+
)
259+
local = getattr(self, "_failure_local", None)
260+
if local is None:
261+
local = threading.local()
262+
self._failure_local = local
263+
local.value = failure
264+
return failure
265+
266+
def get_last_failure(self) -> Optional[MarketDataFailure]:
267+
local = getattr(self, "_failure_local", None)
268+
return getattr(local, "value", None) if local is not None else None
269+
234270
def _init_ccxt_exchange(self, ccxt_exchange_id: str, options: Optional[Dict[str, Any]] = None) -> None:
235271
config: Dict[str, Any] = {
236272
"timeout": CCXTConfig.TIMEOUT,
@@ -503,6 +539,7 @@ def get_kline(
503539
after_time: Optional[int] = None,
504540
) -> List[Dict[str, Any]]:
505541
"""获取加密货币K线数据"""
542+
self._clear_last_failure()
506543
klines = []
507544
symbol_pair = ""
508545

@@ -548,6 +585,11 @@ def get_kline(
548585
if exchange_timeframes and ccxt_timeframe not in exchange_timeframes:
549586
picked = self._pick_resample_source(ccxt_timeframe, exchange_timeframes)
550587
if picked is None:
588+
self._set_last_failure(
589+
f"Unsupported timeframe {ccxt_timeframe} on {self.exchange.id}",
590+
symbol=symbol,
591+
timeframe=timeframe,
592+
)
551593
logger.warning(
552594
f"Exchange '{self.exchange.id}' cannot serve timeframe '{ccxt_timeframe}' "
553595
f"and no finer supported granularity is available for resampling. "
@@ -568,10 +610,18 @@ def get_kline(
568610
symbol_pair = self._symbol_for_scoped_market(symbol)
569611

570612
if not symbol_pair:
613+
self._set_last_failure(
614+
f"Invalid symbol: {symbol}", symbol=symbol, timeframe=timeframe
615+
)
571616
logger.warning(f"Failed to normalize symbol for K-line: {symbol}")
572617
raise _PublicKlineUnavailable
573618

574619
if self._is_invalid_symbol_cached(symbol_pair):
620+
self._set_last_failure(
621+
f"Symbol not found (cached): {symbol_pair}",
622+
symbol=symbol_pair,
623+
timeframe=timeframe,
624+
)
575625
raise _PublicKlineUnavailable
576626

577627
ohlcv = self._fetch_ohlcv(
@@ -580,6 +630,12 @@ def get_kline(
580630
)
581631

582632
if not ohlcv:
633+
if self.get_last_failure() is None:
634+
self._set_last_failure(
635+
"Exchange returned no K-line rows",
636+
symbol=symbol_pair,
637+
timeframe=timeframe,
638+
)
583639
logger.warning(f"CCXT returned no K-lines: {symbol_pair}")
584640
raise _PublicKlineUnavailable
585641

@@ -628,8 +684,14 @@ def get_kline(
628684
pass
629685

630686
except _PublicKlineUnavailable:
631-
pass
687+
if self.get_last_failure() is None:
688+
self._set_last_failure(
689+
"No usable market data",
690+
symbol=symbol_pair or symbol,
691+
timeframe=timeframe,
692+
)
632693
except Exception as e:
694+
self._set_last_failure(e, symbol=symbol_pair or symbol, timeframe=timeframe)
633695
logger.error(f"Failed to fetch crypto K-lines {symbol}: {str(e)}")
634696
import traceback
635697
logger.error(traceback.format_exc())
@@ -876,6 +938,7 @@ def _fetch_ohlcv(
876938
except Exception as e:
877939
if _is_symbol_not_found_error(e):
878940
self._mark_invalid_symbol(symbol_pair, e)
941+
self._set_last_failure(e, symbol=symbol_pair, timeframe=timeframe)
879942
return []
880943
partial_rows = locals().get("all_ohlcv") or []
881944
if partial_rows:
@@ -889,6 +952,7 @@ def _fetch_ohlcv(
889952
by_ts = {int(row[0]): row for row in partial_rows if row and len(row) >= 6}
890953
return sorted(by_ts.values(), key=lambda row: row[0])
891954
logger.warning(f"CCXT fetch_ohlcv failed: {str(e)}; trying fallback")
955+
self._set_last_failure(e, symbol=symbol_pair, timeframe=timeframe)
892956
return self._fetch_ohlcv_fallback(
893957
symbol_pair, ccxt_timeframe, limit, before_time, timeframe, after_time
894958
)
@@ -935,7 +999,9 @@ def _fetch_ohlcv_fallback(
935999
except Exception as e:
9361000
if _is_symbol_not_found_error(e):
9371001
self._mark_invalid_symbol(symbol_pair, e)
1002+
self._set_last_failure(e, symbol=symbol_pair, timeframe=timeframe)
9381003
return []
1004+
self._set_last_failure(e, symbol=symbol_pair, timeframe=timeframe)
9391005
logger.warning("Requested-window fallback failed for %s: %s", symbol_pair, str(e))
9401006

9411007
try:
@@ -957,4 +1023,5 @@ def _fetch_ohlcv_fallback(
9571023
self._mark_invalid_symbol(symbol_pair, e)
9581024
else:
9591025
logger.error("Recent-candle fallback also failed for %s: %s", symbol_pair, str(e))
1026+
self._set_last_failure(e, symbol=symbol_pair, timeframe=timeframe)
9601027
return []

backend_api_python/app/data_sources/errors.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,127 @@
11
from __future__ import annotations
22

3+
import re
4+
from dataclasses import dataclass
5+
from typing import Any, Mapping
6+
37

48
class DataSourceError(Exception):
59
"""Base class for data source related errors."""
610

711

12+
@dataclass(frozen=True)
13+
class MarketDataFailure:
14+
"""Structured public-market-data failure safe to expose to the UI."""
15+
16+
code: str
17+
message: str
18+
technical_detail: str = ""
19+
exchange_id: str = ""
20+
market_type: str = ""
21+
symbol: str = ""
22+
timeframe: str = ""
23+
retryable: bool = True
24+
25+
def as_dict(self) -> dict[str, Any]:
26+
return {
27+
"code": self.code,
28+
"message": self.message,
29+
"technical_detail": self.technical_detail,
30+
"exchange_id": self.exchange_id,
31+
"market_type": self.market_type,
32+
"symbol": self.symbol,
33+
"timeframe": self.timeframe,
34+
"retryable": self.retryable,
35+
}
36+
37+
@classmethod
38+
def from_mapping(cls, value: Mapping[str, Any]) -> "MarketDataFailure":
39+
return cls(
40+
code=str(value.get("code") or "no_market_data"),
41+
message=str(value.get("message") or "No usable market data is available."),
42+
technical_detail=str(value.get("technical_detail") or "")[:500],
43+
exchange_id=str(value.get("exchange_id") or ""),
44+
market_type=str(value.get("market_type") or ""),
45+
symbol=str(value.get("symbol") or ""),
46+
timeframe=str(value.get("timeframe") or ""),
47+
retryable=bool(value.get("retryable", True)),
48+
)
49+
50+
51+
class MarketDataUnavailableError(DataSourceError):
52+
"""Carries a categorized failure through strategy-data loading layers."""
53+
54+
def __init__(self, failure: MarketDataFailure):
55+
self.failure = failure
56+
super().__init__(f"marketData.{failure.code}")
57+
58+
59+
def classify_market_data_failure(
60+
error: Any,
61+
*,
62+
exchange_id: str = "",
63+
market_type: str = "",
64+
symbol: str = "",
65+
timeframe: str = "",
66+
) -> MarketDataFailure:
67+
"""Map provider-specific errors to a small stable frontend contract."""
68+
detail = str(error or "").strip()
69+
detail = re.sub(
70+
r"(?i)(https?://)([^/@\s:]+):([^/@\s]+)@",
71+
r"\1***:***@",
72+
detail,
73+
)
74+
text = detail.lower()
75+
if any(token in text for token in (
76+
"451",
77+
"restricted location",
78+
"legal reasons",
79+
"region restricted",
80+
"block access from your country",
81+
"blocked access from your country",
82+
)):
83+
code = "region_restricted"
84+
message = "The exchange market-data endpoint is unavailable in this region."
85+
retryable = False
86+
elif any(token in text for token in ("proxyerror", "proxy error", "proxyconnect", "proxy connection", "tunnel connection", "socks")):
87+
code = "proxy_failure"
88+
message = "The market-data proxy could not connect to the exchange."
89+
retryable = True
90+
elif any(token in text for token in ("does not have market symbol", "symbol not found", "invalid symbol", "market does not exist", "trading pair not found")):
91+
code = "symbol_not_found"
92+
message = "The trading pair does not exist for this exchange and market type."
93+
retryable = False
94+
elif any(token in text for token in ("429", "too many requests", "rate limit", "ratelimit")):
95+
code = "rate_limited"
96+
message = "The exchange rate limit was reached. Market data will be retried."
97+
retryable = True
98+
elif any(token in text for token in ("timeout", "timed out", "network error", "connection reset", "connection refused", "exchange not available", "service unavailable", "502", "503", "504")):
99+
code = "exchange_unavailable"
100+
message = "The exchange market-data service is temporarily unreachable."
101+
retryable = True
102+
elif "timeframe" in text and any(token in text for token in ("unsupported", "not support", "cannot serve")):
103+
code = "unsupported_timeframe"
104+
message = "This exchange does not provide the requested K-line timeframe."
105+
retryable = False
106+
else:
107+
code = "no_market_data"
108+
message = "The exchange returned no usable market data."
109+
retryable = True
110+
return MarketDataFailure(
111+
code=code,
112+
message=message,
113+
technical_detail=detail[:500],
114+
exchange_id=str(exchange_id or "").strip().lower(),
115+
market_type=str(market_type or "").strip().lower(),
116+
symbol=str(symbol or ""),
117+
timeframe=str(timeframe or ""),
118+
retryable=retryable,
119+
)
120+
121+
8122
class UnsupportedMarketError(DataSourceError):
9123
"""Raised when a requested market type is not supported by DataSourceFactory."""
10124

11125
def __init__(self, market: str):
12126
self.market = str(market or "")
13127
super().__init__(f"Unsupported market type: {self.market}")
14-

backend_api_python/app/data_sources/factory.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from typing import Dict, List, Any, Optional
99

1010
from app.data_sources.base import BaseDataSource
11-
from app.data_sources.errors import UnsupportedMarketError
11+
from app.data_sources.errors import (
12+
MarketDataFailure,
13+
UnsupportedMarketError,
14+
classify_market_data_failure,
15+
)
1216
from app.utils.logger import get_logger
1317
from app.utils.resource_guard import (
1418
ResourceExhaustedError,
@@ -237,15 +241,53 @@ def get_kline(
237241
Returns:
238242
K线数据列表
239243
"""
244+
rows, _failure = cls.get_kline_with_diagnostics(
245+
market=market,
246+
symbol=symbol,
247+
timeframe=timeframe,
248+
limit=limit,
249+
before_time=before_time,
250+
after_time=after_time,
251+
exchange_id=exchange_id,
252+
market_type=market_type,
253+
)
254+
return rows
255+
256+
@classmethod
257+
def get_kline_with_diagnostics(
258+
cls,
259+
*,
260+
market: str,
261+
symbol: str,
262+
timeframe: str,
263+
limit: int,
264+
before_time: Optional[int] = None,
265+
after_time: Optional[int] = None,
266+
exchange_id: Optional[str] = None,
267+
market_type: Optional[str] = None,
268+
) -> tuple[List[Dict[str, Any]], Optional[MarketDataFailure]]:
269+
"""Fetch K-lines and retain a structured provider failure when rows are empty."""
240270
m = cls.normalize_market(market or "")
241271
try:
242272
assert_fd_available(f"market-data kline {m}:{symbol}")
243273
source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type)
244274
klines = source.get_kline(symbol, timeframe, limit, before_time, after_time)
245-
275+
246276
klines.sort(key=lambda x: x['time'])
247-
248-
return klines
277+
failure = None
278+
if not klines:
279+
get_last_failure = getattr(source, "get_last_failure", None)
280+
if callable(get_last_failure):
281+
failure = get_last_failure()
282+
if failure is None:
283+
failure = classify_market_data_failure(
284+
"Exchange returned no K-line rows",
285+
exchange_id=exchange_id or getattr(getattr(source, "exchange", None), "id", ""),
286+
market_type=market_type or "",
287+
symbol=symbol,
288+
timeframe=timeframe,
289+
)
290+
return klines, failure
249291
except ResourceExhaustedError as e:
250292
cls._log_limited(
251293
"error",
@@ -255,7 +297,13 @@ def get_kline(
255297
symbol,
256298
str(e),
257299
)
258-
return []
300+
return [], classify_market_data_failure(
301+
e,
302+
exchange_id=exchange_id or "",
303+
market_type=market_type or "",
304+
symbol=symbol,
305+
timeframe=timeframe,
306+
)
259307
except Exception as e:
260308
if is_fd_exhaustion(e):
261309
mark_fd_exhausted(e)
@@ -268,7 +316,13 @@ def get_kline(
268316
m,
269317
str(e),
270318
)
271-
return []
319+
return [], classify_market_data_failure(
320+
e,
321+
exchange_id=exchange_id or "",
322+
market_type=market_type or "",
323+
symbol=symbol,
324+
timeframe=timeframe,
325+
)
272326

273327
@classmethod
274328
def _resolve_source(

0 commit comments

Comments
 (0)