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
import time
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.market_data.acquisition.models.quote import Quote
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
@dataclass(slots=True)
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
)
_MARKET_PRICE_CACHE_SOURCE_NAME = "legacy-market-price-cache"
class MarketPriceCache:
_prices: dict[tuple[str, str], MarketPriceSnapshot] = {}
# Временный compatibility facade над каноническим Quote Store.
_store: QuoteStoreProtocol = InMemoryQuoteStore()
@classmethod
def _key(cls, *, symbol: str, runtime_key: str = "default") -> tuple[str, str]:
return runtime_key.strip().lower(), symbol.upper()
@classmethod
def set_price(
def set_quote(
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",
) -> None:
settings = load_settings()
if updated_at is None:
updated_at = datetime.now(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
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(),
cls._store.set(
_MARKET_PRICE_CACHE_SOURCE_NAME,
quote,
runtime_key=cls._normalize_runtime_key(runtime_key),
)
@classmethod
def get_price(
def get_quote(
cls,
symbol: str,
*,
runtime_key: str = "default",
) -> MarketPriceSnapshot | None:
return cls._prices.get(cls._key(symbol=symbol, runtime_key=runtime_key))
) -> Quote | None:
return cls._store.get(
_MARKET_PRICE_CACHE_SOURCE_NAME,
cls._normalize_symbol(symbol),
runtime_key=cls._normalize_runtime_key(runtime_key),
)
@classmethod
def clear(
@@ -89,23 +46,24 @@ class MarketPriceCache:
*,
runtime_key: str | None = None,
) -> None:
if symbol is None and runtime_key is None:
cls._prices.clear()
return
cls._store.clear(
source_name=_MARKET_PRICE_CACHE_SOURCE_NAME,
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:
cls._prices.pop(cls._key(symbol=symbol, runtime_key=runtime_key), None)
return
@staticmethod
def _normalize_symbol(symbol: str) -> str:
return str(symbol).strip().upper()
keys_to_delete = []
for key_runtime, key_symbol in cls._prices.keys():
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)
@staticmethod
def _normalize_runtime_key(runtime_key: str) -> str:
return str(runtime_key).strip().lower()

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.service import ExchangeService
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
@@ -297,6 +303,7 @@ class MarketDataRunner:
valid_payload_count = 0
invalid_payload_count = 0
adapter = DzengiWebSocketQuoteAdapter()
async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol,
@@ -306,20 +313,31 @@ class MarketDataRunner:
if current_symbol and current_symbol != symbol:
break
best_bid = cls._extract_best_price(payload, "bids")
best_ask = cls._extract_best_price(payload, "asks")
if best_bid is None or best_ask is None:
try:
quote = adapter.map_message(payload)
except MarketDataAcquisitionError:
invalid_payload_count += 1
if invalid_payload_count >= 5:
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
invalid_payload_count = 0
best_bid = float(quote.bid_price)
best_ask = float(quote.ask_price)
if valid_payload_count == 0:
should_log_connected = (
@@ -354,12 +372,8 @@ class MarketDataRunner:
valid_payload_count += 1
MarketPriceCache.set_price(
symbol=cache_symbol,
price=(best_bid + best_ask) / 2,
bid_price=best_bid,
ask_price=best_ask,
source=f"ws_depth:{context.runtime_key}",
MarketPriceCache.set_quote(
quote,
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.service import ExchangeService
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
@@ -145,6 +151,7 @@ async def start_market_stream() -> None:
symbol = validation.normalized_symbol
client = ExchangeWebSocketClient()
adapter = DzengiWebSocketQuoteAdapter()
journal.log_info(
"market_ws_started",
@@ -153,29 +160,16 @@ async def start_market_stream() -> None:
)
async for message in client.stream_depth(symbol):
event = _extract_market_event(message)
if event is None:
try:
quote = adapter.map_message(message)
except MarketDataAcquisitionError:
continue
price = safe_float(event.get("price"))
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:
if quote.symbol.strip().upper() != symbol.strip().upper():
continue
MarketPriceCache.set_price(
symbol=symbol,
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",
MarketPriceCache.set_quote(
quote,
runtime_key="default",
)

View File

@@ -1,8 +1,12 @@
# app/src/integrations/exchange/mock_data.py
from __future__ import annotations
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:
@@ -13,20 +17,23 @@ def mock_exchange_health() -> ExchangeHealth:
)
def mock_ticker_price(symbol: str) -> TickerPrice:
symbol = symbol.upper().strip()
def mock_quote(symbol: str) -> Quote:
normalized_symbol = symbol.upper().strip()
fake_prices = {
"BTCUSDT": 68425.10,
"ETHUSDT": 3521.44,
"BNBUSDT": 612.33,
"BTCUSDT": Decimal("68425.10"),
"ETHUSDT": Decimal("3521.44"),
"BNBUSDT": Decimal("612.33"),
}
price = fake_prices.get(symbol, 100.00)
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return TickerPrice(
symbol=symbol,
price=price,
price = fake_prices.get(normalized_symbol, Decimal("100.00"))
return Quote(
symbol=normalized_symbol,
last_price=price,
bid_price=price,
ask_price=price,
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="mock",
updated_at=updated_at,
)

View File

@@ -3,6 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.market_data.acquisition.models.instrument import Instrument
# Состояние публичного API биржи.
@@ -25,13 +30,6 @@ class TimeSyncStatus:
message: str
# Текущая рыночная цена инструмента.
@dataclass(slots=True)
class TickerPrice:
symbol: str
price: float
source: str
updated_at: str
# Snapshot цен для execution layer.
@@ -62,26 +60,7 @@ class BalanceSummary:
source: str
# Информация о торговом инструменте биржи.
@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
# Результат проверки символа.
# Результат проверки торгового символа по каноническому справочнику Instrument.
@dataclass(slots=True)
class SymbolValidationResult:
requested_symbol: str
@@ -90,7 +69,7 @@ class SymbolValidationResult:
is_valid: bool
message: str
symbol_info: ExchangeSymbol | None
symbol_info: Instrument | None
# Состояние приватного API аккаунта.
@@ -134,6 +113,7 @@ class KlineBatch:
candles: list[Kline]
source: str
# Информация о торговой комиссии для инструмента.
@dataclass(slots=True)
class TradingFee:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import time
import socket
from datetime import datetime
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
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 (
mock_balance_summary,
mock_exchange_health,
mock_ticker_price,
mock_quote,
)
from src.integrations.exchange.models import (
BalanceSummary,
ExchangeHealth,
ExchangeSymbol,
ExecutionPriceSnapshot,
Kline,
KlineBatch,
PrivateAuthHealth,
SymbolValidationResult,
TickerPrice,
TimeSyncStatus,
TradingFee,
)
@@ -43,12 +41,46 @@ from src.integrations.exchange.status import (
build_mock_exchange_status,
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
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
_QUOTE_SOURCE_NAME = "dzengi"
class ExchangeService:
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
_instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()
_execution_cache_max_age_seconds = 2.0
_default_runtime_key = "auto"
@@ -108,17 +140,28 @@ class ExchangeService:
return status
try:
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
quote = self._get_fresh_quote(
validation.normalized_symbol,
)
except Exception:
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:
return build_market_stale_status(
symbol=validation.normalized_symbol,
age_seconds=age_seconds,
updated_at=str(snapshot.get("updated_at") or ""),
updated_at=self._format_exchange_time(
exchange_timestamp_ms
),
)
return status
@@ -668,7 +711,9 @@ class ExchangeService:
)
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:
return ExchangeHealth(
ok=False,
@@ -679,7 +724,10 @@ class ExchangeService:
return ExchangeHealth(
ok=True,
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 и валидность ключей аккаунта.
@@ -722,149 +770,41 @@ class ExchangeService:
message=f"Private API OK. Балансов получено: {len(balances)}",
)
# Обновить price cache и вернуть TickerPrice.
def refresh_price_cache(
# Получить каноническую текущую котировку из Store или REST Quotes Feed.
def get_quote(
self,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> TickerPrice:
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:
) -> Quote:
symbol_to_use = symbol or self.settings.default_symbol
normalized_runtime_key = self._runtime_key(runtime_key)
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)
if not validation.is_valid:
raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price(
cached_quote = MarketPriceCache.get_quote(
validation.normalized_symbol,
runtime_key=normalized_runtime_key,
)
if cached_price is not None:
return TickerPrice(
symbol=cached_price.symbol,
price=cached_price.price,
source=cached_price.source,
updated_at=cached_price.updated_at,
)
if (
cached_quote is not None
and self._quote_age_seconds(cached_quote)
<= self._execution_cache_max_age_seconds
):
return cached_quote
return self._get_real_price(validation.normalized_symbol)
# Получить market snapshot: last/bid/ask/source/age/freshness.
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,
quote = self._get_fresh_quote(validation.normalized_symbol)
MarketPriceCache.set_quote(
quote,
runtime_key=normalized_runtime_key,
)
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
return quote
# Получить snapshot, пригодный для execution layer.
def get_execution_snapshot(
@@ -877,15 +817,10 @@ class ExchangeService:
normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled:
ticker = mock_ticker_price(symbol_to_use)
return ExecutionPriceSnapshot(
symbol=ticker.symbol,
last_price=ticker.price,
bid_price=ticker.price,
ask_price=ticker.price,
updated_at=ticker.updated_at,
source=ticker.source,
is_fresh=True,
quote = mock_quote(symbol_to_use)
return self._execution_snapshot_from_quote(
quote,
source=quote.source,
age_seconds=0.0,
)
@@ -893,125 +828,96 @@ class ExchangeService:
if not validation.is_valid:
raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price(
quote = MarketPriceCache.get_quote(
validation.normalized_symbol,
runtime_key=normalized_runtime_key,
)
if cached_price is not None:
age = cached_price.age_seconds()
if quote is not None:
age_seconds = self._quote_age_seconds(quote)
if (
age <= self._execution_cache_max_age_seconds
and cached_price.has_bid_ask()
):
bid_price = safe_float(cached_price.bid_price)
ask_price = safe_float(cached_price.ask_price)
last_price = safe_float(cached_price.price)
if age_seconds <= self._execution_cache_max_age_seconds:
return self._execution_snapshot_from_quote(
quote,
source=f"{quote.source}:fresh_cache",
age_seconds=round(age_seconds, 3),
)
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),
)
quote = self._get_fresh_quote(
validation.normalized_symbol
)
MarketPriceCache.set_quote(
quote,
runtime_key=normalized_runtime_key,
)
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
return self._execution_snapshot_from_quote(
quote,
source="rest_fallback",
age_seconds=round(
self._quote_age_seconds(quote),
3,
),
)
last_price = safe_float(snapshot.get("last_price"))
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
def _execution_snapshot_from_quote(
self,
quote: Quote,
*,
source: str,
age_seconds: float,
) -> ExecutionPriceSnapshot:
timestamp = (
quote.exchange_timestamp
if quote.exchange_timestamp is not None
else quote.received_at
)
if last_price is None or bid_price is None or ask_price is None:
raise ExchangeError("Market snapshot contains invalid execution prices.")
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
age_seconds = safe_float(snapshot.get("age_seconds"))
updated_at = timestamp.astimezone(
ZoneInfo(self.settings.tz)
).strftime("%d.%m.%Y %H:%M:%S")
return ExecutionPriceSnapshot(
symbol=str(snapshot["symbol"]),
last_price=last_price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=str(snapshot["updated_at"]),
source="rest_fallback",
is_fresh=bool(snapshot.get("is_fresh")),
symbol=quote.symbol,
last_price=float(quote.last_price),
bid_price=float(quote.bid_price),
ask_price=float(quote.ask_price),
updated_at=updated_at,
source=source,
is_fresh=(
age_seconds
<= self._execution_cache_max_age_seconds
),
age_seconds=age_seconds,
)
# Получить свежий snapshot напрямую из REST API.
def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]:
symbol_to_use = symbol or self.settings.default_symbol
def _quote_age_seconds(self, quote: Quote) -> float:
received_at = quote.received_at
if received_at.tzinfo is None:
received_at = received_at.replace(tzinfo=timezone.utc)
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": "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()
return max(
0.0,
(
datetime.now(timezone.utc)
- received_at.astimezone(timezone.utc)
).total_seconds(),
)
def _get_fresh_quote(self, normalized_symbol: str) -> Quote:
try:
payload = client.get_json(
"/api/v1/ticker/24hr",
params={"symbol": validation.normalized_symbol},
)
return self._load_quote_via_acquisition(normalized_symbol)
except Exception as exc:
self._log_exchange_error(
endpoint="ticker/24hr",
exc=exc,
symbol=validation.normalized_symbol,
symbol=normalized_symbol,
)
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-балансы аккаунта.
def get_balance_summary(self) -> list[BalanceSummary]:
if not self.settings.exchange_enabled:
@@ -1056,20 +962,22 @@ class ExchangeService:
return balances
# Получить и распарсить список инструментов биржи.
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
# Получить канонический справочник инструментов через Instrument Store.
def get_instruments(self) -> tuple[Instrument, ...]:
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:
return cached_symbols
instruments = instrument_store.get(
_INSTRUMENT_REFERENCE_SOURCE_NAME
)
client = ExchangeRestClient()
if instruments is not None:
return instruments
try:
payload = client.get_json("/api/v1/exchangeInfo")
instruments = self._load_instruments_via_acquisition()
except Exception as exc:
self._log_exchange_error(
endpoint="exchangeInfo",
@@ -1077,98 +985,65 @@ class ExchangeService:
)
raise ExchangeError(str(exc)) from exc
symbols_raw = self._extract_exchange_symbols_raw(payload)
items: list[ExchangeSymbol] = []
for item in symbols_raw:
if not isinstance(item, dict):
continue
symbol = self._parse_exchange_symbol(item)
if symbol.symbol:
items.append(symbol)
type(self)._exchange_symbols_cache = items
return items
# Извлечь сырой список symbols из exchangeInfo.
def _extract_exchange_symbols_raw(
self,
payload: dict[str, object],
) -> list[object]:
symbols = payload.get("symbols")
if isinstance(symbols, list):
return symbols
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,
instrument_store.set(
_INSTRUMENT_REFERENCE_SOURCE_NAME,
instruments,
)
raise exc
# Преобразовать один сырой symbol item в ExchangeSymbol.
def _parse_exchange_symbol(
return instruments
# Собрать Quotes acquisition pipeline и вернуть каноническую модель Quote.
def _load_quote_via_acquisition(
self,
item: dict[object, object],
) -> ExchangeSymbol:
filters = item.get("filters")
symbol: str,
) -> Quote:
source = DzengiQuoteDocumentSource()
handler = DzengiQuoteDocumentHandler()
tick_size = safe_float(item.get("tickSize"))
if tick_size is None:
tick_size = self._extract_filter_value(
filters,
filter_names=["PRICE_FILTER"],
keys=["tickSize"],
)
feed = QuotesFeed(
source=source,
handler=handler,
)
step_size = safe_float(item.get("stepSize"))
if step_size is None:
step_size = self._extract_filter_value(
filters,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
keys=["stepSize"],
)
registry = QuoteFeedRegistry()
registry.register(
_QUOTE_SOURCE_NAME,
feed,
)
min_qty = safe_float(item.get("minQty"))
if min_qty is None:
min_qty = self._extract_filter_value(
filters,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
keys=["minQty"],
)
acquisition_service = QuoteAcquisitionService(
registry=registry,
)
min_notional = safe_float(item.get("minNotional"))
if min_notional is None:
min_notional = self._extract_filter_value(
filters,
filter_names=["MIN_NOTIONAL", "NOTIONAL"],
keys=["minNotional", "notional"],
)
return acquisition_service.load_quote(
_QUOTE_SOURCE_NAME,
symbol,
)
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,
# Собрать acquisition pipeline и вернуть канонические модели Instrument.
def _load_instruments_via_acquisition(
self,
) -> tuple[Instrument, ...]:
source = DzengiInstrumentDocumentSource()
handler = DzengiInstrumentDocumentHandler()
feed = InstrumentFeed(
source=source,
handler=handler,
)
registry = InstrumentFeedRegistry()
registry.register(
_INSTRUMENT_REFERENCE_SOURCE_NAME,
feed,
)
acquisition_service = InstrumentAcquisitionService(
registry=registry,
)
return acquisition_service.load_instruments(
_INSTRUMENT_REFERENCE_SOURCE_NAME
)
# Безопасно привести значение к строке.
@@ -1178,91 +1053,6 @@ class ExchangeService:
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:
requested = normalize_symbol(raw_symbol)
@@ -1285,43 +1075,40 @@ class ExchangeService:
symbol_info=None,
)
symbols = self.get_exchange_symbols()
candidates = symbol_candidates(requested)
instruments = self.get_instruments()
for candidate in candidates:
for symbol_info in symbols:
if normalize_symbol(symbol_info.symbol) == candidate:
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=normalize_symbol(symbol_info.symbol),
is_valid=True,
message="Символ найден в exchangeInfo.",
symbol_info=symbol_info,
)
matched_index = resolve_symbol_index(
requested,
[
instrument.symbol
for instrument in instruments
],
)
if matched_index is not None:
instrument = instruments[matched_index]
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=normalize_symbol(
instrument.symbol
),
is_valid=True,
message="Символ найден в exchangeInfo.",
symbol_info=instrument,
)
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=requested,
is_valid=False,
message=f"Символ '{requested}' не найден в exchangeInfo.",
message=(
f"Символ '{requested}' "
"не найден в exchangeInfo."
),
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:
payload = ExchangeRestClient().get_json("/api/v1/time")

View File

@@ -9,6 +9,10 @@ from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeResponseError,
)
from src.market_data.acquisition.models.status import (
InstrumentTradingState,
classify_instrument_status,
)
class ExchangeStatusCode(StrEnum):
@@ -35,7 +39,7 @@ class ExchangeRuntimeStatus:
raw_status: str | None = None
raw_error: str | None = None
# вернуть статус в dict для старого UI-кода на время миграции
# Вернуть статус в dict для старого UI-кода на время миграции.
def as_dict(self) -> dict[str, object]:
return {
"code": self.code.value,
@@ -79,7 +83,7 @@ def build_market_stale_status(
)
# собрать статус mock-режима
# Собрать статус mock-режима.
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
@@ -95,48 +99,21 @@ def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
)
# собрать статус ошибки авторизации аккаунта
# Собрать статус ошибки авторизации аккаунта.
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
return build_exchange_error_status(exc)
OPEN_STATUSES = {
"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-статус по статусу инструмента биржи
# Собрать legacy runtime-статус по канонической классификации инструмента.
def build_market_status_from_symbol_status(
*,
raw_status: str | None,
symbol: str,
) -> 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(
code=ExchangeStatusCode.OPEN,
is_open=True,
@@ -150,15 +127,7 @@ def build_market_status_from_symbol_status(
symbol=symbol,
)
if normalized_status in {
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}:
if classification.state == InstrumentTradingState.NOT_TRADABLE:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
@@ -171,8 +140,8 @@ def build_market_status_from_symbol_status(
raw_status=normalized_status,
symbol=symbol,
)
if normalized_status in BREAK_STATUSES:
if classification.state == InstrumentTradingState.BREAK:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
@@ -198,12 +167,12 @@ def build_market_status_from_symbol_status(
),
ui_line="⚠️ Статус торгов неизвестен",
reason="market_status_unknown",
raw_status=normalized_status or None,
raw_status=normalized_status,
symbol=symbol,
)
# собрать единый статус для неверного торгового инструмента
# Собрать единый статус для неверного торгового инструмента.
def build_invalid_symbol_status(
*,
symbol: str,
@@ -223,7 +192,7 @@ def build_invalid_symbol_status(
)
# собрать единый статус по ошибке exchange/API
# Собрать единый статус по ошибке exchange/API.
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
error_type = classify_exchange_error(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:
text = str(exc).lower()
@@ -326,7 +295,7 @@ def classify_exchange_error(exc: Exception) -> str:
return "generic"
# проверить, относится ли reason к unified exchange status layer
# Проверить, относится ли reason к unified exchange status layer.
def is_exchange_status_reason(reason: str | None) -> bool:
if not reason:
return False

View File

@@ -2,24 +2,13 @@
from __future__ import annotations
def normalize_symbol(raw_symbol: str) -> str:
return (raw_symbol or "").strip().upper()
from src.market_data.acquisition.symbols import (
normalize_symbol,
symbol_candidates,
)
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
__all__ = [
"normalize_symbol",
"symbol_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
import json
@@ -57,4 +59,4 @@ class BalanceSnapshotRepository:
}
)
return items
return items

View File

@@ -41,4 +41,4 @@ def check_database_health() -> tuple[bool, str]:
except Exception as exc:
return False, f"PostgreSQL error: {exc}"
return True, version
return True, version

View File

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

View File

@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
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.telegram.ui.common import mode_line
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:
snapshot = _market_snapshot(getattr(state, "symbol", None))
quote = _market_quote(getattr(state, "symbol", None))
bid_price = _price_from_snapshot(snapshot, "bid_price")
ask_price = _price_from_snapshot(snapshot, "ask_price")
bid_price = _price_from_quote(quote, "bid_price")
ask_price = _price_from_quote(quote, "ask_price")
side = "Long" if signal == "BUY" else "Short"
side_icon = _signal_icon(signal)
@@ -85,28 +86,28 @@ def _build_signal_notification_text(state, signal: str) -> str:
return "\n".join(lines)
def _price_from_snapshot(
snapshot: dict[str, object] | None,
def _price_from_quote(
quote: Quote | None,
key: str,
) -> float | None:
if snapshot is None:
if quote is None:
return None
return safe_float(snapshot.get(key))
return safe_float(getattr(quote, key, 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()
if side == "LONG":
price = snapshot.get("bid_price") or snapshot.get("last_price")
price = quote.bid_price or quote.last_price
elif side == "SHORT":
price = snapshot.get("ask_price") or snapshot.get("last_price")
price = quote.ask_price or quote.last_price
else:
price = snapshot.get("last_price")
price = quote.last_price
parsed = safe_float(price)
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)}"
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
def _market_quote(symbol: str | None) -> Quote | None:
if not symbol:
return None
try:
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
return ExchangeService().get_quote(
symbol,
runtime_key="auto",
)
except Exception:
return None
@@ -907,10 +911,10 @@ def _commission_lines_for_position(
def _current_price(symbol: str | None) -> float | None:
snapshot = _market_snapshot(symbol)
quote = _market_quote(symbol)
if snapshot is not None:
price = snapshot.get("last_price")
if quote is not None:
price = quote.last_price
if price is not None:
try:
parsed = safe_float(price)
@@ -922,25 +926,25 @@ def _current_price(symbol: str | None) -> float | None:
return None
try:
return float(ExchangeService().get_price(symbol).price)
return float(ExchangeService().get_quote(symbol).last_price)
except Exception:
return 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)
signal = (state.last_signal or "HOLD").upper()
if signal == "BUY":
price = snapshot.get("ask_price")
price = quote.ask_price
elif signal == "SELL":
price = snapshot.get("bid_price")
price = quote.bid_price
else:
price = snapshot.get("last_price")
price = quote.last_price
if price is None:
return None
@@ -1535,8 +1539,16 @@ def _trade_word(value: int) -> 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_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)

View File

@@ -3,10 +3,15 @@
from __future__ import annotations
import time
from datetime import datetime, timezone
from decimal import Decimal
from zoneinfo import ZoneInfo
from aiogram.types import InlineKeyboardMarkup
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.trading.debug.service import DebugTradeService
@@ -113,6 +118,23 @@ def _format_updated_at(value: object) -> str:
if not value:
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)
if " " in text:
@@ -121,6 +143,23 @@ def _format_updated_at(value: object) -> str:
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]:
if not symbol:
return [
@@ -141,7 +180,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
error = None
try:
market = ExchangeService().get_market_snapshot(
market = ExchangeService().get_quote(
symbol,
runtime_key="debug_auto",
)
@@ -167,11 +206,11 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
f"Error · {error or 'unknown'}",
]
last_price = market.get("last_price") if market else getattr(execution, "last_price", None)
bid_price = market.get("bid_price") if market else getattr(execution, "bid_price", None)
ask_price = market.get("ask_price") if market else getattr(execution, "ask_price", None)
market_source = market.get("source") if market else ""
market_age = market.get("age_seconds") if market else None
last_price = market.last_price if market else getattr(execution, "last_price", None)
bid_price = market.bid_price if market else getattr(execution, "bid_price", None)
ask_price = market.ask_price if market else getattr(execution, "ask_price", None)
market_source = market.source if market else ""
market_age = _quote_age_seconds(market) if market else None
execution_source = getattr(execution, "source", "") if execution else ""
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"Source · {market_source or ''}",
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>",
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(".")
def _format_money_compact(value: float | int | None) -> str:
def _format_money_compact(
value: float | int | Decimal | None,
) -> str:
if value is None:
return ""
@@ -286,21 +327,25 @@ def _format_money_compact(value: float | int | None) -> str:
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:
return ""
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:
return "off"
return "Выкл."
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:
return ""
@@ -315,7 +360,7 @@ def _format_signed_usd(value: float | int | None) -> str:
return "$ 0"
def _format_age(value: object) -> str:
def _format_age(value: NumericLike | None) -> str:
if value is None:
return ""

View File

@@ -183,6 +183,7 @@ async def _show_journal_page(
await target_message.edit_text(
text,
reply_markup=kb,
parse_mode="HTML",
)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
@@ -197,6 +198,7 @@ async def _show_journal_page(
sent_message = await target_message.answer(
text,
reply_markup=kb,
parse_mode="HTML",
)
_register_journal_screen(sent_message)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import datetime
from html import escape
from zoneinfo import ZoneInfo
from aiogram.types import InlineKeyboardMarkup
@@ -67,6 +68,10 @@ def build_keyboard(
return kb.as_markup()
def _html(value: object) -> str:
return escape(str(value or ""), quote=False)
def build_actions_keyboard() -> InlineKeyboardMarkup:
# Первый экран экспорта: выбираем, что именно экспортировать.
kb = InlineKeyboardBuilder()
@@ -233,8 +238,8 @@ def _render_auto_signal(
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
title = _html(_event_title(event.get("event_type")))
message = _html(_humanize_message(event.get("message")))
lines = [
f"{icon} <b>{level}</b> · {title}",
@@ -253,8 +258,8 @@ def _render_default_event(
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
title = _html(_event_title(event.get("event_type")))
message = _html(_humanize_message(event.get("message")))
lines = [
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 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.market_data.acquisition.models.instrument import Instrument
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:
return CURRENCY_ICONS.get(currency.upper(), currency.upper())
return CURRENCY_ICONS.get(
currency.upper(),
currency.upper(),
)
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:
if is_fiat_currency(currency):
return f"{value:,.2f}".replace(",", " ")
return f"{value:,.8f}".replace(",", " ")
@@ -52,7 +57,9 @@ def format_usd_amount(value: float) -> str:
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:
return ""
@@ -62,7 +69,9 @@ def format_usd_price(value: float | int | str | None) -> str:
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:
return ""
@@ -87,7 +96,10 @@ def render_currency_line(
show_code: bool = True,
) -> str:
icon = get_currency_icon(currency)
amount = format_amount(currency, value)
amount = format_amount(
currency,
value,
)
if show_code:
return f"{icon} {currency.upper()} · {amount}"
@@ -100,61 +112,79 @@ def balance_total(item: BalanceSummary) -> float:
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:
value = (quote_asset or "").upper()
if value == "USD":
return 3
if value == "USDT":
return 2
return 0
def _status_priority(status: str) -> int:
value = (status or "").upper()
if value == "TRADING":
return 2
if value in {"HALT", "BREAK"}:
return 0
return 1
def _market_type_priority(market_type: str) -> int:
value = (market_type or "").upper()
if value == "SPOT":
return 3
if value == "LEVERAGE":
return 2
return 1
def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]:
def _instrument_priority(
instrument: Instrument,
) -> tuple[int, int, int, str]:
return (
_quote_priority(symbol_info.quote_asset),
_status_priority(symbol_info.status),
_market_type_priority(symbol_info.market_type),
symbol_info.symbol.upper(),
_quote_priority(instrument.quote_asset),
_status_priority(instrument.status),
_market_type_priority(instrument.market_type),
instrument.symbol.upper(),
)
def _resolve_asset_quote_symbol(
def _resolve_asset_quote_instrument(
exchange_service: ExchangeService,
asset: str,
) -> ExchangeSymbol | None:
) -> Instrument | None:
asset_upper = asset.upper()
try:
symbols = exchange_service.get_exchange_symbols()
instruments = exchange_service.get_instruments()
except ExchangeError:
return None
candidates: list[ExchangeSymbol] = []
candidates: list[Instrument] = []
for symbol_info in symbols:
base_asset = (symbol_info.base_asset or "").upper()
quote_asset = (symbol_info.quote_asset or "").upper()
for instrument in instruments:
base_asset = (
instrument.base_asset or ""
).upper()
quote_asset = (
instrument.quote_asset or ""
).upper()
if base_asset != asset_upper:
continue
@@ -162,12 +192,16 @@ def _resolve_asset_quote_symbol(
if quote_asset not in {"USD", "USDT"}:
continue
candidates.append(symbol_info)
candidates.append(instrument)
if not candidates:
return None
candidates.sort(key=_symbol_priority, reverse=True)
candidates.sort(
key=_instrument_priority,
reverse=True,
)
return candidates[0]
@@ -184,18 +218,26 @@ def get_asset_usd_rate(
if asset in price_cache:
return price_cache[asset]
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset)
if symbol_info is None:
instrument = _resolve_asset_quote_instrument(
exchange_service,
asset,
)
if instrument is None:
price_cache[asset] = None
return None
try:
ticker = exchange_service.get_price(symbol_info.symbol)
rate = float(ticker.price)
quote = exchange_service.get_quote(
instrument.symbol
)
rate = float(quote.last_price)
# Пока считаем USDT ~= USD
# Пока считаем USDT ~= USD.
price_cache[asset] = rate
return rate
except ExchangeError:
price_cache[asset] = None
return None
@@ -207,10 +249,16 @@ def estimate_balance_usd(
price_cache: dict[str, float | None],
) -> float | None:
total = balance_total(item)
if total <= 0:
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:
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.types import NumericLike
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.strategies.base import BaseStrategy, StrategyContext
from src.trading.strategies.registry import StrategyRegistry
@@ -95,7 +110,7 @@ class AutoLifecycleMixin(
numeric_value = 1000.0
state.allocated_balance_usd = numeric_value
state.execution_block_reason = None
reset_execution_block_state(state)
state.execution_size_adjustment_reason = None
return state
@@ -139,15 +154,14 @@ class AutoLifecycleMixin(
if state.status == "OBSERVING":
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-блокировку,
# чтобы запуск не наследовал паузу прошлого цикла.
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.execution_block_reason = None
reset_loss_cooldown_state(state)
reset_execution_block_state(state)
EventBus.emit(
"auto_status_changed",
@@ -168,30 +182,17 @@ class AutoLifecycleMixin(
state.status = "RUNNING"
self._reset_signal_tracking()
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.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
reset_cycle_statistics_state(state)
reset_loss_cooldown_state(state)
reset_flip_runtime_state(state)
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.signal_started_at = time.monotonic()
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
EventBus.emit(
"auto_status_changed",
{
@@ -227,30 +228,13 @@ class AutoLifecycleMixin(
)
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
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.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
reset_cycle_statistics_state(state)
reset_loss_cooldown_state(state)
reset_flip_runtime_state(state)
reset_execution_runtime_state(state)
reset_position_semantics_state(state)
state.cycle_started_at = None
self._log_auto_status_changed(
previous_status=previous_status,
@@ -279,31 +263,14 @@ class AutoLifecycleMixin(
return state, "Автоторговля уже выключена."
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
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.execution_block_reason = None
state.cycle_winning_trades = 0
state.cycle_trade_fees_usd = 0.0
state.cycle_overnight_fees_usd = 0.0
reset_cycle_statistics_state(state)
reset_loss_cooldown_state(state)
reset_execution_runtime_state(state)
reset_adaptive_size_state(state)
reset_flip_runtime_state(state)
reset_position_semantics_state(state)
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()
EventBus.emit(
@@ -376,7 +343,7 @@ class AutoLifecycleMixin(
def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.max_reserved_balance_percent = safe_float(value)
state.execution_block_reason = None
reset_execution_block_state(state)
return state
def _reset_signal_tracking(self) -> None:
@@ -390,180 +357,23 @@ class AutoLifecycleMixin(
state = self.get_state()
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
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_semantics_state(state)
reset_position_protection_state(state)
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_missing_repeats = self._confirm_repeats
state.signal_confirmation_progress = 0.0
state.signal_confirmation_reason = None
state.signal_started_at = None
state.signal_updated_at = None
state.execution_confidence_required_score = (
self._execution_confidence_required_score
)
state.execution_block_reason = None
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
reset_position_health_state(state)
def _build_strategy_context(self) -> StrategyContext:
state = self.get_state()

View File

@@ -6,6 +6,7 @@ import time
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.models import ExecutionPriceSnapshot
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import (
ExchangeRuntimeStatus,
@@ -59,7 +60,7 @@ class AutoExecutionQualityMixin:
state.market_is_open = status.is_open
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()
if status.is_open:
@@ -253,34 +254,20 @@ class AutoExecutionQualityMixin:
return
try:
snapshot = ExchangeService().get_market_snapshot(
snapshot = ExchangeService().get_execution_snapshot(
state.symbol,
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:
fallback_price = None
try:
fallback_price = safe_float(
ExchangeService().get_price(
ExchangeService().get_quote(
state.symbol,
runtime_key="auto",
).price
).last_price
)
except Exception:
pass
@@ -319,12 +306,12 @@ class AutoExecutionQualityMixin:
)
return
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
last_price = safe_float(snapshot.get("last_price"))
age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "")
bid_price = safe_float(snapshot.bid_price)
ask_price = safe_float(snapshot.ask_price)
last_price = safe_float(snapshot.last_price)
age_seconds = safe_float(snapshot.age_seconds)
is_fresh = snapshot.is_fresh
source = snapshot.source
self._sync_execution_pricing_state(
state,
@@ -432,15 +419,15 @@ class AutoExecutionQualityMixin:
def _sync_execution_pricing_state(
self,
state: AutoTradeState,
snapshot: dict[str, object],
snapshot: ExecutionPriceSnapshot,
) -> 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_bid_price = safe_float(snapshot.get("bid_price"))
state.execution_ask_price = safe_float(snapshot.get("ask_price"))
state.execution_last_price = safe_float(snapshot.get("last_price"))
state.execution_bid_price = safe_float(snapshot.bid_price)
state.execution_ask_price = safe_float(snapshot.ask_price)
state.execution_last_price = safe_float(snapshot.last_price)
if age_seconds is None:
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.types import NumericLike
from src.trading.auto.state import AutoTradeState
from src.trading.auto.state_reset import reset_position_health_state
from src.trading.execution.constants import (
EXECUTION_QUALITY_BLOCKED,
EXECUTION_QUALITY_WARNING,
@@ -37,17 +38,7 @@ class AutoPositionHealthMixin:
# синхронизировать runtime health/risk состояние открытой позиции
def _sync_position_health_state(self, state: AutoTradeState) -> None:
if state.position_side == "NONE" or state.entry_price is 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
reset_position_health_state(state)
return
# PnL % и время удержания больше не считаем здесь.

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from src.core.numbers import safe_float
from src.trading.auto.state import AutoTradeState
from src.trading.auto.state_reset import reset_position_semantics_state
from src.trading.execution.constants import (
POSITION_EXIT_DAMPING_MATURE_MULTIPLIER,
POSITION_EXIT_DAMPING_MATURE_SECONDS,
@@ -33,25 +34,7 @@ class AutoPositionSemanticsMixin:
# синхронизировать semantics-состояние открытой позиции
def _sync_position_semantics_state(self, state: AutoTradeState) -> None:
if state.position_side == "NONE" or state.entry_price is 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
reset_position_semantics_state(state)
return
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.types import JsonDict, NumericLike
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_reset import (
reset_after_market_runtime_expired,
reset_after_signal_runtime_expired,
)
from src.trading.journal.service import JournalService
@@ -137,6 +142,180 @@ class AutoSignalRuntimeMixin:
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(
self,
@@ -216,6 +395,14 @@ class AutoSignalRuntimeMixin:
f"Сигнал {signal} подтверждён, но уверенность низкая: "
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
self._sync_execution_confidence_state(
@@ -234,6 +421,14 @@ class AutoSignalRuntimeMixin:
f"{state.execution_confidence_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
state.is_signal_ready = True
@@ -471,7 +666,7 @@ class AutoSignalRuntimeMixin:
"is_aggregated": True,
"payload": payload or {},
}
# записать итог серии одинаковых сигналов при смене сигнала
def _log_signal_summary(
self,
@@ -522,7 +717,7 @@ class AutoSignalRuntimeMixin:
self,
*,
state: AutoTradeState,
snapshot: JsonDict,
quote: Quote | None,
signal: str,
signal_intent: str,
confidence: float,
@@ -593,9 +788,9 @@ class AutoSignalRuntimeMixin:
"snapshot_age_seconds": state.snapshot_age_seconds,
# ---------- Live Snapshot ----------
"bid_price": snapshot.get("bid_price"),
"ask_price": snapshot.get("ask_price"),
"last_price": snapshot.get("last_price"),
"bid_price": safe_float(quote.bid_price) if quote is not None else None,
"ask_price": safe_float(quote.ask_price) if quote is not None else None,
"last_price": safe_float(quote.last_price) if quote is not None else None,
# ---------- Market Score ----------
"market_score": state.market_score,
@@ -665,7 +860,7 @@ class AutoSignalRuntimeMixin:
"market_status": state.market_status,
"market_status_message": state.market_status_message,
}
# записать событие готовности сигнала к исполнению
def _log_ready_signal(
self,
@@ -681,12 +876,12 @@ class AutoSignalRuntimeMixin:
return
try:
snapshot = ExchangeService().get_market_snapshot(
quote = ExchangeService().get_quote(
state.symbol,
runtime_key="auto",
)
except Exception:
snapshot = {}
quote = None
try:
JournalService().log_ui_info(
@@ -698,7 +893,7 @@ class AutoSignalRuntimeMixin:
action="signal_ready",
payload=self._build_ready_signal_payload(
state=state,
snapshot=snapshot,
quote=quote,
signal=normalized_signal,
signal_intent=signal_intent,
confidence=confidence,
@@ -733,25 +928,13 @@ class AutoSignalRuntimeMixin:
self._last_signal_started_at = None
self._same_signal_count = 0
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.decision_status = "WAITING"
reset_after_signal_runtime_expired(state)
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_progress = 0.0
state.signal_confirmation_reason = None
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.execution_confidence_required_score = (
self._execution_confidence_required_score
)
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
state.runtime_expired_message = "сигнал устарел и был сброшен"
@@ -779,64 +962,7 @@ class AutoSignalRuntimeMixin:
market_age = now - market_updated
if market_age > self._market_analysis_ttl_seconds:
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.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
reset_after_market_runtime_expired(state)
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
state.runtime_expired_message = "анализ рынка устарел"
@@ -866,7 +992,7 @@ class AutoSignalRuntimeMixin:
"status": state.status,
"runtime_expired_reason": reason,
}
# записать событие устаревания runtime данных
def _log_runtime_expired_if_changed(
self,
@@ -941,7 +1067,7 @@ class AutoSignalRuntimeMixin:
"breakout_distance_percent": getattr(state, "breakout_distance_percent", None),
"breakout_reason": getattr(state, "breakout_reason", None),
}
# синхронизировать итоговый execution confidence
def _sync_execution_confidence_state(
self,
@@ -961,10 +1087,8 @@ class AutoSignalRuntimeMixin:
signal_score = self._clamp_score(confidence)
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
# ВАЖНО:
# market_score теперь считается с учётом направления сигнала.
# Раньше BUY мог получить хороший market_score просто потому,
# что рынок трендовый, даже если тренд/моментум были против BUY.
# Сейчас market_score считается как entry-confidence.
# Для reversal/flip это полезно диагностировать, но пока не меняем поведение.
market_score = self._market_confidence_score(
state=state,
signal=signal,
@@ -1077,6 +1201,7 @@ class AutoSignalRuntimeMixin:
return 0.15
# Жёсткая защита от входа против локального тренда.
# Для будущего этапа: именно это может быть слишком жёстко для flip.
if normalized_signal == "BUY" and market_trend == "DOWN":
return 0.05
@@ -1084,6 +1209,7 @@ class AutoSignalRuntimeMixin:
return 0.05
# Жёсткая защита от входа против momentum.
# Для будущего этапа: reversal может начинаться до смены полного trend.
if normalized_signal == "BUY" and momentum_direction == "DOWN":
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
from datetime import datetime
from src.core.types import NumericLike
from src.integrations.exchange.service import ExchangeService
from src.trading.debug.state import DebugPositionState, DebugTradeState
from src.trading.execution.models import ExecutionDecision
@@ -389,49 +390,88 @@ class DebugExecutionEngine:
return self._market_last_price(state.symbol)
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":
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":
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:
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
snapshot = ExchangeService().get_execution_snapshot(
symbol,
runtime_key="debug_auto",
)
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":
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:
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
return self._snapshot_price(snapshot, "last_price")
snapshot = ExchangeService().get_execution_snapshot(
symbol,
runtime_key="debug_auto",
)
return self._execution_price(
snapshot.last_price,
price_name="last_price",
)
def _snapshot_price(
def _execution_price(
self,
snapshot: dict[str, object],
primary_key: str,
fallback_key: str | None = None,
raw_price: NumericLike | None,
fallback_price: NumericLike | None = None,
*,
price_name: str,
) -> float:
raw_price = snapshot.get(primary_key)
value = raw_price
if raw_price is None and fallback_key is not None:
raw_price = snapshot.get(fallback_key)
if value is None:
value = fallback_price
if raw_price is None:
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
if value is None:
raise ValueError(
f"Execution price '{price_name}' is missing."
)
price = float(raw_price)
price = float(value)
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

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:
from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot(
quote = ExchangeService().get_quote(
state.symbol,
runtime_key="auto",
)
side = str(state.position_side or "").upper()
price = snapshot.get("last_price")
price = quote.last_price
if side == "LONG":
price = snapshot.get("bid_price") or price
price = quote.bid_price or price
elif side == "SHORT":
price = snapshot.get("ask_price") or price
price = quote.ask_price or price
return safe_float(price)

View File

@@ -52,6 +52,7 @@ class ExecutionEngine(
_flip_cooldown_seconds = 45
_loss_flip_confidence = 0.75
_last_flip_block_key: str | None = None
_last_flip_diagnostic_key: str | None = None
_runtime_action_cooldown_seconds = 30
_last_runtime_action_key: str | None = None
_emergency_halt_drawdown_usd = 250.0
@@ -107,6 +108,18 @@ class ExecutionEngine(
if protection_decision is not None:
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
if state.decision_status != EXECUTION_DECISION_READY or not state.is_signal_ready:
reason = (
@@ -115,6 +128,13 @@ class ExecutionEngine(
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(
state,
reason,
@@ -123,6 +143,13 @@ class ExecutionEngine(
# Execution supervisor
supervisor_decision = self._process_execution_supervisor(state)
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
# Existing position validation
@@ -131,21 +158,19 @@ class ExecutionEngine(
# Не пытаемся повторно открыть позицию в ту же сторону.
# Сигнал остаётся валидным для UI/Telegram, но execution не дублируется.
if position.side == POSITION_SIDE_LONG and state.last_signal == SIGNAL_BUY:
return ExecutionDecision(
EXECUTION_ACTION_NONE,
False,
return self._skip_execution(
state,
"Сигнал BUY совпадает с уже открытой LONG позицией.",
)
if position.side == POSITION_SIDE_SHORT and state.last_signal == SIGNAL_SELL:
return ExecutionDecision(
EXECUTION_ACTION_NONE,
False,
return self._skip_execution(
state,
"Сигнал SELL совпадает с уже открытой SHORT позицией.",
)
# Position flip
if self._should_flip_position(state):
if flip_requested:
flip_block_reason = self._flip_block_reason(state)
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.types import JsonDict, NumericLike
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.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.journal.service import JournalService
from src.trading.position.state import PositionState
@@ -44,6 +58,7 @@ class _ExecutionFlipProtocol(Protocol):
_flip_cooldown_seconds: int
_loss_flip_confidence: float
_last_flip_block_key: str | None
_last_flip_diagnostic_key: str | None
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
...
@@ -97,8 +112,73 @@ class _ExecutionFlipProtocol(Protocol):
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 отказа flip без изменения состояния
# Собрать payload отказа flip без изменения состояния позиции.
def _build_flip_rejected_payload(
self,
*,
@@ -111,30 +191,17 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
"execution_type": EXECUTION_TYPE_FLIP_REJECTED,
"symbol": state.symbol,
"position_side": position.side,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"execution_confidence_score": state.execution_confidence_score,
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
**build_signal_payload(state),
**build_execution_quality_payload(state),
"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,
"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,
**build_market_context_payload(state),
"opened_at": position.opened_at,
"updated_at": position.updated_at,
}
# собрать payload блокировки flip без изменения состояния
# Собрать payload блокировки flip guard'ами без изменения состояния позиции.
def _build_flip_blocked_payload(
self,
*,
@@ -148,28 +215,20 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
"execution_type": EXECUTION_TYPE_FLIP_BLOCKED,
"symbol": state.symbol,
"position_side": position.side,
"signal": state.last_signal,
"confidence": confidence,
"execution_confidence_score": state.execution_confidence_score,
"repeat_count": state.last_signal_repeat_count,
"reason": reason,
# Общая оценка рынка на момент блокировки flip.
"market_score": getattr(state, "market_score", None),
"market_score_label": getattr(state, "market_score_label", None),
**build_signal_payload(
state,
confidence=confidence,
reason=reason,
),
**build_execution_quality_payload(state),
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"market_state": state.market_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,
**build_market_context_payload(state),
"opened_at": position.opened_at,
"updated_at": position.updated_at,
}
# собрать payload выполненного flip без изменения состояния
# Собрать payload выполненного flip.
# Здесь фиксируем и закрытую старую позицию, и параметры новой позиции.
def _build_flip_executed_payload(
self,
*,
@@ -234,79 +293,28 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
"hold_seconds": metrics.hold_seconds,
"overnight_count": metrics.overnight_count,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"execution_confidence_score": state.execution_confidence_score,
"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),
**build_signal_payload(state),
**build_execution_quality_payload(state),
**build_adaptive_size_payload(state),
"opened_at": old_opened_at,
"new_opened_monotonic_at": opened_monotonic_at,
"closed_at": now,
"new_opened_at": now,
"market_state": state.market_state,
"market_trend": state.market_trend,
"market_phase": state.market_phase,
"market_structure": state.market_structure,
**build_market_context_payload(state),
# ---------- Position health ----------
"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,
**build_position_health_payload(state),
# ---------- 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,
**build_position_intelligence_payload(state),
# ---------- 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,
**build_autonomous_payload(state),
# ---------- Runtime protection ----------
"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,
**build_runtime_protection_payload(state),
"htf_alignment": state.htf_alignment,
"htf_confirmation_score": state.htf_confirmation_score,
"momentum_state": state.momentum_state,
"momentum_direction": state.momentum_direction,
# ---------- Pricing diagnostics ----------
"pricing": PRICING_FLIP_MODE,
"exit_pricing_role": exit_execution.pricing_role,
"exit_price_source": exit_execution.source,
@@ -319,7 +327,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
}
# ---------- Journal helpers ----------
# записать отказ flip execution в журнал
# Записать отказ flip execution в журнал.
# Reject отличается от block: reject происходит уже внутри попытки исполнения,
# например из-за отсутствия цены или невозможности рассчитать size.
def _log_flip_rejected(
self,
*,
@@ -340,17 +350,26 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
)
# ---------- Decision helpers ----------
# записать отказ flip и вернуть стандартное решение без исполнения
# Записать отказ flip и вернуть стандартное решение без исполнения.
# diagnostic_stage указывает, на каком техническом этапе flip был отклонён.
def _reject_flip(
self,
*,
state: AutoTradeState,
reason: str,
diagnostic_stage: str | None = None,
) -> 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)
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
# записать блокировку flip в state, journal и event bus
# Записать блокировку flip guard'ами в state, journal и event bus.
def _block_flip(
self,
state: AutoTradeState,
@@ -359,6 +378,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
position = type(self)._position
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.last_flip_block_reason = reason
state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED
@@ -394,7 +421,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
# ---------- Flip checks ----------
# проверить, нужен ли flip позиции по текущему сигналу
# Проверить, нужен ли flip позиции по текущему сигналу.
# Здесь только факт противоположного сигнала, без оценки качества рынка.
def _should_flip_position(self, state: AutoTradeState) -> bool:
position = type(self)._position
signal = str(state.last_signal or "").upper()
@@ -410,7 +438,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
return False
# определить причину блокировки flip, если flip сейчас опасен
# Определить причину блокировки flip, если flip сейчас опасен.
# Важно: пока торговую логику не меняем, только делаем её наблюдаемой.
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
position = type(self)._position
@@ -419,6 +448,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
execution_confidence = safe_float(state.execution_confidence_score)
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
# hold_seconds считаем через position metrics.
# current_price пока берём entry_price, чтобы не менять текущую механику.
metrics = build_position_metrics(
position,
current_price=position.entry_price,
@@ -531,7 +563,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
return None
# проверить, активен ли cooldown после последнего flip
# Проверить, активен ли cooldown после последнего flip.
def _flip_cooldown_active(self, state: AutoTradeState) -> bool:
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
# определить сторону позиции по сигналу BUY / SELL
# Определить сторону новой позиции по сигналу BUY / SELL.
def _target_side_from_signal(self, signal: str | None) -> str | None:
normalized_signal = str(signal or "").upper()
@@ -553,22 +585,33 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
return None
# ---------- Execution ----------
# закрыть текущую позицию и открыть новую в противоположную сторону
# Закрыть текущую позицию и открыть новую в противоположную сторону.
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
position = type(self)._position
if position.side == POSITION_SIDE_NONE:
self._sync_state_from_position(state)
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)
if new_side is None:
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:
# Для flip нужны две цены:
# 1) exit price — закрытие старой позиции;
# 2) entry price — открытие новой позиции.
exit_execution = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
@@ -582,7 +625,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
except Exception as 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()
opened_monotonic_at = time.monotonic()
@@ -602,7 +649,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
if new_size <= 0:
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(
state=state,
@@ -620,7 +671,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
if new_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.cycle_realized_pnl_usd += pnl
@@ -631,16 +686,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
if pnl > 0:
state.cycle_winning_trades += 1
# прибыльный flip закрывает серию убытков
# Прибыльный flip закрывает серию убытков и выключает loss cooldown.
state.cycle_consecutive_losses = 0
state.loss_cooldown_active = False
state.loss_cooldown_reason = None
elif pnl < 0:
state.cycle_losing_trades += 1
state.cycle_consecutive_losses += 1
state.last_loss_monotonic_at = time.monotonic()
if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES:
@@ -661,9 +714,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
# Flip открывает новую позицию, поэтому autonomous runtime прошлой позиции
# нельзя переносить на новую сделку.
state.autonomous_last_action = None
state.autonomous_last_action_reason = None
state.autonomous_last_action_at = None
reset_autonomous_runtime_state(state)
state.last_flip_old_side = old_side
state.last_flip_new_side = new_side
@@ -703,7 +754,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
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_execution_action = flip_action
state.last_execution_reason = "Направление позиции изменено."
@@ -735,6 +786,13 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
entry_execution=entry_execution,
)
# Отдельная диагностика успешного прохождения всего flip-пайплайна.
self._log_flip_diagnostic(
state=state,
stage="FLIP_EXECUTED",
reason=f"{old_side} -> {new_side}",
)
JournalService().log_ui_info(
event_type="position_flipped",
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.types import JsonDict, NumericLike
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.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.journal.service import JournalService
from src.trading.position.state import PositionState
@@ -124,127 +142,19 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
"action": action,
"reject_reason": reason,
# ---------- Runtime ----------
"status": state.status,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
# ---------- Instrument ----------
"symbol": state.symbol,
"side": side,
# ---------- 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,
# ---------- 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,
**build_runtime_payload(state),
**build_signal_payload(state),
**build_decision_payload(state),
**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),
**build_market_context_payload(state),
}
# собрать payload успешного открытия позиции без изменения состояния
@@ -270,67 +180,15 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
"execution_type": EXECUTION_TYPE_ENTRY,
"action": action,
# ---------- Runtime ----------
"status": state.status,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
# ---------- Position ----------
"symbol": state.symbol,
"side": side,
"entry_price": entry_price,
"size": size,
"leverage": state.leverage,
"opened_at": now,
"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_ENTRY_MODE,
"pricing_role": entry.pricing_role,
@@ -338,81 +196,16 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
"price_age_seconds": entry.age_seconds,
"price_updated_at": entry.updated_at,
# ---------- 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,
# ---------- 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,
**build_runtime_payload(state),
**build_position_health_payload(state),
**build_signal_payload(state),
**build_decision_payload(state),
**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),
**build_market_context_payload(state),
}
# собрать payload закрытия позиции без изменения состояния
@@ -446,10 +239,14 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
"close_reason": close_reason,
"is_forced": forced_reason is not None,
# ---------- Runtime ----------
"status": state.status,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
**build_runtime_payload(state),
**build_signal_payload(state),
**build_decision_payload(state),
**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 ----------
"symbol": state.symbol,
@@ -477,45 +274,6 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
"hold_seconds": metrics.hold_seconds,
"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_EXIT_MODE,
"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_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 ----------
"realized_pnl_usd_before": state.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_overnight_fees_usd_before": state.cycle_overnight_fees_usd,
# ---------- Position Health ----------
"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,
# ---------- 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,
**build_position_health_payload(state),
**build_position_intelligence_payload(state),
**build_autonomous_payload(state),
**build_market_context_payload(state),
}
# ---------- Journal helpers ----------
@@ -811,7 +469,7 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
# чтобы UI/protection/semantics не ждали следующего цикла.
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_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта."
@@ -972,15 +630,13 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
# После закрытия очищаем autonomous cooldown/action,
# чтобы новая сделка не унаследовала runtime-действие прошлой позиции.
state.autonomous_last_action = None
state.autonomous_last_action_reason = None
state.autonomous_last_action_at = None
reset_autonomous_runtime_state(state)
# После закрытия очищаем protection и lifecycle runtime закрытой позиции.
self._reset_runtime_protection_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_execution_action = (

View File

@@ -43,30 +43,28 @@ class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol)
if self._is_normal_pullback_wave(state=state, metrics=metrics):
return None
giveback_reason = self._giveback_close_reason(
giveback_reason = self._apply_intelligence_exit(
state=state,
metrics=metrics,
reason=self._giveback_close_reason(
state=state,
metrics=metrics,
),
algorithm="GIVEBACK",
)
if giveback_reason is not None:
self._sync_intelligence_exit_state(
state=state,
reason=giveback_reason,
algorithm="GIVEBACK",
)
return giveback_reason
time_decay_reason = self._time_decay_close_reason(
time_decay_reason = self._apply_intelligence_exit(
state=state,
metrics=metrics,
reason=self._time_decay_close_reason(
state=state,
metrics=metrics,
),
algorithm="TIME_DECAY",
)
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 None
@@ -87,6 +85,24 @@ class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol)
state.runtime_protection_reason = reason
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(
self,
*,

View File

@@ -10,6 +10,15 @@ from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.state import AutoTradeState
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.pricing import ExecutionPrice
from src.trading.journal.service import JournalService
@@ -187,6 +196,113 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
state.position_protection_reason = reason
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(
self,
*,
@@ -411,9 +527,7 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
"reason": reason,
# ---------- Runtime ----------
"status": state.status,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
**build_runtime_payload(state),
# ---------- Position ----------
"symbol": state.symbol,
@@ -436,116 +550,27 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
"hold_seconds": metrics.hold_seconds,
# ---------- Runtime protection ----------
"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,
**build_runtime_protection_payload(state),
"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": self._protection_thresholds(state),
# ---------- Position Intelligence ----------
"position_health_status": state.position_health_status,
"position_health_score": state.position_health_score,
"position_health_reason": state.position_health_reason,
**build_position_health_payload(state),
"position_pressure": state.position_pressure,
"position_exit_pressure": state.position_exit_pressure,
"position_exit_signal": state.position_exit_signal,
"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,
**build_full_position_intelligence_payload(state),
# ---------- Execution ----------
"execution_quality": state.execution_quality,
"execution_quality_reason": state.execution_quality_reason,
**build_execution_quality_payload(state),
**build_execution_price_payload(state),
"execution_confidence_score": state.execution_confidence_score,
"execution_confidence_level": state.execution_confidence_level,
"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,
# ---------- Market Context ----------
**build_market_context_payload(state),
}
def _log_runtime_protection_event(
self,
*,

View File

@@ -93,6 +93,15 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
state.position_conviction_state = None
state.position_exit_urgency = 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_hold_seconds = None
state.position_pressure = None

View File

@@ -80,7 +80,9 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol):
state.position_exit_signal = None
state.position_intelligence_reason = 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_percent = None
state.position_mfe_percent = None

View File

@@ -55,7 +55,7 @@ class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
forced_pnl=unrealized_pnl,
forced_price_meta=current_execution,
)
# проверить, нужно ли закрыть позицию по max loss / stop loss / take profit
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
position = type(self)._position

View File

@@ -10,6 +10,15 @@ from src.core.numbers import safe_float
from src.core.types import JsonDict
from src.trading.auto.state import AutoTradeState
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.position.state import PositionState
from src.trading.execution.constants import (
@@ -39,26 +48,39 @@ class _ExecutionRuntimeActionsProtocol(Protocol):
def _sync_state_from_position(
self,
state: AutoTradeState,
) -> None: ...
) -> None:
...
def _close_position(
self,
state: AutoTradeState,
*,
forced_reason: str | None = None,
) -> ExecutionDecision: ...
) -> ExecutionDecision:
...
def _force_runtime_protect(
self,
state: AutoTradeState,
*,
reason: str,
) -> bool:
...
class ExecutionRuntimeActionsMixin(
_ExecutionRuntimeActionsProtocol
):
# ----- Runtime autonomous actions subsystem.
class ExecutionRuntimeActionsMixin(_ExecutionRuntimeActionsProtocol):
# ----- Runtime autonomous actions subsystem.
# Отвечает за:
# - runtime EXIT
# - runtime REDUCE
# - runtime PROTECT
# - cooldown runtime действий
# - runtime logging
# - autonomous EXIT;
# - autonomous PROTECT;
# - autonomous REDUCE;
# - cooldown runtime действий;
# - runtime logging.
#
# Важно:
# На текущем этапе PROTECT и REDUCE пока НЕ исполняют реальное действие.
# Они логируются как диагностические runtime-сигналы.
# Реальное закрытие позиции сейчас делает только AUTONOMOUS_ACTION_EXIT.
_runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS
_last_runtime_action_key: str | None = None
@@ -69,6 +91,10 @@ class ExecutionRuntimeActionsMixin(
state: AutoTradeState,
) -> ExecutionDecision:
# Главный runtime action processor.
#
# Этот метод вызывается после основного engine.process().
# Если позиция открыта и autonomous_management выставил EXIT,
# здесь позиция может быть реально закрыта.
self._sync_state_from_position(state)
@@ -111,15 +137,32 @@ class ExecutionRuntimeActionsMixin(
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
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(
state=state,
action=AUTONOMOUS_ACTION_PROTECT,
reason=reason or "позиция требует защиты",
reason=(
protect_reason
if protected
else f"{protect_reason}; protection не применён"
),
confidence=confidence,
executed=False,
executed=protected,
)
if action == AUTONOMOUS_ACTION_REDUCE:
# Пока REDUCE только логируется.
# Если partial close не реализован, лучше позже перевести REDUCE
# в PROTECT или EXIT, чтобы не было иллюзии действия.
return self._log_runtime_action(
state=state,
action=AUTONOMOUS_ACTION_REDUCE,
@@ -129,24 +172,13 @@ class ExecutionRuntimeActionsMixin(
)
if action == AUTONOMOUS_ACTION_EXIT:
if self._early_exit_guard_active(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"]
early_guard_reason = self._early_exit_guard_block_reason(state)
if early_guard_reason is not None:
return self._log_runtime_action(
state=state,
action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
reason=(
"early exit guard: позиция ещё слишком новая для закрытия "
f"({hold_seconds:.0f}s < {min_hold:.0f}s)"
),
reason=early_guard_reason,
confidence=confidence,
executed=False,
cooldown_action=None,
@@ -198,6 +230,9 @@ class ExecutionRuntimeActionsMixin(
action: str,
) -> bool:
# Проверка cooldown runtime action.
# Cooldown нужен, чтобы один и тот же runtime action не спамил
# журнал и EventBus на каждом цикле.
ts = safe_float(
getattr(state, "autonomous_last_action_at", None)
)
@@ -216,6 +251,7 @@ class ExecutionRuntimeActionsMixin(
time.monotonic() - ts
) < self._runtime_action_cooldown_seconds
# ----- PAYLOAD -----
def _build_runtime_action_payload(
self,
*,
@@ -241,9 +277,7 @@ class ExecutionRuntimeActionsMixin(
"confidence": confidence,
# ---------- Runtime ----------
"status": state.status,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
**build_runtime_payload(state),
# ---------- Instrument / Position ----------
"symbol": state.symbol,
@@ -253,83 +287,25 @@ class ExecutionRuntimeActionsMixin(
"leverage": position.leverage,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"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_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 intelligence ----------
"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,
**build_full_position_intelligence_payload(state),
# ---------- Advanced analytics ----------
"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,
# ---------- 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 ----------
**build_autonomous_payload(state),
"autonomous_last_action": state.autonomous_last_action,
"autonomous_last_action_reason": state.autonomous_last_action_reason,
# ---------- Runtime protection ----------
"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,
# ---------- 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,
# ---------- Protection / market / execution ----------
**build_runtime_protection_payload(state),
**build_market_context_payload(state),
**build_execution_quality_payload(state),
}
# ----- LOGGING -----
def _log_runtime_action(
self,
@@ -342,6 +318,11 @@ class ExecutionRuntimeActionsMixin(
cooldown_action: str | None = None,
) -> ExecutionDecision:
# Runtime action logging + deduplication.
# Даже если действие не исполняется, payload помогает понять:
# - почему runtime action появился;
# - почему он был заблокирован;
# - какие были position health / semantics / market context.
position = type(self)._position
trade_id = position.trade_id or state.current_trade_id
@@ -395,12 +376,23 @@ class ExecutionRuntimeActionsMixin(
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))
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
if hold_seconds is None or pnl_percent is None:
return False
return None
thresholds = get_position_exit_thresholds(
getattr(state, "symbol", None)
@@ -410,10 +402,77 @@ class ExecutionRuntimeActionsMixin(
hard_loss = thresholds["hard_loss"]
if hold_seconds >= min_hold:
return False
return None
# Если просадка уже критическая — guard не мешает защите.
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

Some files were not shown because too many files have changed in this diff Show More