07.4.4.1.1 — Market State Human UI + HOLD Lifecycle Fix
This commit is contained in:
@@ -67,4 +67,34 @@ class SymbolValidationResult:
|
||||
@dataclass(slots=True)
|
||||
class PrivateAuthHealth:
|
||||
ok: bool
|
||||
message: str
|
||||
message: str
|
||||
|
||||
|
||||
# =========================================================
|
||||
# MARKET ANALYSIS / KLINES
|
||||
# =========================================================
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Kline:
|
||||
symbol: str
|
||||
interval: str
|
||||
|
||||
open_time: int
|
||||
|
||||
open_price: float
|
||||
high_price: float
|
||||
low_price: float
|
||||
close_price: float
|
||||
|
||||
volume: float
|
||||
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KlineBatch:
|
||||
symbol: str
|
||||
interval: str
|
||||
candles: list[Kline]
|
||||
source: str
|
||||
@@ -22,6 +22,60 @@ class ExchangeRestClient:
|
||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||
self.timeout = self.settings.exchange_timeout_sec
|
||||
|
||||
def get_payload(
|
||||
self,
|
||||
path: str,
|
||||
params: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
query = f"?{urlencode(params)}" if params else ""
|
||||
url = f"{self.base_url}{path}{query}"
|
||||
|
||||
request_headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "dzentra-bot/2.0.0",
|
||||
}
|
||||
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
request = Request(
|
||||
url=url,
|
||||
method="GET",
|
||||
headers=request_headers,
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
status = getattr(response, "status", 200)
|
||||
body = response.read().decode("utf-8")
|
||||
except HTTPError as exc:
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
message = f"HTTP {exc.code} from exchange: {exc.reason}"
|
||||
if error_body:
|
||||
message += f" | body: {error_body}"
|
||||
|
||||
raise ExchangeResponseError(message) from exc
|
||||
except URLError as exc:
|
||||
raise ExchangeConnectionError(
|
||||
f"Network error while calling exchange: {exc.reason}"
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
|
||||
|
||||
if status != 200:
|
||||
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
|
||||
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
|
||||
|
||||
def get_json(
|
||||
self,
|
||||
path: str,
|
||||
|
||||
@@ -19,6 +19,8 @@ from src.integrations.exchange.models import (
|
||||
ExchangeHealth,
|
||||
ExchangeSymbol,
|
||||
ExecutionPriceSnapshot,
|
||||
Kline,
|
||||
KlineBatch,
|
||||
PrivateAuthHealth,
|
||||
SymbolValidationResult,
|
||||
TickerPrice,
|
||||
@@ -142,6 +144,185 @@ class ExchangeService:
|
||||
def _runtime_key(self, runtime_key: str | None) -> str:
|
||||
return (runtime_key or self._default_runtime_key).strip().lower()
|
||||
|
||||
def get_klines(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
*,
|
||||
interval: str = "1m",
|
||||
limit: int = 200,
|
||||
price_type: str = "bid",
|
||||
) -> KlineBatch:
|
||||
symbol_to_use = symbol or self.settings.default_symbol
|
||||
|
||||
if limit <= 0:
|
||||
limit = 200
|
||||
|
||||
if limit > 200:
|
||||
limit = 200
|
||||
|
||||
if interval not in {"1m", "5m", "15m"}:
|
||||
raise ExchangeError(f"Unsupported kline interval: {interval}")
|
||||
|
||||
normalized_price_type = price_type.strip().lower()
|
||||
|
||||
if normalized_price_type not in {"bid", "ask"}:
|
||||
normalized_price_type = "bid"
|
||||
|
||||
if not self.settings.exchange_enabled:
|
||||
raise ExchangeError("Klines are not available in mock exchange mode.")
|
||||
|
||||
validation = self.validate_symbol(symbol_to_use)
|
||||
if not validation.is_valid:
|
||||
raise ExchangeError(validation.message)
|
||||
|
||||
client = ExchangeRestClient()
|
||||
|
||||
try:
|
||||
payload = client.get_payload(
|
||||
"/api/v2/klines",
|
||||
params={
|
||||
"symbol": validation.normalized_symbol,
|
||||
"interval": interval,
|
||||
"limit": str(limit),
|
||||
"priceType": normalized_price_type,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="klines",
|
||||
exc=exc,
|
||||
symbol=validation.normalized_symbol,
|
||||
extra_payload={
|
||||
"interval": interval,
|
||||
"limit": limit,
|
||||
"price_type": normalized_price_type,
|
||||
},
|
||||
)
|
||||
raise ExchangeError(str(exc)) from exc
|
||||
|
||||
candles = self._parse_klines_payload(
|
||||
payload=payload,
|
||||
symbol=validation.normalized_symbol,
|
||||
interval=interval,
|
||||
source=f"rest_klines:{normalized_price_type}",
|
||||
)
|
||||
|
||||
return KlineBatch(
|
||||
symbol=validation.normalized_symbol,
|
||||
interval=interval,
|
||||
candles=candles[-limit:],
|
||||
source=f"rest_klines:{normalized_price_type}",
|
||||
)
|
||||
|
||||
def _parse_klines_payload(
|
||||
self,
|
||||
*,
|
||||
payload: object,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
source: str,
|
||||
) -> list[Kline]:
|
||||
raw_items = self._extract_klines_items(payload)
|
||||
|
||||
candles: list[Kline] = []
|
||||
|
||||
for item in raw_items:
|
||||
candle = self._parse_kline_item(
|
||||
item=item,
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
source=source,
|
||||
)
|
||||
|
||||
if candle is not None:
|
||||
candles.append(candle)
|
||||
|
||||
candles.sort(key=lambda item: item.open_time)
|
||||
|
||||
return candles
|
||||
|
||||
def _extract_klines_items(self, payload: object) -> list:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
if isinstance(payload.get("klines"), list):
|
||||
return payload["klines"]
|
||||
|
||||
if isinstance(payload.get("candles"), list):
|
||||
return payload["candles"]
|
||||
|
||||
if isinstance(payload.get("data"), list):
|
||||
return payload["data"]
|
||||
|
||||
inner = payload.get("payload")
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
|
||||
if isinstance(inner, dict):
|
||||
if isinstance(inner.get("klines"), list):
|
||||
return inner["klines"]
|
||||
|
||||
if isinstance(inner.get("candles"), list):
|
||||
return inner["candles"]
|
||||
|
||||
if isinstance(inner.get("data"), list):
|
||||
return inner["data"]
|
||||
|
||||
if isinstance(payload.get("result"), list):
|
||||
return payload["result"]
|
||||
|
||||
return []
|
||||
|
||||
def _parse_kline_item(
|
||||
self,
|
||||
*,
|
||||
item: object,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
source: str,
|
||||
) -> Kline | None:
|
||||
try:
|
||||
if isinstance(item, dict):
|
||||
open_time = (
|
||||
item.get("openTime")
|
||||
or item.get("open_time")
|
||||
or item.get("time")
|
||||
or item.get("timestamp")
|
||||
)
|
||||
|
||||
return Kline(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
open_time=int(open_time),
|
||||
open_price=float(item.get("open")),
|
||||
high_price=float(item.get("high")),
|
||||
low_price=float(item.get("low")),
|
||||
close_price=float(item.get("close")),
|
||||
volume=float(item.get("volume") or 0.0),
|
||||
source=source,
|
||||
)
|
||||
|
||||
if isinstance(item, list) and len(item) >= 6:
|
||||
return Kline(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
open_time=int(item[0]),
|
||||
open_price=float(item[1]),
|
||||
high_price=float(item[2]),
|
||||
low_price=float(item[3]),
|
||||
close_price=float(item[4]),
|
||||
volume=float(item[5] or 0.0),
|
||||
source=source,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def get_health(self) -> ExchangeHealth:
|
||||
if not self.settings.exchange_enabled:
|
||||
return mock_exchange_health()
|
||||
|
||||
Reference in New Issue
Block a user