Compare commits
33 Commits
2785f83260
...
a996f2f797
| Author | SHA1 | Date | |
|---|---|---|---|
| a996f2f797 | |||
| 26deb861bc | |||
| 5f1f522fd7 | |||
| 3dde8d3e87 | |||
| 8a07d24bd7 | |||
| 4d58fcf2bf | |||
| c608072b7b | |||
| 04f92c1d0e | |||
| c9ce9ccae9 | |||
| a3e5ac324b | |||
| aacb2c409d | |||
| ecf8b5f60d | |||
| bcbec1aa02 | |||
| 33e60c2409 | |||
| 23cf386c38 | |||
| 60728c7efb | |||
| f6029372ef | |||
| a27774fd48 | |||
| ce90e58060 | |||
| 610e6c3043 | |||
| b3211cf024 | |||
| 7ccf406c93 | |||
| f50ba047ee | |||
| 63bea1831f | |||
| 4f57d4a322 | |||
| c632440d97 | |||
| 6ce14a0292 | |||
| cfd7d76806 | |||
| d334461339 | |||
| 777a11207d | |||
| 73ea891843 | |||
| 954ca0e427 | |||
| af276b1ce4 |
71
app/scripts/get_ticker_24hr.py
Normal file
71
app/scripts/get_ticker_24hr.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# app/scripts/get_ticker_24hr.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from src.core.config import load_settings
|
||||||
|
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Получить реальный ответ Dzengi ticker/24hr.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"symbol",
|
||||||
|
nargs="?",
|
||||||
|
default=settings.default_symbol,
|
||||||
|
help=(
|
||||||
|
"Торговый символ. "
|
||||||
|
f"По умолчанию: {settings.default_symbol}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
symbol = str(args.symbol).strip()
|
||||||
|
|
||||||
|
if not symbol:
|
||||||
|
print(
|
||||||
|
"Торговый символ не должен быть пустым.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = ExchangeRestClient().get_json(
|
||||||
|
"/api/v1/ticker/24hr",
|
||||||
|
params={
|
||||||
|
"symbol": symbol,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Не удалось получить ticker/24hr для {symbol}: "
|
||||||
|
f"{type(exc).__name__}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -2,85 +2,42 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from dataclasses import dataclass
|
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
_MARKET_PRICE_CACHE_SOURCE_NAME = "legacy-market-price-cache"
|
||||||
class MarketPriceSnapshot:
|
|
||||||
symbol: str
|
|
||||||
price: float
|
|
||||||
bid_price: float | None
|
|
||||||
ask_price: float | None
|
|
||||||
updated_at: str
|
|
||||||
source: str = "market-cache"
|
|
||||||
runtime_key: str = "default"
|
|
||||||
received_monotonic: float = 0.0
|
|
||||||
|
|
||||||
def age_seconds(self) -> float:
|
|
||||||
if self.received_monotonic <= 0:
|
|
||||||
return 999999.0
|
|
||||||
|
|
||||||
return max(0.0, time.monotonic() - self.received_monotonic)
|
|
||||||
|
|
||||||
def has_bid_ask(self) -> bool:
|
|
||||||
return (
|
|
||||||
self.bid_price is not None
|
|
||||||
and self.ask_price is not None
|
|
||||||
and self.bid_price > 0
|
|
||||||
and self.ask_price > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MarketPriceCache:
|
class MarketPriceCache:
|
||||||
_prices: dict[tuple[str, str], MarketPriceSnapshot] = {}
|
# Временный compatibility facade над каноническим Quote Store.
|
||||||
|
_store: QuoteStoreProtocol = InMemoryQuoteStore()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _key(cls, *, symbol: str, runtime_key: str = "default") -> tuple[str, str]:
|
def set_quote(
|
||||||
return runtime_key.strip().lower(), symbol.upper()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def set_price(
|
|
||||||
cls,
|
cls,
|
||||||
|
quote: Quote,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
|
||||||
price: float,
|
|
||||||
bid_price: float | None = None,
|
|
||||||
ask_price: float | None = None,
|
|
||||||
updated_at: str | None = None,
|
|
||||||
source: str = "market-polling",
|
|
||||||
runtime_key: str = "default",
|
runtime_key: str = "default",
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = load_settings()
|
cls._store.set(
|
||||||
|
_MARKET_PRICE_CACHE_SOURCE_NAME,
|
||||||
if updated_at is None:
|
quote,
|
||||||
updated_at = datetime.now(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
|
runtime_key=cls._normalize_runtime_key(runtime_key),
|
||||||
|
|
||||||
normalized_runtime_key = runtime_key.strip().lower()
|
|
||||||
|
|
||||||
cls._prices[cls._key(symbol=symbol, runtime_key=normalized_runtime_key)] = MarketPriceSnapshot(
|
|
||||||
symbol=symbol.upper(),
|
|
||||||
price=float(price),
|
|
||||||
bid_price=float(bid_price) if bid_price is not None else None,
|
|
||||||
ask_price=float(ask_price) if ask_price is not None else None,
|
|
||||||
updated_at=updated_at,
|
|
||||||
source=source,
|
|
||||||
runtime_key=normalized_runtime_key,
|
|
||||||
received_monotonic=time.monotonic(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_price(
|
def get_quote(
|
||||||
cls,
|
cls,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
*,
|
*,
|
||||||
runtime_key: str = "default",
|
runtime_key: str = "default",
|
||||||
) -> MarketPriceSnapshot | None:
|
) -> Quote | None:
|
||||||
return cls._prices.get(cls._key(symbol=symbol, runtime_key=runtime_key))
|
return cls._store.get(
|
||||||
|
_MARKET_PRICE_CACHE_SOURCE_NAME,
|
||||||
|
cls._normalize_symbol(symbol),
|
||||||
|
runtime_key=cls._normalize_runtime_key(runtime_key),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear(
|
def clear(
|
||||||
@@ -89,23 +46,24 @@ class MarketPriceCache:
|
|||||||
*,
|
*,
|
||||||
runtime_key: str | None = None,
|
runtime_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if symbol is None and runtime_key is None:
|
cls._store.clear(
|
||||||
cls._prices.clear()
|
source_name=_MARKET_PRICE_CACHE_SOURCE_NAME,
|
||||||
return
|
symbol=(
|
||||||
|
cls._normalize_symbol(symbol)
|
||||||
|
if symbol is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
runtime_key=(
|
||||||
|
cls._normalize_runtime_key(runtime_key)
|
||||||
|
if runtime_key is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if symbol is not None and runtime_key is not None:
|
@staticmethod
|
||||||
cls._prices.pop(cls._key(symbol=symbol, runtime_key=runtime_key), None)
|
def _normalize_symbol(symbol: str) -> str:
|
||||||
return
|
return str(symbol).strip().upper()
|
||||||
|
|
||||||
keys_to_delete = []
|
@staticmethod
|
||||||
|
def _normalize_runtime_key(runtime_key: str) -> str:
|
||||||
for key_runtime, key_symbol in cls._prices.keys():
|
return str(runtime_key).strip().lower()
|
||||||
if runtime_key is not None and key_runtime == runtime_key.strip().lower():
|
|
||||||
keys_to_delete.append((key_runtime, key_symbol))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if symbol is not None and key_symbol == symbol.upper():
|
|
||||||
keys_to_delete.append((key_runtime, key_symbol))
|
|
||||||
|
|
||||||
for key in keys_to_delete:
|
|
||||||
cls._prices.pop(key, None)
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ from src.core.types import JsonDict, NumericLike
|
|||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||||
|
DzengiWebSocketQuoteAdapter,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -297,6 +303,7 @@ class MarketDataRunner:
|
|||||||
|
|
||||||
valid_payload_count = 0
|
valid_payload_count = 0
|
||||||
invalid_payload_count = 0
|
invalid_payload_count = 0
|
||||||
|
adapter = DzengiWebSocketQuoteAdapter()
|
||||||
|
|
||||||
async for payload in ExchangeWebSocketClient().stream_depth(
|
async for payload in ExchangeWebSocketClient().stream_depth(
|
||||||
ws_symbol,
|
ws_symbol,
|
||||||
@@ -306,20 +313,31 @@ class MarketDataRunner:
|
|||||||
if current_symbol and current_symbol != symbol:
|
if current_symbol and current_symbol != symbol:
|
||||||
break
|
break
|
||||||
|
|
||||||
best_bid = cls._extract_best_price(payload, "bids")
|
try:
|
||||||
best_ask = cls._extract_best_price(payload, "asks")
|
quote = adapter.map_message(payload)
|
||||||
|
except MarketDataAcquisitionError:
|
||||||
if best_bid is None or best_ask is None:
|
|
||||||
invalid_payload_count += 1
|
invalid_payload_count += 1
|
||||||
|
|
||||||
if invalid_payload_count >= 5:
|
if invalid_payload_count >= 5:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"WebSocket depth stream does not contain valid bids/asks."
|
"WebSocket depth stream does not contain valid quotes."
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if quote.symbol.strip().upper() != cache_symbol.strip().upper():
|
||||||
|
invalid_payload_count += 1
|
||||||
|
|
||||||
|
if invalid_payload_count >= 5:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WebSocket depth stream returned another symbol."
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
invalid_payload_count = 0
|
invalid_payload_count = 0
|
||||||
|
best_bid = float(quote.bid_price)
|
||||||
|
best_ask = float(quote.ask_price)
|
||||||
|
|
||||||
if valid_payload_count == 0:
|
if valid_payload_count == 0:
|
||||||
should_log_connected = (
|
should_log_connected = (
|
||||||
@@ -354,12 +372,8 @@ class MarketDataRunner:
|
|||||||
|
|
||||||
valid_payload_count += 1
|
valid_payload_count += 1
|
||||||
|
|
||||||
MarketPriceCache.set_price(
|
MarketPriceCache.set_quote(
|
||||||
symbol=cache_symbol,
|
quote,
|
||||||
price=(best_bid + best_ask) / 2,
|
|
||||||
bid_price=best_bid,
|
|
||||||
ask_price=best_ask,
|
|
||||||
source=f"ws_depth:{context.runtime_key}",
|
|
||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from src.core.types import JsonDict, NumericLike
|
|||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||||
|
DzengiWebSocketQuoteAdapter,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +151,7 @@ async def start_market_stream() -> None:
|
|||||||
|
|
||||||
symbol = validation.normalized_symbol
|
symbol = validation.normalized_symbol
|
||||||
client = ExchangeWebSocketClient()
|
client = ExchangeWebSocketClient()
|
||||||
|
adapter = DzengiWebSocketQuoteAdapter()
|
||||||
|
|
||||||
journal.log_info(
|
journal.log_info(
|
||||||
"market_ws_started",
|
"market_ws_started",
|
||||||
@@ -153,29 +160,16 @@ async def start_market_stream() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async for message in client.stream_depth(symbol):
|
async for message in client.stream_depth(symbol):
|
||||||
event = _extract_market_event(message)
|
try:
|
||||||
|
quote = adapter.map_message(message)
|
||||||
if event is None:
|
except MarketDataAcquisitionError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price = safe_float(event.get("price"))
|
if quote.symbol.strip().upper() != symbol.strip().upper():
|
||||||
bid_price = safe_float(event.get("bid_price"))
|
|
||||||
ask_price = safe_float(event.get("ask_price"))
|
|
||||||
|
|
||||||
if price is None or bid_price is None or ask_price is None:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
MarketPriceCache.set_price(
|
MarketPriceCache.set_quote(
|
||||||
symbol=symbol,
|
quote,
|
||||||
price=price,
|
|
||||||
bid_price=bid_price,
|
|
||||||
ask_price=ask_price,
|
|
||||||
updated_at=(
|
|
||||||
str(event.get("updated_at"))
|
|
||||||
if event.get("updated_at") is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
source="ws_market_stream",
|
|
||||||
runtime_key="default",
|
runtime_key="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
# app/src/integrations/exchange/mock_data.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice
|
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
|
||||||
|
|
||||||
def mock_exchange_health() -> ExchangeHealth:
|
def mock_exchange_health() -> ExchangeHealth:
|
||||||
@@ -13,20 +17,23 @@ def mock_exchange_health() -> ExchangeHealth:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mock_ticker_price(symbol: str) -> TickerPrice:
|
def mock_quote(symbol: str) -> Quote:
|
||||||
symbol = symbol.upper().strip()
|
normalized_symbol = symbol.upper().strip()
|
||||||
fake_prices = {
|
fake_prices = {
|
||||||
"BTCUSDT": 68425.10,
|
"BTCUSDT": Decimal("68425.10"),
|
||||||
"ETHUSDT": 3521.44,
|
"ETHUSDT": Decimal("3521.44"),
|
||||||
"BNBUSDT": 612.33,
|
"BNBUSDT": Decimal("612.33"),
|
||||||
}
|
}
|
||||||
price = fake_prices.get(symbol, 100.00)
|
price = fake_prices.get(normalized_symbol, Decimal("100.00"))
|
||||||
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
||||||
return TickerPrice(
|
return Quote(
|
||||||
symbol=symbol,
|
symbol=normalized_symbol,
|
||||||
price=price,
|
last_price=price,
|
||||||
|
bid_price=price,
|
||||||
|
ask_price=price,
|
||||||
|
exchange_timestamp=None,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
source="mock",
|
source="mock",
|
||||||
updated_at=updated_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
|
||||||
|
|
||||||
# Состояние публичного API биржи.
|
# Состояние публичного API биржи.
|
||||||
@@ -25,13 +30,6 @@ class TimeSyncStatus:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# Текущая рыночная цена инструмента.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class TickerPrice:
|
|
||||||
symbol: str
|
|
||||||
price: float
|
|
||||||
source: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
# Snapshot цен для execution layer.
|
# Snapshot цен для execution layer.
|
||||||
@@ -62,26 +60,7 @@ class BalanceSummary:
|
|||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
||||||
# Информация о торговом инструменте биржи.
|
# Результат проверки торгового символа по каноническому справочнику Instrument.
|
||||||
@dataclass(slots=True)
|
|
||||||
class ExchangeSymbol:
|
|
||||||
symbol: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
base_asset: str
|
|
||||||
quote_asset: str
|
|
||||||
|
|
||||||
market_modes: list[str]
|
|
||||||
market_type: str
|
|
||||||
|
|
||||||
tick_size: float | None
|
|
||||||
step_size: float | None
|
|
||||||
min_qty: float | None
|
|
||||||
min_notional: float | None
|
|
||||||
|
|
||||||
|
|
||||||
# Результат проверки символа.
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SymbolValidationResult:
|
class SymbolValidationResult:
|
||||||
requested_symbol: str
|
requested_symbol: str
|
||||||
@@ -90,7 +69,7 @@ class SymbolValidationResult:
|
|||||||
is_valid: bool
|
is_valid: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
symbol_info: ExchangeSymbol | None
|
symbol_info: Instrument | None
|
||||||
|
|
||||||
|
|
||||||
# Состояние приватного API аккаунта.
|
# Состояние приватного API аккаунта.
|
||||||
@@ -134,6 +113,7 @@ class KlineBatch:
|
|||||||
candles: list[Kline]
|
candles: list[Kline]
|
||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
||||||
# Информация о торговой комиссии для инструмента.
|
# Информация о торговой комиссии для инструмента.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TradingFee:
|
class TradingFee:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
@@ -16,18 +16,16 @@ from src.integrations.exchange.market_cache import MarketPriceCache
|
|||||||
from src.integrations.exchange.mock_data import (
|
from src.integrations.exchange.mock_data import (
|
||||||
mock_balance_summary,
|
mock_balance_summary,
|
||||||
mock_exchange_health,
|
mock_exchange_health,
|
||||||
mock_ticker_price,
|
mock_quote,
|
||||||
)
|
)
|
||||||
from src.integrations.exchange.models import (
|
from src.integrations.exchange.models import (
|
||||||
BalanceSummary,
|
BalanceSummary,
|
||||||
ExchangeHealth,
|
ExchangeHealth,
|
||||||
ExchangeSymbol,
|
|
||||||
ExecutionPriceSnapshot,
|
ExecutionPriceSnapshot,
|
||||||
Kline,
|
Kline,
|
||||||
KlineBatch,
|
KlineBatch,
|
||||||
PrivateAuthHealth,
|
PrivateAuthHealth,
|
||||||
SymbolValidationResult,
|
SymbolValidationResult,
|
||||||
TickerPrice,
|
|
||||||
TimeSyncStatus,
|
TimeSyncStatus,
|
||||||
TradingFee,
|
TradingFee,
|
||||||
)
|
)
|
||||||
@@ -43,12 +41,46 @@ from src.integrations.exchange.status import (
|
|||||||
build_mock_exchange_status,
|
build_mock_exchange_status,
|
||||||
classify_exchange_error,
|
classify_exchange_error,
|
||||||
)
|
)
|
||||||
from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates
|
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||||
|
DzengiInstrumentDocumentSource,
|
||||||
|
DzengiQuoteDocumentSource,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
|
||||||
|
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
|
||||||
|
from src.market_data.acquisition.handlers.instrument_handler import (
|
||||||
|
DzengiInstrumentDocumentHandler,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.handlers.quotes_handler import (
|
||||||
|
DzengiQuoteDocumentHandler,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.registry import (
|
||||||
|
InstrumentFeedRegistry,
|
||||||
|
QuoteFeedRegistry,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.service import (
|
||||||
|
InstrumentAcquisitionService,
|
||||||
|
QuoteAcquisitionService,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.symbols import (
|
||||||
|
normalize_symbol,
|
||||||
|
resolve_symbol_index,
|
||||||
|
)
|
||||||
|
from src.storage.instrument_store import (
|
||||||
|
InMemoryInstrumentStore,
|
||||||
|
InstrumentStoreProtocol,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
|
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
|
||||||
|
_QUOTE_SOURCE_NAME = "dzengi"
|
||||||
|
|
||||||
|
|
||||||
class ExchangeService:
|
class ExchangeService:
|
||||||
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
|
_instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()
|
||||||
|
|
||||||
_execution_cache_max_age_seconds = 2.0
|
_execution_cache_max_age_seconds = 2.0
|
||||||
_default_runtime_key = "auto"
|
_default_runtime_key = "auto"
|
||||||
|
|
||||||
@@ -108,17 +140,28 @@ class ExchangeService:
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
|
quote = self._get_fresh_quote(
|
||||||
|
validation.normalized_symbol,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return status
|
return status
|
||||||
|
|
||||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
exchange_timestamp_ms = (
|
||||||
|
int(quote.exchange_timestamp.timestamp() * 1000)
|
||||||
|
if quote.exchange_timestamp is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
age_seconds = self._exchange_timestamp_age_seconds(
|
||||||
|
exchange_timestamp_ms
|
||||||
|
)
|
||||||
|
|
||||||
if age_seconds is not None and age_seconds > 60:
|
if age_seconds is not None and age_seconds > 60:
|
||||||
return build_market_stale_status(
|
return build_market_stale_status(
|
||||||
symbol=validation.normalized_symbol,
|
symbol=validation.normalized_symbol,
|
||||||
age_seconds=age_seconds,
|
age_seconds=age_seconds,
|
||||||
updated_at=str(snapshot.get("updated_at") or ""),
|
updated_at=self._format_exchange_time(
|
||||||
|
exchange_timestamp_ms
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return status
|
return status
|
||||||
@@ -668,7 +711,9 @@ class ExchangeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ticker = self._get_real_price(str(status.symbol or self.settings.default_symbol))
|
quote = self._get_fresh_quote(
|
||||||
|
str(status.symbol or self.settings.default_symbol)
|
||||||
|
)
|
||||||
except ExchangeError as exc:
|
except ExchangeError as exc:
|
||||||
return ExchangeHealth(
|
return ExchangeHealth(
|
||||||
ok=False,
|
ok=False,
|
||||||
@@ -679,7 +724,10 @@ class ExchangeService:
|
|||||||
return ExchangeHealth(
|
return ExchangeHealth(
|
||||||
ok=True,
|
ok=True,
|
||||||
mode="real_public_api",
|
mode="real_public_api",
|
||||||
message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}",
|
message=(
|
||||||
|
f"Public API OK. Цена {quote.symbol}: "
|
||||||
|
f"{float(quote.last_price):.2f}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверить доступность приватного API и валидность ключей аккаунта.
|
# Проверить доступность приватного API и валидность ключей аккаунта.
|
||||||
@@ -722,149 +770,41 @@ class ExchangeService:
|
|||||||
message=f"Private API OK. Балансов получено: {len(balances)}",
|
message=f"Private API OK. Балансов получено: {len(balances)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обновить price cache и вернуть TickerPrice.
|
# Получить каноническую текущую котировку из Store или REST Quotes Feed.
|
||||||
def refresh_price_cache(
|
def get_quote(
|
||||||
self,
|
self,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
*,
|
*,
|
||||||
runtime_key: str | None = None,
|
runtime_key: str | None = None,
|
||||||
) -> TickerPrice:
|
) -> Quote:
|
||||||
snapshot = self.refresh_market_snapshot_cache(
|
|
||||||
symbol,
|
|
||||||
runtime_key=runtime_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
price = safe_float(snapshot.get("last_price"))
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
raise ExchangeError("Field 'last_price' is missing in market snapshot.")
|
|
||||||
|
|
||||||
return TickerPrice(
|
|
||||||
symbol=str(snapshot["symbol"]),
|
|
||||||
price=price,
|
|
||||||
source=str(snapshot.get("source") or self._source_name()),
|
|
||||||
updated_at=str(snapshot["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновить market snapshot cache через свежий REST-запрос.
|
|
||||||
def refresh_market_snapshot_cache(
|
|
||||||
self,
|
|
||||||
symbol: str | None = None,
|
|
||||||
*,
|
|
||||||
runtime_key: str | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
normalized_runtime_key = self._runtime_key(runtime_key)
|
|
||||||
snapshot = self.get_fresh_market_snapshot(symbol)
|
|
||||||
|
|
||||||
last_price = safe_float(snapshot.get("last_price"))
|
|
||||||
bid_price = safe_float(snapshot.get("bid_price"))
|
|
||||||
ask_price = safe_float(snapshot.get("ask_price"))
|
|
||||||
|
|
||||||
if last_price is None or bid_price is None or ask_price is None:
|
|
||||||
raise ExchangeError("Market snapshot contains invalid price fields.")
|
|
||||||
|
|
||||||
MarketPriceCache.set_price(
|
|
||||||
symbol=str(snapshot["symbol"]),
|
|
||||||
price=last_price,
|
|
||||||
bid_price=bid_price,
|
|
||||||
ask_price=ask_price,
|
|
||||||
updated_at=str(snapshot["updated_at"]),
|
|
||||||
source=str(snapshot.get("source") or "rest_polling"),
|
|
||||||
runtime_key=normalized_runtime_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
# Получить последнюю цену инструмента из cache или REST API.
|
|
||||||
def get_price(
|
|
||||||
self,
|
|
||||||
symbol: str | None = None,
|
|
||||||
*,
|
|
||||||
runtime_key: str | None = None,
|
|
||||||
) -> TickerPrice:
|
|
||||||
symbol_to_use = symbol or self.settings.default_symbol
|
symbol_to_use = symbol or self.settings.default_symbol
|
||||||
normalized_runtime_key = self._runtime_key(runtime_key)
|
normalized_runtime_key = self._runtime_key(runtime_key)
|
||||||
|
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
return mock_ticker_price(symbol_to_use)
|
return mock_quote(symbol_to_use)
|
||||||
|
|
||||||
validation = self.validate_symbol(symbol_to_use)
|
validation = self.validate_symbol(symbol_to_use)
|
||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
raise ExchangeError(validation.message)
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
cached_price = MarketPriceCache.get_price(
|
cached_quote = MarketPriceCache.get_quote(
|
||||||
validation.normalized_symbol,
|
validation.normalized_symbol,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached_price is not None:
|
if (
|
||||||
return TickerPrice(
|
cached_quote is not None
|
||||||
symbol=cached_price.symbol,
|
and self._quote_age_seconds(cached_quote)
|
||||||
price=cached_price.price,
|
<= self._execution_cache_max_age_seconds
|
||||||
source=cached_price.source,
|
):
|
||||||
updated_at=cached_price.updated_at,
|
return cached_quote
|
||||||
)
|
|
||||||
|
|
||||||
return self._get_real_price(validation.normalized_symbol)
|
quote = self._get_fresh_quote(validation.normalized_symbol)
|
||||||
|
MarketPriceCache.set_quote(
|
||||||
# Получить market snapshot: last/bid/ask/source/age/freshness.
|
quote,
|
||||||
def get_market_snapshot(
|
|
||||||
self,
|
|
||||||
symbol: str | None = None,
|
|
||||||
*,
|
|
||||||
runtime_key: str | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
symbol_to_use = symbol or self.settings.default_symbol
|
|
||||||
normalized_runtime_key = self._runtime_key(runtime_key)
|
|
||||||
|
|
||||||
if not self.settings.exchange_enabled:
|
|
||||||
ticker = mock_ticker_price(symbol_to_use)
|
|
||||||
return {
|
|
||||||
"symbol": ticker.symbol,
|
|
||||||
"last_price": ticker.price,
|
|
||||||
"bid_price": ticker.price,
|
|
||||||
"ask_price": ticker.price,
|
|
||||||
"updated_at": ticker.updated_at,
|
|
||||||
"source": ticker.source,
|
|
||||||
"runtime_key": normalized_runtime_key,
|
|
||||||
"age_seconds": 0.0,
|
|
||||||
"is_fresh": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
validation = self.validate_symbol(symbol_to_use)
|
|
||||||
if not validation.is_valid:
|
|
||||||
raise ExchangeError(validation.message)
|
|
||||||
|
|
||||||
cached_price = MarketPriceCache.get_price(
|
|
||||||
validation.normalized_symbol,
|
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
|
return quote
|
||||||
if cached_price is not None:
|
|
||||||
age = cached_price.age_seconds()
|
|
||||||
|
|
||||||
if age <= self._execution_cache_max_age_seconds:
|
|
||||||
return {
|
|
||||||
"symbol": cached_price.symbol,
|
|
||||||
"last_price": cached_price.price,
|
|
||||||
"bid_price": cached_price.bid_price or cached_price.price,
|
|
||||||
"ask_price": cached_price.ask_price or cached_price.price,
|
|
||||||
"updated_at": cached_price.updated_at,
|
|
||||||
"source": cached_price.source,
|
|
||||||
"runtime_key": cached_price.runtime_key,
|
|
||||||
"age_seconds": round(age, 3),
|
|
||||||
"is_fresh": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot = self.refresh_market_snapshot_cache(
|
|
||||||
validation.normalized_symbol,
|
|
||||||
runtime_key=normalized_runtime_key,
|
|
||||||
)
|
|
||||||
snapshot["runtime_key"] = normalized_runtime_key
|
|
||||||
snapshot["age_seconds"] = 0.0
|
|
||||||
snapshot["is_fresh"] = True
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
# Получить snapshot, пригодный для execution layer.
|
# Получить snapshot, пригодный для execution layer.
|
||||||
def get_execution_snapshot(
|
def get_execution_snapshot(
|
||||||
@@ -877,15 +817,10 @@ class ExchangeService:
|
|||||||
normalized_runtime_key = self._runtime_key(runtime_key)
|
normalized_runtime_key = self._runtime_key(runtime_key)
|
||||||
|
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
ticker = mock_ticker_price(symbol_to_use)
|
quote = mock_quote(symbol_to_use)
|
||||||
return ExecutionPriceSnapshot(
|
return self._execution_snapshot_from_quote(
|
||||||
symbol=ticker.symbol,
|
quote,
|
||||||
last_price=ticker.price,
|
source=quote.source,
|
||||||
bid_price=ticker.price,
|
|
||||||
ask_price=ticker.price,
|
|
||||||
updated_at=ticker.updated_at,
|
|
||||||
source=ticker.source,
|
|
||||||
is_fresh=True,
|
|
||||||
age_seconds=0.0,
|
age_seconds=0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -893,125 +828,96 @@ class ExchangeService:
|
|||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
raise ExchangeError(validation.message)
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
cached_price = MarketPriceCache.get_price(
|
quote = MarketPriceCache.get_quote(
|
||||||
validation.normalized_symbol,
|
validation.normalized_symbol,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached_price is not None:
|
if quote is not None:
|
||||||
age = cached_price.age_seconds()
|
age_seconds = self._quote_age_seconds(quote)
|
||||||
|
|
||||||
if (
|
if age_seconds <= self._execution_cache_max_age_seconds:
|
||||||
age <= self._execution_cache_max_age_seconds
|
return self._execution_snapshot_from_quote(
|
||||||
and cached_price.has_bid_ask()
|
quote,
|
||||||
):
|
source=f"{quote.source}:fresh_cache",
|
||||||
bid_price = safe_float(cached_price.bid_price)
|
age_seconds=round(age_seconds, 3),
|
||||||
ask_price = safe_float(cached_price.ask_price)
|
|
||||||
last_price = safe_float(cached_price.price)
|
|
||||||
|
|
||||||
if (
|
|
||||||
last_price is not None
|
|
||||||
and bid_price is not None
|
|
||||||
and ask_price is not None
|
|
||||||
):
|
|
||||||
return ExecutionPriceSnapshot(
|
|
||||||
symbol=cached_price.symbol,
|
|
||||||
last_price=last_price,
|
|
||||||
bid_price=bid_price,
|
|
||||||
ask_price=ask_price,
|
|
||||||
updated_at=cached_price.updated_at,
|
|
||||||
source=f"{cached_price.source}:fresh_cache",
|
|
||||||
is_fresh=True,
|
|
||||||
age_seconds=round(age, 3),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
|
quote = self._get_fresh_quote(
|
||||||
|
validation.normalized_symbol
|
||||||
|
)
|
||||||
|
MarketPriceCache.set_quote(
|
||||||
|
quote,
|
||||||
|
runtime_key=normalized_runtime_key,
|
||||||
|
)
|
||||||
|
|
||||||
last_price = safe_float(snapshot.get("last_price"))
|
return self._execution_snapshot_from_quote(
|
||||||
bid_price = safe_float(snapshot.get("bid_price"))
|
quote,
|
||||||
ask_price = safe_float(snapshot.get("ask_price"))
|
source="rest_fallback",
|
||||||
|
age_seconds=round(
|
||||||
|
self._quote_age_seconds(quote),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if last_price is None or bid_price is None or ask_price is None:
|
def _execution_snapshot_from_quote(
|
||||||
raise ExchangeError("Market snapshot contains invalid execution prices.")
|
self,
|
||||||
|
quote: Quote,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
age_seconds: float,
|
||||||
|
) -> ExecutionPriceSnapshot:
|
||||||
|
timestamp = (
|
||||||
|
quote.exchange_timestamp
|
||||||
|
if quote.exchange_timestamp is not None
|
||||||
|
else quote.received_at
|
||||||
|
)
|
||||||
|
|
||||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
updated_at = timestamp.astimezone(
|
||||||
|
ZoneInfo(self.settings.tz)
|
||||||
|
).strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
|
||||||
return ExecutionPriceSnapshot(
|
return ExecutionPriceSnapshot(
|
||||||
symbol=str(snapshot["symbol"]),
|
symbol=quote.symbol,
|
||||||
last_price=last_price,
|
last_price=float(quote.last_price),
|
||||||
bid_price=bid_price,
|
bid_price=float(quote.bid_price),
|
||||||
ask_price=ask_price,
|
ask_price=float(quote.ask_price),
|
||||||
updated_at=str(snapshot["updated_at"]),
|
updated_at=updated_at,
|
||||||
source="rest_fallback",
|
source=source,
|
||||||
is_fresh=bool(snapshot.get("is_fresh")),
|
is_fresh=(
|
||||||
|
age_seconds
|
||||||
|
<= self._execution_cache_max_age_seconds
|
||||||
|
),
|
||||||
age_seconds=age_seconds,
|
age_seconds=age_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Получить свежий snapshot напрямую из REST API.
|
def _quote_age_seconds(self, quote: Quote) -> float:
|
||||||
def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]:
|
received_at = quote.received_at
|
||||||
symbol_to_use = symbol or self.settings.default_symbol
|
if received_at.tzinfo is None:
|
||||||
|
received_at = received_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
if not self.settings.exchange_enabled:
|
return max(
|
||||||
ticker = mock_ticker_price(symbol_to_use)
|
0.0,
|
||||||
return {
|
(
|
||||||
"symbol": ticker.symbol,
|
datetime.now(timezone.utc)
|
||||||
"last_price": ticker.price,
|
- received_at.astimezone(timezone.utc)
|
||||||
"bid_price": ticker.price,
|
).total_seconds(),
|
||||||
"ask_price": ticker.price,
|
|
||||||
"updated_at": ticker.updated_at,
|
|
||||||
"source": "mock",
|
|
||||||
"age_seconds": 0.0,
|
|
||||||
"is_fresh": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
validation = self.validate_symbol(symbol_to_use)
|
|
||||||
if not validation.is_valid:
|
|
||||||
raise ExchangeError(validation.message)
|
|
||||||
|
|
||||||
client = ExchangeRestClient()
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = client.get_json(
|
|
||||||
"/api/v1/ticker/24hr",
|
|
||||||
params={"symbol": validation.normalized_symbol},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_fresh_quote(self, normalized_symbol: str) -> Quote:
|
||||||
|
try:
|
||||||
|
return self._load_quote_via_acquisition(normalized_symbol)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_exchange_error(
|
self._log_exchange_error(
|
||||||
endpoint="ticker/24hr",
|
endpoint="ticker/24hr",
|
||||||
exc=exc,
|
exc=exc,
|
||||||
symbol=validation.normalized_symbol,
|
symbol=normalized_symbol,
|
||||||
)
|
)
|
||||||
raise ExchangeError(str(exc)) from exc
|
raise ExchangeError(str(exc)) from exc
|
||||||
|
|
||||||
last_price = safe_float(payload.get("lastPrice"))
|
|
||||||
|
|
||||||
if last_price is None:
|
|
||||||
exc = ExchangeError("Field 'lastPrice' is missing in ticker response.")
|
|
||||||
self._log_exchange_error(
|
|
||||||
endpoint="ticker/24hr",
|
|
||||||
exc=exc,
|
|
||||||
symbol=validation.normalized_symbol,
|
|
||||||
)
|
|
||||||
raise exc
|
|
||||||
|
|
||||||
bid_price = safe_float(payload.get("bidPrice")) or last_price
|
|
||||||
ask_price = safe_float(payload.get("askPrice")) or last_price
|
|
||||||
close_time = payload.get("closeTime") or payload.get("eventTime")
|
|
||||||
|
|
||||||
age_seconds = self._exchange_timestamp_age_seconds(close_time)
|
|
||||||
is_fresh = age_seconds is not None and age_seconds <= 60
|
|
||||||
|
|
||||||
return {
|
|
||||||
"symbol": validation.normalized_symbol,
|
|
||||||
"last_price": last_price,
|
|
||||||
"bid_price": bid_price,
|
|
||||||
"ask_price": ask_price,
|
|
||||||
"updated_at": self._format_exchange_time(close_time),
|
|
||||||
"source": "fresh_rest",
|
|
||||||
"age_seconds": age_seconds,
|
|
||||||
"is_fresh": is_fresh,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Получить live-балансы аккаунта.
|
# Получить live-балансы аккаунта.
|
||||||
def get_balance_summary(self) -> list[BalanceSummary]:
|
def get_balance_summary(self) -> list[BalanceSummary]:
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
@@ -1056,20 +962,22 @@ class ExchangeService:
|
|||||||
|
|
||||||
return balances
|
return balances
|
||||||
|
|
||||||
# Получить и распарсить список инструментов биржи.
|
# Получить канонический справочник инструментов через Instrument Store.
|
||||||
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
|
def get_instruments(self) -> tuple[Instrument, ...]:
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
return []
|
return ()
|
||||||
|
|
||||||
cached_symbols = type(self)._exchange_symbols_cache
|
instrument_store = type(self)._instrument_store
|
||||||
|
|
||||||
if cached_symbols is not None:
|
instruments = instrument_store.get(
|
||||||
return cached_symbols
|
_INSTRUMENT_REFERENCE_SOURCE_NAME
|
||||||
|
)
|
||||||
|
|
||||||
client = ExchangeRestClient()
|
if instruments is not None:
|
||||||
|
return instruments
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = client.get_json("/api/v1/exchangeInfo")
|
instruments = self._load_instruments_via_acquisition()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_exchange_error(
|
self._log_exchange_error(
|
||||||
endpoint="exchangeInfo",
|
endpoint="exchangeInfo",
|
||||||
@@ -1077,98 +985,65 @@ class ExchangeService:
|
|||||||
)
|
)
|
||||||
raise ExchangeError(str(exc)) from exc
|
raise ExchangeError(str(exc)) from exc
|
||||||
|
|
||||||
symbols_raw = self._extract_exchange_symbols_raw(payload)
|
instrument_store.set(
|
||||||
items: list[ExchangeSymbol] = []
|
_INSTRUMENT_REFERENCE_SOURCE_NAME,
|
||||||
|
instruments,
|
||||||
|
)
|
||||||
|
|
||||||
for item in symbols_raw:
|
return instruments
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
symbol = self._parse_exchange_symbol(item)
|
# Собрать Quotes acquisition pipeline и вернуть каноническую модель Quote.
|
||||||
|
def _load_quote_via_acquisition(
|
||||||
if symbol.symbol:
|
|
||||||
items.append(symbol)
|
|
||||||
|
|
||||||
type(self)._exchange_symbols_cache = items
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
# Извлечь сырой список symbols из exchangeInfo.
|
|
||||||
def _extract_exchange_symbols_raw(
|
|
||||||
self,
|
self,
|
||||||
payload: dict[str, object],
|
symbol: str,
|
||||||
) -> list[object]:
|
) -> Quote:
|
||||||
symbols = payload.get("symbols")
|
source = DzengiQuoteDocumentSource()
|
||||||
|
handler = DzengiQuoteDocumentHandler()
|
||||||
|
|
||||||
if isinstance(symbols, list):
|
feed = QuotesFeed(
|
||||||
return symbols
|
source=source,
|
||||||
|
handler=handler,
|
||||||
inner = payload.get("payload")
|
|
||||||
|
|
||||||
if isinstance(inner, dict):
|
|
||||||
nested_symbols = inner.get("symbols")
|
|
||||||
|
|
||||||
if isinstance(nested_symbols, list):
|
|
||||||
return nested_symbols
|
|
||||||
|
|
||||||
exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.")
|
|
||||||
self._log_exchange_error(
|
|
||||||
endpoint="exchangeInfo",
|
|
||||||
exc=exc,
|
|
||||||
)
|
)
|
||||||
raise exc
|
|
||||||
|
|
||||||
# Преобразовать один сырой symbol item в ExchangeSymbol.
|
registry = QuoteFeedRegistry()
|
||||||
def _parse_exchange_symbol(
|
registry.register(
|
||||||
|
_QUOTE_SOURCE_NAME,
|
||||||
|
feed,
|
||||||
|
)
|
||||||
|
|
||||||
|
acquisition_service = QuoteAcquisitionService(
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
return acquisition_service.load_quote(
|
||||||
|
_QUOTE_SOURCE_NAME,
|
||||||
|
symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Собрать acquisition pipeline и вернуть канонические модели Instrument.
|
||||||
|
def _load_instruments_via_acquisition(
|
||||||
self,
|
self,
|
||||||
item: dict[object, object],
|
) -> tuple[Instrument, ...]:
|
||||||
) -> ExchangeSymbol:
|
source = DzengiInstrumentDocumentSource()
|
||||||
filters = item.get("filters")
|
handler = DzengiInstrumentDocumentHandler()
|
||||||
|
|
||||||
tick_size = safe_float(item.get("tickSize"))
|
feed = InstrumentFeed(
|
||||||
if tick_size is None:
|
source=source,
|
||||||
tick_size = self._extract_filter_value(
|
handler=handler,
|
||||||
filters,
|
|
||||||
filter_names=["PRICE_FILTER"],
|
|
||||||
keys=["tickSize"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
step_size = safe_float(item.get("stepSize"))
|
registry = InstrumentFeedRegistry()
|
||||||
if step_size is None:
|
registry.register(
|
||||||
step_size = self._extract_filter_value(
|
_INSTRUMENT_REFERENCE_SOURCE_NAME,
|
||||||
filters,
|
feed,
|
||||||
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
|
|
||||||
keys=["stepSize"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
min_qty = safe_float(item.get("minQty"))
|
acquisition_service = InstrumentAcquisitionService(
|
||||||
if min_qty is None:
|
registry=registry,
|
||||||
min_qty = self._extract_filter_value(
|
|
||||||
filters,
|
|
||||||
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
|
|
||||||
keys=["minQty"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
min_notional = safe_float(item.get("minNotional"))
|
return acquisition_service.load_instruments(
|
||||||
if min_notional is None:
|
_INSTRUMENT_REFERENCE_SOURCE_NAME
|
||||||
min_notional = self._extract_filter_value(
|
|
||||||
filters,
|
|
||||||
filter_names=["MIN_NOTIONAL", "NOTIONAL"],
|
|
||||||
keys=["minNotional", "notional"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return ExchangeSymbol(
|
|
||||||
symbol=self._safe_str(item.get("symbol")),
|
|
||||||
name=self._safe_str(item.get("name")),
|
|
||||||
status=self._parse_exchange_symbol_status(item),
|
|
||||||
base_asset=self._safe_str(item.get("baseAsset")),
|
|
||||||
quote_asset=self._safe_str(item.get("quoteAsset")),
|
|
||||||
market_modes=self._parse_market_modes(item.get("marketModes")),
|
|
||||||
market_type=self._safe_str(item.get("marketType"), "unknown"),
|
|
||||||
tick_size=tick_size,
|
|
||||||
step_size=step_size,
|
|
||||||
min_qty=min_qty,
|
|
||||||
min_notional=min_notional,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Безопасно привести значение к строке.
|
# Безопасно привести значение к строке.
|
||||||
@@ -1178,91 +1053,6 @@ class ExchangeService:
|
|||||||
|
|
||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
def _parse_exchange_symbol_status(self, item: dict[object, object]) -> str:
|
|
||||||
status = self._safe_str(item.get("status"), "unknown")
|
|
||||||
|
|
||||||
false_flags = {
|
|
||||||
"isTradingAllowed",
|
|
||||||
"tradingAllowed",
|
|
||||||
"availableForTrading",
|
|
||||||
"isTradable",
|
|
||||||
"tradable",
|
|
||||||
"isMarketOpen",
|
|
||||||
"marketOpen",
|
|
||||||
"isOpen",
|
|
||||||
"enabled",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in false_flags:
|
|
||||||
if key not in item:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = item.get(key)
|
|
||||||
|
|
||||||
if isinstance(value, bool) and not value:
|
|
||||||
return "NOT_TRADABLE"
|
|
||||||
|
|
||||||
if str(value).strip().lower() in {"false", "0", "no", "disabled"}:
|
|
||||||
return "NOT_TRADABLE"
|
|
||||||
|
|
||||||
for key in ("tradingMode", "tradeMode", "mode", "state"):
|
|
||||||
value = str(item.get(key) or "").strip().upper()
|
|
||||||
|
|
||||||
if value in {
|
|
||||||
"NOT_TRADABLE",
|
|
||||||
"TRADING_DISABLED",
|
|
||||||
"MARKET_DISABLED",
|
|
||||||
"UNAVAILABLE_FOR_TRADING",
|
|
||||||
"CLOSE_ONLY",
|
|
||||||
"REDUCE_ONLY",
|
|
||||||
"VIEW_ONLY",
|
|
||||||
}:
|
|
||||||
return value
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
# Привести marketModes к list[str].
|
|
||||||
def _parse_market_modes(self, value: object) -> list[str]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [
|
|
||||||
str(item).strip()
|
|
||||||
for item in value
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
return [value.strip()]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Извлечь числовое значение из filters exchangeInfo.
|
|
||||||
def _extract_filter_value(
|
|
||||||
self,
|
|
||||||
filters: object,
|
|
||||||
*,
|
|
||||||
filter_names: list[str],
|
|
||||||
keys: list[str],
|
|
||||||
) -> float | None:
|
|
||||||
if not isinstance(filters, list):
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized_filter_names = {name.upper() for name in filter_names}
|
|
||||||
|
|
||||||
for entry in filters:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
filter_type = str(entry.get("filterType", "")).strip().upper()
|
|
||||||
if filter_type not in normalized_filter_names:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for key in keys:
|
|
||||||
value = safe_float(entry.get(key))
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Проверить, существует ли инструмент на бирже.
|
# Проверить, существует ли инструмент на бирже.
|
||||||
def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
|
def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
|
||||||
requested = normalize_symbol(raw_symbol)
|
requested = normalize_symbol(raw_symbol)
|
||||||
@@ -1285,43 +1075,40 @@ class ExchangeService:
|
|||||||
symbol_info=None,
|
symbol_info=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
symbols = self.get_exchange_symbols()
|
instruments = self.get_instruments()
|
||||||
candidates = symbol_candidates(requested)
|
|
||||||
|
matched_index = resolve_symbol_index(
|
||||||
|
requested,
|
||||||
|
[
|
||||||
|
instrument.symbol
|
||||||
|
for instrument in instruments
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if matched_index is not None:
|
||||||
|
instrument = instruments[matched_index]
|
||||||
|
|
||||||
for candidate in candidates:
|
|
||||||
for symbol_info in symbols:
|
|
||||||
if normalize_symbol(symbol_info.symbol) == candidate:
|
|
||||||
return SymbolValidationResult(
|
return SymbolValidationResult(
|
||||||
requested_symbol=requested,
|
requested_symbol=requested,
|
||||||
normalized_symbol=normalize_symbol(symbol_info.symbol),
|
normalized_symbol=normalize_symbol(
|
||||||
|
instrument.symbol
|
||||||
|
),
|
||||||
is_valid=True,
|
is_valid=True,
|
||||||
message="Символ найден в exchangeInfo.",
|
message="Символ найден в exchangeInfo.",
|
||||||
symbol_info=symbol_info,
|
symbol_info=instrument,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SymbolValidationResult(
|
return SymbolValidationResult(
|
||||||
requested_symbol=requested,
|
requested_symbol=requested,
|
||||||
normalized_symbol=requested,
|
normalized_symbol=requested,
|
||||||
is_valid=False,
|
is_valid=False,
|
||||||
message=f"Символ '{requested}' не найден в exchangeInfo.",
|
message=(
|
||||||
|
f"Символ '{requested}' "
|
||||||
|
"не найден в exchangeInfo."
|
||||||
|
),
|
||||||
symbol_info=None,
|
symbol_info=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Получить реальную цену инструмента через свежий REST snapshot.
|
|
||||||
def _get_real_price(self, symbol: str) -> TickerPrice:
|
|
||||||
snapshot = self.get_fresh_market_snapshot(symbol)
|
|
||||||
price = safe_float(snapshot.get("last_price"))
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
raise ExchangeError("Field 'last_price' is missing in market snapshot.")
|
|
||||||
|
|
||||||
return TickerPrice(
|
|
||||||
symbol=str(snapshot["symbol"]),
|
|
||||||
price=price,
|
|
||||||
source=self._source_name(),
|
|
||||||
updated_at=str(snapshot["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_exchange_server_time_ms(self) -> int:
|
def get_exchange_server_time_ms(self) -> int:
|
||||||
payload = ExchangeRestClient().get_json("/api/v1/time")
|
payload = ExchangeRestClient().get_json("/api/v1/time")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from src.integrations.exchange.exceptions import (
|
|||||||
ExchangeConnectionError,
|
ExchangeConnectionError,
|
||||||
ExchangeResponseError,
|
ExchangeResponseError,
|
||||||
)
|
)
|
||||||
|
from src.market_data.acquisition.models.status import (
|
||||||
|
InstrumentTradingState,
|
||||||
|
classify_instrument_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExchangeStatusCode(StrEnum):
|
class ExchangeStatusCode(StrEnum):
|
||||||
@@ -35,7 +39,7 @@ class ExchangeRuntimeStatus:
|
|||||||
raw_status: str | None = None
|
raw_status: str | None = None
|
||||||
raw_error: str | None = None
|
raw_error: str | None = None
|
||||||
|
|
||||||
# вернуть статус в dict для старого UI-кода на время миграции
|
# Вернуть статус в dict для старого UI-кода на время миграции.
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"code": self.code.value,
|
"code": self.code.value,
|
||||||
@@ -79,7 +83,7 @@ def build_market_stale_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# собрать статус mock-режима
|
# Собрать статус mock-режима.
|
||||||
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
||||||
return ExchangeRuntimeStatus(
|
return ExchangeRuntimeStatus(
|
||||||
code=ExchangeStatusCode.OPEN,
|
code=ExchangeStatusCode.OPEN,
|
||||||
@@ -95,48 +99,21 @@ def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# собрать статус ошибки авторизации аккаунта
|
# Собрать статус ошибки авторизации аккаунта.
|
||||||
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
|
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
|
||||||
return build_exchange_error_status(exc)
|
return build_exchange_error_status(exc)
|
||||||
|
|
||||||
|
|
||||||
OPEN_STATUSES = {
|
# Собрать legacy runtime-статус по канонической классификации инструмента.
|
||||||
"TRADING",
|
|
||||||
"OPEN",
|
|
||||||
"ACTIVE",
|
|
||||||
"ENABLED",
|
|
||||||
"ONLINE",
|
|
||||||
}
|
|
||||||
|
|
||||||
BREAK_STATUSES = {
|
|
||||||
"BREAK",
|
|
||||||
"CLOSED",
|
|
||||||
"HALT",
|
|
||||||
"HALTED",
|
|
||||||
"PAUSED",
|
|
||||||
"SUSPENDED",
|
|
||||||
"DISABLED",
|
|
||||||
"SETTLING",
|
|
||||||
"POST_ONLY",
|
|
||||||
"NOT_TRADABLE",
|
|
||||||
"TRADING_DISABLED",
|
|
||||||
"MARKET_DISABLED",
|
|
||||||
"UNAVAILABLE_FOR_TRADING",
|
|
||||||
"CLOSE_ONLY",
|
|
||||||
"REDUCE_ONLY",
|
|
||||||
"VIEW_ONLY",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# определить единый runtime-статус по статусу инструмента биржи
|
|
||||||
def build_market_status_from_symbol_status(
|
def build_market_status_from_symbol_status(
|
||||||
*,
|
*,
|
||||||
raw_status: str | None,
|
raw_status: str | None,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> ExchangeRuntimeStatus:
|
) -> ExchangeRuntimeStatus:
|
||||||
normalized_status = str(raw_status or "").strip().upper()
|
classification = classify_instrument_status(raw_status)
|
||||||
|
normalized_status = classification.normalized_status
|
||||||
|
|
||||||
if normalized_status in OPEN_STATUSES:
|
if classification.state == InstrumentTradingState.OPEN:
|
||||||
return ExchangeRuntimeStatus(
|
return ExchangeRuntimeStatus(
|
||||||
code=ExchangeStatusCode.OPEN,
|
code=ExchangeStatusCode.OPEN,
|
||||||
is_open=True,
|
is_open=True,
|
||||||
@@ -150,15 +127,7 @@ def build_market_status_from_symbol_status(
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
if normalized_status in {
|
if classification.state == InstrumentTradingState.NOT_TRADABLE:
|
||||||
"NOT_TRADABLE",
|
|
||||||
"TRADING_DISABLED",
|
|
||||||
"MARKET_DISABLED",
|
|
||||||
"UNAVAILABLE_FOR_TRADING",
|
|
||||||
"CLOSE_ONLY",
|
|
||||||
"REDUCE_ONLY",
|
|
||||||
"VIEW_ONLY",
|
|
||||||
}:
|
|
||||||
return ExchangeRuntimeStatus(
|
return ExchangeRuntimeStatus(
|
||||||
code=ExchangeStatusCode.BREAK,
|
code=ExchangeStatusCode.BREAK,
|
||||||
is_open=False,
|
is_open=False,
|
||||||
@@ -172,7 +141,7 @@ def build_market_status_from_symbol_status(
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
if normalized_status in BREAK_STATUSES:
|
if classification.state == InstrumentTradingState.BREAK:
|
||||||
return ExchangeRuntimeStatus(
|
return ExchangeRuntimeStatus(
|
||||||
code=ExchangeStatusCode.BREAK,
|
code=ExchangeStatusCode.BREAK,
|
||||||
is_open=False,
|
is_open=False,
|
||||||
@@ -198,12 +167,12 @@ def build_market_status_from_symbol_status(
|
|||||||
),
|
),
|
||||||
ui_line="⚠️ Статус торгов неизвестен",
|
ui_line="⚠️ Статус торгов неизвестен",
|
||||||
reason="market_status_unknown",
|
reason="market_status_unknown",
|
||||||
raw_status=normalized_status or None,
|
raw_status=normalized_status,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# собрать единый статус для неверного торгового инструмента
|
# Собрать единый статус для неверного торгового инструмента.
|
||||||
def build_invalid_symbol_status(
|
def build_invalid_symbol_status(
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -223,7 +192,7 @@ def build_invalid_symbol_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# собрать единый статус по ошибке exchange/API
|
# Собрать единый статус по ошибке exchange/API.
|
||||||
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
|
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
|
||||||
error_type = classify_exchange_error(exc)
|
error_type = classify_exchange_error(exc)
|
||||||
raw_error = str(exc)
|
raw_error = str(exc)
|
||||||
@@ -270,7 +239,7 @@ def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# классифицировать ошибку биржи для единого UI и логов
|
# Классифицировать ошибку биржи для единого UI и логов.
|
||||||
def classify_exchange_error(exc: Exception) -> str:
|
def classify_exchange_error(exc: Exception) -> str:
|
||||||
text = str(exc).lower()
|
text = str(exc).lower()
|
||||||
|
|
||||||
@@ -326,7 +295,7 @@ def classify_exchange_error(exc: Exception) -> str:
|
|||||||
return "generic"
|
return "generic"
|
||||||
|
|
||||||
|
|
||||||
# проверить, относится ли reason к unified exchange status layer
|
# Проверить, относится ли reason к unified exchange status layer.
|
||||||
def is_exchange_status_reason(reason: str | None) -> bool:
|
def is_exchange_status_reason(reason: str | None) -> bool:
|
||||||
if not reason:
|
if not reason:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,24 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.symbols import (
|
||||||
def normalize_symbol(raw_symbol: str) -> str:
|
normalize_symbol,
|
||||||
return (raw_symbol or "").strip().upper()
|
symbol_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def symbol_candidates(raw_symbol: str) -> list[str]:
|
__all__ = [
|
||||||
value = normalize_symbol(raw_symbol)
|
"normalize_symbol",
|
||||||
if not value:
|
"symbol_candidates",
|
||||||
return []
|
]
|
||||||
|
|
||||||
candidates = [value]
|
|
||||||
|
|
||||||
compact = value.replace("%2F", "/")
|
|
||||||
if compact not in candidates:
|
|
||||||
candidates.append(compact)
|
|
||||||
|
|
||||||
no_spaces = compact.replace(" ", "")
|
|
||||||
if no_spaces not in candidates:
|
|
||||||
candidates.append(no_spaces)
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
0
app/src/market_data/__init__.py
Normal file
0
app/src/market_data/__init__.py
Normal file
0
app/src/market_data/acquisition/__init__.py
Normal file
0
app/src/market_data/acquisition/__init__.py
Normal file
316
app/src/market_data/acquisition/adapters/dzengi/mapper.py
Normal file
316
app/src/market_data/acquisition/adapters/dzengi/mapper.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/mapper.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||||
|
DzengiExchangeInfoResponse,
|
||||||
|
DzengiExchangeInfoSymbol,
|
||||||
|
DzengiInstrumentFilter,
|
||||||
|
DzengiLotSizeFilter,
|
||||||
|
DzengiMinNotionalFilter,
|
||||||
|
DzengiRawNumeric,
|
||||||
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketQuoteResponse,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentReferenceMappingError,
|
||||||
|
QuoteMappingError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
|
||||||
|
|
||||||
|
_DZENGI_SOURCE_NAME = "dzengi"
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_symbol_to_instrument(
|
||||||
|
symbol: DzengiExchangeInfoSymbol,
|
||||||
|
) -> Instrument:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенную raw-модель инструмента Dzengi
|
||||||
|
во внутреннюю source-independent модель Instrument.
|
||||||
|
|
||||||
|
Функция предполагает, что до mapper уже были выполнены:
|
||||||
|
schema validation, parsing и value validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lot_size = _find_single_filter(
|
||||||
|
symbol.filters,
|
||||||
|
DzengiLotSizeFilter,
|
||||||
|
filter_name="LOT_SIZE",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
)
|
||||||
|
min_notional = _find_single_filter(
|
||||||
|
symbol.filters,
|
||||||
|
DzengiMinNotionalFilter,
|
||||||
|
filter_name="MIN_NOTIONAL",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Instrument(
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
name=symbol.name,
|
||||||
|
status=symbol.status,
|
||||||
|
base_asset=symbol.base_asset,
|
||||||
|
quote_asset=symbol.quote_asset,
|
||||||
|
asset_type=_optional_text(symbol.asset_type),
|
||||||
|
market_type=symbol.market_type,
|
||||||
|
market_modes=symbol.market_modes,
|
||||||
|
order_types=symbol.order_types,
|
||||||
|
base_asset_precision=symbol.base_asset_precision,
|
||||||
|
quote_asset_precision=symbol.quote_precision,
|
||||||
|
tick_size=_optional_decimal(
|
||||||
|
symbol.tick_size,
|
||||||
|
field_name="tickSize",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
tick_value=_optional_decimal(
|
||||||
|
symbol.tick_value,
|
||||||
|
field_name="tickValue",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
step_size=_optional_decimal(
|
||||||
|
lot_size.step_size if lot_size is not None else None,
|
||||||
|
field_name="stepSize",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
min_qty=_optional_decimal(
|
||||||
|
lot_size.min_qty if lot_size is not None else None,
|
||||||
|
field_name="minQty",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
max_qty=_optional_decimal(
|
||||||
|
lot_size.max_qty if lot_size is not None else None,
|
||||||
|
field_name="maxQty",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
min_notional=_optional_decimal(
|
||||||
|
min_notional.min_notional
|
||||||
|
if min_notional is not None
|
||||||
|
else None,
|
||||||
|
field_name="minNotional",
|
||||||
|
symbol=symbol.symbol,
|
||||||
|
),
|
||||||
|
country=_optional_text(symbol.country),
|
||||||
|
sector=_optional_text(symbol.sector),
|
||||||
|
industry=_optional_text(symbol.industry),
|
||||||
|
trading_hours=_optional_text(symbol.trading_hours),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_exchange_info_to_instruments(
|
||||||
|
response: DzengiExchangeInfoResponse,
|
||||||
|
) -> tuple[Instrument, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать все инструменты exchangeInfo
|
||||||
|
во внутренние модели Instrument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
map_dzengi_symbol_to_instrument(symbol)
|
||||||
|
for symbol in response.payload.symbols
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_ticker_to_quote(
|
||||||
|
response: DzengiTicker24hrResponse,
|
||||||
|
*,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенную raw-модель Dzengi ticker/24hr
|
||||||
|
во внутреннюю source-independent модель Quote.
|
||||||
|
|
||||||
|
Функция предполагает, что до mapper уже были выполнены:
|
||||||
|
schema validation, parsing и value validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_received_at = _require_aware_datetime(
|
||||||
|
received_at,
|
||||||
|
field_name="received_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Quote(
|
||||||
|
symbol=response.symbol.strip(),
|
||||||
|
last_price=_required_quote_decimal(
|
||||||
|
response.last_price,
|
||||||
|
field_name="lastPrice",
|
||||||
|
),
|
||||||
|
bid_price=_required_quote_decimal(
|
||||||
|
response.bid_price,
|
||||||
|
field_name="bidPrice",
|
||||||
|
),
|
||||||
|
ask_price=_required_quote_decimal(
|
||||||
|
response.ask_price,
|
||||||
|
field_name="askPrice",
|
||||||
|
),
|
||||||
|
exchange_timestamp=_timestamp_ms_to_utc_datetime(
|
||||||
|
response.close_time,
|
||||||
|
),
|
||||||
|
received_at=normalized_received_at,
|
||||||
|
source=_DZENGI_SOURCE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_ms_to_utc_datetime(value: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(
|
||||||
|
value / 1000,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError) as exc:
|
||||||
|
raise QuoteMappingError(
|
||||||
|
"Поле closeTime невозможно преобразовать "
|
||||||
|
"в UTC datetime."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _required_quote_decimal(
|
||||||
|
value: DzengiRawNumeric,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise QuoteMappingError(
|
||||||
|
f"Поле {field_name} котировки невозможно "
|
||||||
|
"преобразовать в Decimal."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not result.is_finite():
|
||||||
|
raise QuoteMappingError(
|
||||||
|
f"Поле {field_name} котировки должно быть "
|
||||||
|
"конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware_datetime(
|
||||||
|
value: datetime,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> datetime:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise QuoteMappingError(
|
||||||
|
f"Поле {field_name} должно содержать timezone-aware datetime."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _find_single_filter[
|
||||||
|
FilterT: DzengiInstrumentFilter
|
||||||
|
](
|
||||||
|
filters: tuple[DzengiInstrumentFilter, ...],
|
||||||
|
filter_type: type[FilterT],
|
||||||
|
*,
|
||||||
|
filter_name: str,
|
||||||
|
symbol: str,
|
||||||
|
) -> FilterT | None:
|
||||||
|
matches = tuple(
|
||||||
|
instrument_filter
|
||||||
|
for instrument_filter in filters
|
||||||
|
if isinstance(instrument_filter, filter_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise InstrumentReferenceMappingError(
|
||||||
|
f"Инструмент '{symbol}' содержит несколько "
|
||||||
|
f"фильтров {filter_name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_decimal(
|
||||||
|
value: DzengiRawNumeric | None,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
symbol: str,
|
||||||
|
) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise InstrumentReferenceMappingError(
|
||||||
|
f"Поле {field_name} инструмента '{symbol}' "
|
||||||
|
f"невозможно преобразовать в Decimal."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not result.is_finite():
|
||||||
|
raise InstrumentReferenceMappingError(
|
||||||
|
f"Поле {field_name} инструмента '{symbol}' "
|
||||||
|
f"должно быть конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = value.strip()
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_websocket_quote_to_quote(
|
||||||
|
response: DzengiWebSocketQuoteResponse,
|
||||||
|
*,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенную WebSocket-модель Dzengi в канонический Quote.
|
||||||
|
|
||||||
|
Depth-сообщение не содержит цену последней сделки, поэтому временно
|
||||||
|
используется midpoint best bid / best ask — так же, как в legacy runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_received_at = _require_aware_datetime(
|
||||||
|
received_at,
|
||||||
|
field_name="received_at",
|
||||||
|
)
|
||||||
|
bid_price = _required_quote_decimal(
|
||||||
|
response.bid_price,
|
||||||
|
field_name="bidPrice",
|
||||||
|
)
|
||||||
|
ask_price = _required_quote_decimal(
|
||||||
|
response.ask_price,
|
||||||
|
field_name="askPrice",
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange_timestamp = None
|
||||||
|
if response.timestamp is not None:
|
||||||
|
try:
|
||||||
|
exchange_timestamp = datetime.fromtimestamp(
|
||||||
|
response.timestamp / 1000,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError) as exc:
|
||||||
|
raise QuoteMappingError(
|
||||||
|
"Поле timestamp невозможно преобразовать в UTC datetime."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return Quote(
|
||||||
|
symbol=response.symbol.strip(),
|
||||||
|
last_price=(bid_price + ask_price) / Decimal("2"),
|
||||||
|
bid_price=bid_price,
|
||||||
|
ask_price=ask_price,
|
||||||
|
exchange_timestamp=exchange_timestamp,
|
||||||
|
received_at=normalized_received_at,
|
||||||
|
source=_DZENGI_SOURCE_NAME,
|
||||||
|
)
|
||||||
133
app/src/market_data/acquisition/adapters/dzengi/models.py
Normal file
133
app/src/market_data/acquisition/adapters/dzengi/models.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/models.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
|
|
||||||
|
# Число в исходном JSON-ответе Dzengi без предметного преобразования.
|
||||||
|
DzengiJsonNumber: TypeAlias = int | float
|
||||||
|
|
||||||
|
# Числовое значение, которое Dzengi может передать числом или строкой.
|
||||||
|
DzengiRawNumeric: TypeAlias = str | int | float
|
||||||
|
|
||||||
|
# Скалярное значение неизвестного поля транспортного ответа.
|
||||||
|
DzengiJsonScalar: TypeAlias = str | int | float | bool | None
|
||||||
|
|
||||||
|
|
||||||
|
# Лимит запросов из exchangeInfo.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiRateLimit:
|
||||||
|
interval: str
|
||||||
|
interval_num: int
|
||||||
|
limit: int
|
||||||
|
rate_limit_type: str
|
||||||
|
|
||||||
|
|
||||||
|
# Базовый контракт фильтра инструмента Dzengi.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiInstrumentFilter:
|
||||||
|
filter_type: str
|
||||||
|
|
||||||
|
|
||||||
|
# Ограничения размера заявки.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiLotSizeFilter(DzengiInstrumentFilter):
|
||||||
|
min_qty: DzengiRawNumeric | None
|
||||||
|
max_qty: DzengiRawNumeric | None
|
||||||
|
step_size: DzengiRawNumeric | None
|
||||||
|
|
||||||
|
|
||||||
|
# Ограничение минимальной стоимости заявки.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiMinNotionalFilter(DzengiInstrumentFilter):
|
||||||
|
min_notional: DzengiRawNumeric | None
|
||||||
|
|
||||||
|
|
||||||
|
# Неизвестный тип фильтра, который ещё не поддерживается адаптером.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiUnknownFilter(DzengiInstrumentFilter):
|
||||||
|
fields: tuple[tuple[str, DzengiJsonScalar], ...]
|
||||||
|
|
||||||
|
|
||||||
|
# Один инструмент из ответа Dzengi exchangeInfo.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiExchangeInfoSymbol:
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
asset_type: str | None
|
||||||
|
|
||||||
|
base_asset: str
|
||||||
|
base_asset_precision: int | None
|
||||||
|
|
||||||
|
quote_asset: str
|
||||||
|
quote_asset_id: str | None
|
||||||
|
quote_precision: int | None
|
||||||
|
|
||||||
|
order_types: tuple[str, ...]
|
||||||
|
filters: tuple[DzengiInstrumentFilter, ...]
|
||||||
|
|
||||||
|
market_modes: tuple[str, ...]
|
||||||
|
market_type: str
|
||||||
|
|
||||||
|
country: str | None
|
||||||
|
sector: str | None
|
||||||
|
industry: str | None
|
||||||
|
trading_hours: str | None
|
||||||
|
|
||||||
|
tick_size: DzengiJsonNumber | None
|
||||||
|
tick_value: DzengiJsonNumber | None
|
||||||
|
|
||||||
|
trading_fee: DzengiJsonNumber | None
|
||||||
|
exchange_fee: DzengiJsonNumber | None
|
||||||
|
|
||||||
|
long_rate: DzengiJsonNumber | None
|
||||||
|
short_rate: DzengiJsonNumber | None
|
||||||
|
swap_charge_interval: int | None
|
||||||
|
|
||||||
|
min_sl_gap: DzengiJsonNumber | None
|
||||||
|
max_sl_gap: DzengiJsonNumber | None
|
||||||
|
min_tp_gap: DzengiJsonNumber | None
|
||||||
|
max_tp_gap: DzengiJsonNumber | None
|
||||||
|
|
||||||
|
|
||||||
|
# Содержимое exchangeInfo независимо от внешней оболочки API.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiExchangeInfoPayload:
|
||||||
|
timezone: str | None
|
||||||
|
server_time: int | None
|
||||||
|
rate_limits: tuple[DzengiRateLimit, ...]
|
||||||
|
exchange_filters: tuple[DzengiUnknownFilter, ...]
|
||||||
|
symbols: tuple[DzengiExchangeInfoSymbol, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# Нормализованное транспортное представление ответа exchangeInfo.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiExchangeInfoResponse:
|
||||||
|
payload: DzengiExchangeInfoPayload
|
||||||
|
|
||||||
|
# Поля присутствуют в wrapped-формате ответа и отсутствуют
|
||||||
|
# в фактическом unwrapped-ответе публичного REST endpoint.
|
||||||
|
status: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
|
||||||
|
# Транспортное представление ответа Dzengi GET /api/v1/ticker/24hr.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiTicker24hrResponse:
|
||||||
|
symbol: str
|
||||||
|
last_price: DzengiRawNumeric
|
||||||
|
bid_price: DzengiRawNumeric
|
||||||
|
ask_price: DzengiRawNumeric
|
||||||
|
close_time: int
|
||||||
|
|
||||||
|
|
||||||
|
# Нормализованное транспортное представление котировки из Dzengi WebSocket.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiWebSocketQuoteResponse:
|
||||||
|
symbol: str
|
||||||
|
bid_price: DzengiRawNumeric
|
||||||
|
ask_price: DzengiRawNumeric
|
||||||
|
timestamp: int | None
|
||||||
716
app/src/market_data/acquisition/adapters/dzengi/parser.py
Normal file
716
app/src/market_data/acquisition/adapters/dzengi/parser.py
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/parser.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||||
|
DzengiExchangeInfoPayload,
|
||||||
|
DzengiExchangeInfoResponse,
|
||||||
|
DzengiExchangeInfoSymbol,
|
||||||
|
DzengiInstrumentFilter,
|
||||||
|
DzengiJsonNumber,
|
||||||
|
DzengiJsonScalar,
|
||||||
|
DzengiLotSizeFilter,
|
||||||
|
DzengiMinNotionalFilter,
|
||||||
|
DzengiRateLimit,
|
||||||
|
DzengiRawNumeric,
|
||||||
|
DzengiUnknownFilter,
|
||||||
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketQuoteResponse,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentReferenceParseError,
|
||||||
|
QuoteParseError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedExchangeInfoDocument,
|
||||||
|
ValidatedQuoteDocument,
|
||||||
|
ValidatedWebSocketQuoteDocument,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_exchange_info(
|
||||||
|
document: ValidatedExchangeInfoDocument,
|
||||||
|
) -> DzengiExchangeInfoResponse:
|
||||||
|
"""
|
||||||
|
Преобразовать структурно проверенный exchangeInfo в raw-модели Dzengi.
|
||||||
|
|
||||||
|
Функция не выполняет schema validation, предметную валидацию,
|
||||||
|
нормализацию символов или преобразование в Instrument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = document.payload
|
||||||
|
|
||||||
|
return DzengiExchangeInfoResponse(
|
||||||
|
status=_optional_string(
|
||||||
|
document.status,
|
||||||
|
path="$.status",
|
||||||
|
),
|
||||||
|
correlation_id=_optional_string(
|
||||||
|
document.correlation_id,
|
||||||
|
path="$.correlationId",
|
||||||
|
),
|
||||||
|
payload=DzengiExchangeInfoPayload(
|
||||||
|
timezone=_optional_string(
|
||||||
|
payload.get("timezone"),
|
||||||
|
path="$.payload.timezone",
|
||||||
|
),
|
||||||
|
server_time=_optional_int(
|
||||||
|
payload.get("serverTime"),
|
||||||
|
path="$.payload.serverTime",
|
||||||
|
),
|
||||||
|
rate_limits=_parse_rate_limits(
|
||||||
|
payload.get("rateLimits"),
|
||||||
|
path="$.payload.rateLimits",
|
||||||
|
),
|
||||||
|
exchange_filters=_parse_exchange_filters(
|
||||||
|
payload.get("exchangeFilters"),
|
||||||
|
path="$.payload.exchangeFilters",
|
||||||
|
),
|
||||||
|
symbols=_parse_symbols(
|
||||||
|
payload["symbols"],
|
||||||
|
path="$.payload.symbols",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_symbols(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[DzengiExchangeInfoSymbol, ...]:
|
||||||
|
items = _require_sequence(value, path=path)
|
||||||
|
|
||||||
|
symbols: list[DzengiExchangeInfoSymbol] = []
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
item_path = f"{path}[{index}]"
|
||||||
|
mapping = _require_mapping(item, path=item_path)
|
||||||
|
symbols.append(_parse_symbol(mapping, path=item_path))
|
||||||
|
|
||||||
|
return tuple(symbols)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_symbol(
|
||||||
|
item: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiExchangeInfoSymbol:
|
||||||
|
return DzengiExchangeInfoSymbol(
|
||||||
|
symbol=_required_string(
|
||||||
|
item.get("symbol"),
|
||||||
|
path=f"{path}.symbol",
|
||||||
|
),
|
||||||
|
name=_required_string(
|
||||||
|
item.get("name"),
|
||||||
|
path=f"{path}.name",
|
||||||
|
),
|
||||||
|
status=_required_string(
|
||||||
|
item.get("status"),
|
||||||
|
path=f"{path}.status",
|
||||||
|
),
|
||||||
|
asset_type=_optional_string(
|
||||||
|
item.get("assetType"),
|
||||||
|
path=f"{path}.assetType",
|
||||||
|
),
|
||||||
|
base_asset=_required_string(
|
||||||
|
item.get("baseAsset"),
|
||||||
|
path=f"{path}.baseAsset",
|
||||||
|
),
|
||||||
|
base_asset_precision=_optional_int(
|
||||||
|
item.get("baseAssetPrecision"),
|
||||||
|
path=f"{path}.baseAssetPrecision",
|
||||||
|
),
|
||||||
|
quote_asset=_required_string(
|
||||||
|
item.get("quoteAsset"),
|
||||||
|
path=f"{path}.quoteAsset",
|
||||||
|
),
|
||||||
|
quote_asset_id=_optional_string(
|
||||||
|
item.get("quoteAssetId"),
|
||||||
|
path=f"{path}.quoteAssetId",
|
||||||
|
),
|
||||||
|
quote_precision=_optional_int(
|
||||||
|
item.get("quotePrecision"),
|
||||||
|
path=f"{path}.quotePrecision",
|
||||||
|
),
|
||||||
|
order_types=_optional_string_tuple(
|
||||||
|
item.get("orderTypes"),
|
||||||
|
path=f"{path}.orderTypes",
|
||||||
|
),
|
||||||
|
filters=_parse_instrument_filters(
|
||||||
|
item.get("filters"),
|
||||||
|
path=f"{path}.filters",
|
||||||
|
),
|
||||||
|
market_modes=_optional_string_tuple(
|
||||||
|
item.get("marketModes"),
|
||||||
|
path=f"{path}.marketModes",
|
||||||
|
),
|
||||||
|
market_type=_required_string(
|
||||||
|
item.get("marketType"),
|
||||||
|
path=f"{path}.marketType",
|
||||||
|
),
|
||||||
|
country=_optional_string(
|
||||||
|
item.get("country"),
|
||||||
|
path=f"{path}.country",
|
||||||
|
),
|
||||||
|
sector=_optional_string(
|
||||||
|
item.get("sector"),
|
||||||
|
path=f"{path}.sector",
|
||||||
|
),
|
||||||
|
industry=_optional_string(
|
||||||
|
item.get("industry"),
|
||||||
|
path=f"{path}.industry",
|
||||||
|
),
|
||||||
|
trading_hours=_optional_string(
|
||||||
|
item.get("tradingHours"),
|
||||||
|
path=f"{path}.tradingHours",
|
||||||
|
),
|
||||||
|
tick_size=_optional_json_number(
|
||||||
|
item.get("tickSize"),
|
||||||
|
path=f"{path}.tickSize",
|
||||||
|
),
|
||||||
|
tick_value=_optional_json_number(
|
||||||
|
item.get("tickValue"),
|
||||||
|
path=f"{path}.tickValue",
|
||||||
|
),
|
||||||
|
trading_fee=_optional_json_number(
|
||||||
|
item.get("tradingFee"),
|
||||||
|
path=f"{path}.tradingFee",
|
||||||
|
),
|
||||||
|
exchange_fee=_optional_json_number(
|
||||||
|
item.get("exchangeFee"),
|
||||||
|
path=f"{path}.exchangeFee",
|
||||||
|
),
|
||||||
|
long_rate=_optional_json_number(
|
||||||
|
item.get("longRate"),
|
||||||
|
path=f"{path}.longRate",
|
||||||
|
),
|
||||||
|
short_rate=_optional_json_number(
|
||||||
|
item.get("shortRate"),
|
||||||
|
path=f"{path}.shortRate",
|
||||||
|
),
|
||||||
|
swap_charge_interval=_optional_int(
|
||||||
|
item.get("swapChargeInterval"),
|
||||||
|
path=f"{path}.swapChargeInterval",
|
||||||
|
),
|
||||||
|
min_sl_gap=_optional_json_number(
|
||||||
|
item.get("minSLGap"),
|
||||||
|
path=f"{path}.minSLGap",
|
||||||
|
),
|
||||||
|
max_sl_gap=_optional_json_number(
|
||||||
|
item.get("maxSLGap"),
|
||||||
|
path=f"{path}.maxSLGap",
|
||||||
|
),
|
||||||
|
min_tp_gap=_optional_json_number(
|
||||||
|
item.get("minTPGap"),
|
||||||
|
path=f"{path}.minTPGap",
|
||||||
|
),
|
||||||
|
max_tp_gap=_optional_json_number(
|
||||||
|
item.get("maxTPGap"),
|
||||||
|
path=f"{path}.maxTPGap",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rate_limits(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[DzengiRateLimit, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
items = _require_sequence(value, path=path)
|
||||||
|
rate_limits: list[DzengiRateLimit] = []
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
item_path = f"{path}[{index}]"
|
||||||
|
mapping = _require_mapping(item, path=item_path)
|
||||||
|
|
||||||
|
rate_limits.append(
|
||||||
|
DzengiRateLimit(
|
||||||
|
interval=_required_string(
|
||||||
|
mapping.get("interval"),
|
||||||
|
path=f"{item_path}.interval",
|
||||||
|
),
|
||||||
|
interval_num=_required_int(
|
||||||
|
mapping.get("intervalNum"),
|
||||||
|
path=f"{item_path}.intervalNum",
|
||||||
|
),
|
||||||
|
limit=_required_int(
|
||||||
|
mapping.get("limit"),
|
||||||
|
path=f"{item_path}.limit",
|
||||||
|
),
|
||||||
|
rate_limit_type=_required_string(
|
||||||
|
mapping.get("rateLimitType"),
|
||||||
|
path=f"{item_path}.rateLimitType",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(rate_limits)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exchange_filters(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[DzengiUnknownFilter, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
items = _require_sequence(value, path=path)
|
||||||
|
filters: list[DzengiUnknownFilter] = []
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
item_path = f"{path}[{index}]"
|
||||||
|
mapping = _require_mapping(item, path=item_path)
|
||||||
|
|
||||||
|
filters.append(
|
||||||
|
_parse_unknown_filter(
|
||||||
|
mapping,
|
||||||
|
path=item_path,
|
||||||
|
filter_type_required=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(filters)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_instrument_filters(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[DzengiInstrumentFilter, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
items = _require_sequence(value, path=path)
|
||||||
|
filters: list[DzengiInstrumentFilter] = []
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
item_path = f"{path}[{index}]"
|
||||||
|
mapping = _require_mapping(item, path=item_path)
|
||||||
|
|
||||||
|
filter_type = _required_string(
|
||||||
|
mapping.get("filterType"),
|
||||||
|
path=f"{item_path}.filterType",
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter_type == "LOT_SIZE":
|
||||||
|
filters.append(
|
||||||
|
DzengiLotSizeFilter(
|
||||||
|
filter_type=filter_type,
|
||||||
|
min_qty=_optional_raw_numeric(
|
||||||
|
mapping.get("minQty"),
|
||||||
|
path=f"{item_path}.minQty",
|
||||||
|
),
|
||||||
|
max_qty=_optional_raw_numeric(
|
||||||
|
mapping.get("maxQty"),
|
||||||
|
path=f"{item_path}.maxQty",
|
||||||
|
),
|
||||||
|
step_size=_optional_raw_numeric(
|
||||||
|
mapping.get("stepSize"),
|
||||||
|
path=f"{item_path}.stepSize",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if filter_type == "MIN_NOTIONAL":
|
||||||
|
filters.append(
|
||||||
|
DzengiMinNotionalFilter(
|
||||||
|
filter_type=filter_type,
|
||||||
|
min_notional=_optional_raw_numeric(
|
||||||
|
mapping.get("minNotional"),
|
||||||
|
path=f"{item_path}.minNotional",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
filters.append(
|
||||||
|
_parse_unknown_filter(
|
||||||
|
mapping,
|
||||||
|
path=item_path,
|
||||||
|
filter_type_required=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(filters)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_unknown_filter(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
filter_type_required: bool,
|
||||||
|
) -> DzengiUnknownFilter:
|
||||||
|
if filter_type_required:
|
||||||
|
filter_type = _required_string(
|
||||||
|
mapping.get("filterType"),
|
||||||
|
path=f"{path}.filterType",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filter_type = _optional_string(
|
||||||
|
mapping.get("filterType"),
|
||||||
|
path=f"{path}.filterType",
|
||||||
|
) or ""
|
||||||
|
|
||||||
|
fields: list[tuple[str, DzengiJsonScalar]] = []
|
||||||
|
|
||||||
|
for key, value in mapping.items():
|
||||||
|
if key == "filterType":
|
||||||
|
continue
|
||||||
|
|
||||||
|
fields.append(
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
_require_json_scalar(
|
||||||
|
value,
|
||||||
|
path=f"{path}.{key}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DzengiUnknownFilter(
|
||||||
|
filter_type=filter_type,
|
||||||
|
fields=tuple(fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string_tuple(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
items = _require_sequence(value, path=path)
|
||||||
|
result: list[str] = []
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
result.append(
|
||||||
|
_required_string(
|
||||||
|
item,
|
||||||
|
path=f"{path}[{index}]",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_string(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть строкой, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _required_string(value, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _required_int(value, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_json_number(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiJsonNumber | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть JSON-числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть строкой или JSON-числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_json_scalar(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiJsonScalar:
|
||||||
|
if value is None or isinstance(value, (str, bool)):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value
|
||||||
|
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть скалярным JSON-значением, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mapping(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть отображением, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in value:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} содержит нестроковый ключ "
|
||||||
|
f"типа {type(key).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_sequence(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Sequence[object]:
|
||||||
|
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||||
|
raise InstrumentReferenceParseError(
|
||||||
|
f"{path} должен быть последовательностью, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def parse_quote(
|
||||||
|
document: ValidatedQuoteDocument,
|
||||||
|
) -> DzengiTicker24hrResponse:
|
||||||
|
"""
|
||||||
|
Преобразовать структурно проверенный ticker/24hr в raw-модель Dzengi.
|
||||||
|
|
||||||
|
Функция не выполняет schema validation, предметную валидацию
|
||||||
|
или mapping во внутреннюю модель Quote.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = document.payload
|
||||||
|
|
||||||
|
return DzengiTicker24hrResponse(
|
||||||
|
symbol=_quote_required_string(
|
||||||
|
payload.get("symbol"),
|
||||||
|
path="$.payload.symbol",
|
||||||
|
),
|
||||||
|
last_price=_quote_required_raw_numeric(
|
||||||
|
payload.get("lastPrice"),
|
||||||
|
path="$.payload.lastPrice",
|
||||||
|
),
|
||||||
|
bid_price=_quote_required_raw_numeric(
|
||||||
|
payload.get("bidPrice"),
|
||||||
|
path="$.payload.bidPrice",
|
||||||
|
),
|
||||||
|
ask_price=_quote_required_raw_numeric(
|
||||||
|
payload.get("askPrice"),
|
||||||
|
path="$.payload.askPrice",
|
||||||
|
),
|
||||||
|
close_time=_quote_required_int(
|
||||||
|
payload.get("closeTime"),
|
||||||
|
path="$.payload.closeTime",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_required_string(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"{path} должен быть строкой, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_required_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"{path} должен быть строкой или JSON-числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dzengi_websocket_quote(
|
||||||
|
document: ValidatedWebSocketQuoteDocument,
|
||||||
|
) -> DzengiWebSocketQuoteResponse:
|
||||||
|
"""Преобразовать проверенное WebSocket-сообщение в raw-модель Dzengi."""
|
||||||
|
|
||||||
|
payload = document.payload
|
||||||
|
symbol_value = (
|
||||||
|
payload.get("symbolName")
|
||||||
|
or payload.get("symbol")
|
||||||
|
or document.root_symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
symbol = _quote_required_string(
|
||||||
|
symbol_value,
|
||||||
|
path="$.payload.symbol",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "bid" in payload:
|
||||||
|
bid_price = _quote_required_raw_numeric(
|
||||||
|
payload.get("bid"),
|
||||||
|
path="$.payload.bid",
|
||||||
|
)
|
||||||
|
ask_key = "ofr" if "ofr" in payload else "ask"
|
||||||
|
ask_price = _quote_required_raw_numeric(
|
||||||
|
payload.get(ask_key),
|
||||||
|
path=f"$.payload.{ask_key}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bid_price = _websocket_depth_price(
|
||||||
|
payload.get("bids"),
|
||||||
|
side="bids",
|
||||||
|
)
|
||||||
|
ask_price = _websocket_depth_price(
|
||||||
|
payload.get("asks"),
|
||||||
|
side="asks",
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp = _websocket_optional_timestamp(
|
||||||
|
payload.get("timestamp"),
|
||||||
|
path="$.payload.timestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
return DzengiWebSocketQuoteResponse(
|
||||||
|
symbol=symbol,
|
||||||
|
bid_price=bid_price,
|
||||||
|
ask_price=ask_price,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_depth_price(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"$.payload.{side} должен быть непустым списком."
|
||||||
|
)
|
||||||
|
|
||||||
|
first = value[0]
|
||||||
|
|
||||||
|
if isinstance(first, list):
|
||||||
|
if not first:
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"$.payload.{side}[0] не должен быть пустым."
|
||||||
|
)
|
||||||
|
return _quote_required_raw_numeric(
|
||||||
|
first[0],
|
||||||
|
path=f"$.payload.{side}[0][0]",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(first, Mapping):
|
||||||
|
for key in ("price", "p", "bidPrice", "askPrice"):
|
||||||
|
if key in first:
|
||||||
|
return _quote_required_raw_numeric(
|
||||||
|
first.get(key),
|
||||||
|
path=f"$.payload.{side}[0].{key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"$.payload.{side}[0] не содержит поле цены."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise QuoteParseError(
|
||||||
|
f"$.payload.{side}[0] должен быть JSON-массивом или объектом."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_optional_timestamp(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _quote_required_int(value, path=path)
|
||||||
106
app/src/market_data/acquisition/adapters/dzengi/rest.py
Normal file
106
app/src/market_data/acquisition/adapters/dzengi/rest.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/rest.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentReferenceTransportError,
|
||||||
|
QuoteTransportError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
||||||
|
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
||||||
|
|
||||||
|
|
||||||
|
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
||||||
|
class _PayloadRestClient(Protocol):
|
||||||
|
def get_payload(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> object:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiInstrumentDocumentSource:
|
||||||
|
"""
|
||||||
|
Источник сырого документа Instrument Reference Data через Dzengi REST API.
|
||||||
|
|
||||||
|
На переходном этапе использует legacy ExchangeRestClient.
|
||||||
|
Зависимость должна быть удалена после появления общего transport-клиента
|
||||||
|
или после полного вывода integrations/exchange из эксплуатации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: _PayloadRestClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def fetch_instrument_document(self) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный ответ Dzengi exchangeInfo без его обработки.
|
||||||
|
|
||||||
|
Метод не выполняет schema validation, parsing, value validation,
|
||||||
|
mapping или кэширование.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
client: _PayloadRestClient = (
|
||||||
|
self._client
|
||||||
|
if self._client is not None
|
||||||
|
else ExchangeRestClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
return client.get_payload(_EXCHANGE_INFO_PATH)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise InstrumentReferenceTransportError(
|
||||||
|
"Не удалось получить Instrument Reference Data "
|
||||||
|
f"от Dzengi: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiQuoteDocumentSource:
|
||||||
|
"""Источник сырого документа текущей котировки через Dzengi REST API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: _PayloadRestClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def fetch_quote_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный ответ Dzengi ticker/24hr без его обработки.
|
||||||
|
|
||||||
|
Метод не выполняет нормализацию symbol, schema validation, parsing,
|
||||||
|
value validation, mapping, retry или кэширование.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
client: _PayloadRestClient = (
|
||||||
|
self._client
|
||||||
|
if self._client is not None
|
||||||
|
else ExchangeRestClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
return client.get_payload(
|
||||||
|
_TICKER_24HR_PATH,
|
||||||
|
params={
|
||||||
|
"symbol": symbol,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise QuoteTransportError(
|
||||||
|
"Не удалось получить текущую котировку "
|
||||||
|
f"от Dzengi для символа '{symbol}': {exc}"
|
||||||
|
) from exc
|
||||||
37
app/src/market_data/acquisition/adapters/dzengi/websocket.py
Normal file
37
app/src/market_data/acquisition/adapters/dzengi/websocket.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/websocket.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_websocket_quote_to_quote,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_dzengi_websocket_quote,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_dzengi_websocket_quote_schema,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_dzengi_websocket_quote_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразует одно декодированное сообщение Dzengi WebSocket в Quote.
|
||||||
|
class DzengiWebSocketQuoteAdapter:
|
||||||
|
def map_message(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
received_at: datetime | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
validated = validate_dzengi_websocket_quote_schema(document)
|
||||||
|
response = parse_dzengi_websocket_quote(validated)
|
||||||
|
validate_dzengi_websocket_quote_values(response)
|
||||||
|
|
||||||
|
return map_dzengi_websocket_quote_to_quote(
|
||||||
|
response,
|
||||||
|
received_at=received_at or datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
67
app/src/market_data/acquisition/exceptions.py
Normal file
67
app/src/market_data/acquisition/exceptions.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# app/src/market_data/acquisition/exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
# Базовая ошибка подсистемы получения рыночных данных.
|
||||||
|
class MarketDataAcquisitionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка получения Instrument Reference Data от внешнего источника.
|
||||||
|
class InstrumentReferenceTransportError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка структуры документа Instrument Reference Data.
|
||||||
|
class InstrumentReferenceSchemaError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного документа в raw-модели адаптера.
|
||||||
|
class InstrumentReferenceParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка допустимости значений Instrument Reference Data.
|
||||||
|
class InstrumentReferenceValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования raw-модели источника во внутреннюю модель Instrument.
|
||||||
|
class InstrumentReferenceMappingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка регистрации или получения Instrument Feed.
|
||||||
|
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ошибка получения Quotes Feed от внешнего источника.
|
||||||
|
class QuoteTransportError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка структуры документа Quotes Feed.
|
||||||
|
class QuoteSchemaError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного документа в raw-модель котировки.
|
||||||
|
class QuoteParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка допустимости значений Quotes Feed.
|
||||||
|
class QuoteValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования raw-модели источника во внутреннюю модель Quote.
|
||||||
|
class QuoteMappingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка регистрации или получения Quotes Feed.
|
||||||
|
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
0
app/src/market_data/acquisition/feeds/__init__.py
Normal file
0
app/src/market_data/acquisition/feeds/__init__.py
Normal file
0
app/src/market_data/acquisition/feeds/index_feed.py
Normal file
0
app/src/market_data/acquisition/feeds/index_feed.py
Normal file
33
app/src/market_data/acquisition/feeds/instrument_feed.py
Normal file
33
app/src/market_data/acquisition/feeds/instrument_feed.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# app/src/market_data/acquisition/feeds/instrument_feed.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.protocol import (
|
||||||
|
InstrumentDocumentHandler,
|
||||||
|
InstrumentDocumentSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Feed справочника инструментов: получает документ и передаёт его обработчику.
|
||||||
|
class InstrumentFeed:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: InstrumentDocumentSource,
|
||||||
|
handler: InstrumentDocumentHandler,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._handler = handler
|
||||||
|
|
||||||
|
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||||
|
"""
|
||||||
|
Получить документ от источника и преобразовать его в модели Instrument.
|
||||||
|
|
||||||
|
Feed не выполняет transport, parsing, validation, mapping,
|
||||||
|
кэширование или обработку ошибок самостоятельно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self._source.fetch_instrument_document()
|
||||||
|
|
||||||
|
return self._handler.handle_instrument_document(document)
|
||||||
36
app/src/market_data/acquisition/feeds/quotes_feed.py
Normal file
36
app/src/market_data/acquisition/feeds/quotes_feed.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# app/src/market_data/acquisition/feeds/quotes_feed.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.protocol import (
|
||||||
|
QuoteDocumentHandler,
|
||||||
|
QuoteDocumentSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Feed текущих котировок: получает документ и передаёт его обработчику.
|
||||||
|
class QuotesFeed:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: QuoteDocumentSource,
|
||||||
|
handler: QuoteDocumentHandler,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._handler = handler
|
||||||
|
|
||||||
|
def load_quote(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Получить документ котировки и преобразовать его в модель Quote.
|
||||||
|
|
||||||
|
Feed не выполняет transport, parsing, validation, mapping,
|
||||||
|
нормализацию symbol, retry, кэширование или обработку ошибок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self._source.fetch_quote_document(symbol)
|
||||||
|
|
||||||
|
return self._handler.handle_quote_document(document)
|
||||||
2
app/src/market_data/acquisition/feeds/status_feed.py
Normal file
2
app/src/market_data/acquisition/feeds/status_feed.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# app/src/market_data/acquisition/feeds/status_feed.py
|
||||||
|
|
||||||
0
app/src/market_data/acquisition/feeds/time_feed.py
Normal file
0
app/src/market_data/acquisition/feeds/time_feed.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# app/src/market_data/acquisition/handlers/instrument_handler.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_exchange_info_to_instruments,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_exchange_info,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_exchange_info_schema,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_exchange_info_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик документа Instrument Reference Data формата Dzengi exchangeInfo.
|
||||||
|
class DzengiInstrumentDocumentHandler:
|
||||||
|
def handle_instrument_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> tuple[Instrument, ...]:
|
||||||
|
validated_document = validate_exchange_info_schema(document)
|
||||||
|
|
||||||
|
response = parse_exchange_info(validated_document)
|
||||||
|
|
||||||
|
validate_exchange_info_values(response)
|
||||||
|
|
||||||
|
return map_dzengi_exchange_info_to_instruments(response)
|
||||||
35
app/src/market_data/acquisition/handlers/quotes_handler.py
Normal file
35
app/src/market_data/acquisition/handlers/quotes_handler.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# app/src/market_data/acquisition/handlers/quotes_handler.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_ticker_to_quote,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_quote_schema,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_quote_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик документа Quotes Feed формата Dzengi ticker/24hr.
|
||||||
|
class DzengiQuoteDocumentHandler:
|
||||||
|
def handle_quote_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> Quote:
|
||||||
|
validated_document = validate_quote_schema(document)
|
||||||
|
|
||||||
|
response = parse_quote(validated_document)
|
||||||
|
|
||||||
|
validate_quote_values(response)
|
||||||
|
|
||||||
|
return map_dzengi_ticker_to_quote(
|
||||||
|
response,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# app/src/market_data/acquisition/handlers/status_handler.py
|
||||||
|
|
||||||
1
app/src/market_data/acquisition/models/__init__.py
Normal file
1
app/src/market_data/acquisition/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/models/__init__.py
|
||||||
0
app/src/market_data/acquisition/models/candle.py
Normal file
0
app/src/market_data/acquisition/models/candle.py
Normal file
0
app/src/market_data/acquisition/models/index.py
Normal file
0
app/src/market_data/acquisition/models/index.py
Normal file
38
app/src/market_data/acquisition/models/instrument.py
Normal file
38
app/src/market_data/acquisition/models/instrument.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# app/src/market_data/acquisition/models/instrument.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
# Независимое от источника справочное описание торгового инструмента.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Instrument:
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
base_asset: str
|
||||||
|
quote_asset: str
|
||||||
|
asset_type: str | None
|
||||||
|
|
||||||
|
market_type: str
|
||||||
|
market_modes: tuple[str, ...]
|
||||||
|
order_types: tuple[str, ...]
|
||||||
|
|
||||||
|
base_asset_precision: int | None
|
||||||
|
quote_asset_precision: int | None
|
||||||
|
|
||||||
|
tick_size: Decimal | None
|
||||||
|
tick_value: Decimal | None
|
||||||
|
|
||||||
|
step_size: Decimal | None
|
||||||
|
min_qty: Decimal | None
|
||||||
|
max_qty: Decimal | None
|
||||||
|
min_notional: Decimal | None
|
||||||
|
|
||||||
|
country: str | None
|
||||||
|
sector: str | None
|
||||||
|
industry: str | None
|
||||||
|
trading_hours: str | None
|
||||||
0
app/src/market_data/acquisition/models/orderbook.py
Normal file
0
app/src/market_data/acquisition/models/orderbook.py
Normal file
22
app/src/market_data/acquisition/models/quote.py
Normal file
22
app/src/market_data/acquisition/models/quote.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# app/src/market_data/acquisition/models/quote.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
# Независимый от источника снимок текущей рыночной котировки инструмента.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Quote:
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
last_price: Decimal
|
||||||
|
bid_price: Decimal
|
||||||
|
ask_price: Decimal
|
||||||
|
|
||||||
|
exchange_timestamp: datetime | None
|
||||||
|
received_at: datetime
|
||||||
|
|
||||||
|
source: str
|
||||||
88
app/src/market_data/acquisition/models/status.py
Normal file
88
app/src/market_data/acquisition/models/status.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# app/src/market_data/acquisition/models/status.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
# Каноническое состояние торговой доступности инструмента.
|
||||||
|
class InstrumentTradingState(StrEnum):
|
||||||
|
OPEN = "OPEN"
|
||||||
|
BREAK = "BREAK"
|
||||||
|
NOT_TRADABLE = "NOT_TRADABLE"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
# Результат классификации сырого статуса инструмента.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InstrumentStatusClassification:
|
||||||
|
state: InstrumentTradingState
|
||||||
|
normalized_status: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_OPEN_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"TRADING",
|
||||||
|
"OPEN",
|
||||||
|
"ACTIVE",
|
||||||
|
"ENABLED",
|
||||||
|
"ONLINE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_NOT_TRADABLE_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"NOT_TRADABLE",
|
||||||
|
"TRADING_DISABLED",
|
||||||
|
"MARKET_DISABLED",
|
||||||
|
"UNAVAILABLE_FOR_TRADING",
|
||||||
|
"CLOSE_ONLY",
|
||||||
|
"REDUCE_ONLY",
|
||||||
|
"VIEW_ONLY",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_BREAK_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"BREAK",
|
||||||
|
"CLOSED",
|
||||||
|
"HALT",
|
||||||
|
"HALTED",
|
||||||
|
"PAUSED",
|
||||||
|
"SUSPENDED",
|
||||||
|
"DISABLED",
|
||||||
|
"SETTLING",
|
||||||
|
"POST_ONLY",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Классифицировать сырой статус торгового инструмента.
|
||||||
|
def classify_instrument_status(
|
||||||
|
raw_status: str | None,
|
||||||
|
) -> InstrumentStatusClassification:
|
||||||
|
normalized_status = str(raw_status or "").strip().upper()
|
||||||
|
|
||||||
|
if normalized_status in _OPEN_STATUSES:
|
||||||
|
return InstrumentStatusClassification(
|
||||||
|
state=InstrumentTradingState.OPEN,
|
||||||
|
normalized_status=normalized_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_status in _NOT_TRADABLE_STATUSES:
|
||||||
|
return InstrumentStatusClassification(
|
||||||
|
state=InstrumentTradingState.NOT_TRADABLE,
|
||||||
|
normalized_status=normalized_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_status in _BREAK_STATUSES:
|
||||||
|
return InstrumentStatusClassification(
|
||||||
|
state=InstrumentTradingState.BREAK,
|
||||||
|
normalized_status=normalized_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
return InstrumentStatusClassification(
|
||||||
|
state=InstrumentTradingState.UNKNOWN,
|
||||||
|
normalized_status=normalized_status or None,
|
||||||
|
)
|
||||||
0
app/src/market_data/acquisition/models/time.py
Normal file
0
app/src/market_data/acquisition/models/time.py
Normal file
0
app/src/market_data/acquisition/models/trade.py
Normal file
0
app/src/market_data/acquisition/models/trade.py
Normal file
86
app/src/market_data/acquisition/protocol.py
Normal file
86
app/src/market_data/acquisition/protocol.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# app/src/market_data/acquisition/protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
|
||||||
|
|
||||||
|
# Источник сырого документа Instrument Reference Data.
|
||||||
|
@runtime_checkable
|
||||||
|
class InstrumentDocumentSource(Protocol):
|
||||||
|
def fetch_instrument_document(self) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный транспортный документ Instrument Reference Data.
|
||||||
|
|
||||||
|
Источник не выполняет schema validation, parsing, value validation
|
||||||
|
или mapping во внутреннюю модель Instrument.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик сырого документа Instrument Reference Data.
|
||||||
|
@runtime_checkable
|
||||||
|
class InstrumentDocumentHandler(Protocol):
|
||||||
|
def handle_instrument_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> tuple[Instrument, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать сырой документ в проверенные внутренние модели Instrument.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник готового справочника инструментов для Acquisition Service.
|
||||||
|
@runtime_checkable
|
||||||
|
class InstrumentFeedProtocol(Protocol):
|
||||||
|
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||||
|
"""
|
||||||
|
Получить полный immutable-набор внутренних моделей Instrument.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник сырого документа Quotes Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class QuoteDocumentSource(Protocol):
|
||||||
|
def fetch_quote_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный транспортный документ текущей котировки.
|
||||||
|
|
||||||
|
Источник не выполняет schema validation, parsing, value validation
|
||||||
|
или mapping во внутреннюю модель Quote.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик сырого документа Quotes Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class QuoteDocumentHandler(Protocol):
|
||||||
|
def handle_quote_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Преобразовать сырой документ в проверенную внутреннюю модель Quote.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник готовой текущей котировки для Acquisition Service.
|
||||||
|
@runtime_checkable
|
||||||
|
class QuoteFeedProtocol(Protocol):
|
||||||
|
def load_quote(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Получить внутреннюю модель текущей котировки инструмента.
|
||||||
|
"""
|
||||||
|
...
|
||||||
142
app/src/market_data/acquisition/registry.py
Normal file
142
app/src/market_data/acquisition/registry.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# app/src/market_data/acquisition/registry.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentFeedRegistryError,
|
||||||
|
QuoteFeedRegistryError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.protocol import (
|
||||||
|
InstrumentFeedProtocol,
|
||||||
|
QuoteFeedProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр доступных Feed справочника инструментов.
|
||||||
|
class InstrumentFeedRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._feeds: dict[str, InstrumentFeedProtocol] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
feed: InstrumentFeedProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Зарегистрировать Instrument Feed для указанного источника.
|
||||||
|
|
||||||
|
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||||
|
неявную замену production-зависимости.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
if not isinstance(feed, InstrumentFeedProtocol):
|
||||||
|
raise InstrumentFeedRegistryError(
|
||||||
|
f"Объект для источника '{normalized_source_name}' "
|
||||||
|
"не соответствует InstrumentFeedProtocol."
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_source_name in self._feeds:
|
||||||
|
raise InstrumentFeedRegistryError(
|
||||||
|
f"Instrument Feed для источника "
|
||||||
|
f"'{normalized_source_name}' уже зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._feeds[normalized_source_name] = feed
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> InstrumentFeedProtocol:
|
||||||
|
"""Вернуть зарегистрированный Instrument Feed по имени источника."""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
feed = self._feeds.get(normalized_source_name)
|
||||||
|
|
||||||
|
if feed is None:
|
||||||
|
raise InstrumentFeedRegistryError(
|
||||||
|
f"Instrument Feed для источника "
|
||||||
|
f"'{normalized_source_name}' не зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = source_name.strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise InstrumentFeedRegistryError(
|
||||||
|
"Имя источника Instrument Feed не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр доступных потоков текущих котировок.
|
||||||
|
class QuoteFeedRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._feeds: dict[str, QuoteFeedProtocol] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
feed: QuoteFeedProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Зарегистрировать Quotes Feed для указанного источника.
|
||||||
|
|
||||||
|
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||||
|
неявную замену production-зависимости.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
if not isinstance(feed, QuoteFeedProtocol):
|
||||||
|
raise QuoteFeedRegistryError(
|
||||||
|
f"Объект для источника '{normalized_source_name}' "
|
||||||
|
"не соответствует QuoteFeedProtocol."
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_source_name in self._feeds:
|
||||||
|
raise QuoteFeedRegistryError(
|
||||||
|
f"Quotes Feed для источника "
|
||||||
|
f"'{normalized_source_name}' уже зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._feeds[normalized_source_name] = feed
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> QuoteFeedProtocol:
|
||||||
|
"""Вернуть зарегистрированный Quotes Feed по имени источника."""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
feed = self._feeds.get(normalized_source_name)
|
||||||
|
|
||||||
|
if feed is None:
|
||||||
|
raise QuoteFeedRegistryError(
|
||||||
|
f"Quotes Feed для источника "
|
||||||
|
f"'{normalized_source_name}' не зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = source_name.strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise QuoteFeedRegistryError(
|
||||||
|
"Имя источника Quotes Feed не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
0
app/src/market_data/acquisition/runtime/__init__.py
Normal file
0
app/src/market_data/acquisition/runtime/__init__.py
Normal file
62
app/src/market_data/acquisition/service.py
Normal file
62
app/src/market_data/acquisition/service.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# app/src/market_data/acquisition/service.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.registry import (
|
||||||
|
InstrumentFeedRegistry,
|
||||||
|
QuoteFeedRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Application-level сервис получения справочника инструментов.
|
||||||
|
class InstrumentAcquisitionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
registry: InstrumentFeedRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def load_instruments(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> tuple[Instrument, ...]:
|
||||||
|
"""
|
||||||
|
Получить Instrument Feed из Registry и загрузить справочник инструментов.
|
||||||
|
|
||||||
|
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||||
|
mapping, retry, кэширование или преобразование результата.
|
||||||
|
"""
|
||||||
|
|
||||||
|
feed = self._registry.get(source_name)
|
||||||
|
|
||||||
|
return feed.load_instruments()
|
||||||
|
|
||||||
|
|
||||||
|
# Application-level сервис получения текущих котировок.
|
||||||
|
class QuoteAcquisitionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
registry: QuoteFeedRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def load_quote(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
symbol: str,
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Получить Quotes Feed из Registry и загрузить текущую котировку.
|
||||||
|
|
||||||
|
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||||
|
mapping, нормализацию symbol, retry, кэширование или преобразование
|
||||||
|
результата.
|
||||||
|
"""
|
||||||
|
|
||||||
|
feed = self._registry.get(source_name)
|
||||||
|
|
||||||
|
return feed.load_quote(symbol)
|
||||||
45
app/src/market_data/acquisition/symbols.py
Normal file
45
app/src/market_data/acquisition/symbols.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# app/src/market_data/acquisition/symbols.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
# Привести идентификатор торгового инструмента к базовой канонической форме.
|
||||||
|
def normalize_symbol(raw_symbol: str) -> str:
|
||||||
|
return (raw_symbol or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
# Сформировать упорядоченные варианты идентификатора инструмента.
|
||||||
|
def symbol_candidates(raw_symbol: str) -> list[str]:
|
||||||
|
value = normalize_symbol(raw_symbol)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = [value]
|
||||||
|
|
||||||
|
compact = value.replace("%2F", "/")
|
||||||
|
|
||||||
|
if compact not in candidates:
|
||||||
|
candidates.append(compact)
|
||||||
|
|
||||||
|
no_spaces = compact.replace(" ", "")
|
||||||
|
|
||||||
|
if no_spaces not in candidates:
|
||||||
|
candidates.append(no_spaces)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
# Найти индекс первого доступного символа с учётом порядка кандидатов.
|
||||||
|
def resolve_symbol_index(
|
||||||
|
raw_symbol: str,
|
||||||
|
available_symbols: Sequence[str],
|
||||||
|
) -> int | None:
|
||||||
|
for candidate in symbol_candidates(raw_symbol):
|
||||||
|
for index, available_symbol in enumerate(available_symbols):
|
||||||
|
if normalize_symbol(available_symbol) == candidate:
|
||||||
|
return index
|
||||||
|
|
||||||
|
return None
|
||||||
400
app/src/market_data/acquisition/validation/schema.py
Normal file
400
app/src/market_data/acquisition/validation/schema.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# app/src/market_data/acquisition/validation/schema.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentReferenceSchemaError,
|
||||||
|
QuoteSchemaError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Проверенное структурное представление ответа exchangeInfo.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ValidatedExchangeInfoDocument:
|
||||||
|
payload: Mapping[str, object]
|
||||||
|
is_wrapped: bool
|
||||||
|
status: object | None
|
||||||
|
correlation_id: object | None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exchange_info_schema(
|
||||||
|
document: object,
|
||||||
|
) -> ValidatedExchangeInfoDocument:
|
||||||
|
"""
|
||||||
|
Проверить структуру ответа exchangeInfo без разбора предметных значений.
|
||||||
|
|
||||||
|
Поддерживаются:
|
||||||
|
|
||||||
|
1. Unwrapped-формат:
|
||||||
|
|
||||||
|
{
|
||||||
|
"symbols": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Wrapped-формат:
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"correlationId": "2",
|
||||||
|
"payload": {
|
||||||
|
"symbols": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
root = _require_mapping(
|
||||||
|
document,
|
||||||
|
path="$",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_wrapped = "payload" in root
|
||||||
|
|
||||||
|
if is_wrapped:
|
||||||
|
payload = _require_mapping(
|
||||||
|
root.get("payload"),
|
||||||
|
path="$.payload",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = root
|
||||||
|
|
||||||
|
_validate_exchange_info_payload(payload)
|
||||||
|
|
||||||
|
return ValidatedExchangeInfoDocument(
|
||||||
|
payload=MappingProxyType(dict(payload)),
|
||||||
|
is_wrapped=is_wrapped,
|
||||||
|
status=root.get("status") if is_wrapped else None,
|
||||||
|
correlation_id=(
|
||||||
|
root.get("correlationId")
|
||||||
|
if is_wrapped
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_exchange_info_payload(
|
||||||
|
payload: Mapping[str, object],
|
||||||
|
) -> None:
|
||||||
|
symbols = _require_list(
|
||||||
|
payload.get("symbols"),
|
||||||
|
path="$.payload.symbols",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, symbol in enumerate(symbols):
|
||||||
|
symbol_path = f"$.payload.symbols[{index}]"
|
||||||
|
|
||||||
|
symbol_mapping = _require_mapping(
|
||||||
|
symbol,
|
||||||
|
path=symbol_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_mapping_list(
|
||||||
|
symbol_mapping,
|
||||||
|
key="filters",
|
||||||
|
path=f"{symbol_path}.filters",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_string_list(
|
||||||
|
symbol_mapping,
|
||||||
|
key="marketModes",
|
||||||
|
path=f"{symbol_path}.marketModes",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_string_list(
|
||||||
|
symbol_mapping,
|
||||||
|
key="orderTypes",
|
||||||
|
path=f"{symbol_path}.orderTypes",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_mapping_list(
|
||||||
|
payload,
|
||||||
|
key="rateLimits",
|
||||||
|
path="$.payload.rateLimits",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_mapping_list(
|
||||||
|
payload,
|
||||||
|
key="exchangeFilters",
|
||||||
|
path="$.payload.exchangeFilters",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_mapping_list(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if key not in mapping:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = _require_list(
|
||||||
|
mapping.get(key),
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
_require_mapping(
|
||||||
|
item,
|
||||||
|
path=f"{path}[{index}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_string_list(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if key not in mapping:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = _require_list(
|
||||||
|
mapping.get(key),
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not isinstance(item, str):
|
||||||
|
raise InstrumentReferenceSchemaError(
|
||||||
|
f"{path}[{index}] должен быть строкой, "
|
||||||
|
f"получен {type(item).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mapping(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise InstrumentReferenceSchemaError(
|
||||||
|
f"{path} должен быть JSON-объектом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in value:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise InstrumentReferenceSchemaError(
|
||||||
|
f"{path} содержит нестроковый ключ "
|
||||||
|
f"типа {type(key).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_list(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> list[object]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise InstrumentReferenceSchemaError(
|
||||||
|
f"{path} должен быть JSON-массивом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Структурно проверенное представление ответа ticker/24hr.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ValidatedQuoteDocument:
|
||||||
|
payload: Mapping[str, object]
|
||||||
|
is_wrapped: bool
|
||||||
|
status: object | None
|
||||||
|
correlation_id: object | None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_quote_schema(
|
||||||
|
document: object,
|
||||||
|
) -> ValidatedQuoteDocument:
|
||||||
|
"""
|
||||||
|
Проверить структуру ответа Dzengi ticker/24hr без проверки значений.
|
||||||
|
|
||||||
|
Поддерживаются прямой JSON-объект котировки и wrapped-формат
|
||||||
|
с объектом котировки в поле payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root = _require_quote_mapping(
|
||||||
|
document,
|
||||||
|
path="$",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_wrapped = "payload" in root
|
||||||
|
|
||||||
|
if is_wrapped:
|
||||||
|
payload = _require_quote_mapping(
|
||||||
|
root.get("payload"),
|
||||||
|
path="$.payload",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = root
|
||||||
|
|
||||||
|
_validate_quote_payload(payload)
|
||||||
|
|
||||||
|
return ValidatedQuoteDocument(
|
||||||
|
payload=MappingProxyType(dict(payload)),
|
||||||
|
is_wrapped=is_wrapped,
|
||||||
|
status=root.get("status") if is_wrapped else None,
|
||||||
|
correlation_id=(
|
||||||
|
root.get("correlationId")
|
||||||
|
if is_wrapped
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_quote_payload(
|
||||||
|
payload: Mapping[str, object],
|
||||||
|
) -> None:
|
||||||
|
_require_quote_key(payload, key="symbol", path="$.payload.symbol")
|
||||||
|
_require_quote_key(payload, key="lastPrice", path="$.payload.lastPrice")
|
||||||
|
_require_quote_key(payload, key="bidPrice", path="$.payload.bidPrice")
|
||||||
|
_require_quote_key(payload, key="askPrice", path="$.payload.askPrice")
|
||||||
|
_require_quote_key(payload, key="closeTime", path="$.payload.closeTime")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_quote_key(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if key not in mapping:
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
f"{path} отсутствует в документе ticker/24hr."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_quote_mapping(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
f"{path} должен быть JSON-объектом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in value:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
f"{path} содержит нестроковый ключ "
|
||||||
|
f"типа {type(key).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Структурно проверенное представление сообщения котировки Dzengi WebSocket.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ValidatedWebSocketQuoteDocument:
|
||||||
|
payload: Mapping[str, object]
|
||||||
|
root_symbol: object | None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dzengi_websocket_quote_schema(
|
||||||
|
document: object,
|
||||||
|
) -> ValidatedWebSocketQuoteDocument:
|
||||||
|
"""
|
||||||
|
Проверить структуру одного декодированного сообщения Dzengi WebSocket.
|
||||||
|
|
||||||
|
Поддерживаются сообщения без оболочки и до двух известных оболочек
|
||||||
|
``payload`` / ``Payload``. Проверка не преобразует цены и не выполняет
|
||||||
|
предметную валидацию.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root = _require_quote_mapping(document, path="$")
|
||||||
|
root_symbol = root.get("symbol")
|
||||||
|
payload = _unwrap_websocket_quote_payload(root)
|
||||||
|
|
||||||
|
_validate_websocket_quote_payload(
|
||||||
|
payload,
|
||||||
|
root_symbol=root_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidatedWebSocketQuoteDocument(
|
||||||
|
payload=MappingProxyType(dict(payload)),
|
||||||
|
root_symbol=root_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_websocket_quote_payload(
|
||||||
|
root: Mapping[str, object],
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
payload = root
|
||||||
|
|
||||||
|
for level in range(2):
|
||||||
|
nested: object | None = None
|
||||||
|
nested_path = "$.payload" if level == 0 else "$.payload.payload"
|
||||||
|
|
||||||
|
for key in ("payload", "Payload"):
|
||||||
|
candidate = payload.get(key)
|
||||||
|
if candidate is not None:
|
||||||
|
nested = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if nested is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload = _require_quote_mapping(
|
||||||
|
nested,
|
||||||
|
path=nested_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_websocket_quote_payload(
|
||||||
|
payload: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
root_symbol: object | None,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
"symbolName" not in payload
|
||||||
|
and "symbol" not in payload
|
||||||
|
and root_symbol is None
|
||||||
|
):
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"$.payload не содержит symbolName или symbol."
|
||||||
|
)
|
||||||
|
|
||||||
|
has_direct_bid = "bid" in payload
|
||||||
|
has_direct_ask = "ask" in payload or "ofr" in payload
|
||||||
|
has_depth_bid = "bids" in payload
|
||||||
|
has_depth_ask = "asks" in payload
|
||||||
|
|
||||||
|
if has_direct_bid or has_direct_ask:
|
||||||
|
if not has_direct_bid or not has_direct_ask:
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"WebSocket quote должна содержать полный набор bid и ask/ofr."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if has_depth_bid or has_depth_ask:
|
||||||
|
if not has_depth_bid or not has_depth_ask:
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"WebSocket depth quote должна содержать bids и asks."
|
||||||
|
)
|
||||||
|
|
||||||
|
bids = payload.get("bids")
|
||||||
|
asks = payload.get("asks")
|
||||||
|
|
||||||
|
if not isinstance(bids, list) or not bids:
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"$.payload.bids должен быть непустым JSON-массивом."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(asks, list) or not asks:
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"$.payload.asks должен быть непустым JSON-массивом."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise QuoteSchemaError(
|
||||||
|
"WebSocket quote не содержит bid/ask либо bids/asks."
|
||||||
|
)
|
||||||
1
app/src/market_data/acquisition/validation/sequence.py
Normal file
1
app/src/market_data/acquisition/validation/sequence.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/validation/sequence.py
|
||||||
551
app/src/market_data/acquisition/validation/values.py
Normal file
551
app/src/market_data/acquisition/validation/values.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
# app/src/market_data/acquisition/validation/values.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||||
|
DzengiExchangeInfoResponse,
|
||||||
|
DzengiExchangeInfoSymbol,
|
||||||
|
DzengiInstrumentFilter,
|
||||||
|
DzengiLotSizeFilter,
|
||||||
|
DzengiMinNotionalFilter,
|
||||||
|
DzengiRateLimit,
|
||||||
|
DzengiRawNumeric,
|
||||||
|
DzengiUnknownFilter,
|
||||||
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketQuoteResponse,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
InstrumentReferenceValueError,
|
||||||
|
QuoteValueError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exchange_info_values(
|
||||||
|
response: DzengiExchangeInfoResponse,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Проверить допустимость значений в raw-моделях Dzengi exchangeInfo.
|
||||||
|
|
||||||
|
Функция не изменяет модели, не выполняет mapping в Instrument
|
||||||
|
и не повторяет schema validation или parsing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
response.status,
|
||||||
|
path="$.status",
|
||||||
|
)
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
response.correlation_id,
|
||||||
|
path="$.correlationId",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.payload
|
||||||
|
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
payload.timezone,
|
||||||
|
path="$.payload.timezone",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, rate_limit in enumerate(payload.rate_limits):
|
||||||
|
_validate_rate_limit(
|
||||||
|
rate_limit,
|
||||||
|
path=f"$.payload.rateLimits[{index}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, exchange_filter in enumerate(payload.exchange_filters):
|
||||||
|
_validate_unknown_filter(
|
||||||
|
exchange_filter,
|
||||||
|
path=f"$.payload.exchangeFilters[{index}]",
|
||||||
|
allow_empty_filter_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, symbol in enumerate(payload.symbols):
|
||||||
|
_validate_symbol(
|
||||||
|
symbol,
|
||||||
|
path=f"$.payload.symbols[{index}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_symbol(
|
||||||
|
symbol: DzengiExchangeInfoSymbol,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.symbol,
|
||||||
|
path=f"{path}.symbol",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.name,
|
||||||
|
path=f"{path}.name",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.status,
|
||||||
|
path=f"{path}.status",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.base_asset,
|
||||||
|
path=f"{path}.baseAsset",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.quote_asset,
|
||||||
|
path=f"{path}.quoteAsset",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
symbol.market_type,
|
||||||
|
path=f"{path}.marketType",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
symbol.asset_type,
|
||||||
|
path=f"{path}.assetType",
|
||||||
|
)
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
symbol.quote_asset_id,
|
||||||
|
path=f"{path}.quoteAssetId",
|
||||||
|
)
|
||||||
|
_validate_optional_non_empty_string(
|
||||||
|
symbol.trading_hours,
|
||||||
|
path=f"{path}.tradingHours",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dzengi может возвращать пустые строки для country, sector и industry.
|
||||||
|
# Эти значения сохраняются как часть raw-контракта и не считаются ошибкой.
|
||||||
|
|
||||||
|
_validate_non_empty_string_tuple(
|
||||||
|
symbol.order_types,
|
||||||
|
path=f"{path}.orderTypes",
|
||||||
|
)
|
||||||
|
_validate_non_empty_string_tuple(
|
||||||
|
symbol.market_modes,
|
||||||
|
path=f"{path}.marketModes",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_non_negative_int(
|
||||||
|
symbol.base_asset_precision,
|
||||||
|
path=f"{path}.baseAssetPrecision",
|
||||||
|
)
|
||||||
|
_validate_optional_non_negative_int(
|
||||||
|
symbol.quote_precision,
|
||||||
|
path=f"{path}.quotePrecision",
|
||||||
|
)
|
||||||
|
_validate_optional_non_negative_int(
|
||||||
|
symbol.swap_charge_interval,
|
||||||
|
path=f"{path}.swapChargeInterval",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_positive_number(
|
||||||
|
symbol.tick_size,
|
||||||
|
path=f"{path}.tickSize",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.tick_value,
|
||||||
|
path=f"{path}.tickValue",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.trading_fee,
|
||||||
|
path=f"{path}.tradingFee",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.exchange_fee,
|
||||||
|
path=f"{path}.exchangeFee",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.long_rate,
|
||||||
|
path=f"{path}.longRate",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.short_rate,
|
||||||
|
path=f"{path}.shortRate",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.min_sl_gap,
|
||||||
|
path=f"{path}.minSLGap",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.max_sl_gap,
|
||||||
|
path=f"{path}.maxSLGap",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.min_tp_gap,
|
||||||
|
path=f"{path}.minTPGap",
|
||||||
|
)
|
||||||
|
_validate_optional_finite_number(
|
||||||
|
symbol.max_tp_gap,
|
||||||
|
path=f"{path}.maxTPGap",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, instrument_filter in enumerate(symbol.filters):
|
||||||
|
_validate_instrument_filter(
|
||||||
|
instrument_filter,
|
||||||
|
path=f"{path}.filters[{index}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rate_limit(
|
||||||
|
rate_limit: DzengiRateLimit,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
rate_limit.interval,
|
||||||
|
path=f"{path}.interval",
|
||||||
|
)
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
rate_limit.rate_limit_type,
|
||||||
|
path=f"{path}.rateLimitType",
|
||||||
|
)
|
||||||
|
_validate_positive_int(
|
||||||
|
rate_limit.interval_num,
|
||||||
|
path=f"{path}.intervalNum",
|
||||||
|
)
|
||||||
|
_validate_positive_int(
|
||||||
|
rate_limit.limit,
|
||||||
|
path=f"{path}.limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_instrument_filter(
|
||||||
|
instrument_filter: DzengiInstrumentFilter,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
instrument_filter.filter_type,
|
||||||
|
path=f"{path}.filterType",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(instrument_filter, DzengiLotSizeFilter):
|
||||||
|
_validate_lot_size_filter(
|
||||||
|
instrument_filter,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(instrument_filter, DzengiMinNotionalFilter):
|
||||||
|
_validate_min_notional_filter(
|
||||||
|
instrument_filter,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(instrument_filter, DzengiUnknownFilter):
|
||||||
|
_validate_unknown_filter(
|
||||||
|
instrument_filter,
|
||||||
|
path=path,
|
||||||
|
allow_empty_filter_type=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_lot_size_filter(
|
||||||
|
lot_size: DzengiLotSizeFilter,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
min_qty = _validate_optional_positive_raw_numeric(
|
||||||
|
lot_size.min_qty,
|
||||||
|
path=f"{path}.minQty",
|
||||||
|
)
|
||||||
|
max_qty = _validate_optional_positive_raw_numeric(
|
||||||
|
lot_size.max_qty,
|
||||||
|
path=f"{path}.maxQty",
|
||||||
|
)
|
||||||
|
_validate_optional_positive_raw_numeric(
|
||||||
|
lot_size.step_size,
|
||||||
|
path=f"{path}.stepSize",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
min_qty is not None
|
||||||
|
and max_qty is not None
|
||||||
|
and min_qty > max_qty
|
||||||
|
):
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path}.minQty не должно превышать {path}.maxQty."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_min_notional_filter(
|
||||||
|
min_notional: DzengiMinNotionalFilter,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
_validate_optional_non_negative_raw_numeric(
|
||||||
|
min_notional.min_notional,
|
||||||
|
path=f"{path}.minNotional",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_unknown_filter(
|
||||||
|
unknown_filter: DzengiUnknownFilter,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
allow_empty_filter_type: bool,
|
||||||
|
) -> None:
|
||||||
|
if allow_empty_filter_type:
|
||||||
|
if unknown_filter.filter_type and not unknown_filter.filter_type.strip():
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path}.filterType не должен состоять только из пробелов."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_validate_required_non_empty_string(
|
||||||
|
unknown_filter.filter_type,
|
||||||
|
path=f"{path}.filterType",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_required_non_empty_string(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if not value.strip():
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_non_empty_string(
|
||||||
|
value: str | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not value.strip():
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_non_empty_string_tuple(
|
||||||
|
values: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
for index, value in enumerate(values):
|
||||||
|
if not value.strip():
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path}[{index}] не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_non_negative_int(
|
||||||
|
value: int | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if value < 0:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть больше или равно нулю."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_positive_int(
|
||||||
|
value: int,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if value <= 0:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть больше нуля."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_positive_number(
|
||||||
|
value: int | float | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
decimal_value = _to_finite_decimal(
|
||||||
|
value,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if decimal_value <= 0:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть больше нуля."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_finite_number(
|
||||||
|
value: int | float | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_to_finite_decimal(
|
||||||
|
value,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_positive_raw_numeric(
|
||||||
|
value: DzengiRawNumeric | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
decimal_value = _to_finite_decimal(
|
||||||
|
value,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if decimal_value <= 0:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть больше нуля."
|
||||||
|
)
|
||||||
|
|
||||||
|
return decimal_value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_non_negative_raw_numeric(
|
||||||
|
value: DzengiRawNumeric | None,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
decimal_value = _to_finite_decimal(
|
||||||
|
value,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if decimal_value < 0:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть больше или равно нулю."
|
||||||
|
)
|
||||||
|
|
||||||
|
return decimal_value
|
||||||
|
|
||||||
|
|
||||||
|
def _to_finite_decimal(
|
||||||
|
value: str | int | float,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
decimal_value = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть корректным числом."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not decimal_value.is_finite():
|
||||||
|
raise InstrumentReferenceValueError(
|
||||||
|
f"{path} должно быть конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return decimal_value
|
||||||
|
|
||||||
|
def validate_quote_values(
|
||||||
|
response: DzengiTicker24hrResponse,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Проверить допустимость значений raw-модели Dzengi ticker/24hr.
|
||||||
|
|
||||||
|
Функция не изменяет модель и не выполняет mapping в Quote.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not response.symbol.strip():
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.symbol не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
last_price = _quote_positive_decimal(
|
||||||
|
response.last_price,
|
||||||
|
path="$.payload.lastPrice",
|
||||||
|
)
|
||||||
|
bid_price = _quote_positive_decimal(
|
||||||
|
response.bid_price,
|
||||||
|
path="$.payload.bidPrice",
|
||||||
|
)
|
||||||
|
ask_price = _quote_positive_decimal(
|
||||||
|
response.ask_price,
|
||||||
|
path="$.payload.askPrice",
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.close_time <= 0:
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.closeTime должно быть больше нуля."
|
||||||
|
)
|
||||||
|
|
||||||
|
if bid_price > ask_price:
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.bidPrice не должно превышать $.payload.askPrice."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Явное чтение сохраняет проверку обязательности lastPrice
|
||||||
|
# как самостоятельного положительного рыночного значения.
|
||||||
|
del last_price
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_positive_decimal(
|
||||||
|
value: DzengiRawNumeric,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
decimal_value = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise QuoteValueError(
|
||||||
|
f"{path} должно быть корректным числом."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not decimal_value.is_finite():
|
||||||
|
raise QuoteValueError(
|
||||||
|
f"{path} должно быть конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
if decimal_value <= 0:
|
||||||
|
raise QuoteValueError(
|
||||||
|
f"{path} должно быть больше нуля."
|
||||||
|
)
|
||||||
|
|
||||||
|
return decimal_value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dzengi_websocket_quote_values(
|
||||||
|
response: DzengiWebSocketQuoteResponse,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить значения raw-модели котировки Dzengi WebSocket."""
|
||||||
|
|
||||||
|
if not response.symbol.strip():
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.symbol не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
bid_price = _quote_positive_decimal(
|
||||||
|
response.bid_price,
|
||||||
|
path="$.payload.bidPrice",
|
||||||
|
)
|
||||||
|
ask_price = _quote_positive_decimal(
|
||||||
|
response.ask_price,
|
||||||
|
path="$.payload.askPrice",
|
||||||
|
)
|
||||||
|
|
||||||
|
if bid_price > ask_price:
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.bidPrice не должно превышать $.payload.askPrice."
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.timestamp is not None and response.timestamp <= 0:
|
||||||
|
raise QuoteValueError(
|
||||||
|
"$.payload.timestamp должно быть больше нуля."
|
||||||
|
)
|
||||||
18
app/src/storage/exceptions.py
Normal file
18
app/src/storage/exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# app/src/storage/exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
# Базовая ошибка storage-слоя.
|
||||||
|
class StorageError(Exception):
|
||||||
|
"""Base storage layer error."""
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка хранилища справочника инструментов.
|
||||||
|
class InstrumentStoreError(StorageError):
|
||||||
|
"""Instrument store contract or operation error."""
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка хранилища канонических котировок.
|
||||||
|
class QuoteStoreError(StorageError):
|
||||||
|
"""Quote store contract or operation error."""
|
||||||
110
app/src/storage/instrument_store.py
Normal file
110
app/src/storage/instrument_store.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# app/src/storage/instrument_store.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
from src.storage.exceptions import InstrumentStoreError
|
||||||
|
|
||||||
|
|
||||||
|
# Контракт runtime-хранилища канонического справочника инструментов.
|
||||||
|
@runtime_checkable
|
||||||
|
class InstrumentStoreProtocol(Protocol):
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> tuple[Instrument, ...] | None:
|
||||||
|
"""
|
||||||
|
Вернуть сохранённый набор инструментов для источника.
|
||||||
|
|
||||||
|
None означает cache miss: данные для источника ещё не сохранялись.
|
||||||
|
Пустой tuple означает успешное сохранение пустого справочника.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
instruments: tuple[Instrument, ...],
|
||||||
|
) -> None:
|
||||||
|
"""Сохранить полный immutable-набор инструментов источника."""
|
||||||
|
|
||||||
|
def clear(
|
||||||
|
self,
|
||||||
|
source_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Очистить данные одного источника или всё хранилище.
|
||||||
|
|
||||||
|
source_name=None очищает все сохранённые источники.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory реализация runtime-хранилища справочника инструментов.
|
||||||
|
class InMemoryInstrumentStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._items: dict[str, tuple[Instrument, ...]] = {}
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> tuple[Instrument, ...] | None:
|
||||||
|
normalized_source_name = self._normalize_source_name(
|
||||||
|
source_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._items.get(normalized_source_name)
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
instruments: tuple[Instrument, ...],
|
||||||
|
) -> None:
|
||||||
|
normalized_source_name = self._normalize_source_name(
|
||||||
|
source_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(instruments, tuple):
|
||||||
|
raise InstrumentStoreError(
|
||||||
|
"Справочник инструментов должен быть передан как tuple."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all(
|
||||||
|
isinstance(instrument, Instrument)
|
||||||
|
for instrument in instruments
|
||||||
|
):
|
||||||
|
raise InstrumentStoreError(
|
||||||
|
"Справочник содержит объект, не являющийся Instrument."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._items[normalized_source_name] = instruments
|
||||||
|
|
||||||
|
def clear(
|
||||||
|
self,
|
||||||
|
source_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if source_name is None:
|
||||||
|
self._items.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(
|
||||||
|
source_name
|
||||||
|
)
|
||||||
|
|
||||||
|
self._items.pop(
|
||||||
|
normalized_source_name,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = str(source_name or "").strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise InstrumentStoreError(
|
||||||
|
"Имя источника Instrument Store не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
215
app/src/storage/quote_store.py
Normal file
215
app/src/storage/quote_store.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# app/src/storage/quote_store.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.storage.exceptions import QuoteStoreError
|
||||||
|
|
||||||
|
|
||||||
|
# Контракт runtime-хранилища канонических котировок.
|
||||||
|
@runtime_checkable
|
||||||
|
class QuoteStoreProtocol(Protocol):
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
runtime_key: str = "default",
|
||||||
|
) -> Quote | None:
|
||||||
|
"""Вернуть котировку или None, если запись отсутствует."""
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
quote: Quote,
|
||||||
|
*,
|
||||||
|
runtime_key: str = "default",
|
||||||
|
) -> None:
|
||||||
|
"""Сохранить каноническую котировку без копирования модели."""
|
||||||
|
|
||||||
|
def clear(
|
||||||
|
self,
|
||||||
|
source_name: str | None = None,
|
||||||
|
symbol: str | None = None,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Удалить записи, соответствующие переданным фильтрам."""
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory реализация runtime-хранилища канонических котировок.
|
||||||
|
class InMemoryQuoteStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._items: dict[tuple[str, str, str], Quote] = {}
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
runtime_key: str = "default",
|
||||||
|
) -> Quote | None:
|
||||||
|
return self._items.get(
|
||||||
|
self._key(
|
||||||
|
source_name=source_name,
|
||||||
|
symbol=symbol,
|
||||||
|
runtime_key=runtime_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
quote: Quote,
|
||||||
|
*,
|
||||||
|
runtime_key: str = "default",
|
||||||
|
) -> None:
|
||||||
|
normalized_source_name = self._normalize_source_name(
|
||||||
|
source_name
|
||||||
|
)
|
||||||
|
normalized_runtime_key = self._normalize_runtime_key(
|
||||||
|
runtime_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(quote, Quote):
|
||||||
|
raise QuoteStoreError(
|
||||||
|
"Quote Store принимает только объект Quote."
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_symbol = self._normalize_symbol(
|
||||||
|
quote.symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
self._items[
|
||||||
|
(
|
||||||
|
normalized_source_name,
|
||||||
|
normalized_runtime_key,
|
||||||
|
normalized_symbol,
|
||||||
|
)
|
||||||
|
] = quote
|
||||||
|
|
||||||
|
def clear(
|
||||||
|
self,
|
||||||
|
source_name: str | None = None,
|
||||||
|
symbol: str | None = None,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
source_name is None
|
||||||
|
and symbol is None
|
||||||
|
and runtime_key is None
|
||||||
|
):
|
||||||
|
self._items.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized_source_name = (
|
||||||
|
self._normalize_source_name(source_name)
|
||||||
|
if source_name is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
normalized_symbol = (
|
||||||
|
self._normalize_symbol(symbol)
|
||||||
|
if symbol is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
normalized_runtime_key = (
|
||||||
|
self._normalize_runtime_key(runtime_key)
|
||||||
|
if runtime_key is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
keys_to_delete = [
|
||||||
|
key
|
||||||
|
for key in self._items
|
||||||
|
if self._matches_filters(
|
||||||
|
key,
|
||||||
|
source_name=normalized_source_name,
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
runtime_key=normalized_runtime_key,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in keys_to_delete:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
|
||||||
|
def _key(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_name: str,
|
||||||
|
symbol: str,
|
||||||
|
runtime_key: str,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
self._normalize_source_name(source_name),
|
||||||
|
self._normalize_runtime_key(runtime_key),
|
||||||
|
self._normalize_symbol(symbol),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _matches_filters(
|
||||||
|
self,
|
||||||
|
key: tuple[str, str, str],
|
||||||
|
*,
|
||||||
|
source_name: str | None,
|
||||||
|
symbol: str | None,
|
||||||
|
runtime_key: str | None,
|
||||||
|
) -> bool:
|
||||||
|
key_source_name, key_runtime_key, key_symbol = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
source_name is not None
|
||||||
|
and key_source_name != source_name
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
runtime_key is not None
|
||||||
|
and key_runtime_key != runtime_key
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if symbol is not None and key_symbol != symbol:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = str(source_name or "").strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise QuoteStoreError(
|
||||||
|
"Имя источника Quote Store не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
|
|
||||||
|
def _normalize_runtime_key(
|
||||||
|
self,
|
||||||
|
runtime_key: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_runtime_key = str(runtime_key or "").strip().lower()
|
||||||
|
|
||||||
|
if not normalized_runtime_key:
|
||||||
|
raise QuoteStoreError(
|
||||||
|
"Runtime key Quote Store не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_runtime_key
|
||||||
|
|
||||||
|
def _normalize_symbol(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_symbol = str(symbol or "").strip().upper()
|
||||||
|
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise QuoteStoreError(
|
||||||
|
"Символ Quote Store не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_symbol
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/storage/repositories/balance_snapshots.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
# app/src/telegram/handlers/__init__.py
|
||||||
|
|
||||||
"""Package marker."""
|
"""Package marker."""
|
||||||
@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
|
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
|
||||||
from src.telegram.ui.common import mode_line
|
from src.telegram.ui.common import mode_line
|
||||||
from src.trading.auto.service import AutoTradeService
|
from src.trading.auto.service import AutoTradeService
|
||||||
@@ -40,10 +41,10 @@ def build_auto_notification_text() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _build_signal_notification_text(state, signal: str) -> str:
|
def _build_signal_notification_text(state, signal: str) -> str:
|
||||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
quote = _market_quote(getattr(state, "symbol", None))
|
||||||
|
|
||||||
bid_price = _price_from_snapshot(snapshot, "bid_price")
|
bid_price = _price_from_quote(quote, "bid_price")
|
||||||
ask_price = _price_from_snapshot(snapshot, "ask_price")
|
ask_price = _price_from_quote(quote, "ask_price")
|
||||||
|
|
||||||
side = "Long" if signal == "BUY" else "Short"
|
side = "Long" if signal == "BUY" else "Short"
|
||||||
side_icon = _signal_icon(signal)
|
side_icon = _signal_icon(signal)
|
||||||
@@ -85,28 +86,28 @@ def _build_signal_notification_text(state, signal: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _price_from_snapshot(
|
def _price_from_quote(
|
||||||
snapshot: dict[str, object] | None,
|
quote: Quote | None,
|
||||||
key: str,
|
key: str,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
if snapshot is None:
|
if quote is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return safe_float(snapshot.get(key))
|
return safe_float(getattr(quote, key, None))
|
||||||
|
|
||||||
|
|
||||||
def _position_current_price(state) -> float | None:
|
def _position_current_price(state) -> float | None:
|
||||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
quote = _market_quote(getattr(state, "symbol", None))
|
||||||
|
|
||||||
if snapshot is not None:
|
if quote is not None:
|
||||||
side = str(getattr(state, "position_side", "") or "").upper()
|
side = str(getattr(state, "position_side", "") or "").upper()
|
||||||
|
|
||||||
if side == "LONG":
|
if side == "LONG":
|
||||||
price = snapshot.get("bid_price") or snapshot.get("last_price")
|
price = quote.bid_price or quote.last_price
|
||||||
elif side == "SHORT":
|
elif side == "SHORT":
|
||||||
price = snapshot.get("ask_price") or snapshot.get("last_price")
|
price = quote.ask_price or quote.last_price
|
||||||
else:
|
else:
|
||||||
price = snapshot.get("last_price")
|
price = quote.last_price
|
||||||
|
|
||||||
parsed = safe_float(price)
|
parsed = safe_float(price)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
@@ -720,12 +721,15 @@ def _max_reserved_line(state, price: float | None = None) -> str:
|
|||||||
return f"Маржа · {_format_usd_compact(own_funds_usd)}"
|
return f"Маржа · {_format_usd_compact(own_funds_usd)}"
|
||||||
|
|
||||||
|
|
||||||
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
def _market_quote(symbol: str | None) -> Quote | None:
|
||||||
if not symbol:
|
if not symbol:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
|
return ExchangeService().get_quote(
|
||||||
|
symbol,
|
||||||
|
runtime_key="auto",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -907,10 +911,10 @@ def _commission_lines_for_position(
|
|||||||
|
|
||||||
|
|
||||||
def _current_price(symbol: str | None) -> float | None:
|
def _current_price(symbol: str | None) -> float | None:
|
||||||
snapshot = _market_snapshot(symbol)
|
quote = _market_quote(symbol)
|
||||||
|
|
||||||
if snapshot is not None:
|
if quote is not None:
|
||||||
price = snapshot.get("last_price")
|
price = quote.last_price
|
||||||
if price is not None:
|
if price is not None:
|
||||||
try:
|
try:
|
||||||
parsed = safe_float(price)
|
parsed = safe_float(price)
|
||||||
@@ -922,25 +926,25 @@ def _current_price(symbol: str | None) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return float(ExchangeService().get_price(symbol).price)
|
return float(ExchangeService().get_quote(symbol).last_price)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _signal_entry_price(state) -> float | None:
|
def _signal_entry_price(state) -> float | None:
|
||||||
snapshot = _market_snapshot(state.symbol)
|
quote = _market_quote(state.symbol)
|
||||||
|
|
||||||
if snapshot is None:
|
if quote is None:
|
||||||
return _current_price(state.symbol)
|
return _current_price(state.symbol)
|
||||||
|
|
||||||
signal = (state.last_signal or "HOLD").upper()
|
signal = (state.last_signal or "HOLD").upper()
|
||||||
|
|
||||||
if signal == "BUY":
|
if signal == "BUY":
|
||||||
price = snapshot.get("ask_price")
|
price = quote.ask_price
|
||||||
elif signal == "SELL":
|
elif signal == "SELL":
|
||||||
price = snapshot.get("bid_price")
|
price = quote.bid_price
|
||||||
else:
|
else:
|
||||||
price = snapshot.get("last_price")
|
price = quote.last_price
|
||||||
|
|
||||||
if price is None:
|
if price is None:
|
||||||
return None
|
return None
|
||||||
@@ -1535,8 +1539,16 @@ def _trade_word(value: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _cycle_summary_lines(state) -> list[str]:
|
def _cycle_summary_lines(state) -> list[str]:
|
||||||
# Единый блок статистики текущего цикла.
|
status = str(getattr(state, "status", "") or "").upper()
|
||||||
# Показываем номер цикла всегда, даже если закрытых сделок ещё нет.
|
|
||||||
|
# В режиме наблюдения торгового цикла нет:
|
||||||
|
# бот только анализирует рынок и не исполняет сделки.
|
||||||
|
if status == "OBSERVING":
|
||||||
|
return ["🔬 Анализ рынка"]
|
||||||
|
|
||||||
|
if status != "RUNNING":
|
||||||
|
return []
|
||||||
|
|
||||||
cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
|
cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
|
||||||
cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)
|
cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from src.core.config import load_settings
|
||||||
|
from src.core.types import NumericLike
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.trading.debug.service import DebugTradeService
|
from src.trading.debug.service import DebugTradeService
|
||||||
|
|
||||||
@@ -113,6 +118,23 @@ def _format_updated_at(value: object) -> str:
|
|||||||
if not value:
|
if not value:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
current = value
|
||||||
|
|
||||||
|
if current.tzinfo is None:
|
||||||
|
current = current.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
current = current.astimezone(
|
||||||
|
ZoneInfo(settings.tz),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
current = current.astimezone()
|
||||||
|
|
||||||
|
return current.strftime("%H:%M:%S")
|
||||||
|
|
||||||
text = str(value)
|
text = str(value)
|
||||||
|
|
||||||
if " " in text:
|
if " " in text:
|
||||||
@@ -121,6 +143,23 @@ def _format_updated_at(value: object) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_age_seconds(quote: object) -> float | None:
|
||||||
|
received_at = getattr(quote, "received_at", None)
|
||||||
|
if not isinstance(received_at, datetime):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if received_at.tzinfo is None:
|
||||||
|
received_at = received_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return max(
|
||||||
|
0.0,
|
||||||
|
(
|
||||||
|
datetime.now(timezone.utc)
|
||||||
|
- received_at.astimezone(timezone.utc)
|
||||||
|
).total_seconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||||
if not symbol:
|
if not symbol:
|
||||||
return [
|
return [
|
||||||
@@ -141,7 +180,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
|||||||
error = None
|
error = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
market = ExchangeService().get_market_snapshot(
|
market = ExchangeService().get_quote(
|
||||||
symbol,
|
symbol,
|
||||||
runtime_key="debug_auto",
|
runtime_key="debug_auto",
|
||||||
)
|
)
|
||||||
@@ -167,11 +206,11 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
|||||||
f"Error · {error or 'unknown'}",
|
f"Error · {error or 'unknown'}",
|
||||||
]
|
]
|
||||||
|
|
||||||
last_price = market.get("last_price") if market else getattr(execution, "last_price", None)
|
last_price = market.last_price if market else getattr(execution, "last_price", None)
|
||||||
bid_price = market.get("bid_price") if market else getattr(execution, "bid_price", None)
|
bid_price = market.bid_price if market else getattr(execution, "bid_price", None)
|
||||||
ask_price = market.get("ask_price") if market else getattr(execution, "ask_price", None)
|
ask_price = market.ask_price if market else getattr(execution, "ask_price", None)
|
||||||
market_source = market.get("source") if market else "—"
|
market_source = market.source if market else "—"
|
||||||
market_age = market.get("age_seconds") if market else None
|
market_age = _quote_age_seconds(market) if market else None
|
||||||
|
|
||||||
execution_source = getattr(execution, "source", "—") if execution else "—"
|
execution_source = getattr(execution, "source", "—") if execution else "—"
|
||||||
execution_age = getattr(execution, "age_seconds", None) if execution else None
|
execution_age = getattr(execution, "age_seconds", None) if execution else None
|
||||||
@@ -184,7 +223,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
|||||||
f"Ask · {_format_usd_or_dash(ask_price)}",
|
f"Ask · {_format_usd_or_dash(ask_price)}",
|
||||||
f"Source · {market_source or '—'}",
|
f"Source · {market_source or '—'}",
|
||||||
f"Quote age · {_format_age(market_age)}",
|
f"Quote age · {_format_age(market_age)}",
|
||||||
f"Exchange time · {_format_updated_at(market.get('updated_at') if market else None)}",
|
f"Exchange time · {_format_updated_at(market.exchange_timestamp if market else None)}",
|
||||||
"",
|
"",
|
||||||
"<b>Execution</b>",
|
"<b>Execution</b>",
|
||||||
f"Source · {execution_source or '—'}",
|
f"Source · {execution_source or '—'}",
|
||||||
@@ -274,7 +313,9 @@ def _format_crypto_size(value: float | int | None) -> str:
|
|||||||
return f"{float(value):.5f}".rstrip("0").rstrip(".")
|
return f"{float(value):.5f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def _format_money_compact(value: float | int | None) -> str:
|
def _format_money_compact(
|
||||||
|
value: float | int | Decimal | None,
|
||||||
|
) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
@@ -286,21 +327,25 @@ def _format_money_compact(value: float | int | None) -> str:
|
|||||||
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def _format_usd_or_dash(value: float | int | None) -> str:
|
def _format_usd_or_dash(
|
||||||
|
value: float | int | Decimal | None,
|
||||||
|
) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
return f"$ {_format_money_compact(value)}"
|
return f"$ {_format_money_compact(value)}"
|
||||||
|
|
||||||
|
|
||||||
def _format_usd_or_off(value: float | int | None) -> str:
|
def _format_usd_or_off(
|
||||||
|
value: float | int | Decimal | None,
|
||||||
|
) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "off"
|
return "Выкл."
|
||||||
|
|
||||||
return f"$ {_format_money_compact(value)}"
|
return f"$ {_format_money_compact(value)}"
|
||||||
|
|
||||||
|
|
||||||
def _format_signed_usd(value: float | int | None) -> str:
|
def _format_signed_usd(value: float | int | Decimal | None) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
@@ -315,7 +360,7 @@ def _format_signed_usd(value: float | int | None) -> str:
|
|||||||
return "$ 0"
|
return "$ 0"
|
||||||
|
|
||||||
|
|
||||||
def _format_age(value: object) -> str:
|
def _format_age(value: NumericLike | None) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ async def _show_journal_page(
|
|||||||
await target_message.edit_text(
|
await target_message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
except TelegramBadRequest as exc:
|
except TelegramBadRequest as exc:
|
||||||
if "message is not modified" in str(exc).lower():
|
if "message is not modified" in str(exc).lower():
|
||||||
@@ -197,6 +198,7 @@ async def _show_journal_page(
|
|||||||
sent_message = await target_message.answer(
|
sent_message = await target_message.answer(
|
||||||
text,
|
text,
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
_register_journal_screen(sent_message)
|
_register_journal_screen(sent_message)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from html import escape
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
@@ -67,6 +68,10 @@ def build_keyboard(
|
|||||||
return kb.as_markup()
|
return kb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def _html(value: object) -> str:
|
||||||
|
return escape(str(value or ""), quote=False)
|
||||||
|
|
||||||
|
|
||||||
def build_actions_keyboard() -> InlineKeyboardMarkup:
|
def build_actions_keyboard() -> InlineKeyboardMarkup:
|
||||||
# Первый экран экспорта: выбираем, что именно экспортировать.
|
# Первый экран экспорта: выбираем, что именно экспортировать.
|
||||||
kb = InlineKeyboardBuilder()
|
kb = InlineKeyboardBuilder()
|
||||||
@@ -233,8 +238,8 @@ def _render_auto_signal(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
level = str(event.get("level") or "INFO").upper()
|
level = str(event.get("level") or "INFO").upper()
|
||||||
icon = LEVEL_ICONS.get(level, "•")
|
icon = LEVEL_ICONS.get(level, "•")
|
||||||
title = _event_title(event.get("event_type"))
|
title = _html(_event_title(event.get("event_type")))
|
||||||
message = _humanize_message(event.get("message"))
|
message = _html(_humanize_message(event.get("message")))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"{icon} <b>{level}</b> · {title}",
|
f"{icon} <b>{level}</b> · {title}",
|
||||||
@@ -253,8 +258,8 @@ def _render_default_event(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
level = str(event.get("level") or "INFO").upper()
|
level = str(event.get("level") or "INFO").upper()
|
||||||
icon = LEVEL_ICONS.get(level, "•")
|
icon = LEVEL_ICONS.get(level, "•")
|
||||||
title = _event_title(event.get("event_type"))
|
title = _html(_event_title(event.get("event_type")))
|
||||||
message = _humanize_message(event.get("message"))
|
message = _html(_humanize_message(event.get("message")))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"{icon} <b>{level}</b> · {title}",
|
f"{icon} <b>{level}</b> · {title}",
|
||||||
|
|||||||
@@ -1,505 +0,0 @@
|
|||||||
# app/src/telegram/handlers/market.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import (
|
|
||||||
CallbackQuery,
|
|
||||||
InaccessibleMessage,
|
|
||||||
InlineKeyboardMarkup,
|
|
||||||
Message,
|
|
||||||
)
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.core.types import NumericLike
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
from src.integrations.exchange.status import (
|
|
||||||
ExchangeRuntimeStatus,
|
|
||||||
ExchangeStatusCode,
|
|
||||||
build_exchange_error_status,
|
|
||||||
classify_exchange_error,
|
|
||||||
)
|
|
||||||
from src.telegram.live.active_screen import ActiveScreenManager
|
|
||||||
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
|
||||||
from src.telegram.ui.common import mode_line, now_line
|
|
||||||
from src.telegram.ui.currency_ui import format_usd_amount
|
|
||||||
from src.telegram.ui.exchange_error import (
|
|
||||||
show_callback_exchange_error,
|
|
||||||
show_message_exchange_error,
|
|
||||||
)
|
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="market")
|
|
||||||
|
|
||||||
_last_market_prices: dict[str, float] = {}
|
|
||||||
_last_market_directions: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _require_message(callback: CallbackQuery) -> Message | None:
|
|
||||||
message = callback.message
|
|
||||||
|
|
||||||
if message is None or isinstance(message, InaccessibleMessage):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
|
||||||
builder.adjust(1)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
# собрать текст, когда рынок/биржа недоступны через unified status layer
|
|
||||||
def _build_market_status_text(status: ExchangeRuntimeStatus) -> str:
|
|
||||||
icon = "⏸️" if status.code == ExchangeStatusCode.BREAK else "⛔️"
|
|
||||||
|
|
||||||
return (
|
|
||||||
"<b>📈 Рынок</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
f"{icon} {status.title}\n\n"
|
|
||||||
f"{status.message}\n\n"
|
|
||||||
f"{now_line()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_market_text(
|
|
||||||
*,
|
|
||||||
ticker_price: NumericLike,
|
|
||||||
name: str,
|
|
||||||
market_type: str,
|
|
||||||
base_asset: str,
|
|
||||||
quote_asset: str,
|
|
||||||
) -> str:
|
|
||||||
price = safe_float(ticker_price)
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
price = 0.0
|
|
||||||
|
|
||||||
previous_price = _last_market_prices.get(name)
|
|
||||||
price_direction = _last_market_directions.get(name, "▲")
|
|
||||||
|
|
||||||
if previous_price is not None:
|
|
||||||
if price > previous_price:
|
|
||||||
price_direction = "🔺"
|
|
||||||
elif price < previous_price:
|
|
||||||
price_direction = "🔻"
|
|
||||||
|
|
||||||
_last_market_prices[name] = price
|
|
||||||
_last_market_directions[name] = price_direction
|
|
||||||
|
|
||||||
type_map = {
|
|
||||||
"LEVERAGE": "leverage",
|
|
||||||
"SPOT": "spot",
|
|
||||||
}
|
|
||||||
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
|
|
||||||
|
|
||||||
return (
|
|
||||||
"<b>📈 Рынок</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
"\n"
|
|
||||||
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
|
|
||||||
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
|
|
||||||
f"{now_line()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# live-render должен сам уметь показать ошибку, иначе runner просто потеряет экран
|
|
||||||
def _build_market_live_text() -> str:
|
|
||||||
service = ExchangeService()
|
|
||||||
requested_symbol = service.settings.default_symbol
|
|
||||||
|
|
||||||
try:
|
|
||||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
|
||||||
except Exception as exc:
|
|
||||||
return _build_market_status_text(build_exchange_error_status(exc))
|
|
||||||
|
|
||||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
|
||||||
return _build_market_status_text(runtime_status)
|
|
||||||
|
|
||||||
symbol = runtime_status.symbol or requested_symbol
|
|
||||||
|
|
||||||
validation = service.validate_symbol(symbol)
|
|
||||||
|
|
||||||
if not validation.is_valid:
|
|
||||||
return _build_market_status_text(
|
|
||||||
service.get_symbol_runtime_status(requested_symbol)
|
|
||||||
)
|
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
|
||||||
base_asset = (
|
|
||||||
symbol_info.base_asset
|
|
||||||
if symbol_info and symbol_info.base_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
quote_asset = (
|
|
||||||
symbol_info.quote_asset
|
|
||||||
if symbol_info and symbol_info.quote_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
name = (
|
|
||||||
symbol_info.name
|
|
||||||
if symbol_info and symbol_info.name
|
|
||||||
else ticker.symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
return _build_market_text(
|
|
||||||
ticker_price=ticker.price,
|
|
||||||
name=name,
|
|
||||||
market_type=market_type,
|
|
||||||
base_asset=base_asset,
|
|
||||||
quote_asset=quote_asset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_market_live_screen(message: Message) -> None:
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
LiveScreenRunner.unregister_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
ScreenRegistry.unregister_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
LiveScreenRunner.register_screen(
|
|
||||||
LiveScreen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
render_text=_build_market_live_text,
|
|
||||||
render_markup=_market_keyboard,
|
|
||||||
interval_seconds=5,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
LiveScreenRunner.start("market")
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_market_from_message(message: Message) -> bool:
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_market_from_callback(callback: CallbackQuery) -> bool:
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
await callback.answer("Bot недоступен", show_alert=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
keep_message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_or_edit_market_screen(
|
|
||||||
target_message: Message,
|
|
||||||
*,
|
|
||||||
text: str,
|
|
||||||
edit_mode: bool,
|
|
||||||
) -> None:
|
|
||||||
if edit_mode:
|
|
||||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
|
||||||
_register_market_live_screen(target_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=target_message)
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_message = await target_message.answer(
|
|
||||||
text,
|
|
||||||
reply_markup=_market_keyboard(),
|
|
||||||
)
|
|
||||||
_register_market_live_screen(sent_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
|
||||||
|
|
||||||
|
|
||||||
async def _render_market_screen(
|
|
||||||
target_message: Message,
|
|
||||||
*,
|
|
||||||
user_id: int | None,
|
|
||||||
chat_id: int | None,
|
|
||||||
edit_mode: bool,
|
|
||||||
action: str,
|
|
||||||
) -> None:
|
|
||||||
service = ExchangeService()
|
|
||||||
journal = JournalService()
|
|
||||||
requested_symbol = service.settings.default_symbol
|
|
||||||
|
|
||||||
journal.log_ui_info(
|
|
||||||
event_type="market_open_requested",
|
|
||||||
message="Запрошено открытие экрана рынка.",
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={"symbol": requested_symbol},
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
|
||||||
|
|
||||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="market_status_blocked",
|
|
||||||
message=runtime_status.message,
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload=runtime_status.as_dict(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await _send_or_edit_market_screen(
|
|
||||||
target_message,
|
|
||||||
text=_build_market_status_text(runtime_status),
|
|
||||||
edit_mode=edit_mode,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
symbol = runtime_status.symbol or requested_symbol
|
|
||||||
validation = service.validate_symbol(symbol)
|
|
||||||
|
|
||||||
if not validation.is_valid:
|
|
||||||
invalid_status = service.get_symbol_runtime_status(requested_symbol)
|
|
||||||
|
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="market_symbol_invalid",
|
|
||||||
message=invalid_status.message,
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload=invalid_status.as_dict(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await _send_or_edit_market_screen(
|
|
||||||
target_message,
|
|
||||||
text=_build_market_status_text(invalid_status),
|
|
||||||
edit_mode=edit_mode,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
|
||||||
base_asset = (
|
|
||||||
symbol_info.base_asset
|
|
||||||
if symbol_info and symbol_info.base_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
quote_asset = (
|
|
||||||
symbol_info.quote_asset
|
|
||||||
if symbol_info and symbol_info.quote_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
name = (
|
|
||||||
symbol_info.name
|
|
||||||
if symbol_info and symbol_info.name
|
|
||||||
else ticker.symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
text = _build_market_text(
|
|
||||||
ticker_price=ticker.price,
|
|
||||||
name=name,
|
|
||||||
market_type=market_type,
|
|
||||||
base_asset=base_asset,
|
|
||||||
quote_asset=quote_asset,
|
|
||||||
)
|
|
||||||
|
|
||||||
journal.log_ui_info(
|
|
||||||
event_type="market_open_success",
|
|
||||||
message="Экран рынка загружен.",
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={
|
|
||||||
"symbol": ticker.symbol,
|
|
||||||
"price": safe_float(ticker.price),
|
|
||||||
"runtime_status": runtime_status.as_dict(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await _send_or_edit_market_screen(
|
|
||||||
target_message,
|
|
||||||
text=text,
|
|
||||||
edit_mode=edit_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "📈 Рынок")
|
|
||||||
async def open_market(message: Message, state: FSMContext) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_message(message):
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = message.from_user.id if message.from_user else None
|
|
||||||
chat_id = message.chat.id if message.chat else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=False,
|
|
||||||
action="open",
|
|
||||||
)
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_open_error",
|
|
||||||
message="Не удалось загрузить экран рынка.",
|
|
||||||
screen="market",
|
|
||||||
action="open",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_message_exchange_error(
|
|
||||||
message,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "monitoring:market")
|
|
||||||
async def open_market_from_monitoring(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id if callback.from_user else None
|
|
||||||
chat_id = message.chat.id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=True,
|
|
||||||
action="open_from_monitoring",
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_open_error",
|
|
||||||
message="Не удалось загрузить экран рынка из мониторинга.",
|
|
||||||
screen="market",
|
|
||||||
action="open_from_monitoring",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "market:retry")
|
|
||||||
async def retry_market(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id if callback.from_user else None
|
|
||||||
chat_id = message.chat.id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=True,
|
|
||||||
action="retry",
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_retry_error",
|
|
||||||
message="Не удалось обновить экран рынка.",
|
|
||||||
screen="market",
|
|
||||||
action="retry",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.models import BalanceSummary, ExchangeSymbol
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
|
|
||||||
|
|
||||||
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
|
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
|
||||||
@@ -31,7 +32,10 @@ def is_fiat_currency(currency: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_currency_icon(currency: str) -> str:
|
def get_currency_icon(currency: str) -> str:
|
||||||
return CURRENCY_ICONS.get(currency.upper(), currency.upper())
|
return CURRENCY_ICONS.get(
|
||||||
|
currency.upper(),
|
||||||
|
currency.upper(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_currency_label(currency: str) -> str:
|
def get_currency_label(currency: str) -> str:
|
||||||
@@ -45,6 +49,7 @@ def render_currency_title(currency: str) -> str:
|
|||||||
def format_amount(currency: str, value: float) -> str:
|
def format_amount(currency: str, value: float) -> str:
|
||||||
if is_fiat_currency(currency):
|
if is_fiat_currency(currency):
|
||||||
return f"{value:,.2f}".replace(",", " ")
|
return f"{value:,.2f}".replace(",", " ")
|
||||||
|
|
||||||
return f"{value:,.8f}".replace(",", " ")
|
return f"{value:,.8f}".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +57,9 @@ def format_usd_amount(value: float) -> str:
|
|||||||
return f"{value:,.2f}".replace(",", " ")
|
return f"{value:,.2f}".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
def format_usd_price(value: float | int | str | None) -> str:
|
def format_usd_price(
|
||||||
|
value: float | int | str | None,
|
||||||
|
) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
@@ -62,7 +69,9 @@ def format_usd_price(value: float | int | str | None) -> str:
|
|||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
def format_usd_pnl(value: float | int | str | None) -> str:
|
def format_usd_pnl(
|
||||||
|
value: float | int | str | None,
|
||||||
|
) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
@@ -87,7 +96,10 @@ def render_currency_line(
|
|||||||
show_code: bool = True,
|
show_code: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
icon = get_currency_icon(currency)
|
icon = get_currency_icon(currency)
|
||||||
amount = format_amount(currency, value)
|
amount = format_amount(
|
||||||
|
currency,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
if show_code:
|
if show_code:
|
||||||
return f"{icon} {currency.upper()} · {amount}"
|
return f"{icon} {currency.upper()} · {amount}"
|
||||||
@@ -100,61 +112,79 @@ def balance_total(item: BalanceSummary) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def is_zero_balance(item: BalanceSummary) -> bool:
|
def is_zero_balance(item: BalanceSummary) -> bool:
|
||||||
return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
|
return (
|
||||||
|
abs(item.available) < 1e-12
|
||||||
|
and abs(item.locked) < 1e-12
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _quote_priority(quote_asset: str) -> int:
|
def _quote_priority(quote_asset: str) -> int:
|
||||||
value = (quote_asset or "").upper()
|
value = (quote_asset or "").upper()
|
||||||
|
|
||||||
if value == "USD":
|
if value == "USD":
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
if value == "USDT":
|
if value == "USDT":
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _status_priority(status: str) -> int:
|
def _status_priority(status: str) -> int:
|
||||||
value = (status or "").upper()
|
value = (status or "").upper()
|
||||||
|
|
||||||
if value == "TRADING":
|
if value == "TRADING":
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
if value in {"HALT", "BREAK"}:
|
if value in {"HALT", "BREAK"}:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _market_type_priority(market_type: str) -> int:
|
def _market_type_priority(market_type: str) -> int:
|
||||||
value = (market_type or "").upper()
|
value = (market_type or "").upper()
|
||||||
|
|
||||||
if value == "SPOT":
|
if value == "SPOT":
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
if value == "LEVERAGE":
|
if value == "LEVERAGE":
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]:
|
def _instrument_priority(
|
||||||
|
instrument: Instrument,
|
||||||
|
) -> tuple[int, int, int, str]:
|
||||||
return (
|
return (
|
||||||
_quote_priority(symbol_info.quote_asset),
|
_quote_priority(instrument.quote_asset),
|
||||||
_status_priority(symbol_info.status),
|
_status_priority(instrument.status),
|
||||||
_market_type_priority(symbol_info.market_type),
|
_market_type_priority(instrument.market_type),
|
||||||
symbol_info.symbol.upper(),
|
instrument.symbol.upper(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_asset_quote_symbol(
|
def _resolve_asset_quote_instrument(
|
||||||
exchange_service: ExchangeService,
|
exchange_service: ExchangeService,
|
||||||
asset: str,
|
asset: str,
|
||||||
) -> ExchangeSymbol | None:
|
) -> Instrument | None:
|
||||||
asset_upper = asset.upper()
|
asset_upper = asset.upper()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
symbols = exchange_service.get_exchange_symbols()
|
instruments = exchange_service.get_instruments()
|
||||||
except ExchangeError:
|
except ExchangeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
candidates: list[ExchangeSymbol] = []
|
candidates: list[Instrument] = []
|
||||||
|
|
||||||
for symbol_info in symbols:
|
for instrument in instruments:
|
||||||
base_asset = (symbol_info.base_asset or "").upper()
|
base_asset = (
|
||||||
quote_asset = (symbol_info.quote_asset or "").upper()
|
instrument.base_asset or ""
|
||||||
|
).upper()
|
||||||
|
quote_asset = (
|
||||||
|
instrument.quote_asset or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
if base_asset != asset_upper:
|
if base_asset != asset_upper:
|
||||||
continue
|
continue
|
||||||
@@ -162,12 +192,16 @@ def _resolve_asset_quote_symbol(
|
|||||||
if quote_asset not in {"USD", "USDT"}:
|
if quote_asset not in {"USD", "USDT"}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidates.append(symbol_info)
|
candidates.append(instrument)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
candidates.sort(key=_symbol_priority, reverse=True)
|
candidates.sort(
|
||||||
|
key=_instrument_priority,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
@@ -184,18 +218,26 @@ def get_asset_usd_rate(
|
|||||||
if asset in price_cache:
|
if asset in price_cache:
|
||||||
return price_cache[asset]
|
return price_cache[asset]
|
||||||
|
|
||||||
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset)
|
instrument = _resolve_asset_quote_instrument(
|
||||||
if symbol_info is None:
|
exchange_service,
|
||||||
|
asset,
|
||||||
|
)
|
||||||
|
|
||||||
|
if instrument is None:
|
||||||
price_cache[asset] = None
|
price_cache[asset] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ticker = exchange_service.get_price(symbol_info.symbol)
|
quote = exchange_service.get_quote(
|
||||||
rate = float(ticker.price)
|
instrument.symbol
|
||||||
|
)
|
||||||
|
rate = float(quote.last_price)
|
||||||
|
|
||||||
# Пока считаем USDT ~= USD
|
# Пока считаем USDT ~= USD.
|
||||||
price_cache[asset] = rate
|
price_cache[asset] = rate
|
||||||
|
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
except ExchangeError:
|
except ExchangeError:
|
||||||
price_cache[asset] = None
|
price_cache[asset] = None
|
||||||
return None
|
return None
|
||||||
@@ -207,10 +249,16 @@ def estimate_balance_usd(
|
|||||||
price_cache: dict[str, float | None],
|
price_cache: dict[str, float | None],
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
total = balance_total(item)
|
total = balance_total(item)
|
||||||
|
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
rate = get_asset_usd_rate(exchange_service, item.currency, price_cache)
|
rate = get_asset_usd_rate(
|
||||||
|
exchange_service,
|
||||||
|
item.currency,
|
||||||
|
price_cache,
|
||||||
|
)
|
||||||
|
|
||||||
if rate is None:
|
if rate is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ from src.core.event_bus import EventBus
|
|||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import NumericLike
|
from src.core.types import NumericLike
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import (
|
||||||
|
reset_adaptive_size_state,
|
||||||
|
reset_autonomous_runtime_state,
|
||||||
|
reset_cycle_statistics_state,
|
||||||
|
reset_execution_runtime_state,
|
||||||
|
reset_flip_runtime_state,
|
||||||
|
reset_loss_cooldown_state,
|
||||||
|
reset_market_analysis_state,
|
||||||
|
reset_position_protection_state,
|
||||||
|
reset_position_semantics_state,
|
||||||
|
reset_runtime_expiration_state,
|
||||||
|
reset_signal_runtime_state,
|
||||||
|
reset_execution_block_state,
|
||||||
|
reset_position_health_state,
|
||||||
|
)
|
||||||
from src.trading.execution.engine import ExecutionEngine
|
from src.trading.execution.engine import ExecutionEngine
|
||||||
from src.trading.strategies.base import BaseStrategy, StrategyContext
|
from src.trading.strategies.base import BaseStrategy, StrategyContext
|
||||||
from src.trading.strategies.registry import StrategyRegistry
|
from src.trading.strategies.registry import StrategyRegistry
|
||||||
@@ -95,7 +110,7 @@ class AutoLifecycleMixin(
|
|||||||
numeric_value = 1000.0
|
numeric_value = 1000.0
|
||||||
|
|
||||||
state.allocated_balance_usd = numeric_value
|
state.allocated_balance_usd = numeric_value
|
||||||
state.execution_block_reason = None
|
reset_execution_block_state(state)
|
||||||
state.execution_size_adjustment_reason = None
|
state.execution_size_adjustment_reason = None
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@@ -139,15 +154,14 @@ class AutoLifecycleMixin(
|
|||||||
|
|
||||||
if state.status == "OBSERVING":
|
if state.status == "OBSERVING":
|
||||||
state.status = "RUNNING"
|
state.status = "RUNNING"
|
||||||
|
if state.cycle_started_at is None:
|
||||||
|
state.cycle_started_at = time.monotonic()
|
||||||
|
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
|
||||||
# При ручном запуске из OBSERVING очищаем старую cooldown-блокировку,
|
# При ручном запуске из OBSERVING очищаем старую cooldown-блокировку,
|
||||||
# чтобы запуск не наследовал паузу прошлого цикла.
|
# чтобы запуск не наследовал паузу прошлого цикла.
|
||||||
state.loss_cooldown_active = False
|
reset_loss_cooldown_state(state)
|
||||||
state.loss_cooldown_reason = None
|
|
||||||
state.last_loss_monotonic_at = None
|
reset_execution_block_state(state)
|
||||||
state.execution_block_title = None
|
|
||||||
state.execution_block_message = None
|
|
||||||
state.execution_block_action = None
|
|
||||||
state.execution_block_reason = None
|
|
||||||
|
|
||||||
EventBus.emit(
|
EventBus.emit(
|
||||||
"auto_status_changed",
|
"auto_status_changed",
|
||||||
@@ -168,30 +182,17 @@ class AutoLifecycleMixin(
|
|||||||
|
|
||||||
state.status = "RUNNING"
|
state.status = "RUNNING"
|
||||||
self._reset_signal_tracking()
|
self._reset_signal_tracking()
|
||||||
state.cycle_realized_pnl_usd = 0.0
|
reset_cycle_statistics_state(state)
|
||||||
state.cycle_closed_trades = 0
|
reset_loss_cooldown_state(state)
|
||||||
state.cycle_winning_trades = 0
|
reset_flip_runtime_state(state)
|
||||||
# Новый цикл должен начинаться без старой блокировки после убытков.
|
|
||||||
state.cycle_losing_trades = 0
|
|
||||||
state.cycle_consecutive_losses = 0
|
|
||||||
state.loss_cooldown_active = False
|
|
||||||
state.loss_cooldown_reason = None
|
|
||||||
state.last_loss_monotonic_at = None
|
|
||||||
state.execution_block_title = None
|
|
||||||
state.execution_block_message = None
|
|
||||||
state.execution_block_action = None
|
|
||||||
state.cycle_trade_fees_usd = 0.0
|
|
||||||
state.cycle_overnight_fees_usd = 0.0
|
|
||||||
state.cycle_started_at = time.monotonic()
|
state.cycle_started_at = time.monotonic()
|
||||||
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
|
|
||||||
state.last_flip_old_side = None
|
|
||||||
state.last_flip_new_side = None
|
|
||||||
state.last_flip_pnl_usd = None
|
|
||||||
state.last_flip_reason = None
|
|
||||||
state.last_flip_monotonic_at = None
|
|
||||||
state.last_signal = "HOLD"
|
state.last_signal = "HOLD"
|
||||||
state.signal_started_at = time.monotonic()
|
state.signal_started_at = time.monotonic()
|
||||||
|
|
||||||
|
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
|
||||||
|
|
||||||
EventBus.emit(
|
EventBus.emit(
|
||||||
"auto_status_changed",
|
"auto_status_changed",
|
||||||
{
|
{
|
||||||
@@ -227,30 +228,13 @@ class AutoLifecycleMixin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if previous_status == "OFF":
|
if previous_status == "OFF":
|
||||||
state.cycle_realized_pnl_usd = 0.0
|
reset_cycle_statistics_state(state)
|
||||||
state.cycle_closed_trades = 0
|
reset_loss_cooldown_state(state)
|
||||||
state.cycle_losing_trades = 0
|
reset_flip_runtime_state(state)
|
||||||
state.cycle_consecutive_losses = 0
|
reset_execution_runtime_state(state)
|
||||||
state.loss_cooldown_active = False
|
reset_position_semantics_state(state)
|
||||||
state.loss_cooldown_reason = None
|
|
||||||
state.last_loss_monotonic_at = None
|
state.cycle_started_at = None
|
||||||
state.cycle_winning_trades = 0
|
|
||||||
state.cycle_trade_fees_usd = 0.0
|
|
||||||
state.cycle_overnight_fees_usd = 0.0
|
|
||||||
state.cycle_started_at = time.monotonic()
|
|
||||||
state.last_flip_old_side = None
|
|
||||||
state.last_flip_new_side = None
|
|
||||||
state.last_flip_pnl_usd = None
|
|
||||||
state.last_flip_reason = None
|
|
||||||
state.last_flip_monotonic_at = None
|
|
||||||
state.position_stall_state = None
|
|
||||||
state.position_stall_reason = None
|
|
||||||
state.position_mfe_percent = None
|
|
||||||
state.position_mae_percent = None
|
|
||||||
state.execution_block_title = None
|
|
||||||
state.execution_block_message = None
|
|
||||||
state.execution_block_action = None
|
|
||||||
state.execution_block_reason = None
|
|
||||||
|
|
||||||
self._log_auto_status_changed(
|
self._log_auto_status_changed(
|
||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
@@ -279,31 +263,14 @@ class AutoLifecycleMixin(
|
|||||||
return state, "Автоторговля уже выключена."
|
return state, "Автоторговля уже выключена."
|
||||||
|
|
||||||
state.status = "OFF"
|
state.status = "OFF"
|
||||||
state.cycle_realized_pnl_usd = 0.0
|
reset_cycle_statistics_state(state)
|
||||||
state.cycle_closed_trades = 0
|
reset_loss_cooldown_state(state)
|
||||||
state.cycle_losing_trades = 0
|
reset_execution_runtime_state(state)
|
||||||
state.cycle_consecutive_losses = 0
|
reset_adaptive_size_state(state)
|
||||||
state.loss_cooldown_active = False
|
reset_flip_runtime_state(state)
|
||||||
state.loss_cooldown_reason = None
|
reset_position_semantics_state(state)
|
||||||
state.last_loss_monotonic_at = None
|
|
||||||
state.execution_block_title = None
|
|
||||||
state.execution_block_message = None
|
|
||||||
state.execution_block_action = None
|
|
||||||
state.execution_block_reason = None
|
|
||||||
state.cycle_winning_trades = 0
|
|
||||||
state.cycle_trade_fees_usd = 0.0
|
|
||||||
state.cycle_overnight_fees_usd = 0.0
|
|
||||||
state.cycle_started_at = None
|
state.cycle_started_at = None
|
||||||
state.adaptive_size_changed_at = None
|
|
||||||
state.last_flip_old_side = None
|
|
||||||
state.last_flip_new_side = None
|
|
||||||
state.last_flip_pnl_usd = None
|
|
||||||
state.last_flip_reason = None
|
|
||||||
state.last_flip_monotonic_at = None
|
|
||||||
state.position_stall_state = None
|
|
||||||
state.position_stall_reason = None
|
|
||||||
state.position_mfe_percent = None
|
|
||||||
state.position_mae_percent = None
|
|
||||||
self.stop_loop()
|
self.stop_loop()
|
||||||
|
|
||||||
EventBus.emit(
|
EventBus.emit(
|
||||||
@@ -376,7 +343,7 @@ class AutoLifecycleMixin(
|
|||||||
def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
|
def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
|
||||||
state = self.get_state()
|
state = self.get_state()
|
||||||
state.max_reserved_balance_percent = safe_float(value)
|
state.max_reserved_balance_percent = safe_float(value)
|
||||||
state.execution_block_reason = None
|
reset_execution_block_state(state)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _reset_signal_tracking(self) -> None:
|
def _reset_signal_tracking(self) -> None:
|
||||||
@@ -390,180 +357,23 @@ class AutoLifecycleMixin(
|
|||||||
|
|
||||||
state = self.get_state()
|
state = self.get_state()
|
||||||
|
|
||||||
state.adaptive_size_base = None
|
reset_adaptive_size_state(state)
|
||||||
state.adaptive_size_final = None
|
reset_signal_runtime_state(state)
|
||||||
state.adaptive_size_multiplier = None
|
reset_execution_runtime_state(state)
|
||||||
state.adaptive_size_reason = None
|
reset_market_analysis_state(state)
|
||||||
state.adaptive_size_factors = None
|
reset_runtime_expiration_state(state)
|
||||||
state.effective_risk_percent = None
|
reset_position_semantics_state(state)
|
||||||
state.effective_target_risk_usd = None
|
reset_position_protection_state(state)
|
||||||
state.execution_size_adjustment_reason = None
|
reset_autonomous_runtime_state(state)
|
||||||
|
reset_loss_cooldown_state(state)
|
||||||
|
|
||||||
state.last_signal = "HOLD"
|
|
||||||
state.last_signal_repeat_count = 0
|
|
||||||
state.last_signal_confidence = 0.0
|
|
||||||
state.last_signal_reason = None
|
|
||||||
state.decision_status = "WAITING"
|
|
||||||
state.decision_reason = None
|
|
||||||
state.is_signal_confirmed = False
|
|
||||||
state.is_signal_ready = False
|
|
||||||
state.signal_confirmation_seconds = 0
|
|
||||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||||
state.signal_confirmation_progress = 0.0
|
state.execution_confidence_required_score = (
|
||||||
state.signal_confirmation_reason = None
|
self._execution_confidence_required_score
|
||||||
state.signal_started_at = None
|
)
|
||||||
state.signal_updated_at = None
|
|
||||||
|
|
||||||
state.execution_block_reason = None
|
reset_position_health_state(state)
|
||||||
state.execution_semantic_status = None
|
|
||||||
state.execution_semantic_message = None
|
|
||||||
state.execution_semantic_reason = None
|
|
||||||
state.execution_quality = None
|
|
||||||
state.execution_quality_reason = None
|
|
||||||
state.execution_quality_message = None
|
|
||||||
state.execution_price_source = None
|
|
||||||
state.execution_price_age_seconds = None
|
|
||||||
state.execution_bid_price = None
|
|
||||||
state.execution_ask_price = None
|
|
||||||
state.execution_last_price = None
|
|
||||||
state.execution_price_freshness = None
|
|
||||||
state.execution_confidence_score = None
|
|
||||||
state.execution_confidence_level = None
|
|
||||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
|
||||||
state.execution_confidence_reason = None
|
|
||||||
state.execution_confidence_factors = None
|
|
||||||
|
|
||||||
state.market_state = None
|
|
||||||
state.market_trend = None
|
|
||||||
state.market_volatility = None
|
|
||||||
state.market_analysis_interval = None
|
|
||||||
state.market_analysis_reason = None
|
|
||||||
state.market_analysis_updated_at = None
|
|
||||||
state.market_runtime_degraded = False
|
|
||||||
state.market_trend_strength = None
|
|
||||||
state.market_trend_quality = None
|
|
||||||
state.market_phase = None
|
|
||||||
state.market_phase_direction = None
|
|
||||||
state.market_structure = None
|
|
||||||
state.market_structure_reason = None
|
|
||||||
state.market_score = None
|
|
||||||
state.market_score_label = None
|
|
||||||
state.market_long_score = None
|
|
||||||
state.market_short_score = None
|
|
||||||
|
|
||||||
state.last_closed_candle_change_percent = None
|
|
||||||
state.last_closed_candle_direction = None
|
|
||||||
state.current_interval_change_percent = None
|
|
||||||
state.current_interval_direction = None
|
|
||||||
state.current_interval_label = None
|
|
||||||
|
|
||||||
state.market_trend_gap_percent = None
|
|
||||||
state.market_trend_consistency = None
|
|
||||||
state.market_trend_efficiency = None
|
|
||||||
state.trend_quality_score = None
|
|
||||||
state.ema_distance_atr_ratio = None
|
|
||||||
state.ema_distance_state = None
|
|
||||||
state.entry_timing_state = None
|
|
||||||
state.entry_timing_reason = None
|
|
||||||
state.ema_fast_slope_percent = None
|
|
||||||
state.ema_slow_slope_percent = None
|
|
||||||
state.candle_noise_score = None
|
|
||||||
state.price_position_score = None
|
|
||||||
|
|
||||||
state.htf_interval = None
|
|
||||||
state.htf_atr_percent = None
|
|
||||||
state.htf_atr_percent_baseline = None
|
|
||||||
state.htf_volatility_ratio = None
|
|
||||||
state.htf_volatility = None
|
|
||||||
state.htf_market_state = None
|
|
||||||
state.htf_trend = None
|
|
||||||
state.htf_trend_strength = None
|
|
||||||
state.htf_trend_quality = None
|
|
||||||
state.htf_market_phase = None
|
|
||||||
state.htf_alignment = None
|
|
||||||
state.htf_confirmation_score = None
|
|
||||||
state.htf_reason = None
|
|
||||||
|
|
||||||
state.entry_block_reason = None
|
|
||||||
state.entry_block_message = None
|
|
||||||
|
|
||||||
state.momentum_state = None
|
|
||||||
state.momentum_direction = None
|
|
||||||
state.momentum_change_percent = None
|
|
||||||
state.momentum_strength = None
|
|
||||||
state.breakout_level = None
|
|
||||||
state.breakout_distance_percent = None
|
|
||||||
state.breakout_reason = None
|
|
||||||
|
|
||||||
state.runtime_expired_reason = None
|
|
||||||
state.runtime_expired_message = None
|
|
||||||
state.snapshot_age_seconds = None
|
|
||||||
state.spread_percent = None
|
|
||||||
|
|
||||||
state.position_pnl_percent = None
|
|
||||||
state.position_hold_seconds = None
|
|
||||||
state.position_pressure = None
|
|
||||||
state.position_health_score = None
|
|
||||||
state.position_health_status = None
|
|
||||||
state.position_health_reason = None
|
|
||||||
state.position_risk_level = None
|
|
||||||
state.position_risk_reason = None
|
|
||||||
state.position_trend_alignment = None
|
|
||||||
state.position_adverse_momentum = False
|
|
||||||
state.position_exit_pressure = None
|
|
||||||
|
|
||||||
state.position_lifecycle_stage = None
|
|
||||||
state.position_hold_quality = None
|
|
||||||
state.position_decay_state = None
|
|
||||||
state.position_exit_confidence = None
|
|
||||||
state.position_exit_signal = None
|
|
||||||
state.position_intelligence_reason = None
|
|
||||||
state.position_recommended_action = None
|
|
||||||
|
|
||||||
state.position_peak_pnl_usd = None
|
|
||||||
state.position_peak_pnl_percent = None
|
|
||||||
state.position_mfe_percent = None
|
|
||||||
state.position_mae_percent = None
|
|
||||||
state.position_fatigue_score = None
|
|
||||||
state.position_fatigue_state = None
|
|
||||||
state.position_giveback_percent = None
|
|
||||||
state.position_conviction_state = None
|
|
||||||
state.position_exit_urgency = None
|
|
||||||
state.position_reversal_risk = None
|
|
||||||
state.position_stall_state = None
|
|
||||||
state.position_stall_reason = None
|
|
||||||
|
|
||||||
state.position_protection_status = None
|
|
||||||
state.position_protection_reason = None
|
|
||||||
state.break_even_armed = False
|
|
||||||
state.break_even_price = None
|
|
||||||
state.trailing_stop_active = False
|
|
||||||
state.trailing_stop_price = None
|
|
||||||
state.profit_lock_active = False
|
|
||||||
state.profit_lock_price = None
|
|
||||||
state.runtime_protection_action = None
|
|
||||||
state.runtime_protection_reason = None
|
|
||||||
state.runtime_protection_updated_at = None
|
|
||||||
|
|
||||||
state.autonomous_action = None
|
|
||||||
state.autonomous_action_reason = None
|
|
||||||
state.autonomous_action_confidence = None
|
|
||||||
state.autonomous_protection_required = False
|
|
||||||
state.autonomous_reduce_required = False
|
|
||||||
state.autonomous_exit_required = False
|
|
||||||
state.autonomous_last_action = None
|
|
||||||
state.autonomous_last_action_reason = None
|
|
||||||
state.autonomous_last_action_at = None
|
|
||||||
state.last_loss_monotonic_at = None
|
|
||||||
|
|
||||||
# Сброс именно runtime-блокировки, чтобы после нового запуска
|
|
||||||
# не оставалась старая пауза после прошлой убыточной сделки.
|
|
||||||
state.loss_cooldown_active = False
|
|
||||||
state.loss_cooldown_reason = None
|
|
||||||
state.execution_block_title = None
|
|
||||||
state.execution_block_message = None
|
|
||||||
state.execution_block_action = None
|
|
||||||
|
|
||||||
def _build_strategy_context(self) -> StrategyContext:
|
def _build_strategy_context(self) -> StrategyContext:
|
||||||
state = self.get_state()
|
state = self.get_state()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import time
|
|||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import NumericLike
|
from src.core.types import NumericLike
|
||||||
|
from src.integrations.exchange.models import ExecutionPriceSnapshot
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.status import (
|
from src.integrations.exchange.status import (
|
||||||
ExchangeRuntimeStatus,
|
ExchangeRuntimeStatus,
|
||||||
@@ -59,7 +60,7 @@ class AutoExecutionQualityMixin:
|
|||||||
|
|
||||||
state.market_is_open = status.is_open
|
state.market_is_open = status.is_open
|
||||||
state.market_status = status.code.value
|
state.market_status = status.code.value
|
||||||
state.market_status_message = status.ui_line
|
state.market_status_message = str(status.ui_line or "").strip()
|
||||||
state.market_status_updated_at = time.monotonic()
|
state.market_status_updated_at = time.monotonic()
|
||||||
|
|
||||||
if status.is_open:
|
if status.is_open:
|
||||||
@@ -253,34 +254,20 @@ class AutoExecutionQualityMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
snapshot = ExchangeService().get_market_snapshot(
|
snapshot = ExchangeService().get_execution_snapshot(
|
||||||
state.symbol,
|
state.symbol,
|
||||||
runtime_key="auto",
|
runtime_key="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
|
||||||
|
|
||||||
if (
|
|
||||||
age_seconds is not None
|
|
||||||
and age_seconds > self._warning_snapshot_age_seconds
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
snapshot = ExchangeService().refresh_market_snapshot_cache(
|
|
||||||
state.symbol,
|
|
||||||
runtime_key="auto",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
fallback_price = None
|
fallback_price = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fallback_price = safe_float(
|
fallback_price = safe_float(
|
||||||
ExchangeService().get_price(
|
ExchangeService().get_quote(
|
||||||
state.symbol,
|
state.symbol,
|
||||||
runtime_key="auto",
|
runtime_key="auto",
|
||||||
).price
|
).last_price
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -319,12 +306,12 @@ class AutoExecutionQualityMixin:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
bid_price = safe_float(snapshot.get("bid_price"))
|
bid_price = safe_float(snapshot.bid_price)
|
||||||
ask_price = safe_float(snapshot.get("ask_price"))
|
ask_price = safe_float(snapshot.ask_price)
|
||||||
last_price = safe_float(snapshot.get("last_price"))
|
last_price = safe_float(snapshot.last_price)
|
||||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
age_seconds = safe_float(snapshot.age_seconds)
|
||||||
is_fresh = bool(snapshot.get("is_fresh", False))
|
is_fresh = snapshot.is_fresh
|
||||||
source = str(snapshot.get("source") or "")
|
source = snapshot.source
|
||||||
|
|
||||||
self._sync_execution_pricing_state(
|
self._sync_execution_pricing_state(
|
||||||
state,
|
state,
|
||||||
@@ -432,15 +419,15 @@ class AutoExecutionQualityMixin:
|
|||||||
def _sync_execution_pricing_state(
|
def _sync_execution_pricing_state(
|
||||||
self,
|
self,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
snapshot: dict[str, object],
|
snapshot: ExecutionPriceSnapshot,
|
||||||
) -> None:
|
) -> None:
|
||||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
age_seconds = safe_float(snapshot.age_seconds)
|
||||||
|
|
||||||
state.execution_price_source = str(snapshot.get("source") or "")
|
state.execution_price_source = snapshot.source
|
||||||
state.execution_price_age_seconds = age_seconds
|
state.execution_price_age_seconds = age_seconds
|
||||||
state.execution_bid_price = safe_float(snapshot.get("bid_price"))
|
state.execution_bid_price = safe_float(snapshot.bid_price)
|
||||||
state.execution_ask_price = safe_float(snapshot.get("ask_price"))
|
state.execution_ask_price = safe_float(snapshot.ask_price)
|
||||||
state.execution_last_price = safe_float(snapshot.get("last_price"))
|
state.execution_last_price = safe_float(snapshot.last_price)
|
||||||
|
|
||||||
if age_seconds is None:
|
if age_seconds is None:
|
||||||
state.execution_price_freshness = "UNKNOWN"
|
state.execution_price_freshness = "UNKNOWN"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import NumericLike
|
from src.core.types import NumericLike
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import reset_position_health_state
|
||||||
from src.trading.execution.constants import (
|
from src.trading.execution.constants import (
|
||||||
EXECUTION_QUALITY_BLOCKED,
|
EXECUTION_QUALITY_BLOCKED,
|
||||||
EXECUTION_QUALITY_WARNING,
|
EXECUTION_QUALITY_WARNING,
|
||||||
@@ -37,17 +38,7 @@ class AutoPositionHealthMixin:
|
|||||||
# синхронизировать runtime health/risk состояние открытой позиции
|
# синхронизировать runtime health/risk состояние открытой позиции
|
||||||
def _sync_position_health_state(self, state: AutoTradeState) -> None:
|
def _sync_position_health_state(self, state: AutoTradeState) -> None:
|
||||||
if state.position_side == "NONE" or state.entry_price is None:
|
if state.position_side == "NONE" or state.entry_price is None:
|
||||||
state.position_pnl_percent = None
|
reset_position_health_state(state)
|
||||||
state.position_hold_seconds = None
|
|
||||||
state.position_pressure = None
|
|
||||||
state.position_health_score = None
|
|
||||||
state.position_health_status = None
|
|
||||||
state.position_health_reason = None
|
|
||||||
state.position_risk_level = None
|
|
||||||
state.position_risk_reason = None
|
|
||||||
state.position_trend_alignment = None
|
|
||||||
state.position_adverse_momentum = False
|
|
||||||
state.position_exit_pressure = None
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# PnL % и время удержания больше не считаем здесь.
|
# PnL % и время удержания больше не считаем здесь.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import reset_position_semantics_state
|
||||||
from src.trading.execution.constants import (
|
from src.trading.execution.constants import (
|
||||||
POSITION_EXIT_DAMPING_MATURE_MULTIPLIER,
|
POSITION_EXIT_DAMPING_MATURE_MULTIPLIER,
|
||||||
POSITION_EXIT_DAMPING_MATURE_SECONDS,
|
POSITION_EXIT_DAMPING_MATURE_SECONDS,
|
||||||
@@ -33,25 +34,7 @@ class AutoPositionSemanticsMixin:
|
|||||||
# синхронизировать semantics-состояние открытой позиции
|
# синхронизировать semantics-состояние открытой позиции
|
||||||
def _sync_position_semantics_state(self, state: AutoTradeState) -> None:
|
def _sync_position_semantics_state(self, state: AutoTradeState) -> None:
|
||||||
if state.position_side == "NONE" or state.entry_price is None:
|
if state.position_side == "NONE" or state.entry_price is None:
|
||||||
state.position_lifecycle_stage = None
|
reset_position_semantics_state(state)
|
||||||
state.position_hold_quality = None
|
|
||||||
state.position_decay_state = None
|
|
||||||
state.position_exit_confidence = None
|
|
||||||
state.position_exit_signal = None
|
|
||||||
state.position_intelligence_reason = None
|
|
||||||
state.position_recommended_action = None
|
|
||||||
state.position_peak_pnl_usd = None
|
|
||||||
state.position_peak_pnl_percent = None
|
|
||||||
state.position_mfe_percent = None
|
|
||||||
state.position_mae_percent = None
|
|
||||||
state.position_fatigue_score = None
|
|
||||||
state.position_fatigue_state = None
|
|
||||||
state.position_giveback_percent = None
|
|
||||||
state.position_conviction_state = None
|
|
||||||
state.position_exit_urgency = None
|
|
||||||
state.position_reversal_risk = None
|
|
||||||
state.position_stall_state = None
|
|
||||||
state.position_stall_reason = None
|
|
||||||
return
|
return
|
||||||
|
|
||||||
lifecycle_stage = self._position_lifecycle_stage(state)
|
lifecycle_stage = self._position_lifecycle_stage(state)
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ from src.core.event_bus import EventBus
|
|||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import JsonDict, NumericLike
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import (
|
||||||
|
reset_after_market_runtime_expired,
|
||||||
|
reset_after_signal_runtime_expired,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +142,180 @@ class AutoSignalRuntimeMixin:
|
|||||||
|
|
||||||
return "NOISE"
|
return "NOISE"
|
||||||
|
|
||||||
|
# собрать payload диагностики заблокированного reversal-сигнала
|
||||||
|
def _build_reversal_signal_blocked_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
signal: str,
|
||||||
|
confidence: float,
|
||||||
|
block_stage: str,
|
||||||
|
block_reason: str,
|
||||||
|
) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"event_type": "reversal_signal_blocked",
|
||||||
|
"action": "reversal_signal_blocked",
|
||||||
|
"block_stage": block_stage,
|
||||||
|
"block_reason": block_reason,
|
||||||
|
|
||||||
|
# ---------- Signal ----------
|
||||||
|
"signal": signal,
|
||||||
|
"signal_intent": "REVERSAL_CANDIDATE",
|
||||||
|
"confidence": confidence,
|
||||||
|
"signal_reason": state.last_signal_reason,
|
||||||
|
"repeat_count": state.last_signal_repeat_count,
|
||||||
|
|
||||||
|
# ---------- Decision ----------
|
||||||
|
"decision_status": state.decision_status,
|
||||||
|
"decision_reason": state.decision_reason,
|
||||||
|
"is_signal_confirmed": state.is_signal_confirmed,
|
||||||
|
"is_signal_ready": state.is_signal_ready,
|
||||||
|
"confirmation_seconds": state.signal_confirmation_seconds,
|
||||||
|
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||||||
|
"confirmation_missing_repeats": state.signal_confirmation_missing_repeats,
|
||||||
|
"confirmation_progress": state.signal_confirmation_progress,
|
||||||
|
"confirmation_reason": state.signal_confirmation_reason,
|
||||||
|
|
||||||
|
# ---------- Position ----------
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"strategy": state.strategy,
|
||||||
|
"position_side": state.position_side,
|
||||||
|
"entry_price": state.entry_price,
|
||||||
|
"position_size": state.position_size,
|
||||||
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
|
"position_pnl_percent": state.position_pnl_percent,
|
||||||
|
"position_health_status": state.position_health_status,
|
||||||
|
"position_health_reason": state.position_health_reason,
|
||||||
|
"position_risk_level": state.position_risk_level,
|
||||||
|
"position_risk_reason": state.position_risk_reason,
|
||||||
|
"position_trend_alignment": state.position_trend_alignment,
|
||||||
|
"position_adverse_momentum": state.position_adverse_momentum,
|
||||||
|
|
||||||
|
# ---------- Execution Confidence ----------
|
||||||
|
"execution_confidence_score": state.execution_confidence_score,
|
||||||
|
"execution_confidence_level": state.execution_confidence_level,
|
||||||
|
"execution_confidence_required_score": (
|
||||||
|
state.execution_confidence_required_score
|
||||||
|
),
|
||||||
|
"execution_confidence_reason": state.execution_confidence_reason,
|
||||||
|
"execution_confidence_factors": state.execution_confidence_factors,
|
||||||
|
|
||||||
|
# ---------- Execution Quality ----------
|
||||||
|
"execution_quality": state.execution_quality,
|
||||||
|
"execution_quality_reason": state.execution_quality_reason,
|
||||||
|
"execution_quality_message": state.execution_quality_message,
|
||||||
|
"spread_percent": state.spread_percent,
|
||||||
|
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||||
|
|
||||||
|
# ---------- Market ----------
|
||||||
|
"market_score": state.market_score,
|
||||||
|
"market_score_label": state.market_score_label,
|
||||||
|
"market_long_score": state.market_long_score,
|
||||||
|
"market_short_score": state.market_short_score,
|
||||||
|
"market_state": state.market_state,
|
||||||
|
"market_trend": state.market_trend,
|
||||||
|
"market_volatility": state.market_volatility,
|
||||||
|
"market_trend_strength": state.market_trend_strength,
|
||||||
|
"market_trend_quality": state.market_trend_quality,
|
||||||
|
"market_phase": state.market_phase,
|
||||||
|
"market_phase_direction": state.market_phase_direction,
|
||||||
|
|
||||||
|
# ---------- Current interval ----------
|
||||||
|
"current_interval_change_percent": state.current_interval_change_percent,
|
||||||
|
"current_interval_direction": state.current_interval_direction,
|
||||||
|
"current_interval_label": state.current_interval_label,
|
||||||
|
|
||||||
|
# ---------- Structure / timing ----------
|
||||||
|
"market_structure": state.market_structure,
|
||||||
|
"market_structure_reason": state.market_structure_reason,
|
||||||
|
"trend_quality_score": state.trend_quality_score,
|
||||||
|
"ema_distance_state": state.ema_distance_state,
|
||||||
|
"entry_timing_state": state.entry_timing_state,
|
||||||
|
"entry_timing_reason": state.entry_timing_reason,
|
||||||
|
|
||||||
|
# ---------- Momentum ----------
|
||||||
|
"momentum_state": state.momentum_state,
|
||||||
|
"momentum_direction": state.momentum_direction,
|
||||||
|
"momentum_change_percent": state.momentum_change_percent,
|
||||||
|
"momentum_strength": state.momentum_strength,
|
||||||
|
"breakout_level": state.breakout_level,
|
||||||
|
"breakout_distance_percent": state.breakout_distance_percent,
|
||||||
|
"breakout_reason": state.breakout_reason,
|
||||||
|
|
||||||
|
# ---------- HTF ----------
|
||||||
|
"htf_interval": state.htf_interval,
|
||||||
|
"htf_market_state": state.htf_market_state,
|
||||||
|
"htf_trend": state.htf_trend,
|
||||||
|
"htf_trend_strength": state.htf_trend_strength,
|
||||||
|
"htf_trend_quality": state.htf_trend_quality,
|
||||||
|
"htf_market_phase": state.htf_market_phase,
|
||||||
|
"htf_alignment": state.htf_alignment,
|
||||||
|
"htf_confirmation_score": state.htf_confirmation_score,
|
||||||
|
"htf_reason": state.htf_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
# записать диагностику, если reversal-кандидат был заблокирован до READY
|
||||||
|
def _log_reversal_signal_blocked_if_needed(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
signal: str,
|
||||||
|
confidence: float,
|
||||||
|
block_stage: str,
|
||||||
|
block_reason: str,
|
||||||
|
) -> None:
|
||||||
|
signal_intent = self._signal_intent(
|
||||||
|
state=state,
|
||||||
|
signal=signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
if signal_intent != "REVERSAL_CANDIDATE":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Дедупликация: не пишем одно и то же состояние на каждом цикле.
|
||||||
|
key = (
|
||||||
|
f"{state.status}:"
|
||||||
|
f"{state.symbol}:"
|
||||||
|
f"{state.strategy}:"
|
||||||
|
f"{state.position_side}:"
|
||||||
|
f"{signal}:"
|
||||||
|
f"{state.last_signal_repeat_count}:"
|
||||||
|
f"{confidence:.2f}:"
|
||||||
|
f"{block_stage}:"
|
||||||
|
f"{block_reason}:"
|
||||||
|
f"{state.execution_confidence_score}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_key = getattr(type(self), "_last_reversal_signal_block_key", None)
|
||||||
|
|
||||||
|
if key == last_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
setattr(type(self), "_last_reversal_signal_block_key", key)
|
||||||
|
|
||||||
|
payload = self._build_reversal_signal_blocked_payload(
|
||||||
|
state=state,
|
||||||
|
signal=signal,
|
||||||
|
confidence=confidence,
|
||||||
|
block_stage=block_stage,
|
||||||
|
block_reason=block_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
JournalService().log_ui_warning(
|
||||||
|
event_type="reversal_signal_blocked",
|
||||||
|
message=(
|
||||||
|
f"Reversal-сигнал {signal} заблокирован: {block_reason}"
|
||||||
|
),
|
||||||
|
screen="auto",
|
||||||
|
action="signal_runtime",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
EventBus.emit("reversal_signal_blocked", payload)
|
||||||
|
|
||||||
# обновить статус решения по текущему сигналу
|
# обновить статус решения по текущему сигналу
|
||||||
def _update_decision_state(
|
def _update_decision_state(
|
||||||
self,
|
self,
|
||||||
@@ -216,6 +395,14 @@ class AutoSignalRuntimeMixin:
|
|||||||
f"Сигнал {signal} подтверждён, но уверенность низкая: "
|
f"Сигнал {signal} подтверждён, но уверенность низкая: "
|
||||||
f"{confidence:.2f} < {self._ready_confidence:.2f}."
|
f"{confidence:.2f} < {self._ready_confidence:.2f}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._log_reversal_signal_blocked_if_needed(
|
||||||
|
state=state,
|
||||||
|
signal=signal,
|
||||||
|
confidence=confidence,
|
||||||
|
block_stage="LOW_SIGNAL_CONFIDENCE",
|
||||||
|
block_reason=state.decision_reason,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._sync_execution_confidence_state(
|
self._sync_execution_confidence_state(
|
||||||
@@ -234,6 +421,14 @@ class AutoSignalRuntimeMixin:
|
|||||||
f"{state.execution_confidence_score:.2f} < "
|
f"{state.execution_confidence_score:.2f} < "
|
||||||
f"{self._execution_confidence_required_score:.2f}."
|
f"{self._execution_confidence_required_score:.2f}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._log_reversal_signal_blocked_if_needed(
|
||||||
|
state=state,
|
||||||
|
signal=signal,
|
||||||
|
confidence=confidence,
|
||||||
|
block_stage="LOW_EXECUTION_CONFIDENCE",
|
||||||
|
block_reason=state.decision_reason,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
state.is_signal_ready = True
|
state.is_signal_ready = True
|
||||||
@@ -522,7 +717,7 @@ class AutoSignalRuntimeMixin:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
snapshot: JsonDict,
|
quote: Quote | None,
|
||||||
signal: str,
|
signal: str,
|
||||||
signal_intent: str,
|
signal_intent: str,
|
||||||
confidence: float,
|
confidence: float,
|
||||||
@@ -593,9 +788,9 @@ class AutoSignalRuntimeMixin:
|
|||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||||
|
|
||||||
# ---------- Live Snapshot ----------
|
# ---------- Live Snapshot ----------
|
||||||
"bid_price": snapshot.get("bid_price"),
|
"bid_price": safe_float(quote.bid_price) if quote is not None else None,
|
||||||
"ask_price": snapshot.get("ask_price"),
|
"ask_price": safe_float(quote.ask_price) if quote is not None else None,
|
||||||
"last_price": snapshot.get("last_price"),
|
"last_price": safe_float(quote.last_price) if quote is not None else None,
|
||||||
|
|
||||||
# ---------- Market Score ----------
|
# ---------- Market Score ----------
|
||||||
"market_score": state.market_score,
|
"market_score": state.market_score,
|
||||||
@@ -681,12 +876,12 @@ class AutoSignalRuntimeMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
snapshot = ExchangeService().get_market_snapshot(
|
quote = ExchangeService().get_quote(
|
||||||
state.symbol,
|
state.symbol,
|
||||||
runtime_key="auto",
|
runtime_key="auto",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
snapshot = {}
|
quote = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
JournalService().log_ui_info(
|
JournalService().log_ui_info(
|
||||||
@@ -698,7 +893,7 @@ class AutoSignalRuntimeMixin:
|
|||||||
action="signal_ready",
|
action="signal_ready",
|
||||||
payload=self._build_ready_signal_payload(
|
payload=self._build_ready_signal_payload(
|
||||||
state=state,
|
state=state,
|
||||||
snapshot=snapshot,
|
quote=quote,
|
||||||
signal=normalized_signal,
|
signal=normalized_signal,
|
||||||
signal_intent=signal_intent,
|
signal_intent=signal_intent,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
@@ -733,25 +928,13 @@ class AutoSignalRuntimeMixin:
|
|||||||
self._last_signal_started_at = None
|
self._last_signal_started_at = None
|
||||||
self._same_signal_count = 0
|
self._same_signal_count = 0
|
||||||
|
|
||||||
state.last_signal = "HOLD"
|
reset_after_signal_runtime_expired(state)
|
||||||
state.last_signal_repeat_count = 0
|
|
||||||
state.last_signal_confidence = 0.0
|
|
||||||
state.last_signal_reason = None
|
|
||||||
state.signal_started_at = None
|
|
||||||
state.signal_updated_at = None
|
|
||||||
state.decision_status = "WAITING"
|
|
||||||
state.decision_reason = "Сигнал устарел."
|
state.decision_reason = "Сигнал устарел."
|
||||||
state.is_signal_confirmed = False
|
|
||||||
state.is_signal_ready = False
|
|
||||||
state.signal_confirmation_seconds = 0
|
|
||||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||||
state.signal_confirmation_progress = 0.0
|
state.execution_confidence_required_score = (
|
||||||
state.signal_confirmation_reason = None
|
self._execution_confidence_required_score
|
||||||
state.execution_confidence_score = None
|
)
|
||||||
state.execution_confidence_level = None
|
|
||||||
state.execution_confidence_reason = None
|
|
||||||
state.execution_confidence_factors = None
|
|
||||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
|
||||||
|
|
||||||
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
||||||
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
||||||
@@ -779,64 +962,7 @@ class AutoSignalRuntimeMixin:
|
|||||||
market_age = now - market_updated
|
market_age = now - market_updated
|
||||||
|
|
||||||
if market_age > self._market_analysis_ttl_seconds:
|
if market_age > self._market_analysis_ttl_seconds:
|
||||||
state.market_state = None
|
reset_after_market_runtime_expired(state)
|
||||||
state.market_trend = None
|
|
||||||
state.market_volatility = None
|
|
||||||
state.market_analysis_interval = None
|
|
||||||
state.market_analysis_reason = None
|
|
||||||
state.market_analysis_updated_at = None
|
|
||||||
state.entry_block_reason = None
|
|
||||||
state.entry_block_message = None
|
|
||||||
state.market_trend_strength = None
|
|
||||||
state.market_trend_quality = None
|
|
||||||
state.market_phase = None
|
|
||||||
state.market_phase_direction = None
|
|
||||||
state.current_interval_change_percent = None
|
|
||||||
state.current_interval_direction = None
|
|
||||||
state.current_interval_label = None
|
|
||||||
state.last_closed_candle_change_percent = None
|
|
||||||
state.last_closed_candle_direction = None
|
|
||||||
# Сбрасываем общую оценку рынка вместе с market context,
|
|
||||||
# чтобы UI не показывал старый процент после истечения TTL.
|
|
||||||
state.market_score = None
|
|
||||||
state.market_score_label = None
|
|
||||||
state.market_long_score = None
|
|
||||||
state.market_short_score = None
|
|
||||||
state.market_structure = None
|
|
||||||
state.market_structure_reason = None
|
|
||||||
state.market_trend_gap_percent = None
|
|
||||||
state.market_trend_consistency = None
|
|
||||||
state.market_trend_efficiency = None
|
|
||||||
state.trend_quality_score = None
|
|
||||||
state.ema_distance_atr_ratio = None
|
|
||||||
state.ema_distance_state = None
|
|
||||||
state.entry_timing_state = None
|
|
||||||
state.entry_timing_reason = None
|
|
||||||
state.ema_fast_slope_percent = None
|
|
||||||
state.ema_slow_slope_percent = None
|
|
||||||
state.candle_noise_score = None
|
|
||||||
state.price_position_score = None
|
|
||||||
state.htf_interval = None
|
|
||||||
state.htf_atr_percent = None
|
|
||||||
state.htf_atr_percent_baseline = None
|
|
||||||
state.htf_volatility_ratio = None
|
|
||||||
state.htf_volatility = None
|
|
||||||
state.htf_market_state = None
|
|
||||||
state.htf_trend = None
|
|
||||||
state.htf_trend_strength = None
|
|
||||||
state.htf_trend_quality = None
|
|
||||||
state.htf_market_phase = None
|
|
||||||
state.htf_alignment = None
|
|
||||||
state.htf_confirmation_score = None
|
|
||||||
state.htf_reason = None
|
|
||||||
|
|
||||||
state.momentum_state = None
|
|
||||||
state.momentum_direction = None
|
|
||||||
state.momentum_change_percent = None
|
|
||||||
state.momentum_strength = None
|
|
||||||
state.breakout_level = None
|
|
||||||
state.breakout_distance_percent = None
|
|
||||||
state.breakout_reason = None
|
|
||||||
|
|
||||||
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
|
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
|
||||||
state.runtime_expired_message = "анализ рынка устарел"
|
state.runtime_expired_message = "анализ рынка устарел"
|
||||||
@@ -961,10 +1087,8 @@ class AutoSignalRuntimeMixin:
|
|||||||
signal_score = self._clamp_score(confidence)
|
signal_score = self._clamp_score(confidence)
|
||||||
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
|
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
|
||||||
|
|
||||||
# ВАЖНО:
|
# Сейчас market_score считается как entry-confidence.
|
||||||
# market_score теперь считается с учётом направления сигнала.
|
# Для reversal/flip это полезно диагностировать, но пока не меняем поведение.
|
||||||
# Раньше BUY мог получить хороший market_score просто потому,
|
|
||||||
# что рынок трендовый, даже если тренд/моментум были против BUY.
|
|
||||||
market_score = self._market_confidence_score(
|
market_score = self._market_confidence_score(
|
||||||
state=state,
|
state=state,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
@@ -1077,6 +1201,7 @@ class AutoSignalRuntimeMixin:
|
|||||||
return 0.15
|
return 0.15
|
||||||
|
|
||||||
# Жёсткая защита от входа против локального тренда.
|
# Жёсткая защита от входа против локального тренда.
|
||||||
|
# Для будущего этапа: именно это может быть слишком жёстко для flip.
|
||||||
if normalized_signal == "BUY" and market_trend == "DOWN":
|
if normalized_signal == "BUY" and market_trend == "DOWN":
|
||||||
return 0.05
|
return 0.05
|
||||||
|
|
||||||
@@ -1084,6 +1209,7 @@ class AutoSignalRuntimeMixin:
|
|||||||
return 0.05
|
return 0.05
|
||||||
|
|
||||||
# Жёсткая защита от входа против momentum.
|
# Жёсткая защита от входа против momentum.
|
||||||
|
# Для будущего этапа: reversal может начинаться до смены полного trend.
|
||||||
if normalized_signal == "BUY" and momentum_direction == "DOWN":
|
if normalized_signal == "BUY" and momentum_direction == "DOWN":
|
||||||
return 0.05
|
return 0.05
|
||||||
|
|
||||||
|
|||||||
402
app/src/trading/auto/state_reset.py
Normal file
402
app/src/trading/auto/state_reset.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# app/src/trading/auto/state_reset.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Centralized runtime reset helpers for AutoTrade.
|
||||||
|
|
||||||
|
Файл содержит только чистые функции сброса AutoTradeState.
|
||||||
|
Без EventBus, JournalService, ExecutionEngine и другой бизнес-логики.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Adaptive Position Sizing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_adaptive_size_state(state: AutoTradeState) -> None:
|
||||||
|
state.adaptive_size_base = None
|
||||||
|
state.adaptive_size_final = None
|
||||||
|
state.adaptive_size_multiplier = None
|
||||||
|
state.adaptive_size_reason = None
|
||||||
|
state.adaptive_size_factors = None
|
||||||
|
state.effective_risk_percent = None
|
||||||
|
state.effective_target_risk_usd = None
|
||||||
|
state.execution_size_adjustment_reason = None
|
||||||
|
state.adaptive_size_changed_at = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Signal Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_signal_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
state.last_signal = "HOLD"
|
||||||
|
state.last_signal_repeat_count = 0
|
||||||
|
state.last_signal_confidence = 0.0
|
||||||
|
state.last_signal_reason = None
|
||||||
|
|
||||||
|
state.signal_started_at = None
|
||||||
|
state.signal_updated_at = None
|
||||||
|
state.signal_confirmation_seconds = 0
|
||||||
|
state.signal_confirmation_missing_repeats = 0
|
||||||
|
state.signal_confirmation_progress = 0.0
|
||||||
|
state.signal_confirmation_reason = None
|
||||||
|
|
||||||
|
state.decision_status = "WAITING"
|
||||||
|
state.decision_reason = None
|
||||||
|
state.is_signal_confirmed = False
|
||||||
|
state.is_signal_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Execution Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_execution_block_state(state: AutoTradeState) -> None:
|
||||||
|
state.execution_block_reason = None
|
||||||
|
state.execution_block_title = None
|
||||||
|
state.execution_block_message = None
|
||||||
|
state.execution_block_action = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_execution_semantic_state(state: AutoTradeState) -> None:
|
||||||
|
state.execution_semantic_status = None
|
||||||
|
state.execution_semantic_message = None
|
||||||
|
state.execution_semantic_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_execution_quality_state(state: AutoTradeState) -> None:
|
||||||
|
state.execution_quality = None
|
||||||
|
state.execution_quality_reason = None
|
||||||
|
state.execution_quality_message = None
|
||||||
|
state.snapshot_age_seconds = None
|
||||||
|
state.spread_percent = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_execution_pricing_state(state: AutoTradeState) -> None:
|
||||||
|
state.execution_price_source = None
|
||||||
|
state.execution_price_age_seconds = None
|
||||||
|
state.execution_bid_price = None
|
||||||
|
state.execution_ask_price = None
|
||||||
|
state.execution_last_price = None
|
||||||
|
state.execution_price_freshness = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_execution_confidence_state(state: AutoTradeState) -> None:
|
||||||
|
state.execution_confidence_score = None
|
||||||
|
state.execution_confidence_level = None
|
||||||
|
state.execution_confidence_reason = None
|
||||||
|
state.execution_confidence_factors = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_execution_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
reset_execution_block_state(state)
|
||||||
|
reset_execution_semantic_state(state)
|
||||||
|
reset_execution_quality_state(state)
|
||||||
|
reset_execution_pricing_state(state)
|
||||||
|
reset_execution_confidence_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Market Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_market_base_state(state: AutoTradeState) -> None:
|
||||||
|
state.market_state = None
|
||||||
|
state.market_trend = None
|
||||||
|
state.market_volatility = None
|
||||||
|
state.market_trend_strength = None
|
||||||
|
state.market_trend_quality = None
|
||||||
|
state.market_phase = None
|
||||||
|
state.market_phase_direction = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_score_state(state: AutoTradeState) -> None:
|
||||||
|
state.market_score = None
|
||||||
|
state.market_score_label = None
|
||||||
|
state.market_long_score = None
|
||||||
|
state.market_short_score = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_candle_state(state: AutoTradeState) -> None:
|
||||||
|
state.last_closed_candle_change_percent = None
|
||||||
|
state.last_closed_candle_direction = None
|
||||||
|
state.current_interval_change_percent = None
|
||||||
|
state.current_interval_direction = None
|
||||||
|
state.current_interval_label = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_structure_state(state: AutoTradeState) -> None:
|
||||||
|
state.market_structure = None
|
||||||
|
state.market_structure_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_trend_quality_state(state: AutoTradeState) -> None:
|
||||||
|
state.market_trend_gap_percent = None
|
||||||
|
state.market_trend_consistency = None
|
||||||
|
state.market_trend_efficiency = None
|
||||||
|
state.trend_quality_score = None
|
||||||
|
|
||||||
|
state.ema_distance_atr_ratio = None
|
||||||
|
state.ema_distance_state = None
|
||||||
|
state.entry_timing_state = None
|
||||||
|
state.entry_timing_reason = None
|
||||||
|
|
||||||
|
state.ema_fast_slope_percent = None
|
||||||
|
state.ema_slow_slope_percent = None
|
||||||
|
state.candle_noise_score = None
|
||||||
|
state.price_position_score = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_htf_state(state: AutoTradeState) -> None:
|
||||||
|
state.htf_interval = None
|
||||||
|
state.htf_atr_percent = None
|
||||||
|
state.htf_atr_percent_baseline = None
|
||||||
|
state.htf_volatility_ratio = None
|
||||||
|
state.htf_volatility = None
|
||||||
|
|
||||||
|
state.htf_market_state = None
|
||||||
|
state.htf_trend = None
|
||||||
|
state.htf_trend_strength = None
|
||||||
|
state.htf_trend_quality = None
|
||||||
|
state.htf_market_phase = None
|
||||||
|
|
||||||
|
state.htf_alignment = None
|
||||||
|
state.htf_confirmation_score = None
|
||||||
|
state.htf_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_momentum_state(state: AutoTradeState) -> None:
|
||||||
|
state.momentum_state = None
|
||||||
|
state.momentum_direction = None
|
||||||
|
state.momentum_change_percent = None
|
||||||
|
state.momentum_strength = None
|
||||||
|
state.breakout_level = None
|
||||||
|
state.breakout_distance_percent = None
|
||||||
|
state.breakout_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_analysis_meta_state(state: AutoTradeState) -> None:
|
||||||
|
state.market_analysis_interval = None
|
||||||
|
state.market_analysis_reason = None
|
||||||
|
state.market_analysis_updated_at = None
|
||||||
|
|
||||||
|
state.entry_block_reason = None
|
||||||
|
state.entry_block_message = None
|
||||||
|
state.market_runtime_degraded = False
|
||||||
|
|
||||||
|
|
||||||
|
def reset_market_analysis_state(state: AutoTradeState) -> None:
|
||||||
|
reset_market_base_state(state)
|
||||||
|
reset_market_score_state(state)
|
||||||
|
reset_market_candle_state(state)
|
||||||
|
reset_market_structure_state(state)
|
||||||
|
reset_market_trend_quality_state(state)
|
||||||
|
reset_market_htf_state(state)
|
||||||
|
reset_market_momentum_state(state)
|
||||||
|
reset_market_analysis_meta_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Runtime Expiration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_runtime_expiration_state(state: AutoTradeState) -> None:
|
||||||
|
state.runtime_expired_reason = None
|
||||||
|
state.runtime_expired_message = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Position Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_position_identity_state(state: AutoTradeState) -> None:
|
||||||
|
state.position_side = "NONE"
|
||||||
|
state.entry_price = None
|
||||||
|
state.position_size = None
|
||||||
|
state.position_opened_monotonic_at = None
|
||||||
|
state.unrealized_pnl_usd = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_position_health_state(state: AutoTradeState) -> None:
|
||||||
|
state.position_pnl_percent = None
|
||||||
|
state.position_hold_seconds = None
|
||||||
|
state.position_pressure = None
|
||||||
|
state.position_health_score = None
|
||||||
|
state.position_health_status = None
|
||||||
|
state.position_health_reason = None
|
||||||
|
state.position_risk_level = None
|
||||||
|
state.position_risk_reason = None
|
||||||
|
state.position_trend_alignment = None
|
||||||
|
state.position_adverse_momentum = False
|
||||||
|
state.position_exit_pressure = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_position_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
reset_position_identity_state(state)
|
||||||
|
reset_position_health_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_position_semantics_state(state: AutoTradeState) -> None:
|
||||||
|
state.position_lifecycle_stage = None
|
||||||
|
state.position_hold_quality = None
|
||||||
|
state.position_decay_state = None
|
||||||
|
state.position_exit_confidence = None
|
||||||
|
state.position_exit_signal = None
|
||||||
|
state.position_intelligence_reason = None
|
||||||
|
state.position_recommended_action = None
|
||||||
|
|
||||||
|
state.position_peak_pnl_usd = None
|
||||||
|
state.position_peak_pnl_percent = None
|
||||||
|
state.position_mfe_percent = None
|
||||||
|
state.position_mae_percent = None
|
||||||
|
state.position_fatigue_score = None
|
||||||
|
state.position_fatigue_state = None
|
||||||
|
state.position_giveback_percent = None
|
||||||
|
state.position_conviction_state = None
|
||||||
|
state.position_exit_urgency = None
|
||||||
|
state.position_reversal_risk = None
|
||||||
|
state.position_stall_state = None
|
||||||
|
state.position_stall_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Protection Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_position_protection_state(state: AutoTradeState) -> None:
|
||||||
|
state.position_protection_status = None
|
||||||
|
state.position_protection_reason = None
|
||||||
|
|
||||||
|
state.break_even_armed = False
|
||||||
|
state.break_even_price = None
|
||||||
|
state.trailing_stop_active = False
|
||||||
|
state.trailing_stop_price = None
|
||||||
|
state.profit_lock_active = False
|
||||||
|
state.profit_lock_price = None
|
||||||
|
|
||||||
|
state.runtime_protection_action = None
|
||||||
|
state.runtime_protection_reason = None
|
||||||
|
state.runtime_protection_updated_at = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Autonomous Runtime
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_autonomous_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
state.autonomous_action = None
|
||||||
|
state.autonomous_action_reason = None
|
||||||
|
state.autonomous_action_confidence = None
|
||||||
|
state.autonomous_protection_required = False
|
||||||
|
state.autonomous_reduce_required = False
|
||||||
|
state.autonomous_exit_required = False
|
||||||
|
|
||||||
|
state.autonomous_last_action = None
|
||||||
|
state.autonomous_last_action_reason = None
|
||||||
|
state.autonomous_last_action_at = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Loss Cooldown / Cycle / Flip / Last Execution
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_loss_cooldown_state(state: AutoTradeState) -> None:
|
||||||
|
state.last_loss_monotonic_at = None
|
||||||
|
state.loss_cooldown_active = False
|
||||||
|
state.loss_cooldown_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cycle_statistics_state(state: AutoTradeState) -> None:
|
||||||
|
state.cycle_realized_pnl_usd = 0.0
|
||||||
|
state.cycle_closed_trades = 0
|
||||||
|
state.cycle_winning_trades = 0
|
||||||
|
state.cycle_losing_trades = 0
|
||||||
|
state.cycle_consecutive_losses = 0
|
||||||
|
state.cycle_trade_fees_usd = 0.0
|
||||||
|
state.cycle_overnight_fees_usd = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def reset_flip_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
state.last_flip_old_side = None
|
||||||
|
state.last_flip_new_side = None
|
||||||
|
state.last_flip_pnl_usd = None
|
||||||
|
state.last_flip_reason = None
|
||||||
|
state.last_flip_monotonic_at = None
|
||||||
|
state.last_flip_at = None
|
||||||
|
state.last_flip_block_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_last_execution_state(state: AutoTradeState) -> None:
|
||||||
|
state.last_execution_action = None
|
||||||
|
state.last_execution_reason = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Full Runtime Reset
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_full_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
reset_cycle_statistics_state(state)
|
||||||
|
reset_adaptive_size_state(state)
|
||||||
|
reset_signal_runtime_state(state)
|
||||||
|
reset_execution_runtime_state(state)
|
||||||
|
reset_market_analysis_state(state)
|
||||||
|
reset_runtime_expiration_state(state)
|
||||||
|
reset_position_runtime_state(state)
|
||||||
|
reset_position_semantics_state(state)
|
||||||
|
reset_position_protection_state(state)
|
||||||
|
reset_autonomous_runtime_state(state)
|
||||||
|
reset_loss_cooldown_state(state)
|
||||||
|
reset_flip_runtime_state(state)
|
||||||
|
reset_last_execution_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_runtime_state(state: AutoTradeState) -> None:
|
||||||
|
reset_full_runtime_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Lifecycle Scenario Resets
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset_after_auto_start(state: AutoTradeState) -> None:
|
||||||
|
reset_cycle_statistics_state(state)
|
||||||
|
reset_loss_cooldown_state(state)
|
||||||
|
reset_adaptive_size_state(state)
|
||||||
|
reset_signal_runtime_state(state)
|
||||||
|
reset_execution_runtime_state(state)
|
||||||
|
reset_runtime_expiration_state(state)
|
||||||
|
reset_flip_runtime_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_after_auto_stop(state: AutoTradeState) -> None:
|
||||||
|
reset_full_runtime_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_after_symbol_or_strategy_change(state: AutoTradeState) -> None:
|
||||||
|
reset_adaptive_size_state(state)
|
||||||
|
reset_signal_runtime_state(state)
|
||||||
|
reset_execution_runtime_state(state)
|
||||||
|
reset_market_analysis_state(state)
|
||||||
|
reset_runtime_expiration_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_after_position_closed(state: AutoTradeState) -> None:
|
||||||
|
reset_position_runtime_state(state)
|
||||||
|
reset_position_semantics_state(state)
|
||||||
|
reset_position_protection_state(state)
|
||||||
|
reset_autonomous_runtime_state(state)
|
||||||
|
reset_flip_runtime_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_after_market_runtime_expired(state: AutoTradeState) -> None:
|
||||||
|
reset_market_analysis_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_after_signal_runtime_expired(state: AutoTradeState) -> None:
|
||||||
|
reset_signal_runtime_state(state)
|
||||||
|
reset_execution_runtime_state(state)
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.core.types import NumericLike
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.trading.debug.state import DebugPositionState, DebugTradeState
|
from src.trading.debug.state import DebugPositionState, DebugTradeState
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
@@ -389,49 +390,88 @@ class DebugExecutionEngine:
|
|||||||
return self._market_last_price(state.symbol)
|
return self._market_last_price(state.symbol)
|
||||||
|
|
||||||
def _entry_price_for_side(self, symbol: str, side: str) -> float:
|
def _entry_price_for_side(self, symbol: str, side: str) -> float:
|
||||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
snapshot = ExchangeService().get_execution_snapshot(
|
||||||
|
symbol,
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
|
||||||
if side == "LONG":
|
if side == "LONG":
|
||||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.ask_price,
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="ask_price",
|
||||||
|
)
|
||||||
|
|
||||||
if side == "SHORT":
|
if side == "SHORT":
|
||||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.bid_price,
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="bid_price",
|
||||||
|
)
|
||||||
|
|
||||||
return self._snapshot_price(snapshot, "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="last_price",
|
||||||
|
)
|
||||||
|
|
||||||
def _exit_price_for_side(self, symbol: str, side: str) -> float:
|
def _exit_price_for_side(self, symbol: str, side: str) -> float:
|
||||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
snapshot = ExchangeService().get_execution_snapshot(
|
||||||
|
symbol,
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
|
||||||
if side == "LONG":
|
if side == "LONG":
|
||||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.bid_price,
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="bid_price",
|
||||||
|
)
|
||||||
|
|
||||||
if side == "SHORT":
|
if side == "SHORT":
|
||||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.ask_price,
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="ask_price",
|
||||||
|
)
|
||||||
|
|
||||||
return self._snapshot_price(snapshot, "last_price")
|
return self._execution_price(
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="last_price",
|
||||||
|
)
|
||||||
|
|
||||||
def _market_last_price(self, symbol: str) -> float:
|
def _market_last_price(self, symbol: str) -> float:
|
||||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
snapshot = ExchangeService().get_execution_snapshot(
|
||||||
return self._snapshot_price(snapshot, "last_price")
|
symbol,
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
return self._execution_price(
|
||||||
|
snapshot.last_price,
|
||||||
|
price_name="last_price",
|
||||||
|
)
|
||||||
|
|
||||||
def _snapshot_price(
|
def _execution_price(
|
||||||
self,
|
self,
|
||||||
snapshot: dict[str, object],
|
raw_price: NumericLike | None,
|
||||||
primary_key: str,
|
fallback_price: NumericLike | None = None,
|
||||||
fallback_key: str | None = None,
|
*,
|
||||||
|
price_name: str,
|
||||||
) -> float:
|
) -> float:
|
||||||
raw_price = snapshot.get(primary_key)
|
value = raw_price
|
||||||
|
|
||||||
if raw_price is None and fallback_key is not None:
|
if value is None:
|
||||||
raw_price = snapshot.get(fallback_key)
|
value = fallback_price
|
||||||
|
|
||||||
if raw_price is None:
|
if value is None:
|
||||||
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
|
raise ValueError(
|
||||||
|
f"Execution price '{price_name}' is missing."
|
||||||
|
)
|
||||||
|
|
||||||
price = float(raw_price)
|
price = float(value)
|
||||||
|
|
||||||
if price <= 0:
|
if price <= 0:
|
||||||
raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
|
raise ValueError(
|
||||||
|
f"Execution price '{price_name}' is invalid: {price}"
|
||||||
|
)
|
||||||
|
|
||||||
return price
|
return price
|
||||||
|
|
||||||
|
|||||||
1
app/src/trading/decision/__init__.py
Normal file
1
app/src/trading/decision/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app/src/trading/decision/__init__.py
|
||||||
19
app/src/trading/decision/exceptions.py
Normal file
19
app/src/trading/decision/exceptions.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# app/src/trading/decision/exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class TradingError(Exception):
|
||||||
|
"""Базовая ошибка Trading Layer."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTradingDecisionError(TradingError):
|
||||||
|
"""Trading Layer сформировал некорректное торговое решение."""
|
||||||
|
|
||||||
|
|
||||||
|
class TradingValidationError(TradingError):
|
||||||
|
"""Ошибка проверки входных данных Trading Layer."""
|
||||||
|
|
||||||
|
|
||||||
|
class TradingExecutionError(TradingError):
|
||||||
|
"""Ошибка выполнения Trading Layer."""
|
||||||
67
app/src/trading/decision/models.py
Normal file
67
app/src/trading/decision/models.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# app/src/trading/decision/models.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from src.trading.market_intelligence.common.enums import EngineStatus
|
||||||
|
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||||
|
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||||
|
from src.trading.market_intelligence.common.scores import (
|
||||||
|
EngineConfidence,
|
||||||
|
EngineScore,
|
||||||
|
)
|
||||||
|
from src.trading.market_intelligence.common.types import (
|
||||||
|
ContextDict,
|
||||||
|
DiagnosticMessages,
|
||||||
|
DurationMs,
|
||||||
|
PayloadDict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradingDiagnostics:
|
||||||
|
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||||
|
details: ContextDict = field(default_factory=dict)
|
||||||
|
warnings: DiagnosticMessages = field(default_factory=list)
|
||||||
|
errors: DiagnosticMessages = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_warnings(self) -> bool:
|
||||||
|
return bool(self.warnings)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
return bool(self.errors)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradingEvaluationMeta:
|
||||||
|
trading_version: str
|
||||||
|
calculated_at: float | None = None
|
||||||
|
duration_ms: DurationMs | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradingDecision:
|
||||||
|
coordinator_result: CoordinatorResult
|
||||||
|
diagnostics: TradingDiagnostics = field(default_factory=TradingDiagnostics)
|
||||||
|
meta: TradingEvaluationMeta | None = None
|
||||||
|
payload: PayloadDict = field(default_factory=dict)
|
||||||
|
|
||||||
|
status: EngineStatus = EngineStatus.UNKNOWN
|
||||||
|
score: EngineScore = field(default_factory=EngineScore)
|
||||||
|
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||||
|
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_usable(self) -> bool:
|
||||||
|
return self.status in {
|
||||||
|
EngineStatus.OK,
|
||||||
|
EngineStatus.PARTIAL,
|
||||||
|
EngineStatus.STALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
return self.diagnostics.has_errors
|
||||||
19
app/src/trading/decision/protocol.py
Normal file
19
app/src/trading/decision/protocol.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# app/src/trading/decision/protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||||
|
from src.trading.decision.models import TradingDecision
|
||||||
|
|
||||||
|
|
||||||
|
class TradingProtocol(Protocol):
|
||||||
|
"""Контракт Trading Layer."""
|
||||||
|
|
||||||
|
async def decide(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> TradingDecision:
|
||||||
|
"""Принять торговое решение на основе CoordinatorResult."""
|
||||||
|
...
|
||||||
65
app/src/trading/decision/rules.py
Normal file
65
app/src/trading/decision/rules.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# app/src/trading/decision/rules.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.trading.market_intelligence.common.enums import EngineStatus
|
||||||
|
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||||
|
from src.trading.decision.models import (
|
||||||
|
TradingDecision,
|
||||||
|
TradingDiagnostics,
|
||||||
|
TradingEvaluationMeta,
|
||||||
|
)
|
||||||
|
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||||
|
from src.trading.market_intelligence.common.scores import (
|
||||||
|
EngineConfidence,
|
||||||
|
EngineScore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradingRules:
|
||||||
|
"""Правила формирования торгового решения."""
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> TradingDecision:
|
||||||
|
"""Сформировать TradingDecision."""
|
||||||
|
return TradingDecision(
|
||||||
|
coordinator_result=coordinator_result,
|
||||||
|
diagnostics=TradingDiagnostics(),
|
||||||
|
meta=TradingEvaluationMeta(
|
||||||
|
trading_version="1.0",
|
||||||
|
),
|
||||||
|
status=self._resolve_status(coordinator_result),
|
||||||
|
score=self._resolve_score(coordinator_result),
|
||||||
|
confidence=self._resolve_confidence(coordinator_result),
|
||||||
|
reason=self._resolve_reason(coordinator_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_status(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> EngineStatus:
|
||||||
|
"""Определить итоговый статус Trading."""
|
||||||
|
return coordinator_result.status
|
||||||
|
|
||||||
|
def _resolve_score(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> EngineScore:
|
||||||
|
"""Вычислить итоговый Score."""
|
||||||
|
return EngineScore()
|
||||||
|
|
||||||
|
def _resolve_confidence(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> EngineConfidence:
|
||||||
|
"""Вычислить итоговый Confidence."""
|
||||||
|
return EngineConfidence()
|
||||||
|
|
||||||
|
def _resolve_reason(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> ReasonCode:
|
||||||
|
"""Определить причину принятого решения."""
|
||||||
|
return ReasonCode.UNKNOWN
|
||||||
29
app/src/trading/decision/service.py
Normal file
29
app/src/trading/decision/service.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# app/src/trading/decision/service.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||||
|
from src.trading.decision.models import TradingDecision
|
||||||
|
from src.trading.decision.protocol import TradingProtocol
|
||||||
|
from src.trading.decision.rules import TradingRules
|
||||||
|
from src.trading.decision.validation import (
|
||||||
|
TradingValidation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradingService(TradingProtocol):
|
||||||
|
"""Единая публичная точка входа Trading Layer."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Создать Trading Service."""
|
||||||
|
self._validation = TradingValidation()
|
||||||
|
self._rules = TradingRules()
|
||||||
|
|
||||||
|
async def decide(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> TradingDecision:
|
||||||
|
"""Сформировать торговое решение."""
|
||||||
|
self._validation.validate(coordinator_result)
|
||||||
|
|
||||||
|
return self._rules.decide(coordinator_result)
|
||||||
51
app/src/trading/decision/validation.py
Normal file
51
app/src/trading/decision/validation.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# app/src/trading/decision/validation.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||||
|
from src.trading.decision.exceptions import (
|
||||||
|
TradingValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradingValidation:
|
||||||
|
"""Проверка входного CoordinatorResult для Trading Layer."""
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить CoordinatorResult перед принятием торгового решения."""
|
||||||
|
self._validate_result_exists(coordinator_result)
|
||||||
|
self._validate_result_usable(coordinator_result)
|
||||||
|
self._validate_result_has_no_errors(coordinator_result)
|
||||||
|
|
||||||
|
def _validate_result_exists(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить, что CoordinatorResult передан."""
|
||||||
|
if coordinator_result is None:
|
||||||
|
raise TradingValidationError(
|
||||||
|
"CoordinatorResult is required for Trading."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_result_usable(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить пригодность CoordinatorResult для принятия решения."""
|
||||||
|
if not coordinator_result.is_usable:
|
||||||
|
raise TradingValidationError(
|
||||||
|
"CoordinatorResult is not usable."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_result_has_no_errors(
|
||||||
|
self,
|
||||||
|
coordinator_result: CoordinatorResult,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить отсутствие критических ошибок Coordinator."""
|
||||||
|
if coordinator_result.has_errors:
|
||||||
|
raise TradingValidationError(
|
||||||
|
"CoordinatorResult contains errors."
|
||||||
|
)
|
||||||
@@ -259,20 +259,20 @@ class SemanticDiagnosticSnapshotBuilder:
|
|||||||
try:
|
try:
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
|
||||||
snapshot = ExchangeService().get_market_snapshot(
|
quote = ExchangeService().get_quote(
|
||||||
state.symbol,
|
state.symbol,
|
||||||
runtime_key="auto",
|
runtime_key="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
side = str(state.position_side or "").upper()
|
side = str(state.position_side or "").upper()
|
||||||
|
|
||||||
price = snapshot.get("last_price")
|
price = quote.last_price
|
||||||
|
|
||||||
if side == "LONG":
|
if side == "LONG":
|
||||||
price = snapshot.get("bid_price") or price
|
price = quote.bid_price or price
|
||||||
|
|
||||||
elif side == "SHORT":
|
elif side == "SHORT":
|
||||||
price = snapshot.get("ask_price") or price
|
price = quote.ask_price or price
|
||||||
|
|
||||||
return safe_float(price)
|
return safe_float(price)
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class ExecutionEngine(
|
|||||||
_flip_cooldown_seconds = 45
|
_flip_cooldown_seconds = 45
|
||||||
_loss_flip_confidence = 0.75
|
_loss_flip_confidence = 0.75
|
||||||
_last_flip_block_key: str | None = None
|
_last_flip_block_key: str | None = None
|
||||||
|
_last_flip_diagnostic_key: str | None = None
|
||||||
_runtime_action_cooldown_seconds = 30
|
_runtime_action_cooldown_seconds = 30
|
||||||
_last_runtime_action_key: str | None = None
|
_last_runtime_action_key: str | None = None
|
||||||
_emergency_halt_drawdown_usd = 250.0
|
_emergency_halt_drawdown_usd = 250.0
|
||||||
@@ -107,6 +108,18 @@ class ExecutionEngine(
|
|||||||
if protection_decision is not None:
|
if protection_decision is not None:
|
||||||
return protection_decision
|
return protection_decision
|
||||||
|
|
||||||
|
# Flip diagnostics before READY / supervisor.
|
||||||
|
# Это не меняет торговую логику: только фиксирует, что противоположный
|
||||||
|
# сигнал появился при уже открытой позиции.
|
||||||
|
flip_requested = self._should_flip_position(state)
|
||||||
|
|
||||||
|
if flip_requested:
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage="FLIP_REQUESTED",
|
||||||
|
reason="opposite signal while position is open",
|
||||||
|
)
|
||||||
|
|
||||||
# Signal readiness validation
|
# Signal readiness validation
|
||||||
if state.decision_status != EXECUTION_DECISION_READY or not state.is_signal_ready:
|
if state.decision_status != EXECUTION_DECISION_READY or not state.is_signal_ready:
|
||||||
reason = (
|
reason = (
|
||||||
@@ -115,6 +128,13 @@ class ExecutionEngine(
|
|||||||
f"ready={state.is_signal_ready})."
|
f"ready={state.is_signal_ready})."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if flip_requested:
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage="FLIP_BLOCKED_BY_READY",
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
return self._skip_execution(
|
return self._skip_execution(
|
||||||
state,
|
state,
|
||||||
reason,
|
reason,
|
||||||
@@ -123,6 +143,13 @@ class ExecutionEngine(
|
|||||||
# Execution supervisor
|
# Execution supervisor
|
||||||
supervisor_decision = self._process_execution_supervisor(state)
|
supervisor_decision = self._process_execution_supervisor(state)
|
||||||
if supervisor_decision is not None:
|
if supervisor_decision is not None:
|
||||||
|
if flip_requested:
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage="FLIP_BLOCKED_BY_SUPERVISOR",
|
||||||
|
reason=supervisor_decision.reason,
|
||||||
|
)
|
||||||
|
|
||||||
return supervisor_decision
|
return supervisor_decision
|
||||||
|
|
||||||
# Existing position validation
|
# Existing position validation
|
||||||
@@ -131,21 +158,19 @@ class ExecutionEngine(
|
|||||||
# Не пытаемся повторно открыть позицию в ту же сторону.
|
# Не пытаемся повторно открыть позицию в ту же сторону.
|
||||||
# Сигнал остаётся валидным для UI/Telegram, но execution не дублируется.
|
# Сигнал остаётся валидным для UI/Telegram, но execution не дублируется.
|
||||||
if position.side == POSITION_SIDE_LONG and state.last_signal == SIGNAL_BUY:
|
if position.side == POSITION_SIDE_LONG and state.last_signal == SIGNAL_BUY:
|
||||||
return ExecutionDecision(
|
return self._skip_execution(
|
||||||
EXECUTION_ACTION_NONE,
|
state,
|
||||||
False,
|
|
||||||
"Сигнал BUY совпадает с уже открытой LONG позицией.",
|
"Сигнал BUY совпадает с уже открытой LONG позицией.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if position.side == POSITION_SIDE_SHORT and state.last_signal == SIGNAL_SELL:
|
if position.side == POSITION_SIDE_SHORT and state.last_signal == SIGNAL_SELL:
|
||||||
return ExecutionDecision(
|
return self._skip_execution(
|
||||||
EXECUTION_ACTION_NONE,
|
state,
|
||||||
False,
|
|
||||||
"Сигнал SELL совпадает с уже открытой SHORT позицией.",
|
"Сигнал SELL совпадает с уже открытой SHORT позицией.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Position flip
|
# Position flip
|
||||||
if self._should_flip_position(state):
|
if flip_requested:
|
||||||
flip_block_reason = self._flip_block_reason(state)
|
flip_block_reason = self._flip_block_reason(state)
|
||||||
|
|
||||||
if flip_block_reason is not None:
|
if flip_block_reason is not None:
|
||||||
|
|||||||
@@ -9,7 +9,21 @@ from src.core.event_bus import EventBus
|
|||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import JsonDict, NumericLike
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import (
|
||||||
|
reset_autonomous_runtime_state,
|
||||||
|
reset_execution_block_state,
|
||||||
|
)
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.payloads import (
|
||||||
|
build_adaptive_size_payload,
|
||||||
|
build_autonomous_payload,
|
||||||
|
build_execution_quality_payload,
|
||||||
|
build_market_context_payload,
|
||||||
|
build_position_health_payload,
|
||||||
|
build_position_intelligence_payload,
|
||||||
|
build_runtime_protection_payload,
|
||||||
|
build_signal_payload,
|
||||||
|
)
|
||||||
from src.trading.execution.pricing import ExecutionPrice
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
from src.trading.position.state import PositionState
|
from src.trading.position.state import PositionState
|
||||||
@@ -44,6 +58,7 @@ class _ExecutionFlipProtocol(Protocol):
|
|||||||
_flip_cooldown_seconds: int
|
_flip_cooldown_seconds: int
|
||||||
_loss_flip_confidence: float
|
_loss_flip_confidence: float
|
||||||
_last_flip_block_key: str | None
|
_last_flip_block_key: str | None
|
||||||
|
_last_flip_diagnostic_key: str | None
|
||||||
|
|
||||||
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
|
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
|
||||||
...
|
...
|
||||||
@@ -97,8 +112,73 @@ class _ExecutionFlipProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||||
|
# ---------- Diagnostics ----------
|
||||||
|
# Записать диагностическое событие по пути flip без изменения торговой логики.
|
||||||
|
# Эти события нужны, чтобы понять, где именно разворот был остановлен:
|
||||||
|
# READY, supervisor, flip guard, price, sizing или успешное исполнение.
|
||||||
|
def _log_flip_diagnostic(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
stage: str,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
confidence = safe_float(state.last_signal_confidence) or 0.0
|
||||||
|
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
|
||||||
|
|
||||||
|
# Дедупликация защищает журнал от спама на каждом тике одного и того же
|
||||||
|
# состояния. Если причина/стадия изменилась — событие будет записано.
|
||||||
|
key = (
|
||||||
|
f"{stage}:"
|
||||||
|
f"{state.symbol}:"
|
||||||
|
f"{position.side}:"
|
||||||
|
f"{state.last_signal}:"
|
||||||
|
f"{repeat_count}:"
|
||||||
|
f"{confidence:.2f}:"
|
||||||
|
f"{reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if key == type(self)._last_flip_diagnostic_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
type(self)._last_flip_diagnostic_key = key
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"execution_type": "FLIP_DIAGNOSTIC",
|
||||||
|
"stage": stage,
|
||||||
|
"reason": reason,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"position_side": position.side,
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"signal_confidence": confidence,
|
||||||
|
"signal_repeat_count": repeat_count,
|
||||||
|
"decision_status": state.decision_status,
|
||||||
|
"is_signal_ready": state.is_signal_ready,
|
||||||
|
"is_signal_confirmed": state.is_signal_confirmed,
|
||||||
|
"execution_confidence_score": state.execution_confidence_score,
|
||||||
|
"execution_confidence_required_score": (
|
||||||
|
state.execution_confidence_required_score
|
||||||
|
),
|
||||||
|
"execution_block_reason": state.execution_block_reason,
|
||||||
|
"entry_block_reason": state.entry_block_reason,
|
||||||
|
"entry_block_message": state.entry_block_message,
|
||||||
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
|
**build_market_context_payload(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_info(
|
||||||
|
event_type="position_flip_diagnostic",
|
||||||
|
message=f"Flip diagnostic: {stage} · {reason}",
|
||||||
|
screen="auto",
|
||||||
|
action="paper_execution",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit("paper_flip_diagnostic", payload)
|
||||||
|
|
||||||
# ---------- Payload builders ----------
|
# ---------- Payload builders ----------
|
||||||
# собрать payload отказа flip без изменения состояния
|
# Собрать payload отказа flip без изменения состояния позиции.
|
||||||
def _build_flip_rejected_payload(
|
def _build_flip_rejected_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -111,30 +191,17 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
"execution_type": EXECUTION_TYPE_FLIP_REJECTED,
|
"execution_type": EXECUTION_TYPE_FLIP_REJECTED,
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
"position_side": position.side,
|
"position_side": position.side,
|
||||||
"signal": state.last_signal,
|
**build_signal_payload(state),
|
||||||
"confidence": state.last_signal_confidence,
|
**build_execution_quality_payload(state),
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
|
||||||
"reason": state.last_signal_reason,
|
|
||||||
"reject_reason": reason,
|
"reject_reason": reason,
|
||||||
# Общая оценка рынка на момент отказа flip.
|
|
||||||
"market_score": getattr(state, "market_score", None),
|
|
||||||
"market_score_label": getattr(state, "market_score_label", None),
|
|
||||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"entry_timing_state": state.entry_timing_state,
|
"entry_timing_state": state.entry_timing_state,
|
||||||
|
**build_market_context_payload(state),
|
||||||
"opened_at": position.opened_at,
|
"opened_at": position.opened_at,
|
||||||
"updated_at": position.updated_at,
|
"updated_at": position.updated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
# собрать payload блокировки flip без изменения состояния
|
# Собрать payload блокировки flip guard'ами без изменения состояния позиции.
|
||||||
def _build_flip_blocked_payload(
|
def _build_flip_blocked_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -148,28 +215,20 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
"execution_type": EXECUTION_TYPE_FLIP_BLOCKED,
|
"execution_type": EXECUTION_TYPE_FLIP_BLOCKED,
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
"position_side": position.side,
|
"position_side": position.side,
|
||||||
"signal": state.last_signal,
|
**build_signal_payload(
|
||||||
"confidence": confidence,
|
state,
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
confidence=confidence,
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
reason=reason,
|
||||||
"reason": reason,
|
),
|
||||||
# Общая оценка рынка на момент блокировки flip.
|
**build_execution_quality_payload(state),
|
||||||
"market_score": getattr(state, "market_score", None),
|
|
||||||
"market_score_label": getattr(state, "market_score_label", None),
|
|
||||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
"market_state": state.market_state,
|
**build_market_context_payload(state),
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"opened_at": position.opened_at,
|
"opened_at": position.opened_at,
|
||||||
"updated_at": position.updated_at,
|
"updated_at": position.updated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
# собрать payload выполненного flip без изменения состояния
|
# Собрать payload выполненного flip.
|
||||||
|
# Здесь фиксируем и закрытую старую позицию, и параметры новой позиции.
|
||||||
def _build_flip_executed_payload(
|
def _build_flip_executed_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -234,79 +293,28 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
"hold_seconds": metrics.hold_seconds,
|
"hold_seconds": metrics.hold_seconds,
|
||||||
"overnight_count": metrics.overnight_count,
|
"overnight_count": metrics.overnight_count,
|
||||||
|
|
||||||
"signal": state.last_signal,
|
**build_signal_payload(state),
|
||||||
"confidence": state.last_signal_confidence,
|
**build_execution_quality_payload(state),
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
**build_adaptive_size_payload(state),
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
|
||||||
"execution_confidence_reason": state.execution_confidence_reason,
|
|
||||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
|
||||||
"adaptive_size_reason": state.adaptive_size_reason,
|
|
||||||
"adaptive_size_factors": state.adaptive_size_factors,
|
|
||||||
"effective_risk_percent": state.effective_risk_percent,
|
|
||||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
|
||||||
"adaptive_size_base": state.adaptive_size_base,
|
|
||||||
"adaptive_size_final": state.adaptive_size_final,
|
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
|
||||||
"reason": state.last_signal_reason,
|
|
||||||
# Общая оценка рынка на момент смены направления позиции.
|
|
||||||
# Фиксируем её вместе с adaptive size, чтобы видеть контекст flip.
|
|
||||||
"market_score": getattr(state, "market_score", None),
|
|
||||||
"market_score_label": getattr(state, "market_score_label", None),
|
|
||||||
"opened_at": old_opened_at,
|
"opened_at": old_opened_at,
|
||||||
"new_opened_monotonic_at": opened_monotonic_at,
|
"new_opened_monotonic_at": opened_monotonic_at,
|
||||||
"closed_at": now,
|
"closed_at": now,
|
||||||
"new_opened_at": now,
|
"new_opened_at": now,
|
||||||
"market_state": state.market_state,
|
**build_market_context_payload(state),
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
|
|
||||||
# ---------- Position health ----------
|
# ---------- Position health ----------
|
||||||
"position_hold_seconds": state.position_hold_seconds,
|
**build_position_health_payload(state),
|
||||||
"position_health_status": state.position_health_status,
|
|
||||||
"position_health_score": state.position_health_score,
|
|
||||||
"position_health_reason": state.position_health_reason,
|
|
||||||
"position_risk_level": state.position_risk_level,
|
|
||||||
"position_risk_reason": state.position_risk_reason,
|
|
||||||
"position_trend_alignment": state.position_trend_alignment,
|
|
||||||
"position_adverse_momentum": state.position_adverse_momentum,
|
|
||||||
|
|
||||||
# ---------- Position intelligence ----------
|
# ---------- Position intelligence ----------
|
||||||
"position_exit_signal": state.position_exit_signal,
|
**build_position_intelligence_payload(state),
|
||||||
"position_exit_confidence": state.position_exit_confidence,
|
|
||||||
"position_exit_urgency": state.position_exit_urgency,
|
|
||||||
"position_reversal_risk": state.position_reversal_risk,
|
|
||||||
"position_fatigue_state": state.position_fatigue_state,
|
|
||||||
"position_giveback_percent": state.position_giveback_percent,
|
|
||||||
"position_mfe_percent": state.position_mfe_percent,
|
|
||||||
"position_mae_percent": state.position_mae_percent,
|
|
||||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
|
||||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
|
||||||
|
|
||||||
# ---------- Autonomous ----------
|
# ---------- Autonomous ----------
|
||||||
"autonomous_action": state.autonomous_action,
|
**build_autonomous_payload(state),
|
||||||
"autonomous_action_reason": state.autonomous_action_reason,
|
|
||||||
"autonomous_action_confidence": state.autonomous_action_confidence,
|
|
||||||
"autonomous_protection_required": state.autonomous_protection_required,
|
|
||||||
"autonomous_reduce_required": state.autonomous_reduce_required,
|
|
||||||
"autonomous_exit_required": state.autonomous_exit_required,
|
|
||||||
|
|
||||||
# ---------- Runtime protection ----------
|
# ---------- Runtime protection ----------
|
||||||
"position_protection_status": state.position_protection_status,
|
**build_runtime_protection_payload(state),
|
||||||
"position_protection_reason": state.position_protection_reason,
|
|
||||||
"runtime_protection_action": state.runtime_protection_action,
|
|
||||||
"runtime_protection_reason": state.runtime_protection_reason,
|
|
||||||
"break_even_armed": state.break_even_armed,
|
|
||||||
"break_even_price": state.break_even_price,
|
|
||||||
"profit_lock_active": state.profit_lock_active,
|
|
||||||
"profit_lock_price": state.profit_lock_price,
|
|
||||||
"trailing_stop_active": state.trailing_stop_active,
|
|
||||||
"trailing_stop_price": state.trailing_stop_price,
|
|
||||||
|
|
||||||
"htf_alignment": state.htf_alignment,
|
# ---------- Pricing diagnostics ----------
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"pricing": PRICING_FLIP_MODE,
|
"pricing": PRICING_FLIP_MODE,
|
||||||
"exit_pricing_role": exit_execution.pricing_role,
|
"exit_pricing_role": exit_execution.pricing_role,
|
||||||
"exit_price_source": exit_execution.source,
|
"exit_price_source": exit_execution.source,
|
||||||
@@ -319,7 +327,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------- Journal helpers ----------
|
# ---------- Journal helpers ----------
|
||||||
# записать отказ flip execution в журнал
|
# Записать отказ flip execution в журнал.
|
||||||
|
# Reject отличается от block: reject происходит уже внутри попытки исполнения,
|
||||||
|
# например из-за отсутствия цены или невозможности рассчитать size.
|
||||||
def _log_flip_rejected(
|
def _log_flip_rejected(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -340,17 +350,26 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---------- Decision helpers ----------
|
# ---------- Decision helpers ----------
|
||||||
# записать отказ flip и вернуть стандартное решение без исполнения
|
# Записать отказ flip и вернуть стандартное решение без исполнения.
|
||||||
|
# diagnostic_stage указывает, на каком техническом этапе flip был отклонён.
|
||||||
def _reject_flip(
|
def _reject_flip(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
reason: str,
|
reason: str,
|
||||||
|
diagnostic_stage: str | None = None,
|
||||||
) -> ExecutionDecision:
|
) -> ExecutionDecision:
|
||||||
|
if diagnostic_stage is not None:
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage=diagnostic_stage,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
self._log_flip_rejected(state=state, reason=reason)
|
self._log_flip_rejected(state=state, reason=reason)
|
||||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||||
|
|
||||||
# записать блокировку flip в state, journal и event bus
|
# Записать блокировку flip guard'ами в state, journal и event bus.
|
||||||
def _block_flip(
|
def _block_flip(
|
||||||
self,
|
self,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
@@ -359,6 +378,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
confidence = safe_float(state.last_signal_confidence) or 0.0
|
confidence = safe_float(state.last_signal_confidence) or 0.0
|
||||||
|
|
||||||
|
# Диагностика отдельно показывает, что flip дошёл до flip.py,
|
||||||
|
# но был остановлен именно flip-specific guard'ами.
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage="FLIP_BLOCKED_BY_FLIP_GUARD",
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
state.execution_block_reason = reason
|
state.execution_block_reason = reason
|
||||||
state.last_flip_block_reason = reason
|
state.last_flip_block_reason = reason
|
||||||
state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED
|
state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED
|
||||||
@@ -394,7 +421,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||||
|
|
||||||
# ---------- Flip checks ----------
|
# ---------- Flip checks ----------
|
||||||
# проверить, нужен ли flip позиции по текущему сигналу
|
# Проверить, нужен ли flip позиции по текущему сигналу.
|
||||||
|
# Здесь только факт противоположного сигнала, без оценки качества рынка.
|
||||||
def _should_flip_position(self, state: AutoTradeState) -> bool:
|
def _should_flip_position(self, state: AutoTradeState) -> bool:
|
||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
signal = str(state.last_signal or "").upper()
|
signal = str(state.last_signal or "").upper()
|
||||||
@@ -410,7 +438,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# определить причину блокировки flip, если flip сейчас опасен
|
# Определить причину блокировки flip, если flip сейчас опасен.
|
||||||
|
# Важно: пока торговую логику не меняем, только делаем её наблюдаемой.
|
||||||
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
|
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
|
||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
|
|
||||||
@@ -419,6 +448,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
execution_confidence = safe_float(state.execution_confidence_score)
|
execution_confidence = safe_float(state.execution_confidence_score)
|
||||||
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
|
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
|
||||||
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
|
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
|
||||||
|
|
||||||
|
# hold_seconds считаем через position metrics.
|
||||||
|
# current_price пока берём entry_price, чтобы не менять текущую механику.
|
||||||
metrics = build_position_metrics(
|
metrics = build_position_metrics(
|
||||||
position,
|
position,
|
||||||
current_price=position.entry_price,
|
current_price=position.entry_price,
|
||||||
@@ -531,7 +563,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# проверить, активен ли cooldown после последнего flip
|
# Проверить, активен ли cooldown после последнего flip.
|
||||||
def _flip_cooldown_active(self, state: AutoTradeState) -> bool:
|
def _flip_cooldown_active(self, state: AutoTradeState) -> bool:
|
||||||
ts = safe_float(getattr(state, "last_flip_monotonic_at", None))
|
ts = safe_float(getattr(state, "last_flip_monotonic_at", None))
|
||||||
|
|
||||||
@@ -540,7 +572,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
return (time.monotonic() - ts) < self._flip_cooldown_seconds
|
return (time.monotonic() - ts) < self._flip_cooldown_seconds
|
||||||
|
|
||||||
# определить сторону позиции по сигналу BUY / SELL
|
# Определить сторону новой позиции по сигналу BUY / SELL.
|
||||||
def _target_side_from_signal(self, signal: str | None) -> str | None:
|
def _target_side_from_signal(self, signal: str | None) -> str | None:
|
||||||
normalized_signal = str(signal or "").upper()
|
normalized_signal = str(signal or "").upper()
|
||||||
|
|
||||||
@@ -553,22 +585,33 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# ---------- Execution ----------
|
# ---------- Execution ----------
|
||||||
# закрыть текущую позицию и открыть новую в противоположную сторону
|
# Закрыть текущую позицию и открыть новую в противоположную сторону.
|
||||||
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
|
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
|
||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
|
|
||||||
if position.side == POSITION_SIDE_NONE:
|
if position.side == POSITION_SIDE_NONE:
|
||||||
self._sync_state_from_position(state)
|
self._sync_state_from_position(state)
|
||||||
reason = "Нет позиции для flip."
|
reason = "Нет позиции для flip."
|
||||||
return self._reject_flip(state=state, reason=reason)
|
return self._reject_flip(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
diagnostic_stage="FLIP_REJECTED_NO_POSITION",
|
||||||
|
)
|
||||||
|
|
||||||
new_side = self._target_side_from_signal(state.last_signal)
|
new_side = self._target_side_from_signal(state.last_signal)
|
||||||
|
|
||||||
if new_side is None:
|
if new_side is None:
|
||||||
reason = "Нет направления для flip."
|
reason = "Нет направления для flip."
|
||||||
return self._reject_flip(state=state, reason=reason)
|
return self._reject_flip(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
diagnostic_stage="FLIP_REJECTED_NO_DIRECTION",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Для flip нужны две цены:
|
||||||
|
# 1) exit price — закрытие старой позиции;
|
||||||
|
# 2) entry price — открытие новой позиции.
|
||||||
exit_execution = self._exit_price_for_side(
|
exit_execution = self._exit_price_for_side(
|
||||||
position.symbol or state.symbol,
|
position.symbol or state.symbol,
|
||||||
position.side,
|
position.side,
|
||||||
@@ -582,7 +625,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reason = f"Ошибка получения цены для flip: {exc}"
|
reason = f"Ошибка получения цены для flip: {exc}"
|
||||||
return self._reject_flip(state=state, reason=reason)
|
return self._reject_flip(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
diagnostic_stage="FLIP_REJECTED_BY_PRICE",
|
||||||
|
)
|
||||||
|
|
||||||
now = self._now_time()
|
now = self._now_time()
|
||||||
opened_monotonic_at = time.monotonic()
|
opened_monotonic_at = time.monotonic()
|
||||||
@@ -602,7 +649,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
if new_size <= 0:
|
if new_size <= 0:
|
||||||
reason = "Flip отменён: невозможно рассчитать adaptive size."
|
reason = "Flip отменён: невозможно рассчитать adaptive size."
|
||||||
return self._reject_flip(state=state, reason=reason)
|
return self._reject_flip(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
diagnostic_stage="FLIP_REJECTED_BY_SIZING",
|
||||||
|
)
|
||||||
|
|
||||||
new_size = self._adjust_size_by_margin_limit(
|
new_size = self._adjust_size_by_margin_limit(
|
||||||
state=state,
|
state=state,
|
||||||
@@ -620,7 +671,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
if new_size <= 0:
|
if new_size <= 0:
|
||||||
reason = "Flip отменён: итоговый size равен 0."
|
reason = "Flip отменён: итоговый size равен 0."
|
||||||
return self._reject_flip(state=state, reason=reason)
|
return self._reject_flip(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
diagnostic_stage="FLIP_REJECTED_BY_SIZING",
|
||||||
|
)
|
||||||
|
|
||||||
state.realized_pnl_usd += pnl
|
state.realized_pnl_usd += pnl
|
||||||
state.cycle_realized_pnl_usd += pnl
|
state.cycle_realized_pnl_usd += pnl
|
||||||
@@ -631,16 +686,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
if pnl > 0:
|
if pnl > 0:
|
||||||
state.cycle_winning_trades += 1
|
state.cycle_winning_trades += 1
|
||||||
|
|
||||||
# прибыльный flip закрывает серию убытков
|
# Прибыльный flip закрывает серию убытков и выключает loss cooldown.
|
||||||
state.cycle_consecutive_losses = 0
|
state.cycle_consecutive_losses = 0
|
||||||
|
|
||||||
state.loss_cooldown_active = False
|
state.loss_cooldown_active = False
|
||||||
state.loss_cooldown_reason = None
|
state.loss_cooldown_reason = None
|
||||||
|
|
||||||
elif pnl < 0:
|
elif pnl < 0:
|
||||||
state.cycle_losing_trades += 1
|
state.cycle_losing_trades += 1
|
||||||
state.cycle_consecutive_losses += 1
|
state.cycle_consecutive_losses += 1
|
||||||
|
|
||||||
state.last_loss_monotonic_at = time.monotonic()
|
state.last_loss_monotonic_at = time.monotonic()
|
||||||
|
|
||||||
if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES:
|
if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES:
|
||||||
@@ -661,9 +714,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
# Flip открывает новую позицию, поэтому autonomous runtime прошлой позиции
|
# Flip открывает новую позицию, поэтому autonomous runtime прошлой позиции
|
||||||
# нельзя переносить на новую сделку.
|
# нельзя переносить на новую сделку.
|
||||||
state.autonomous_last_action = None
|
reset_autonomous_runtime_state(state)
|
||||||
state.autonomous_last_action_reason = None
|
|
||||||
state.autonomous_last_action_at = None
|
|
||||||
|
|
||||||
state.last_flip_old_side = old_side
|
state.last_flip_old_side = old_side
|
||||||
state.last_flip_new_side = new_side
|
state.last_flip_new_side = new_side
|
||||||
@@ -703,7 +754,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
|
|
||||||
state.position_opened_monotonic_at = opened_monotonic_at
|
state.position_opened_monotonic_at = opened_monotonic_at
|
||||||
|
|
||||||
state.execution_block_reason = None
|
reset_execution_block_state(state)
|
||||||
state.last_flip_block_reason = None
|
state.last_flip_block_reason = None
|
||||||
state.last_execution_action = flip_action
|
state.last_execution_action = flip_action
|
||||||
state.last_execution_reason = "Направление позиции изменено."
|
state.last_execution_reason = "Направление позиции изменено."
|
||||||
@@ -735,6 +786,13 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
|||||||
entry_execution=entry_execution,
|
entry_execution=entry_execution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Отдельная диагностика успешного прохождения всего flip-пайплайна.
|
||||||
|
self._log_flip_diagnostic(
|
||||||
|
state=state,
|
||||||
|
stage="FLIP_EXECUTED",
|
||||||
|
reason=f"{old_side} -> {new_side}",
|
||||||
|
)
|
||||||
|
|
||||||
JournalService().log_ui_info(
|
JournalService().log_ui_info(
|
||||||
event_type="position_flipped",
|
event_type="position_flipped",
|
||||||
message=f"Направление позиции изменено: {old_side} → {new_side}.",
|
message=f"Направление позиции изменено: {old_side} → {new_side}.",
|
||||||
|
|||||||
252
app/src/trading/execution/payloads.py
Normal file
252
app/src/trading/execution/payloads.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# app/src/trading/execution/payloads.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.core.types import JsonDict
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"market_score": state.market_score,
|
||||||
|
"market_score_label": state.market_score_label,
|
||||||
|
"market_long_score": state.market_long_score,
|
||||||
|
"market_short_score": state.market_short_score,
|
||||||
|
"market_state": state.market_state,
|
||||||
|
"market_trend": state.market_trend,
|
||||||
|
"market_volatility": state.market_volatility,
|
||||||
|
"market_trend_strength": state.market_trend_strength,
|
||||||
|
"market_trend_quality": state.market_trend_quality,
|
||||||
|
"market_phase": state.market_phase,
|
||||||
|
"market_phase_direction": state.market_phase_direction,
|
||||||
|
"market_structure": state.market_structure,
|
||||||
|
"market_structure_reason": state.market_structure_reason,
|
||||||
|
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
||||||
|
"last_closed_candle_direction": state.last_closed_candle_direction,
|
||||||
|
"current_interval_change_percent": state.current_interval_change_percent,
|
||||||
|
"current_interval_direction": state.current_interval_direction,
|
||||||
|
"current_interval_label": state.current_interval_label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_momentum_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"momentum_state": state.momentum_state,
|
||||||
|
"momentum_direction": state.momentum_direction,
|
||||||
|
"momentum_strength": state.momentum_strength,
|
||||||
|
"momentum_change_percent": state.momentum_change_percent,
|
||||||
|
"breakout_level": state.breakout_level,
|
||||||
|
"breakout_distance_percent": state.breakout_distance_percent,
|
||||||
|
"breakout_reason": state.breakout_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_htf_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"htf_interval": state.htf_interval,
|
||||||
|
"htf_atr_percent": state.htf_atr_percent,
|
||||||
|
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
||||||
|
"htf_volatility_ratio": state.htf_volatility_ratio,
|
||||||
|
"htf_volatility": state.htf_volatility,
|
||||||
|
"htf_market_state": state.htf_market_state,
|
||||||
|
"htf_trend": state.htf_trend,
|
||||||
|
"htf_trend_strength": state.htf_trend_strength,
|
||||||
|
"htf_trend_quality": state.htf_trend_quality,
|
||||||
|
"htf_market_phase": state.htf_market_phase,
|
||||||
|
"htf_alignment": state.htf_alignment,
|
||||||
|
"htf_confirmation_score": state.htf_confirmation_score,
|
||||||
|
"htf_reason": state.htf_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_runtime_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"market_runtime_degraded": state.market_runtime_degraded,
|
||||||
|
"runtime_expired_reason": state.runtime_expired_reason,
|
||||||
|
"runtime_expired_message": state.runtime_expired_message,
|
||||||
|
"market_is_open": state.market_is_open,
|
||||||
|
"market_status": state.market_status,
|
||||||
|
"market_status_message": state.market_status_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_context_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
**build_market_payload(state),
|
||||||
|
**build_momentum_payload(state),
|
||||||
|
**build_htf_payload(state),
|
||||||
|
**build_market_runtime_payload(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_runtime_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"status": state.status,
|
||||||
|
"strategy": state.strategy,
|
||||||
|
"cycle_number": state.cycle_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_signal_payload(
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
confidence: float | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"confidence": (
|
||||||
|
state.last_signal_confidence
|
||||||
|
if confidence is None
|
||||||
|
else confidence
|
||||||
|
),
|
||||||
|
"repeat_count": state.last_signal_repeat_count,
|
||||||
|
"reason": (
|
||||||
|
state.last_signal_reason
|
||||||
|
if reason is None
|
||||||
|
else reason
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_decision_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"decision_status": state.decision_status,
|
||||||
|
"decision_reason": state.decision_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_blocks_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"entry_block_reason": state.entry_block_reason,
|
||||||
|
"entry_block_message": state.entry_block_message,
|
||||||
|
"execution_block_reason": state.execution_block_reason,
|
||||||
|
"execution_block_title": state.execution_block_title,
|
||||||
|
"execution_block_message": state.execution_block_message,
|
||||||
|
"execution_block_action": state.execution_block_action,
|
||||||
|
"last_flip_block_reason": state.last_flip_block_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_execution_quality_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"execution_confidence_score": state.execution_confidence_score,
|
||||||
|
"execution_confidence_level": state.execution_confidence_level,
|
||||||
|
"execution_confidence_reason": state.execution_confidence_reason,
|
||||||
|
"execution_quality": state.execution_quality,
|
||||||
|
"execution_quality_reason": state.execution_quality_reason,
|
||||||
|
"execution_quality_message": state.execution_quality_message,
|
||||||
|
"spread_percent": state.spread_percent,
|
||||||
|
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_execution_price_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"execution_price_source": state.execution_price_source,
|
||||||
|
"execution_price_age_seconds": state.execution_price_age_seconds,
|
||||||
|
"execution_bid_price": state.execution_bid_price,
|
||||||
|
"execution_ask_price": state.execution_ask_price,
|
||||||
|
"execution_last_price": state.execution_last_price,
|
||||||
|
"execution_price_freshness": state.execution_price_freshness,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_adaptive_size_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"adaptive_size_base": state.adaptive_size_base,
|
||||||
|
"adaptive_size_final": state.adaptive_size_final,
|
||||||
|
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||||
|
"adaptive_size_reason": state.adaptive_size_reason,
|
||||||
|
"adaptive_size_factors": state.adaptive_size_factors,
|
||||||
|
"effective_risk_percent": state.effective_risk_percent,
|
||||||
|
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_risk_settings_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"risk_percent": state.risk_percent,
|
||||||
|
"stop_loss_percent": state.stop_loss_percent,
|
||||||
|
"take_profit_percent": state.take_profit_percent,
|
||||||
|
"max_loss_usd": state.max_loss_usd,
|
||||||
|
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
||||||
|
"allocated_balance_usd": state.allocated_balance_usd,
|
||||||
|
"leverage": state.leverage,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_position_health_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"position_hold_seconds": state.position_hold_seconds,
|
||||||
|
"position_health_status": state.position_health_status,
|
||||||
|
"position_health_score": state.position_health_score,
|
||||||
|
"position_health_reason": state.position_health_reason,
|
||||||
|
"position_risk_level": state.position_risk_level,
|
||||||
|
"position_risk_reason": state.position_risk_reason,
|
||||||
|
"position_trend_alignment": state.position_trend_alignment,
|
||||||
|
"position_adverse_momentum": state.position_adverse_momentum,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_position_intelligence_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"position_exit_signal": state.position_exit_signal,
|
||||||
|
"position_exit_confidence": state.position_exit_confidence,
|
||||||
|
"position_exit_urgency": state.position_exit_urgency,
|
||||||
|
"position_reversal_risk": state.position_reversal_risk,
|
||||||
|
"position_fatigue_state": state.position_fatigue_state,
|
||||||
|
"position_giveback_percent": state.position_giveback_percent,
|
||||||
|
"position_mfe_percent": state.position_mfe_percent,
|
||||||
|
"position_mae_percent": state.position_mae_percent,
|
||||||
|
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
||||||
|
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_position_intelligence_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"position_lifecycle_stage": state.position_lifecycle_stage,
|
||||||
|
"position_hold_quality": state.position_hold_quality,
|
||||||
|
"position_decay_state": state.position_decay_state,
|
||||||
|
"position_exit_signal": state.position_exit_signal,
|
||||||
|
"position_exit_confidence": state.position_exit_confidence,
|
||||||
|
"position_exit_urgency": state.position_exit_urgency,
|
||||||
|
"position_reversal_risk": state.position_reversal_risk,
|
||||||
|
"position_intelligence_reason": state.position_intelligence_reason,
|
||||||
|
"position_recommended_action": state.position_recommended_action,
|
||||||
|
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
||||||
|
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
||||||
|
"position_mfe_percent": state.position_mfe_percent,
|
||||||
|
"position_mae_percent": state.position_mae_percent,
|
||||||
|
"position_fatigue_score": state.position_fatigue_score,
|
||||||
|
"position_fatigue_state": state.position_fatigue_state,
|
||||||
|
"position_giveback_percent": state.position_giveback_percent,
|
||||||
|
"position_stall_state": state.position_stall_state,
|
||||||
|
"position_stall_reason": state.position_stall_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_autonomous_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"autonomous_action": state.autonomous_action,
|
||||||
|
"autonomous_action_reason": state.autonomous_action_reason,
|
||||||
|
"autonomous_action_confidence": state.autonomous_action_confidence,
|
||||||
|
"autonomous_protection_required": state.autonomous_protection_required,
|
||||||
|
"autonomous_reduce_required": state.autonomous_reduce_required,
|
||||||
|
"autonomous_exit_required": state.autonomous_exit_required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_protection_payload(state: AutoTradeState) -> JsonDict:
|
||||||
|
return {
|
||||||
|
"position_protection_status": state.position_protection_status,
|
||||||
|
"position_protection_reason": state.position_protection_reason,
|
||||||
|
"runtime_protection_action": state.runtime_protection_action,
|
||||||
|
"runtime_protection_reason": state.runtime_protection_reason,
|
||||||
|
"break_even_armed": state.break_even_armed,
|
||||||
|
"break_even_price": state.break_even_price,
|
||||||
|
"profit_lock_active": state.profit_lock_active,
|
||||||
|
"profit_lock_price": state.profit_lock_price,
|
||||||
|
"trailing_stop_active": state.trailing_stop_active,
|
||||||
|
"trailing_stop_price": state.trailing_stop_price,
|
||||||
|
}
|
||||||
@@ -9,7 +9,25 @@ from src.core.event_bus import EventBus
|
|||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import JsonDict, NumericLike
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.auto.state_reset import (
|
||||||
|
reset_autonomous_runtime_state,
|
||||||
|
reset_execution_block_state,
|
||||||
|
)
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.payloads import (
|
||||||
|
build_adaptive_size_payload,
|
||||||
|
build_decision_payload,
|
||||||
|
build_execution_price_payload,
|
||||||
|
build_execution_quality_payload,
|
||||||
|
build_market_context_payload,
|
||||||
|
build_position_health_payload,
|
||||||
|
build_position_intelligence_payload,
|
||||||
|
build_risk_settings_payload,
|
||||||
|
build_runtime_blocks_payload,
|
||||||
|
build_runtime_payload,
|
||||||
|
build_signal_payload,
|
||||||
|
build_autonomous_payload,
|
||||||
|
)
|
||||||
from src.trading.execution.pricing import ExecutionPrice
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
from src.trading.position.state import PositionState
|
from src.trading.position.state import PositionState
|
||||||
@@ -124,127 +142,19 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"action": action,
|
"action": action,
|
||||||
"reject_reason": reason,
|
"reject_reason": reason,
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
|
||||||
"status": state.status,
|
|
||||||
"strategy": state.strategy,
|
|
||||||
"cycle_number": state.cycle_number,
|
|
||||||
|
|
||||||
# ---------- Instrument ----------
|
# ---------- Instrument ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
"side": side,
|
"side": side,
|
||||||
|
|
||||||
# ---------- Signal ----------
|
**build_runtime_payload(state),
|
||||||
"signal": state.last_signal,
|
**build_signal_payload(state),
|
||||||
"confidence": state.last_signal_confidence,
|
**build_decision_payload(state),
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
**build_runtime_blocks_payload(state),
|
||||||
"reason": state.last_signal_reason,
|
**build_execution_quality_payload(state),
|
||||||
|
**build_execution_price_payload(state),
|
||||||
# ---------- Decision ----------
|
**build_adaptive_size_payload(state),
|
||||||
"decision_status": state.decision_status,
|
**build_risk_settings_payload(state),
|
||||||
"decision_reason": state.decision_reason,
|
**build_market_context_payload(state),
|
||||||
|
|
||||||
# ---------- Runtime blocks ----------
|
|
||||||
"entry_block_reason": state.entry_block_reason,
|
|
||||||
"entry_block_message": state.entry_block_message,
|
|
||||||
"execution_block_reason": state.execution_block_reason,
|
|
||||||
"execution_block_title": state.execution_block_title,
|
|
||||||
"execution_block_message": state.execution_block_message,
|
|
||||||
"execution_block_action": state.execution_block_action,
|
|
||||||
"last_flip_block_reason": state.last_flip_block_reason,
|
|
||||||
|
|
||||||
# ---------- Execution ----------
|
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
|
||||||
"execution_confidence_reason": state.execution_confidence_reason,
|
|
||||||
"execution_quality": state.execution_quality,
|
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
|
||||||
"execution_quality_message": state.execution_quality_message,
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
|
|
||||||
# ---------- Execution price ----------
|
|
||||||
"execution_price_source": state.execution_price_source,
|
|
||||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
|
||||||
"execution_bid_price": state.execution_bid_price,
|
|
||||||
"execution_ask_price": state.execution_ask_price,
|
|
||||||
"execution_last_price": state.execution_last_price,
|
|
||||||
"execution_price_freshness": state.execution_price_freshness,
|
|
||||||
|
|
||||||
# ---------- Adaptive size ----------
|
|
||||||
"adaptive_size_base": state.adaptive_size_base,
|
|
||||||
"adaptive_size_final": state.adaptive_size_final,
|
|
||||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
|
||||||
"adaptive_size_reason": state.adaptive_size_reason,
|
|
||||||
"adaptive_size_factors": state.adaptive_size_factors,
|
|
||||||
"effective_risk_percent": state.effective_risk_percent,
|
|
||||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
|
||||||
|
|
||||||
# ---------- Risk settings ----------
|
|
||||||
"risk_percent": state.risk_percent,
|
|
||||||
"stop_loss_percent": state.stop_loss_percent,
|
|
||||||
"take_profit_percent": state.take_profit_percent,
|
|
||||||
"max_loss_usd": state.max_loss_usd,
|
|
||||||
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
|
||||||
"allocated_balance_usd": state.allocated_balance_usd,
|
|
||||||
"leverage": state.leverage,
|
|
||||||
|
|
||||||
# ---------- Market score ----------
|
|
||||||
"market_score": state.market_score,
|
|
||||||
"market_score_label": state.market_score_label,
|
|
||||||
"market_long_score": state.market_long_score,
|
|
||||||
"market_short_score": state.market_short_score,
|
|
||||||
|
|
||||||
# ---------- Market ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_volatility": state.market_volatility,
|
|
||||||
"market_trend_strength": state.market_trend_strength,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_phase_direction": state.market_phase_direction,
|
|
||||||
|
|
||||||
# ---------- Candle ----------
|
|
||||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
|
||||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
|
||||||
"current_interval_change_percent": state.current_interval_change_percent,
|
|
||||||
"current_interval_direction": state.current_interval_direction,
|
|
||||||
"current_interval_label": state.current_interval_label,
|
|
||||||
|
|
||||||
# ---------- Structure ----------
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"market_structure_reason": state.market_structure_reason,
|
|
||||||
|
|
||||||
# ---------- Momentum ----------
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"momentum_change_percent": state.momentum_change_percent,
|
|
||||||
"breakout_level": state.breakout_level,
|
|
||||||
"breakout_distance_percent": state.breakout_distance_percent,
|
|
||||||
"breakout_reason": state.breakout_reason,
|
|
||||||
|
|
||||||
# ---------- HTF ----------
|
|
||||||
"htf_interval": state.htf_interval,
|
|
||||||
"htf_atr_percent": state.htf_atr_percent,
|
|
||||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
|
||||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
|
||||||
"htf_volatility": state.htf_volatility,
|
|
||||||
"htf_market_state": state.htf_market_state,
|
|
||||||
"htf_trend": state.htf_trend,
|
|
||||||
"htf_trend_strength": state.htf_trend_strength,
|
|
||||||
"htf_trend_quality": state.htf_trend_quality,
|
|
||||||
"htf_market_phase": state.htf_market_phase,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"htf_reason": state.htf_reason,
|
|
||||||
|
|
||||||
# ---------- Market runtime ----------
|
|
||||||
"market_runtime_degraded": state.market_runtime_degraded,
|
|
||||||
"runtime_expired_reason": state.runtime_expired_reason,
|
|
||||||
"runtime_expired_message": state.runtime_expired_message,
|
|
||||||
"market_is_open": state.market_is_open,
|
|
||||||
"market_status": state.market_status,
|
|
||||||
"market_status_message": state.market_status_message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# собрать payload успешного открытия позиции без изменения состояния
|
# собрать payload успешного открытия позиции без изменения состояния
|
||||||
@@ -270,67 +180,15 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"execution_type": EXECUTION_TYPE_ENTRY,
|
"execution_type": EXECUTION_TYPE_ENTRY,
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
|
||||||
"status": state.status,
|
|
||||||
"strategy": state.strategy,
|
|
||||||
"cycle_number": state.cycle_number,
|
|
||||||
|
|
||||||
# ---------- Position ----------
|
# ---------- Position ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
"side": side,
|
"side": side,
|
||||||
"entry_price": entry_price,
|
"entry_price": entry_price,
|
||||||
"size": size,
|
"size": size,
|
||||||
"leverage": state.leverage,
|
|
||||||
|
|
||||||
"opened_at": now,
|
"opened_at": now,
|
||||||
"opened_monotonic_at": opened_monotonic_at,
|
"opened_monotonic_at": opened_monotonic_at,
|
||||||
|
|
||||||
# ---------- Runtime position state ----------
|
|
||||||
"position_pressure": state.position_pressure,
|
|
||||||
"position_health_status": state.position_health_status,
|
|
||||||
"position_health_score": state.position_health_score,
|
|
||||||
"position_risk_level": state.position_risk_level,
|
|
||||||
"position_risk_reason": state.position_risk_reason,
|
|
||||||
|
|
||||||
# ---------- Signal ----------
|
|
||||||
"signal": state.last_signal,
|
|
||||||
"confidence": state.last_signal_confidence,
|
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
|
||||||
"reason": state.last_signal_reason,
|
|
||||||
|
|
||||||
# ---------- Decision ----------
|
|
||||||
"decision_status": state.decision_status,
|
|
||||||
"decision_reason": state.decision_reason,
|
|
||||||
|
|
||||||
# ---------- Runtime blocks ----------
|
|
||||||
"entry_block_reason": state.entry_block_reason,
|
|
||||||
"entry_block_message": state.entry_block_message,
|
|
||||||
"execution_block_reason": state.execution_block_reason,
|
|
||||||
"execution_block_title": state.execution_block_title,
|
|
||||||
"execution_block_message": state.execution_block_message,
|
|
||||||
"execution_block_action": state.execution_block_action,
|
|
||||||
"last_flip_block_reason": state.last_flip_block_reason,
|
|
||||||
|
|
||||||
# ---------- Execution ----------
|
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
|
||||||
"execution_confidence_reason": state.execution_confidence_reason,
|
|
||||||
|
|
||||||
"execution_quality": state.execution_quality,
|
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
|
||||||
"execution_quality_message": state.execution_quality_message,
|
|
||||||
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
|
|
||||||
# ---------- Execution price ----------
|
|
||||||
"execution_price_source": state.execution_price_source,
|
|
||||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
|
||||||
"execution_bid_price": state.execution_bid_price,
|
|
||||||
"execution_ask_price": state.execution_ask_price,
|
|
||||||
"execution_last_price": state.execution_last_price,
|
|
||||||
"execution_price_freshness": state.execution_price_freshness,
|
|
||||||
|
|
||||||
# ---------- Pricing ----------
|
# ---------- Pricing ----------
|
||||||
"pricing": PRICING_ENTRY_MODE,
|
"pricing": PRICING_ENTRY_MODE,
|
||||||
"pricing_role": entry.pricing_role,
|
"pricing_role": entry.pricing_role,
|
||||||
@@ -338,81 +196,16 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"price_age_seconds": entry.age_seconds,
|
"price_age_seconds": entry.age_seconds,
|
||||||
"price_updated_at": entry.updated_at,
|
"price_updated_at": entry.updated_at,
|
||||||
|
|
||||||
# ---------- Adaptive size ----------
|
**build_runtime_payload(state),
|
||||||
"adaptive_size_base": state.adaptive_size_base,
|
**build_position_health_payload(state),
|
||||||
"adaptive_size_final": state.adaptive_size_final,
|
**build_signal_payload(state),
|
||||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
**build_decision_payload(state),
|
||||||
"adaptive_size_reason": state.adaptive_size_reason,
|
**build_runtime_blocks_payload(state),
|
||||||
"adaptive_size_factors": state.adaptive_size_factors,
|
**build_execution_quality_payload(state),
|
||||||
|
**build_execution_price_payload(state),
|
||||||
"effective_risk_percent": state.effective_risk_percent,
|
**build_adaptive_size_payload(state),
|
||||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
**build_risk_settings_payload(state),
|
||||||
|
**build_market_context_payload(state),
|
||||||
# ---------- Risk settings ----------
|
|
||||||
"risk_percent": state.risk_percent,
|
|
||||||
"stop_loss_percent": state.stop_loss_percent,
|
|
||||||
"take_profit_percent": state.take_profit_percent,
|
|
||||||
"max_loss_usd": state.max_loss_usd,
|
|
||||||
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
|
||||||
"allocated_balance_usd": state.allocated_balance_usd,
|
|
||||||
|
|
||||||
# ---------- Market score ----------
|
|
||||||
"market_score": state.market_score,
|
|
||||||
"market_score_label": state.market_score_label,
|
|
||||||
"market_long_score": state.market_long_score,
|
|
||||||
"market_short_score": state.market_short_score,
|
|
||||||
|
|
||||||
# ---------- Market ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_volatility": state.market_volatility,
|
|
||||||
"market_trend_strength": state.market_trend_strength,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_phase_direction": state.market_phase_direction,
|
|
||||||
|
|
||||||
# ---------- Candle ----------
|
|
||||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
|
||||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
|
||||||
"current_interval_change_percent": state.current_interval_change_percent,
|
|
||||||
"current_interval_direction": state.current_interval_direction,
|
|
||||||
"current_interval_label": state.current_interval_label,
|
|
||||||
|
|
||||||
# ---------- Structure ----------
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"market_structure_reason": state.market_structure_reason,
|
|
||||||
|
|
||||||
# ---------- Momentum ----------
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"momentum_change_percent": state.momentum_change_percent,
|
|
||||||
"breakout_level": state.breakout_level,
|
|
||||||
"breakout_distance_percent": state.breakout_distance_percent,
|
|
||||||
"breakout_reason": state.breakout_reason,
|
|
||||||
|
|
||||||
# ---------- HTF ----------
|
|
||||||
"htf_interval": state.htf_interval,
|
|
||||||
"htf_atr_percent": state.htf_atr_percent,
|
|
||||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
|
||||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
|
||||||
"htf_volatility": state.htf_volatility,
|
|
||||||
"htf_market_state": state.htf_market_state,
|
|
||||||
"htf_trend": state.htf_trend,
|
|
||||||
"htf_trend_strength": state.htf_trend_strength,
|
|
||||||
"htf_trend_quality": state.htf_trend_quality,
|
|
||||||
"htf_market_phase": state.htf_market_phase,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"htf_reason": state.htf_reason,
|
|
||||||
|
|
||||||
# ---------- Market runtime ----------
|
|
||||||
"market_runtime_degraded": state.market_runtime_degraded,
|
|
||||||
"runtime_expired_reason": state.runtime_expired_reason,
|
|
||||||
"runtime_expired_message": state.runtime_expired_message,
|
|
||||||
"market_is_open": state.market_is_open,
|
|
||||||
"market_status": state.market_status,
|
|
||||||
"market_status_message": state.market_status_message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# собрать payload закрытия позиции без изменения состояния
|
# собрать payload закрытия позиции без изменения состояния
|
||||||
@@ -446,10 +239,14 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"close_reason": close_reason,
|
"close_reason": close_reason,
|
||||||
"is_forced": forced_reason is not None,
|
"is_forced": forced_reason is not None,
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
**build_runtime_payload(state),
|
||||||
"status": state.status,
|
**build_signal_payload(state),
|
||||||
"strategy": state.strategy,
|
**build_decision_payload(state),
|
||||||
"cycle_number": state.cycle_number,
|
**build_runtime_blocks_payload(state),
|
||||||
|
**build_execution_quality_payload(state),
|
||||||
|
**build_execution_price_payload(state),
|
||||||
|
**build_adaptive_size_payload(state),
|
||||||
|
**build_risk_settings_payload(state),
|
||||||
|
|
||||||
# ---------- Instrument / Position ----------
|
# ---------- Instrument / Position ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
@@ -477,45 +274,6 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"hold_seconds": metrics.hold_seconds,
|
"hold_seconds": metrics.hold_seconds,
|
||||||
"overnight_count": metrics.overnight_count,
|
"overnight_count": metrics.overnight_count,
|
||||||
|
|
||||||
# ---------- Signal ----------
|
|
||||||
"signal": state.last_signal,
|
|
||||||
"confidence": state.last_signal_confidence,
|
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
|
||||||
"reason": state.last_signal_reason,
|
|
||||||
|
|
||||||
# ---------- Decision ----------
|
|
||||||
"decision_status": state.decision_status,
|
|
||||||
"decision_reason": state.decision_reason,
|
|
||||||
|
|
||||||
# ---------- Runtime blocks ----------
|
|
||||||
"entry_block_reason": state.entry_block_reason,
|
|
||||||
"entry_block_message": state.entry_block_message,
|
|
||||||
"execution_block_reason": state.execution_block_reason,
|
|
||||||
"execution_block_title": state.execution_block_title,
|
|
||||||
"execution_block_message": state.execution_block_message,
|
|
||||||
"execution_block_action": state.execution_block_action,
|
|
||||||
"last_flip_block_reason": state.last_flip_block_reason,
|
|
||||||
|
|
||||||
# ---------- Execution ----------
|
|
||||||
"execution_quality": state.execution_quality,
|
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
|
||||||
"execution_quality_message": state.execution_quality_message,
|
|
||||||
|
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
|
||||||
"execution_confidence_reason": state.execution_confidence_reason,
|
|
||||||
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
|
|
||||||
# ---------- Execution price ----------
|
|
||||||
"execution_price_source": state.execution_price_source,
|
|
||||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
|
||||||
"execution_bid_price": state.execution_bid_price,
|
|
||||||
"execution_ask_price": state.execution_ask_price,
|
|
||||||
"execution_last_price": state.execution_last_price,
|
|
||||||
"execution_price_freshness": state.execution_price_freshness,
|
|
||||||
|
|
||||||
# ---------- Pricing ----------
|
# ---------- Pricing ----------
|
||||||
"pricing": PRICING_EXIT_MODE,
|
"pricing": PRICING_EXIT_MODE,
|
||||||
"pricing_role": exit_execution.pricing_role if exit_execution else None,
|
"pricing_role": exit_execution.pricing_role if exit_execution else None,
|
||||||
@@ -523,23 +281,6 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"price_age_seconds": exit_execution.age_seconds if exit_execution else None,
|
"price_age_seconds": exit_execution.age_seconds if exit_execution else None,
|
||||||
"price_updated_at": exit_execution.updated_at if exit_execution else None,
|
"price_updated_at": exit_execution.updated_at if exit_execution else None,
|
||||||
|
|
||||||
# ---------- Adaptive size ----------
|
|
||||||
"adaptive_size_base": state.adaptive_size_base,
|
|
||||||
"adaptive_size_final": state.adaptive_size_final,
|
|
||||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
|
||||||
"adaptive_size_reason": state.adaptive_size_reason,
|
|
||||||
"adaptive_size_factors": state.adaptive_size_factors,
|
|
||||||
"effective_risk_percent": state.effective_risk_percent,
|
|
||||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
|
||||||
|
|
||||||
# ---------- Risk Settings ----------
|
|
||||||
"risk_percent": state.risk_percent,
|
|
||||||
"stop_loss_percent": state.stop_loss_percent,
|
|
||||||
"take_profit_percent": state.take_profit_percent,
|
|
||||||
"max_loss_usd": state.max_loss_usd,
|
|
||||||
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
|
||||||
"allocated_balance_usd": state.allocated_balance_usd,
|
|
||||||
|
|
||||||
# ---------- Cycle Stats Before Close Sync ----------
|
# ---------- Cycle Stats Before Close Sync ----------
|
||||||
"realized_pnl_usd_before": state.realized_pnl_usd,
|
"realized_pnl_usd_before": state.realized_pnl_usd,
|
||||||
"cycle_realized_pnl_usd_before": state.cycle_realized_pnl_usd,
|
"cycle_realized_pnl_usd_before": state.cycle_realized_pnl_usd,
|
||||||
@@ -550,93 +291,10 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
"cycle_trade_fees_usd_before": state.cycle_trade_fees_usd,
|
"cycle_trade_fees_usd_before": state.cycle_trade_fees_usd,
|
||||||
"cycle_overnight_fees_usd_before": state.cycle_overnight_fees_usd,
|
"cycle_overnight_fees_usd_before": state.cycle_overnight_fees_usd,
|
||||||
|
|
||||||
# ---------- Position Health ----------
|
**build_position_health_payload(state),
|
||||||
"position_hold_seconds": state.position_hold_seconds,
|
**build_position_intelligence_payload(state),
|
||||||
"position_health_status": state.position_health_status,
|
**build_autonomous_payload(state),
|
||||||
"position_health_score": state.position_health_score,
|
**build_market_context_payload(state),
|
||||||
"position_health_reason": state.position_health_reason,
|
|
||||||
"position_risk_level": state.position_risk_level,
|
|
||||||
"position_risk_reason": state.position_risk_reason,
|
|
||||||
"position_trend_alignment": state.position_trend_alignment,
|
|
||||||
"position_adverse_momentum": state.position_adverse_momentum,
|
|
||||||
|
|
||||||
# ---------- Position Intelligence ----------
|
|
||||||
"position_exit_signal": state.position_exit_signal,
|
|
||||||
"position_exit_confidence": state.position_exit_confidence,
|
|
||||||
"position_exit_urgency": state.position_exit_urgency,
|
|
||||||
"position_reversal_risk": state.position_reversal_risk,
|
|
||||||
"position_fatigue_state": state.position_fatigue_state,
|
|
||||||
"position_giveback_percent": state.position_giveback_percent,
|
|
||||||
"position_mfe_percent": state.position_mfe_percent,
|
|
||||||
"position_mae_percent": state.position_mae_percent,
|
|
||||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
|
||||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
|
||||||
|
|
||||||
# ---------- Autonomous ----------
|
|
||||||
"autonomous_action": state.autonomous_action,
|
|
||||||
"autonomous_action_reason": state.autonomous_action_reason,
|
|
||||||
"autonomous_action_confidence": state.autonomous_action_confidence,
|
|
||||||
"autonomous_protection_required": state.autonomous_protection_required,
|
|
||||||
"autonomous_reduce_required": state.autonomous_reduce_required,
|
|
||||||
"autonomous_exit_required": state.autonomous_exit_required,
|
|
||||||
|
|
||||||
# ---------- Market Score ----------
|
|
||||||
"market_score": state.market_score,
|
|
||||||
"market_score_label": state.market_score_label,
|
|
||||||
"market_long_score": state.market_long_score,
|
|
||||||
"market_short_score": state.market_short_score,
|
|
||||||
|
|
||||||
# ---------- Market ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_volatility": state.market_volatility,
|
|
||||||
"market_trend_strength": state.market_trend_strength,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_phase_direction": state.market_phase_direction,
|
|
||||||
|
|
||||||
# ---------- Candle ----------
|
|
||||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
|
||||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
|
||||||
"current_interval_change_percent": state.current_interval_change_percent,
|
|
||||||
"current_interval_direction": state.current_interval_direction,
|
|
||||||
"current_interval_label": state.current_interval_label,
|
|
||||||
|
|
||||||
# ---------- Structure ----------
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"market_structure_reason": state.market_structure_reason,
|
|
||||||
|
|
||||||
# ---------- Momentum ----------
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"momentum_change_percent": state.momentum_change_percent,
|
|
||||||
"breakout_level": state.breakout_level,
|
|
||||||
"breakout_distance_percent": state.breakout_distance_percent,
|
|
||||||
"breakout_reason": state.breakout_reason,
|
|
||||||
|
|
||||||
# ---------- HTF ----------
|
|
||||||
"htf_interval": state.htf_interval,
|
|
||||||
"htf_atr_percent": state.htf_atr_percent,
|
|
||||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
|
||||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
|
||||||
"htf_volatility": state.htf_volatility,
|
|
||||||
"htf_market_state": state.htf_market_state,
|
|
||||||
"htf_trend": state.htf_trend,
|
|
||||||
"htf_trend_strength": state.htf_trend_strength,
|
|
||||||
"htf_trend_quality": state.htf_trend_quality,
|
|
||||||
"htf_market_phase": state.htf_market_phase,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"htf_reason": state.htf_reason,
|
|
||||||
|
|
||||||
# ---------- Market runtime ----------
|
|
||||||
"market_runtime_degraded": state.market_runtime_degraded,
|
|
||||||
"runtime_expired_reason": state.runtime_expired_reason,
|
|
||||||
"runtime_expired_message": state.runtime_expired_message,
|
|
||||||
"market_is_open": state.market_is_open,
|
|
||||||
"market_status": state.market_status,
|
|
||||||
"market_status_message": state.market_status_message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- Journal helpers ----------
|
# ---------- Journal helpers ----------
|
||||||
@@ -811,7 +469,7 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
# чтобы UI/protection/semantics не ждали следующего цикла.
|
# чтобы UI/protection/semantics не ждали следующего цикла.
|
||||||
state.position_opened_monotonic_at = opened_monotonic_at
|
state.position_opened_monotonic_at = opened_monotonic_at
|
||||||
|
|
||||||
state.execution_block_reason = None
|
reset_execution_block_state(state)
|
||||||
state.last_flip_block_reason = None
|
state.last_flip_block_reason = None
|
||||||
state.last_execution_action = action
|
state.last_execution_action = action
|
||||||
state.last_execution_reason = f"Позиция {side} открыта."
|
state.last_execution_reason = f"Позиция {side} открыта."
|
||||||
@@ -972,15 +630,13 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
|||||||
|
|
||||||
# После закрытия очищаем autonomous cooldown/action,
|
# После закрытия очищаем autonomous cooldown/action,
|
||||||
# чтобы новая сделка не унаследовала runtime-действие прошлой позиции.
|
# чтобы новая сделка не унаследовала runtime-действие прошлой позиции.
|
||||||
state.autonomous_last_action = None
|
reset_autonomous_runtime_state(state)
|
||||||
state.autonomous_last_action_reason = None
|
|
||||||
state.autonomous_last_action_at = None
|
|
||||||
|
|
||||||
# После закрытия очищаем protection и lifecycle runtime закрытой позиции.
|
# После закрытия очищаем protection и lifecycle runtime закрытой позиции.
|
||||||
self._reset_runtime_protection_state(state)
|
self._reset_runtime_protection_state(state)
|
||||||
self._reset_position_lifecycle_state(state)
|
self._reset_position_lifecycle_state(state)
|
||||||
|
|
||||||
state.execution_block_reason = None
|
reset_execution_block_state(state)
|
||||||
state.last_flip_block_reason = None
|
state.last_flip_block_reason = None
|
||||||
|
|
||||||
state.last_execution_action = (
|
state.last_execution_action = (
|
||||||
|
|||||||
@@ -43,30 +43,28 @@ class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol)
|
|||||||
if self._is_normal_pullback_wave(state=state, metrics=metrics):
|
if self._is_normal_pullback_wave(state=state, metrics=metrics):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
giveback_reason = self._giveback_close_reason(
|
giveback_reason = self._apply_intelligence_exit(
|
||||||
|
state=state,
|
||||||
|
reason=self._giveback_close_reason(
|
||||||
state=state,
|
state=state,
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
|
),
|
||||||
|
algorithm="GIVEBACK",
|
||||||
)
|
)
|
||||||
|
|
||||||
if giveback_reason is not None:
|
if giveback_reason is not None:
|
||||||
self._sync_intelligence_exit_state(
|
|
||||||
state=state,
|
|
||||||
reason=giveback_reason,
|
|
||||||
algorithm="GIVEBACK",
|
|
||||||
)
|
|
||||||
return giveback_reason
|
return giveback_reason
|
||||||
|
|
||||||
time_decay_reason = self._time_decay_close_reason(
|
time_decay_reason = self._apply_intelligence_exit(
|
||||||
|
state=state,
|
||||||
|
reason=self._time_decay_close_reason(
|
||||||
state=state,
|
state=state,
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
|
),
|
||||||
|
algorithm="TIME_DECAY",
|
||||||
)
|
)
|
||||||
|
|
||||||
if time_decay_reason is not None:
|
if time_decay_reason is not None:
|
||||||
self._sync_intelligence_exit_state(
|
|
||||||
state=state,
|
|
||||||
reason=time_decay_reason,
|
|
||||||
algorithm="TIME_DECAY",
|
|
||||||
)
|
|
||||||
return time_decay_reason
|
return time_decay_reason
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -87,6 +85,24 @@ class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol)
|
|||||||
state.runtime_protection_reason = reason
|
state.runtime_protection_reason = reason
|
||||||
state.runtime_protection_updated_at = time.monotonic()
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
def _apply_intelligence_exit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
reason: str | None,
|
||||||
|
algorithm: str,
|
||||||
|
) -> str | None:
|
||||||
|
if reason is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._sync_intelligence_exit_state(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
algorithm=algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
return reason
|
||||||
|
|
||||||
def _giveback_close_reason(
|
def _giveback_close_reason(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ from src.core.numbers import safe_float
|
|||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import JsonDict, NumericLike
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.payloads import (
|
||||||
|
build_execution_price_payload,
|
||||||
|
build_execution_quality_payload,
|
||||||
|
build_full_position_intelligence_payload,
|
||||||
|
build_market_context_payload,
|
||||||
|
build_position_health_payload,
|
||||||
|
build_runtime_payload,
|
||||||
|
build_runtime_protection_payload,
|
||||||
|
)
|
||||||
from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics
|
from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics
|
||||||
from src.trading.execution.pricing import ExecutionPrice
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
@@ -187,6 +196,113 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
|||||||
state.position_protection_reason = reason
|
state.position_protection_reason = reason
|
||||||
state.runtime_protection_updated_at = time.monotonic()
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
# принудительно усилить защиту позиции по запросу autonomous PROTECT
|
||||||
|
def _force_runtime_protect(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
) -> bool:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_execution = self._exit_price_for_side(
|
||||||
|
position.symbol or state.symbol,
|
||||||
|
position.side,
|
||||||
|
)
|
||||||
|
current_price = safe_float(current_execution.price)
|
||||||
|
|
||||||
|
if current_price is None or current_price <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
metrics = build_position_metrics(
|
||||||
|
position,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_price = safe_float(position.entry_price)
|
||||||
|
price_move_percent = safe_float(metrics.price_move_percent)
|
||||||
|
|
||||||
|
if entry_price is None or entry_price <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if price_move_percent is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# PROTECT не должен ставить защиту, если позиция уже в минусе.
|
||||||
|
# В минусовой позиции protection-цена может немедленно закрыть сделку
|
||||||
|
# или создать ложное ощущение защиты.
|
||||||
|
if price_move_percent <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# 1. Если позиция уже в прибыли, но break-even ещё не включён —
|
||||||
|
# включаем его сразу, не дожидаясь обычного порога.
|
||||||
|
if not state.break_even_armed:
|
||||||
|
buffer_percent = 0.03
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
state.break_even_price = entry_price * (1 + buffer_percent / 100)
|
||||||
|
elif position.side == "SHORT":
|
||||||
|
state.break_even_price = entry_price * (1 - buffer_percent / 100)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state.break_even_armed = True
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 2. Если прибыль уже покрывает хотя бы небольшой запас,
|
||||||
|
# подтягиваем profit-lock ближе, чем обычные thresholds.
|
||||||
|
# Это помогает не отдавать маленькую прибыль обратно комиссии/шуму.
|
||||||
|
if price_move_percent >= 0.25:
|
||||||
|
lock_distance_percent = 0.18
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
min_lock_price = entry_price * 1.0002
|
||||||
|
dynamic_lock_price = current_price * (1 - lock_distance_percent / 100)
|
||||||
|
lock_price = max(min_lock_price, dynamic_lock_price)
|
||||||
|
|
||||||
|
previous_price = safe_float(state.profit_lock_price)
|
||||||
|
if previous_price is None or lock_price > previous_price:
|
||||||
|
state.profit_lock_active = True
|
||||||
|
state.profit_lock_price = round(lock_price, 8)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
elif position.side == "SHORT":
|
||||||
|
min_lock_price = entry_price * 0.9998
|
||||||
|
dynamic_lock_price = current_price * (1 + lock_distance_percent / 100)
|
||||||
|
lock_price = min(min_lock_price, dynamic_lock_price)
|
||||||
|
|
||||||
|
previous_price = safe_float(state.profit_lock_price)
|
||||||
|
if previous_price is None or lock_price < previous_price:
|
||||||
|
state.profit_lock_active = True
|
||||||
|
state.profit_lock_price = round(lock_price, 8)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state.runtime_protection_action = "FORCED_PROTECT"
|
||||||
|
state.runtime_protection_reason = reason
|
||||||
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
self._log_runtime_protection_event(
|
||||||
|
state=state,
|
||||||
|
action="FORCED_PROTECT",
|
||||||
|
reason=reason,
|
||||||
|
current_price=current_price,
|
||||||
|
metrics=metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _update_break_even_protection(
|
def _update_break_even_protection(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -411,9 +527,7 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
# ---------- Runtime ----------
|
||||||
"status": state.status,
|
**build_runtime_payload(state),
|
||||||
"strategy": state.strategy,
|
|
||||||
"cycle_number": state.cycle_number,
|
|
||||||
|
|
||||||
# ---------- Position ----------
|
# ---------- Position ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
@@ -436,114 +550,25 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
|||||||
"hold_seconds": metrics.hold_seconds,
|
"hold_seconds": metrics.hold_seconds,
|
||||||
|
|
||||||
# ---------- Runtime protection ----------
|
# ---------- Runtime protection ----------
|
||||||
"position_protection_status": state.position_protection_status,
|
**build_runtime_protection_payload(state),
|
||||||
"position_protection_reason": state.position_protection_reason,
|
|
||||||
"runtime_protection_action": state.runtime_protection_action,
|
|
||||||
"runtime_protection_reason": state.runtime_protection_reason,
|
|
||||||
"runtime_protection_updated_at": state.runtime_protection_updated_at,
|
"runtime_protection_updated_at": state.runtime_protection_updated_at,
|
||||||
|
|
||||||
"break_even_armed": state.break_even_armed,
|
|
||||||
"break_even_price": state.break_even_price,
|
|
||||||
|
|
||||||
"profit_lock_active": state.profit_lock_active,
|
|
||||||
"profit_lock_price": state.profit_lock_price,
|
|
||||||
|
|
||||||
"trailing_stop_active": state.trailing_stop_active,
|
|
||||||
"trailing_stop_price": state.trailing_stop_price,
|
|
||||||
|
|
||||||
# ---------- Protection thresholds ----------
|
# ---------- Protection thresholds ----------
|
||||||
"protection_thresholds": self._protection_thresholds(state),
|
"protection_thresholds": self._protection_thresholds(state),
|
||||||
|
|
||||||
# ---------- Position Intelligence ----------
|
# ---------- Position Intelligence ----------
|
||||||
"position_health_status": state.position_health_status,
|
**build_position_health_payload(state),
|
||||||
"position_health_score": state.position_health_score,
|
"position_pressure": state.position_pressure,
|
||||||
"position_health_reason": state.position_health_reason,
|
"position_exit_pressure": state.position_exit_pressure,
|
||||||
|
|
||||||
"position_exit_signal": state.position_exit_signal,
|
**build_full_position_intelligence_payload(state),
|
||||||
"position_exit_confidence": state.position_exit_confidence,
|
|
||||||
"position_exit_urgency": state.position_exit_urgency,
|
|
||||||
|
|
||||||
"position_risk_level": state.position_risk_level,
|
|
||||||
"position_risk_reason": state.position_risk_reason,
|
|
||||||
|
|
||||||
"position_trend_alignment": state.position_trend_alignment,
|
|
||||||
"position_adverse_momentum": state.position_adverse_momentum,
|
|
||||||
|
|
||||||
"position_reversal_risk": state.position_reversal_risk,
|
|
||||||
"position_fatigue_state": state.position_fatigue_state,
|
|
||||||
|
|
||||||
"position_giveback_percent": state.position_giveback_percent,
|
|
||||||
"position_mfe_percent": state.position_mfe_percent,
|
|
||||||
"position_mae_percent": state.position_mae_percent,
|
|
||||||
|
|
||||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
|
||||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
|
||||||
|
|
||||||
# ---------- Execution ----------
|
# ---------- Execution ----------
|
||||||
"execution_quality": state.execution_quality,
|
**build_execution_quality_payload(state),
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
**build_execution_price_payload(state),
|
||||||
|
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
# ---------- Market Context ----------
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
**build_market_context_payload(state),
|
||||||
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
|
|
||||||
# ---------- Execution price ----------
|
|
||||||
"execution_price_source": state.execution_price_source,
|
|
||||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
|
||||||
"execution_bid_price": state.execution_bid_price,
|
|
||||||
"execution_ask_price": state.execution_ask_price,
|
|
||||||
"execution_last_price": state.execution_last_price,
|
|
||||||
|
|
||||||
# ---------- Market Score ----------
|
|
||||||
"market_score": state.market_score,
|
|
||||||
"market_score_label": state.market_score_label,
|
|
||||||
"market_long_score": state.market_long_score,
|
|
||||||
"market_short_score": state.market_short_score,
|
|
||||||
|
|
||||||
# ---------- Market ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_trend_strength": state.market_trend_strength,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_phase_direction": state.market_phase_direction,
|
|
||||||
|
|
||||||
# ---------- Candle ----------
|
|
||||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
|
||||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
|
||||||
"current_interval_change_percent": state.current_interval_change_percent,
|
|
||||||
"current_interval_direction": state.current_interval_direction,
|
|
||||||
"current_interval_label": state.current_interval_label,
|
|
||||||
|
|
||||||
# ---------- Structure ----------
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"market_structure_reason": state.market_structure_reason,
|
|
||||||
|
|
||||||
# ---------- Momentum ----------
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"momentum_change_percent": state.momentum_change_percent,
|
|
||||||
"breakout_level": state.breakout_level,
|
|
||||||
"breakout_distance_percent": state.breakout_distance_percent,
|
|
||||||
"breakout_reason": state.breakout_reason,
|
|
||||||
|
|
||||||
# ---------- HTF ----------
|
|
||||||
"htf_interval": state.htf_interval,
|
|
||||||
"htf_atr_percent": state.htf_atr_percent,
|
|
||||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
|
||||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
|
||||||
"htf_volatility": state.htf_volatility,
|
|
||||||
"htf_market_state": state.htf_market_state,
|
|
||||||
"htf_trend": state.htf_trend,
|
|
||||||
"htf_trend_strength": state.htf_trend_strength,
|
|
||||||
"htf_trend_quality": state.htf_trend_quality,
|
|
||||||
"htf_market_phase": state.htf_market_phase,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"htf_reason": state.htf_reason,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _log_runtime_protection_event(
|
def _log_runtime_protection_event(
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
|||||||
state.position_conviction_state = None
|
state.position_conviction_state = None
|
||||||
state.position_exit_urgency = None
|
state.position_exit_urgency = None
|
||||||
state.position_reversal_risk = None
|
state.position_reversal_risk = None
|
||||||
|
state.position_lifecycle_stage = None
|
||||||
|
state.position_hold_quality = None
|
||||||
|
state.position_decay_state = None
|
||||||
|
state.position_exit_signal = None
|
||||||
|
state.position_exit_confidence = None
|
||||||
|
state.position_intelligence_reason = None
|
||||||
|
state.position_recommended_action = None
|
||||||
|
state.position_stall_state = None
|
||||||
|
state.position_stall_reason = None
|
||||||
state.position_pnl_percent = None
|
state.position_pnl_percent = None
|
||||||
state.position_hold_seconds = None
|
state.position_hold_seconds = None
|
||||||
state.position_pressure = None
|
state.position_pressure = None
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
|||||||
state.position_exit_signal = None
|
state.position_exit_signal = None
|
||||||
state.position_intelligence_reason = None
|
state.position_intelligence_reason = None
|
||||||
state.position_recommended_action = None
|
state.position_recommended_action = None
|
||||||
|
state.position_stall_state = None
|
||||||
|
state.position_stall_reason = None
|
||||||
|
|
||||||
state.position_peak_pnl_usd = None
|
state.position_peak_pnl_usd = None
|
||||||
state.position_peak_pnl_percent = None
|
state.position_peak_pnl_percent = None
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ from src.core.numbers import safe_float
|
|||||||
from src.core.types import JsonDict
|
from src.core.types import JsonDict
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.payloads import (
|
||||||
|
build_autonomous_payload,
|
||||||
|
build_execution_quality_payload,
|
||||||
|
build_full_position_intelligence_payload,
|
||||||
|
build_market_context_payload,
|
||||||
|
build_position_health_payload,
|
||||||
|
build_runtime_payload,
|
||||||
|
build_runtime_protection_payload,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
from src.trading.position.state import PositionState
|
from src.trading.position.state import PositionState
|
||||||
from src.trading.execution.constants import (
|
from src.trading.execution.constants import (
|
||||||
@@ -39,26 +48,39 @@ class _ExecutionRuntimeActionsProtocol(Protocol):
|
|||||||
def _sync_state_from_position(
|
def _sync_state_from_position(
|
||||||
self,
|
self,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
) -> None: ...
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
def _close_position(
|
def _close_position(
|
||||||
self,
|
self,
|
||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
*,
|
*,
|
||||||
forced_reason: str | None = None,
|
forced_reason: str | None = None,
|
||||||
) -> ExecutionDecision: ...
|
) -> ExecutionDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
def _force_runtime_protect(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class ExecutionRuntimeActionsMixin(
|
class ExecutionRuntimeActionsMixin(_ExecutionRuntimeActionsProtocol):
|
||||||
_ExecutionRuntimeActionsProtocol
|
|
||||||
):
|
|
||||||
# ----- Runtime autonomous actions subsystem.
|
# ----- Runtime autonomous actions subsystem.
|
||||||
# Отвечает за:
|
# Отвечает за:
|
||||||
# - runtime EXIT
|
# - autonomous EXIT;
|
||||||
# - runtime REDUCE
|
# - autonomous PROTECT;
|
||||||
# - runtime PROTECT
|
# - autonomous REDUCE;
|
||||||
# - cooldown runtime действий
|
# - cooldown runtime действий;
|
||||||
# - runtime logging
|
# - runtime logging.
|
||||||
|
#
|
||||||
|
# Важно:
|
||||||
|
# На текущем этапе PROTECT и REDUCE пока НЕ исполняют реальное действие.
|
||||||
|
# Они логируются как диагностические runtime-сигналы.
|
||||||
|
# Реальное закрытие позиции сейчас делает только AUTONOMOUS_ACTION_EXIT.
|
||||||
|
|
||||||
_runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS
|
_runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS
|
||||||
_last_runtime_action_key: str | None = None
|
_last_runtime_action_key: str | None = None
|
||||||
@@ -69,6 +91,10 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
state: AutoTradeState,
|
state: AutoTradeState,
|
||||||
) -> ExecutionDecision:
|
) -> ExecutionDecision:
|
||||||
# Главный runtime action processor.
|
# Главный runtime action processor.
|
||||||
|
#
|
||||||
|
# Этот метод вызывается после основного engine.process().
|
||||||
|
# Если позиция открыта и autonomous_management выставил EXIT,
|
||||||
|
# здесь позиция может быть реально закрыта.
|
||||||
|
|
||||||
self._sync_state_from_position(state)
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
@@ -111,15 +137,32 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
|
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
|
||||||
|
|
||||||
if action == AUTONOMOUS_ACTION_PROTECT:
|
if action == AUTONOMOUS_ACTION_PROTECT:
|
||||||
|
protect_reason = reason or "позиция требует защиты"
|
||||||
|
|
||||||
|
# Теперь PROTECT — это не только лог.
|
||||||
|
# Если позиция уже в плюсе, protection layer принудительно включает
|
||||||
|
# break-even и при достаточной прибыли подтягивает profit-lock.
|
||||||
|
protected = self._force_runtime_protect(
|
||||||
|
state,
|
||||||
|
reason=protect_reason,
|
||||||
|
)
|
||||||
|
|
||||||
return self._log_runtime_action(
|
return self._log_runtime_action(
|
||||||
state=state,
|
state=state,
|
||||||
action=AUTONOMOUS_ACTION_PROTECT,
|
action=AUTONOMOUS_ACTION_PROTECT,
|
||||||
reason=reason or "позиция требует защиты",
|
reason=(
|
||||||
|
protect_reason
|
||||||
|
if protected
|
||||||
|
else f"{protect_reason}; protection не применён"
|
||||||
|
),
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
executed=False,
|
executed=protected,
|
||||||
)
|
)
|
||||||
|
|
||||||
if action == AUTONOMOUS_ACTION_REDUCE:
|
if action == AUTONOMOUS_ACTION_REDUCE:
|
||||||
|
# Пока REDUCE только логируется.
|
||||||
|
# Если partial close не реализован, лучше позже перевести REDUCE
|
||||||
|
# в PROTECT или EXIT, чтобы не было иллюзии действия.
|
||||||
return self._log_runtime_action(
|
return self._log_runtime_action(
|
||||||
state=state,
|
state=state,
|
||||||
action=AUTONOMOUS_ACTION_REDUCE,
|
action=AUTONOMOUS_ACTION_REDUCE,
|
||||||
@@ -129,24 +172,13 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if action == AUTONOMOUS_ACTION_EXIT:
|
if action == AUTONOMOUS_ACTION_EXIT:
|
||||||
if self._early_exit_guard_active(state):
|
early_guard_reason = self._early_exit_guard_block_reason(state)
|
||||||
hold_seconds = safe_float(
|
|
||||||
getattr(state, "position_hold_seconds", None)
|
|
||||||
) or 0.0
|
|
||||||
|
|
||||||
thresholds = get_position_exit_thresholds(
|
|
||||||
getattr(state, "symbol", None)
|
|
||||||
)
|
|
||||||
|
|
||||||
min_hold = thresholds["min_hold"]
|
|
||||||
|
|
||||||
|
if early_guard_reason is not None:
|
||||||
return self._log_runtime_action(
|
return self._log_runtime_action(
|
||||||
state=state,
|
state=state,
|
||||||
action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
|
action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
|
||||||
reason=(
|
reason=early_guard_reason,
|
||||||
"early exit guard: позиция ещё слишком новая для закрытия "
|
|
||||||
f"({hold_seconds:.0f}s < {min_hold:.0f}s)"
|
|
||||||
),
|
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
executed=False,
|
executed=False,
|
||||||
cooldown_action=None,
|
cooldown_action=None,
|
||||||
@@ -198,6 +230,9 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
action: str,
|
action: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
# Проверка cooldown runtime action.
|
# Проверка cooldown runtime action.
|
||||||
|
# Cooldown нужен, чтобы один и тот же runtime action не спамил
|
||||||
|
# журнал и EventBus на каждом цикле.
|
||||||
|
|
||||||
ts = safe_float(
|
ts = safe_float(
|
||||||
getattr(state, "autonomous_last_action_at", None)
|
getattr(state, "autonomous_last_action_at", None)
|
||||||
)
|
)
|
||||||
@@ -216,6 +251,7 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
time.monotonic() - ts
|
time.monotonic() - ts
|
||||||
) < self._runtime_action_cooldown_seconds
|
) < self._runtime_action_cooldown_seconds
|
||||||
|
|
||||||
|
# ----- PAYLOAD -----
|
||||||
def _build_runtime_action_payload(
|
def _build_runtime_action_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -241,9 +277,7 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
# ---------- Runtime ----------
|
||||||
"status": state.status,
|
**build_runtime_payload(state),
|
||||||
"strategy": state.strategy,
|
|
||||||
"cycle_number": state.cycle_number,
|
|
||||||
|
|
||||||
# ---------- Instrument / Position ----------
|
# ---------- Instrument / Position ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
@@ -253,81 +287,23 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
"leverage": position.leverage,
|
"leverage": position.leverage,
|
||||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
"position_pnl_percent": state.position_pnl_percent,
|
"position_pnl_percent": state.position_pnl_percent,
|
||||||
"position_hold_seconds": state.position_hold_seconds,
|
|
||||||
|
|
||||||
# ---------- Position health ----------
|
# ---------- Health / intelligence ----------
|
||||||
|
**build_position_health_payload(state),
|
||||||
"position_pressure": state.position_pressure,
|
"position_pressure": state.position_pressure,
|
||||||
"position_health_status": state.position_health_status,
|
|
||||||
"position_health_score": state.position_health_score,
|
|
||||||
"position_health_reason": state.position_health_reason,
|
|
||||||
"position_risk_level": state.position_risk_level,
|
|
||||||
"position_risk_reason": state.position_risk_reason,
|
|
||||||
"position_trend_alignment": state.position_trend_alignment,
|
|
||||||
"position_adverse_momentum": state.position_adverse_momentum,
|
|
||||||
"position_exit_pressure": state.position_exit_pressure,
|
"position_exit_pressure": state.position_exit_pressure,
|
||||||
|
|
||||||
# ---------- Position intelligence ----------
|
**build_full_position_intelligence_payload(state),
|
||||||
"position_lifecycle_stage": state.position_lifecycle_stage,
|
|
||||||
"position_hold_quality": state.position_hold_quality,
|
|
||||||
"position_decay_state": state.position_decay_state,
|
|
||||||
"position_exit_signal": state.position_exit_signal,
|
|
||||||
"position_exit_confidence": state.position_exit_confidence,
|
|
||||||
"position_exit_urgency": state.position_exit_urgency,
|
|
||||||
"position_reversal_risk": state.position_reversal_risk,
|
|
||||||
"position_intelligence_reason": state.position_intelligence_reason,
|
|
||||||
"position_recommended_action": state.position_recommended_action,
|
|
||||||
|
|
||||||
# ---------- Advanced analytics ----------
|
# ---------- Autonomous ----------
|
||||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
**build_autonomous_payload(state),
|
||||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
|
||||||
"position_mfe_percent": state.position_mfe_percent,
|
|
||||||
"position_mae_percent": state.position_mae_percent,
|
|
||||||
"position_fatigue_score": state.position_fatigue_score,
|
|
||||||
"position_fatigue_state": state.position_fatigue_state,
|
|
||||||
"position_giveback_percent": state.position_giveback_percent,
|
|
||||||
"position_stall_state": state.position_stall_state,
|
|
||||||
"position_stall_reason": state.position_stall_reason,
|
|
||||||
|
|
||||||
# ---------- Autonomous management ----------
|
|
||||||
"autonomous_action": state.autonomous_action,
|
|
||||||
"autonomous_action_reason": state.autonomous_action_reason,
|
|
||||||
"autonomous_action_confidence": state.autonomous_action_confidence,
|
|
||||||
"autonomous_protection_required": state.autonomous_protection_required,
|
|
||||||
"autonomous_reduce_required": state.autonomous_reduce_required,
|
|
||||||
"autonomous_exit_required": state.autonomous_exit_required,
|
|
||||||
"autonomous_last_action": state.autonomous_last_action,
|
"autonomous_last_action": state.autonomous_last_action,
|
||||||
"autonomous_last_action_reason": state.autonomous_last_action_reason,
|
"autonomous_last_action_reason": state.autonomous_last_action_reason,
|
||||||
|
|
||||||
# ---------- Runtime protection ----------
|
# ---------- Protection / market / execution ----------
|
||||||
"position_protection_status": state.position_protection_status,
|
**build_runtime_protection_payload(state),
|
||||||
"position_protection_reason": state.position_protection_reason,
|
**build_market_context_payload(state),
|
||||||
"runtime_protection_action": state.runtime_protection_action,
|
**build_execution_quality_payload(state),
|
||||||
"runtime_protection_reason": state.runtime_protection_reason,
|
|
||||||
"break_even_armed": state.break_even_armed,
|
|
||||||
"break_even_price": state.break_even_price,
|
|
||||||
"profit_lock_active": state.profit_lock_active,
|
|
||||||
"profit_lock_price": state.profit_lock_price,
|
|
||||||
"trailing_stop_active": state.trailing_stop_active,
|
|
||||||
"trailing_stop_price": state.trailing_stop_price,
|
|
||||||
|
|
||||||
# ---------- Market context ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_volatility": state.market_volatility,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
|
|
||||||
# ---------- Execution context ----------
|
|
||||||
"execution_quality": state.execution_quality,
|
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----- LOGGING -----
|
# ----- LOGGING -----
|
||||||
@@ -342,6 +318,11 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
cooldown_action: str | None = None,
|
cooldown_action: str | None = None,
|
||||||
) -> ExecutionDecision:
|
) -> ExecutionDecision:
|
||||||
# Runtime action logging + deduplication.
|
# Runtime action logging + deduplication.
|
||||||
|
# Даже если действие не исполняется, payload помогает понять:
|
||||||
|
# - почему runtime action появился;
|
||||||
|
# - почему он был заблокирован;
|
||||||
|
# - какие были position health / semantics / market context.
|
||||||
|
|
||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
trade_id = position.trade_id or state.current_trade_id
|
trade_id = position.trade_id or state.current_trade_id
|
||||||
|
|
||||||
@@ -395,12 +376,23 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _early_exit_guard_active(self, state: AutoTradeState) -> bool:
|
# ----- EARLY EXIT GUARD -----
|
||||||
|
def _early_exit_guard_block_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
# Early exit guard защищает от слишком раннего закрытия позиции
|
||||||
|
# на обычном шуме/спреде/первой волне после входа.
|
||||||
|
#
|
||||||
|
# Но раньше он блокировал выход почти всегда до min_hold,
|
||||||
|
# пока убыток не доходил до hard_loss.
|
||||||
|
#
|
||||||
|
# Новая логика:
|
||||||
|
# - обычный ранний шум всё ещё блокируется;
|
||||||
|
# - реальное ухудшение позиции guard больше НЕ блокирует.
|
||||||
|
|
||||||
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None))
|
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None))
|
||||||
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
|
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
|
||||||
|
|
||||||
if hold_seconds is None or pnl_percent is None:
|
if hold_seconds is None or pnl_percent is None:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
thresholds = get_position_exit_thresholds(
|
thresholds = get_position_exit_thresholds(
|
||||||
getattr(state, "symbol", None)
|
getattr(state, "symbol", None)
|
||||||
@@ -410,10 +402,77 @@ class ExecutionRuntimeActionsMixin(
|
|||||||
hard_loss = thresholds["hard_loss"]
|
hard_loss = thresholds["hard_loss"]
|
||||||
|
|
||||||
if hold_seconds >= min_hold:
|
if hold_seconds >= min_hold:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
# Если просадка уже критическая — guard не мешает защите.
|
# Если просадка уже критическая — guard не мешает защите.
|
||||||
if pnl_percent <= hard_loss:
|
if pnl_percent <= hard_loss:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
return True
|
bypass_reason = self._early_exit_guard_bypass_reason(state)
|
||||||
|
|
||||||
|
if bypass_reason is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
"early exit guard: позиция ещё слишком новая для закрытия "
|
||||||
|
f"({hold_seconds:.0f}s < {min_hold:.0f}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _early_exit_guard_bypass_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
# Причины, при которых ранний выход нужно разрешить.
|
||||||
|
# Это не делает выход автоматическим само по себе:
|
||||||
|
# action всё равно должен быть AUTONOMOUS_ACTION_EXIT,
|
||||||
|
# а confidence должен пройти RUNTIME_EXIT_CONFIDENCE_THRESHOLD.
|
||||||
|
|
||||||
|
adverse_momentum = bool(
|
||||||
|
getattr(state, "position_adverse_momentum", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
trend_alignment = str(
|
||||||
|
getattr(state, "position_trend_alignment", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
risk_level = str(
|
||||||
|
getattr(state, "position_risk_level", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
conviction_state = str(
|
||||||
|
getattr(state, "position_conviction_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
stall_state = str(
|
||||||
|
getattr(state, "position_stall_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
exit_urgency = str(
|
||||||
|
getattr(state, "position_exit_urgency", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
decay_state = str(
|
||||||
|
getattr(state, "position_decay_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
if risk_level in {"HIGH", "ELEVATED"}:
|
||||||
|
return f"early exit allowed: position risk is {risk_level}"
|
||||||
|
|
||||||
|
if adverse_momentum and trend_alignment == "AGAINST":
|
||||||
|
return "early exit allowed: trend and momentum are against position"
|
||||||
|
|
||||||
|
if conviction_state == "BROKEN":
|
||||||
|
return "early exit allowed: position conviction is broken"
|
||||||
|
|
||||||
|
if stall_state == "ADVERSE_STALLED":
|
||||||
|
return "early exit allowed: position is adverse stalled"
|
||||||
|
|
||||||
|
if exit_urgency in {"IMMEDIATE", "HIGH"}:
|
||||||
|
return f"early exit allowed: exit urgency is {exit_urgency}"
|
||||||
|
|
||||||
|
if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}:
|
||||||
|
return f"early exit allowed: position decay is {decay_state}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Старый публичный helper оставляем для совместимости,
|
||||||
|
# если где-то ещё в коде он вызывается напрямую.
|
||||||
|
def _early_exit_guard_active(self, state: AutoTradeState) -> bool:
|
||||||
|
return self._early_exit_guard_block_reason(state) is not None
|
||||||
@@ -43,12 +43,10 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
|||||||
balance_usd = safe_float(state.allocated_balance_usd) or 0.0
|
balance_usd = safe_float(state.allocated_balance_usd) or 0.0
|
||||||
|
|
||||||
if risk_percent is None or risk_percent <= 0:
|
if risk_percent is None or risk_percent <= 0:
|
||||||
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
|
return self._zero_position_size(state)
|
||||||
return 0.0
|
|
||||||
|
|
||||||
if stop_loss_percent is None or stop_loss_percent <= 0:
|
if stop_loss_percent is None or stop_loss_percent <= 0:
|
||||||
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
|
return self._zero_position_size(state)
|
||||||
return 0.0
|
|
||||||
|
|
||||||
price = safe_float(entry_price)
|
price = safe_float(entry_price)
|
||||||
|
|
||||||
@@ -59,15 +57,13 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
|||||||
price = None
|
price = None
|
||||||
|
|
||||||
if price is None or price <= 0:
|
if price is None or price <= 0:
|
||||||
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
|
return self._zero_position_size(state)
|
||||||
return 0.0
|
|
||||||
|
|
||||||
target_risk_usd = balance_usd * (risk_percent / 100)
|
target_risk_usd = balance_usd * (risk_percent / 100)
|
||||||
stop_loss_distance_usd = price * (stop_loss_percent / 100)
|
stop_loss_distance_usd = price * (stop_loss_percent / 100)
|
||||||
|
|
||||||
if target_risk_usd <= 0 or stop_loss_distance_usd <= 0:
|
if target_risk_usd <= 0 or stop_loss_distance_usd <= 0:
|
||||||
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
|
return self._zero_position_size(state)
|
||||||
return 0.0
|
|
||||||
|
|
||||||
base_size = target_risk_usd / stop_loss_distance_usd
|
base_size = target_risk_usd / stop_loss_distance_usd
|
||||||
multiplier = self._adaptive_size_multiplier(state)
|
multiplier = self._adaptive_size_multiplier(state)
|
||||||
@@ -82,6 +78,17 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
|||||||
|
|
||||||
return self._round_size(final_size)
|
return self._round_size(final_size)
|
||||||
|
|
||||||
|
# единый выход из расчёта size, когда вход невозможен:
|
||||||
|
# сбрасывает adaptive size/runtime risk в 0 и возвращает 0.0
|
||||||
|
def _zero_position_size(self, state: AutoTradeState) -> float:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
# рассчитать коэффициент изменения размера позиции по итоговым runtime/context факторам
|
# рассчитать коэффициент изменения размера позиции по итоговым runtime/context факторам
|
||||||
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
|
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
|
||||||
multiplier = 1.0
|
multiplier = 1.0
|
||||||
@@ -204,9 +211,10 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
|||||||
4,
|
4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
allocated_balance = safe_float(state.allocated_balance_usd) or 0.0
|
||||||
|
|
||||||
state.effective_target_risk_usd = round(
|
state.effective_target_risk_usd = round(
|
||||||
state.allocated_balance_usd
|
allocated_balance * (state.effective_risk_percent / 100),
|
||||||
* (state.effective_risk_percent / 100),
|
|
||||||
4,
|
4,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,9 +311,10 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
|||||||
4,
|
4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
allocated_balance = safe_float(state.allocated_balance_usd) or 0.0
|
||||||
|
|
||||||
state.effective_target_risk_usd = round(
|
state.effective_target_risk_usd = round(
|
||||||
state.allocated_balance_usd
|
allocated_balance * (state.effective_risk_percent / 100),
|
||||||
* (state.effective_risk_percent / 100),
|
|
||||||
4,
|
4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ from src.core.numbers import safe_float
|
|||||||
from src.core.types import JsonDict
|
from src.core.types import JsonDict
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
from src.trading.execution.models import ExecutionDecision
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.payloads import (
|
||||||
|
build_decision_payload,
|
||||||
|
build_execution_price_payload,
|
||||||
|
build_execution_quality_payload,
|
||||||
|
build_market_context_payload,
|
||||||
|
build_risk_settings_payload,
|
||||||
|
build_runtime_blocks_payload,
|
||||||
|
build_runtime_payload,
|
||||||
|
build_signal_payload,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -338,67 +348,36 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
|||||||
# ---------- Event ----------
|
# ---------- Event ----------
|
||||||
"execution_type": "SUPERVISOR_BLOCK",
|
"execution_type": "SUPERVISOR_BLOCK",
|
||||||
"action": action,
|
"action": action,
|
||||||
"reason": reason,
|
|
||||||
|
|
||||||
# ---------- Runtime ----------
|
# В supervisor reason — причина блокировки.
|
||||||
"status": state.status,
|
# Поэтому signal reason сохраняем отдельно ниже как signal_reason.
|
||||||
"strategy": state.strategy,
|
"reason": reason,
|
||||||
"cycle_number": state.cycle_number,
|
|
||||||
|
|
||||||
# ---------- Instrument ----------
|
# ---------- Instrument ----------
|
||||||
"symbol": state.symbol,
|
"symbol": state.symbol,
|
||||||
|
|
||||||
# ---------- Signal ----------
|
**build_runtime_payload(state),
|
||||||
"signal": state.last_signal,
|
|
||||||
"confidence": state.last_signal_confidence,
|
|
||||||
"repeat_count": state.last_signal_repeat_count,
|
|
||||||
"signal_reason": state.last_signal_reason,
|
|
||||||
|
|
||||||
# ---------- Decision ----------
|
# build_signal_payload даёт поле reason как причину сигнала,
|
||||||
"decision_status": state.decision_status,
|
# поэтому ниже reason события перезаписываем обратно.
|
||||||
"decision_reason": state.decision_reason,
|
**build_signal_payload(state),
|
||||||
|
"signal_reason": state.last_signal_reason,
|
||||||
|
"reason": reason,
|
||||||
|
|
||||||
|
**build_decision_payload(state),
|
||||||
"is_signal_confirmed": state.is_signal_confirmed,
|
"is_signal_confirmed": state.is_signal_confirmed,
|
||||||
"is_signal_ready": state.is_signal_ready,
|
"is_signal_ready": state.is_signal_ready,
|
||||||
|
|
||||||
# ---------- Runtime blocks ----------
|
**build_runtime_blocks_payload(state),
|
||||||
"entry_block_reason": state.entry_block_reason,
|
**build_execution_quality_payload(state),
|
||||||
"entry_block_message": state.entry_block_message,
|
|
||||||
"execution_block_reason": state.execution_block_reason,
|
|
||||||
"execution_block_title": state.execution_block_title,
|
|
||||||
"execution_block_message": state.execution_block_message,
|
|
||||||
"execution_block_action": state.execution_block_action,
|
|
||||||
"last_flip_block_reason": state.last_flip_block_reason,
|
|
||||||
|
|
||||||
# ---------- Execution ----------
|
"execution_confidence_required_score": (
|
||||||
"execution_confidence_score": state.execution_confidence_score,
|
state.execution_confidence_required_score
|
||||||
"execution_confidence_level": state.execution_confidence_level,
|
),
|
||||||
"execution_confidence_required_score": state.execution_confidence_required_score,
|
|
||||||
"execution_confidence_reason": state.execution_confidence_reason,
|
|
||||||
"execution_confidence_factors": state.execution_confidence_factors,
|
"execution_confidence_factors": state.execution_confidence_factors,
|
||||||
|
|
||||||
"execution_quality": state.execution_quality,
|
**build_execution_price_payload(state),
|
||||||
"execution_quality_reason": state.execution_quality_reason,
|
**build_risk_settings_payload(state),
|
||||||
"execution_quality_message": state.execution_quality_message,
|
|
||||||
|
|
||||||
"spread_percent": state.spread_percent,
|
|
||||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
|
||||||
|
|
||||||
# ---------- Execution price ----------
|
|
||||||
"execution_price_source": state.execution_price_source,
|
|
||||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
|
||||||
"execution_bid_price": state.execution_bid_price,
|
|
||||||
"execution_ask_price": state.execution_ask_price,
|
|
||||||
"execution_last_price": state.execution_last_price,
|
|
||||||
"execution_price_freshness": state.execution_price_freshness,
|
|
||||||
|
|
||||||
# ---------- Risk settings ----------
|
|
||||||
"risk_percent": state.risk_percent,
|
|
||||||
"stop_loss_percent": state.stop_loss_percent,
|
|
||||||
"take_profit_percent": state.take_profit_percent,
|
|
||||||
"max_loss_usd": state.max_loss_usd,
|
|
||||||
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
|
||||||
"allocated_balance_usd": state.allocated_balance_usd,
|
|
||||||
"leverage": state.leverage,
|
|
||||||
|
|
||||||
# ---------- Position ----------
|
# ---------- Position ----------
|
||||||
"position_side": state.position_side,
|
"position_side": state.position_side,
|
||||||
@@ -416,63 +395,7 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
|||||||
"loss_cooldown_active": state.loss_cooldown_active,
|
"loss_cooldown_active": state.loss_cooldown_active,
|
||||||
"loss_cooldown_reason": state.loss_cooldown_reason,
|
"loss_cooldown_reason": state.loss_cooldown_reason,
|
||||||
|
|
||||||
# ---------- Market score ----------
|
**build_market_context_payload(state),
|
||||||
"market_score": state.market_score,
|
|
||||||
"market_score_label": state.market_score_label,
|
|
||||||
"market_long_score": state.market_long_score,
|
|
||||||
"market_short_score": state.market_short_score,
|
|
||||||
|
|
||||||
# ---------- Market ----------
|
|
||||||
"market_state": state.market_state,
|
|
||||||
"market_trend": state.market_trend,
|
|
||||||
"market_volatility": state.market_volatility,
|
|
||||||
"market_trend_strength": state.market_trend_strength,
|
|
||||||
"market_trend_quality": state.market_trend_quality,
|
|
||||||
"market_phase": state.market_phase,
|
|
||||||
"market_phase_direction": state.market_phase_direction,
|
|
||||||
|
|
||||||
# ---------- Candle ----------
|
|
||||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
|
||||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
|
||||||
"current_interval_change_percent": state.current_interval_change_percent,
|
|
||||||
"current_interval_direction": state.current_interval_direction,
|
|
||||||
"current_interval_label": state.current_interval_label,
|
|
||||||
|
|
||||||
# ---------- Structure ----------
|
|
||||||
"market_structure": state.market_structure,
|
|
||||||
"market_structure_reason": state.market_structure_reason,
|
|
||||||
|
|
||||||
# ---------- Momentum ----------
|
|
||||||
"momentum_state": state.momentum_state,
|
|
||||||
"momentum_direction": state.momentum_direction,
|
|
||||||
"momentum_strength": state.momentum_strength,
|
|
||||||
"momentum_change_percent": state.momentum_change_percent,
|
|
||||||
"breakout_level": state.breakout_level,
|
|
||||||
"breakout_distance_percent": state.breakout_distance_percent,
|
|
||||||
"breakout_reason": state.breakout_reason,
|
|
||||||
|
|
||||||
# ---------- HTF ----------
|
|
||||||
"htf_interval": state.htf_interval,
|
|
||||||
"htf_atr_percent": state.htf_atr_percent,
|
|
||||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
|
||||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
|
||||||
"htf_volatility": state.htf_volatility,
|
|
||||||
"htf_market_state": state.htf_market_state,
|
|
||||||
"htf_trend": state.htf_trend,
|
|
||||||
"htf_trend_strength": state.htf_trend_strength,
|
|
||||||
"htf_trend_quality": state.htf_trend_quality,
|
|
||||||
"htf_market_phase": state.htf_market_phase,
|
|
||||||
"htf_alignment": state.htf_alignment,
|
|
||||||
"htf_confirmation_score": state.htf_confirmation_score,
|
|
||||||
"htf_reason": state.htf_reason,
|
|
||||||
|
|
||||||
# ---------- Market runtime ----------
|
|
||||||
"market_runtime_degraded": state.market_runtime_degraded,
|
|
||||||
"runtime_expired_reason": state.runtime_expired_reason,
|
|
||||||
"runtime_expired_message": state.runtime_expired_message,
|
|
||||||
"market_is_open": state.market_is_open,
|
|
||||||
"market_status": state.market_status,
|
|
||||||
"market_status_message": state.market_status_message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _block_execution(
|
def _block_execution(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user