07.4.4.1.1 — Market State Human UI + HOLD Lifecycle Fix

This commit is contained in:
2026-05-10 23:20:54 +03:00
parent 8024cd9d9a
commit ef7cec68cc
14 changed files with 1209 additions and 39 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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()