Compare commits

..

33 Commits

Author SHA1 Message Date
a996f2f797 feat: add market data architecture and complete migration through build 039 2026-07-14 09:58:16 +03:00
26deb861bc Execute autonomous protect action 2026-07-03 13:51:40 +03:00
5f1f522fd7 Relax early autonomous exit guard 2026-07-03 13:15:38 +03:00
3dde8d3e87 Add reversal signal block diagnostics 2026-07-03 12:47:12 +03:00
8a07d24bd7 Add detailed flip diagnostics 2026-07-03 12:29:42 +03:00
4d58fcf2bf Add flip execution diagnostics 2026-07-03 12:21:07 +03:00
c608072b7b fix(execution): reset position stall state 2026-07-03 11:59:53 +03:00
04f92c1d0e refactor(execution): clean risk close formatting 2026-07-03 11:57:31 +03:00
c9ce9ccae9 fix(execution): reset stale position intelligence state 2026-07-03 11:55:51 +03:00
a3e5ac324b refactor(execution): clean runtime protection payload formatting 2026-07-03 11:53:53 +03:00
aacb2c409d refactor(execution): preserve closed position leverage payload 2026-07-03 11:52:03 +03:00
ecf8b5f60d refactor(execution): simplify zero size handling 2026-07-03 11:45:21 +03:00
bcbec1aa02 refactor(execution): avoid duplicate opened position leverage payload 2026-07-03 11:37:28 +03:00
33e60c2409 refactor(execution): simplify intelligence exit sync 2026-07-03 11:33:38 +03:00
23cf386c38 fix(auto): trim market status message for ui 2026-07-03 11:24:26 +03:00
60728c7efb refactor(execution): reuse payload helpers in runtime actions 2026-07-03 11:24:09 +03:00
f6029372ef fix(telegram): escape journal html content 2026-07-03 11:06:04 +03:00
a27774fd48 refactor(execution): reuse shared payload builders in supervisor 2026-07-03 11:05:59 +03:00
ce90e58060 refactor(execution): reuse payload helpers in runtime protection 2026-07-03 10:40:52 +03:00
610e6c3043 refactor(execution): reuse shared runtime payload builders 2026-07-03 10:33:52 +03:00
b3211cf024 refactor(execution): reuse shared payload builders in flip events 2026-07-03 10:20:02 +03:00
7ccf406c93 fix(journal): disable parse mode for journal rendering 2026-07-03 09:52:16 +03:00
f50ba047ee refactor(execution): reuse shared payload builders in position actions 2026-07-03 09:43:29 +03:00
63bea1831f refactor(execution): add shared payload section builders 2026-07-03 09:31:12 +03:00
4f57d4a322 refactor(execution): reuse market context payload in flip events 2026-07-03 08:45:50 +03:00
c632440d97 refactor(execution): reuse market context payload in position actions 2026-07-02 23:02:39 +03:00
6ce14a0292 refactor(execution): add shared payload builders 2026-07-02 22:56:55 +03:00
cfd7d76806 refactor(execution): standardize execution skip and reset cleanup 2026-07-02 22:50:04 +03:00
d334461339 refactor(execution): reuse reset helpers in flip flow 2026-07-02 22:42:56 +03:00
777a11207d refactor(auto): finalize runtime state reset cleanup 2026-07-02 22:38:38 +03:00
73ea891843 refactor(auto): use reset helpers for signal runtime expiration 2026-07-02 22:05:33 +03:00
954ca0e427 refactor(auto): improve runtime reset structure and observing UI 2026-07-02 21:59:22 +03:00
af276b1ce4 refactor(auto): add runtime state reset helpers 2026-07-02 21:18:58 +03:00
461 changed files with 82036 additions and 2559 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View 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,
)

View 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

View 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)

View 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

View 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),
)

View 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

View 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)

View 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)

View File

@@ -0,0 +1,2 @@
# app/src/market_data/acquisition/feeds/status_feed.py

View 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)

View 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),
)

View File

@@ -0,0 +1,2 @@
# app/src/market_data/acquisition/handlers/status_handler.py

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/models/__init__.py

View 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

View 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

View 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,
)

View 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:
"""
Получить внутреннюю модель текущей котировки инструмента.
"""
...

View 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

View 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)

View 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

View 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."
)

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/validation/sequence.py

View 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 должно быть больше нуля."
)

View 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."""

View 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

View 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

View File

@@ -1,3 +1,5 @@
# app/src/storage/repositories/balance_snapshots.py
from __future__ import annotations from __future__ import annotations
import json import json

View File

@@ -1 +1,3 @@
# app/src/telegram/handlers/__init__.py
"""Package marker.""" """Package marker."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 % и время удержания больше не считаем здесь.

View File

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

View File

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

View 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)

View File

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

View File

@@ -0,0 +1 @@
# app/src/trading/decision/__init__.py

View 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."""

View 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

View 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."""
...

View 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

View 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)

View 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."
)

View File

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

View File

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

View File

@@ -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}.",

View 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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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