Compare commits

..

2 Commits

230 changed files with 2179 additions and 45292 deletions

View File

@@ -1,7 +1,3 @@
# app/requirements.txt
aiogram==3.13.1 aiogram==3.13.1
python-dotenv==1.0.1 python-dotenv==1.0.1
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
openpyxl==3.1.5
websockets==13.1

View File

@@ -1,5 +1,3 @@
# app/src/bootstrap/app_factory.py
from __future__ import annotations from __future__ import annotations
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
@@ -7,7 +5,6 @@ from aiogram.client.default import DefaultBotProperties
from src.bootstrap.logging import setup_logging from src.bootstrap.logging import setup_logging
from src.core.config import load_settings from src.core.config import load_settings
from src.notifications.targets import NotificationTargetRegistry
from src.storage.schema import init_schema from src.storage.schema import init_schema
from src.telegram.routers import setup_routers from src.telegram.routers import setup_routers
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -15,7 +12,6 @@ from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]: def create_app() -> tuple[Bot, Dispatcher]:
settings = load_settings() settings = load_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
journal = JournalService() journal = JournalService()
@@ -39,8 +35,8 @@ def create_app() -> tuple[Bot, Dispatcher]:
try: try:
journal.log_info( journal.log_info(
"app_started", "app_start",
"Приложение запущено", "Приложение запущено.",
{ {
"env": settings.app_env, "env": settings.app_env,
"exchange_name": settings.exchange_name, "exchange_name": settings.exchange_name,
@@ -48,17 +44,14 @@ def create_app() -> tuple[Bot, Dispatcher]:
}, },
) )
except Exception: except Exception:
# журнал не должен ломать запуск приложения
pass pass
bot = Bot( bot = Bot(
token=settings.bot_token, token=settings.bot_token,
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode), default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
) )
NotificationTargetRegistry.set_bot(bot)
dispatcher = Dispatcher() dispatcher = Dispatcher()
setup_routers(dispatcher) setup_routers(dispatcher)
return bot, dispatcher return bot, dispatcher

View File

@@ -1,5 +1,3 @@
# app/src/bootstrap/logging.py
from __future__ import annotations from __future__ import annotations
import logging import logging

View File

@@ -1,110 +1,61 @@
# app/src/core/config.py
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# корень проекта
BASE_DIR = Path(__file__).resolve().parents[2] BASE_DIR = Path(__file__).resolve().parents[2]
# .env файл
ENV_FILE = BASE_DIR / ".env" ENV_FILE = BASE_DIR / ".env"
# загружаем переменные окружения
load_dotenv(ENV_FILE) load_dotenv(ENV_FILE)
@dataclass(slots=True) @dataclass(slots=True)
class Settings: class Settings:
# Telegram
bot_token: str bot_token: str
bot_parse_mode: str bot_parse_mode: str
# App
app_env: str app_env: str
log_level: str log_level: str
tz: str tz: str
# Exchange
exchange_enabled: bool exchange_enabled: bool
exchange_name: str exchange_name: str
exchange_base_url: str exchange_base_url: str
exchange_ws_url: str
exchange_api_key: str exchange_api_key: str
exchange_api_secret: str exchange_api_secret: str
exchange_timeout_sec: int exchange_timeout_sec: int
exchange_testnet: bool exchange_testnet: bool
default_symbol: str default_symbol: str
# Database
db_host: str db_host: str
db_port: int db_port: int
db_name: str db_name: str
db_user: str db_user: str
db_password: str db_password: str
def is_demo_mode(self) -> bool:
# Debag helper return "demo" in self.exchange_base_url.lower()
debug_enabled: bool
# helper: demo/live mode
def is_demo_mode(self) -> bool:
return "demo" in self.exchange_base_url.lower()
# parse bool
def _parse_bool(raw_value: str, default: bool = False) -> bool: def _parse_bool(raw_value: str, default: bool = False) -> bool:
value = (raw_value or "").strip().lower() value = (raw_value or "").strip().lower()
if not value: if not value:
return default return default
return value in {"1", "true", "yes", "on"} return value in {"1", "true", "yes", "on"}
# parse int
def _parse_int(raw_value: str, default: int) -> int: def _parse_int(raw_value: str, default: int) -> int:
value = (raw_value or "").strip() value = (raw_value or "").strip()
if not value: if not value:
return default return default
return int(value) return int(value)
# load all settings
def load_settings() -> Settings: def load_settings() -> Settings:
bot_token = os.getenv("BOT_TOKEN", "").strip() bot_token = os.getenv("BOT_TOKEN", "").strip()
if not bot_token: if not bot_token:
raise RuntimeError("BOT_TOKEN is not set in app/.env") raise RuntimeError("BOT_TOKEN is not set in app/.env")
return Settings( return Settings(
# Telegram
bot_token=bot_token, bot_token=bot_token,
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML", bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
# App
app_env=os.getenv("APP_ENV", "dev").strip() or "dev", app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk", tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
debug_enabled=_parse_bool(os.getenv("DEBUG_ENABLED", "false")),
# Exchange
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")), exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi", exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(), exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(),
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(), exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(),
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE",
or "BTC/USD_LEVERAGE",
# Database
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost", db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432), db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot", db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",

View File

@@ -1,4 +1,2 @@
# app/src/core/constants.py
APP_NAME = "Dzentra Bot" APP_NAME = "Dzentra Bot"
APP_VERSION = "2.0.0" APP_VERSION = "2.0.0"

View File

@@ -1,28 +0,0 @@
# app/src/core/event_bus.py
from __future__ import annotations
from typing import Any
class EventBus:
_version: int = 0
_last_event_type: str | None = None
_last_payload: dict[str, Any] = {}
# зафиксировать важное событие системы
@classmethod
def emit(cls, event_type: str, payload: dict[str, Any] | None = None) -> None:
cls._version += 1
cls._last_event_type = event_type
cls._last_payload = payload or {}
# текущая версия событий
@classmethod
def version(cls) -> int:
return cls._version
# последнее событие
@classmethod
def last_event(cls) -> tuple[str | None, dict[str, Any]]:
return cls._last_event_type, dict(cls._last_payload)

View File

@@ -1,96 +0,0 @@
# app/src/core/event_titles.py
from __future__ import annotations
EVENT_TITLES = {
# Сигналы
"signal_summary": "Сигнал",
"signal_ready": "Сигнал",
# Execution
"position_opened": "Позиция",
"position_closed": "Позиция",
"position_flipped": "Позиция",
"position_flip_blocked": "Позиция",
# Настройки
"auto_settings_updated": "Автоторговля",
"auto_status_changed": "Автоторговля",
"risk_settings_updated": "Защита",
# Аналитика автоторговли
"market_state_changed": "Автоторговля",
"market_volatility_changed": "Автоторговля",
# Рыночные данные runtime
"market_monitor_started": "Автоторговля",
"market_monitor_stopped": "Автоторговля",
"market_stream_connected": "Автоторговля",
"market_stream_disconnected": "Автоторговля",
"market_symbol_changed": "Автоторговля",
# Мониторинг позиций
"entry_blocked": "Вход в позицию",
# Журнал
"journal_exported": "Журнал",
"journal_export_error": "Журнал",
"journal_cleared": "Журнал",
# Уведомления
"notification_sent": "Уведомление",
"notification_error": "Уведомление",
# Приложение
"app_started": "Приложение",
"app_bootstrap_failed": "Приложение",
# Legacy
"app_start": "Приложение",
"journal_open_requested": "Журнал",
"journal_export_csv_success": "Журнал",
"journal_export_csv_error": "Журнал",
"journal_export_xlsx_success": "Журнал",
"journal_export_xlsx_error": "Журнал",
"journal_cleared_old": "Журнал",
"system_open_requested": "Система",
"system_open_alert": "Система",
"system_open_success": "Система",
"system_retry": "Система",
"system_about_opened": "Система",
"portfolio_open_requested": "Портфель",
"portfolio_open_success": "Портфель",
"portfolio_open_error": "Портфель",
"portfolio_partial_estimate": "Портфель",
"exchange_request_error": "Биржа",
"exchange_auth_error": "Аккаунт",
"exchange_auth_restored": "Аккаунт",
"exchange_time_sync_error": "Время биржи",
"exchange_time_sync_restored": "Время биржи",
"balance_summary_loaded": "Баланс",
"balance_summary_error": "Баланс",
"runtime_expired": "Runtime",
"market_status_unavailable": "Автоторговля",
"market_status_restored": "Автоторговля",
"market_closed": "Автоторговля",
"market_rest_fallback_available": "Автоторговля",
"market_rest_fallback_unavailable": "Автоторговля",
}
def event_title(event_type: object) -> str:
value = str(event_type or "").strip()
if not value:
return "Событие"
return EVENT_TITLES.get(value, "Событие")

View File

@@ -1,23 +0,0 @@
# app/src/core/numbers.py
# src/core/numbers.py
from __future__ import annotations
from src.core.types import NumericLike
def safe_float(
value: object,
default: float | None = None,
) -> float | None:
if value is None:
return default
if isinstance(value, bool):
return default
try:
return float(str(value).strip())
except (TypeError, ValueError):
return default

View File

@@ -1,17 +1,11 @@
# app/src/core/system_status.py
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION from src.core.constants import APP_NAME, APP_VERSION
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import build_exchange_error_status
from src.storage.session import check_database_health from src.storage.session import check_database_health
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -34,96 +28,6 @@ class SystemSnapshot:
components: list[ComponentStatus] components: list[ComponentStatus]
def _build_exchange_alert_components(
*,
default_symbol: str,
) -> list[ComponentStatus]:
exchange_service = ExchangeService()
try:
runtime_status = exchange_service.get_symbol_runtime_status(default_symbol)
except Exception as exc:
runtime_status = build_exchange_error_status(exc)
if not runtime_status.is_available:
return [
ComponentStatus(
name="Биржа",
state=runtime_status.ui_line,
details=runtime_status.message,
)
]
alerts = build_runtime_exchange_alerts(symbol=default_symbol)
exchange_unavailable_alert = next(
(
alert
for alert in alerts
if str(alert.get("code") or "") == "EXCHANGE_UNAVAILABLE"
),
None,
)
if exchange_unavailable_alert is not None:
return [
ComponentStatus(
name="Биржа",
state=str(
exchange_unavailable_alert.get("ui_line")
or exchange_unavailable_alert.get("title")
or "⛔️ Биржа недоступна"
),
details=str(exchange_unavailable_alert.get("details") or ""),
)
]
components: list[ComponentStatus] = [
ComponentStatus(
name="Биржа",
state="🟢",
details=runtime_status.message,
)
]
has_account_alert = False
for alert in alerts:
code = str(alert.get("code") or "")
state = str(
alert.get("ui_line")
or alert.get("title")
or "⛔️ Ошибка биржи"
)
if code == "AUTH_ERROR":
has_account_alert = True
name = "Аккаунт"
elif code == "TIME_ERROR":
name = "Время биржи"
else:
name = "Биржа"
components.append(
ComponentStatus(
name=name,
state=state,
details=str(alert.get("details") or ""),
)
)
if not has_account_alert:
components.append(
ComponentStatus(
name="Аккаунт",
state="🟢",
)
)
return components
# извлечь короткую версию PostgreSQL из строки health-check
def _extract_postgres_version(raw: str) -> str: def _extract_postgres_version(raw: str) -> str:
if not raw: if not raw:
return "PostgreSQL" return "PostgreSQL"
@@ -135,72 +39,94 @@ def _extract_postgres_version(raw: str) -> str:
return "PostgreSQL" return "PostgreSQL"
# проверить подключение к БД и вернуть компонент + подпись версии def _build_exchange_status(
exchange_service: ExchangeService, default_symbol: str
) -> ComponentStatus:
try:
symbol_validation = exchange_service.validate_symbol(default_symbol)
except Exception as exc:
return ComponentStatus(
name="Биржа",
state="🔴",
details=f"Не удалось проверить инструмент: {exc}",
)
exchange_health = exchange_service.get_health()
if exchange_health.ok and symbol_validation.is_valid:
return ComponentStatus(name="Биржа", state="🟢")
if not exchange_health.ok:
return ComponentStatus(
name="Биржа",
state="🔴",
details=exchange_health.message or "Ошибка подключения к API биржи.",
)
return ComponentStatus(
name="Биржа",
state="🔴",
details=symbol_validation.message or "Инструмент не прошёл проверку.",
)
def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus:
private_auth_health = exchange_service.get_private_auth_health()
if private_auth_health.ok:
return ComponentStatus(name="Аккаунт", state="🟢")
return ComponentStatus(
name="Аккаунт",
state="🔴",
details=private_auth_health.message or "Ошибка private API.",
)
def _build_database_status() -> tuple[ComponentStatus, str]: def _build_database_status() -> tuple[ComponentStatus, str]:
db_ok, db_message = check_database_health() db_ok, db_message = check_database_health()
db_label = _extract_postgres_version(db_message) db_label = _extract_postgres_version(db_message)
if db_ok: if db_ok:
return ( return ComponentStatus(name="База данных", state="🟢"), db_label
ComponentStatus(
name="База данных",
state="🟢",
),
db_label,
)
return ( return (
ComponentStatus( ComponentStatus(
name="База данных", name="База данных",
state="🔴 База данных недоступна", state="🔴",
details=db_message or "Ошибка подключения к БД.",
), ),
db_label, db_label,
) )
# проверить доступность журнала событий
def _build_journal_status() -> ComponentStatus: def _build_journal_status() -> ComponentStatus:
ok, _ = JournalService().get_journal_health() ok, message = JournalService().get_journal_health()
if ok: if ok:
return ComponentStatus( return ComponentStatus(name="Журнал", state="🟢")
name="Журнал",
state="🟢",
)
return ComponentStatus( return ComponentStatus(name="Журнал", state="🔴", details=message)
name="Журнал",
state="🔴 Журнал недоступен",
)
# определить runtime-режим по base_url биржи def _resolve_mode_label(settings) -> str:
def get_runtime_mode_key() -> str: is_demo = "demo" in settings.exchange_base_url.lower()
settings = load_settings() return "ДЕМО аккаунт" if is_demo else "РЕАЛЬНЫЙ аккаунт"
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
# вернуть человекочитаемую подпись runtime-режима
def get_runtime_mode_label() -> str:
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
# собрать полный snapshot системного экрана
def get_system_snapshot() -> SystemSnapshot: def get_system_snapshot() -> SystemSnapshot:
settings = load_settings() settings = load_settings()
exchange_service = ExchangeService()
database_status, db_label = _build_database_status() database_status, db_label = _build_database_status()
exchange_status = _build_exchange_status(exchange_service, settings.default_symbol)
account_status = _build_account_status(exchange_service)
journal_status = _build_journal_status() journal_status = _build_journal_status()
exchange_components = _build_exchange_alert_components(
default_symbol=settings.default_symbol,
)
components = [ components = [
ComponentStatus(name="Приложение", state="🟢"), ComponentStatus(name="Приложение", state="🟢"),
database_status, database_status,
ComponentStatus(name="Telegram", state="🟢"), ComponentStatus(name="Telegram", state="🟢"),
*exchange_components, exchange_status,
account_status,
journal_status, journal_status,
] ]
@@ -209,54 +135,32 @@ def get_system_snapshot() -> SystemSnapshot:
app_version=APP_VERSION, app_version=APP_VERSION,
db_label=db_label, db_label=db_label,
timezone_name=settings.tz, timezone_name=settings.tz,
mode_label=get_runtime_mode_label(), mode_label=_resolve_mode_label(settings),
default_symbol=settings.default_symbol, default_symbol=settings.default_symbol,
components=components, components=components,
) )
# определить, есть ли системные предупреждения
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
return any(component.state != "🟢" for component in snapshot.components)
# отрендерить одну строку компонента системы
def _render_component(component: ComponentStatus) -> str: def _render_component(component: ComponentStatus) -> str:
if component.state == "🟢": if component.state == "🟢":
return f"{component.state} {component.name}" return f"{component.state} <b>{component.name}</b>"
return component.state return f"{component.state} <b>{component.name}</b>\n{component.details}"
# получить текущее локальное время для подписи обновления def build_system_text() -> str:
def _now_hhmmss() -> str:
settings = load_settings()
tz_name = settings.tz or "UTC"
try:
local_dt = datetime.now(ZoneInfo(tz_name))
except Exception:
local_dt = datetime.utcnow()
return local_dt.strftime("%H:%M:%S")
# собрать текст экрана "Система"
def build_system_text(*, include_updated_at: bool = False) -> str:
snapshot = get_system_snapshot() snapshot = get_system_snapshot()
components_block = "\n".join( components_block = "\n".join(
_render_component(component) _render_component(component) for component in snapshot.components
for component in snapshot.components
) )
text = ( return (
"<b>🖥 Система</b>\n" "<b> Система</b>\n\n"
f"🔸 <b>{snapshot.mode_label}</b>\n\n" f"{components_block}\n\n"
f"{components_block}" "<b>🌐 Окружение</b>\n"
f"• приложение: {snapshot.app_name} {snapshot.app_version}\n"
f"• база данных: {snapshot.db_label}\n"
f"• часовой пояс: {snapshot.timezone_name}\n"
f"• режим: {snapshot.mode_label}\n"
f"• инструмент: {snapshot.default_symbol}"
) )
if include_updated_at:
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
return text

View File

@@ -1,18 +0,0 @@
# app/src/core/telegram_errors.py
from __future__ import annotations
from aiogram.exceptions import TelegramBadRequest
def is_message_not_modified(exc: TelegramBadRequest) -> bool:
return "message is not modified" in str(exc).lower()
def is_message_to_edit_not_found(exc: TelegramBadRequest) -> bool:
text = str(exc).lower()
return (
"message to edit not found" in text
or "message_id_invalid" in text
)

View File

@@ -1,12 +0,0 @@
# app/src/core/types.py
from __future__ import annotations
from typing import Any, TypeAlias
NumericLike: TypeAlias = float | int | str
JsonDict: TypeAlias = dict[str, Any]
JsonList: TypeAlias = list[Any]

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/exceptions.py
from __future__ import annotations from __future__ import annotations
@@ -13,41 +11,3 @@ class ExchangeConnectionError(ExchangeError):
class ExchangeResponseError(ExchangeError): class ExchangeResponseError(ExchangeError):
"""Unexpected HTTP response or malformed JSON.""" """Unexpected HTTP response or malformed JSON."""
def is_exchange_time_sync_error(exc: Exception) -> bool:
text = str(exc).lower()
return (
"-1021" in text
or "doesn't match server time" in text
or "server time" in text and "match" in text
or "рассинхрон" in text
)
def format_exchange_error_for_user(exc: Exception) -> str:
if is_exchange_time_sync_error(exc):
return (
"Биржа отклонила запрос из-за рассинхронизации времени. "
"Проверь системное время и повтори попытку."
)
if isinstance(exc, ExchangeConnectionError):
return (
"Не удалось получить данные биржи: таймаут или ошибка сети. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeResponseError):
return (
"Биржа вернула ошибку ответа. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeError):
return (
"Не удалось получить данные биржи. "
"Попробуй ещё раз через несколько секунд."
)
return "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."

View File

@@ -1,111 +0,0 @@
# app/src/integrations/exchange/market_cache.py
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
@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
)
class MarketPriceCache:
_prices: dict[tuple[str, str], MarketPriceSnapshot] = {}
@classmethod
def _key(cls, *, symbol: str, runtime_key: str = "default") -> tuple[str, str]:
return runtime_key.strip().lower(), symbol.upper()
@classmethod
def set_price(
cls,
*,
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(),
)
@classmethod
def get_price(
cls,
symbol: str,
*,
runtime_key: str = "default",
) -> MarketPriceSnapshot | None:
return cls._prices.get(cls._key(symbol=symbol, runtime_key=runtime_key))
@classmethod
def clear(
cls,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> None:
if symbol is None and runtime_key is None:
cls._prices.clear()
return
if symbol is not None and runtime_key is not None:
cls._prices.pop(cls._key(symbol=symbol, runtime_key=runtime_key), None)
return
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)

View File

@@ -1,623 +0,0 @@
# app/src/integrations/exchange/market_data_runner.py
from __future__ import annotations
import asyncio
import time
import traceback
from dataclasses import dataclass
from typing import Callable
from src.core.numbers import safe_float
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.trading.journal.service import JournalService
@dataclass
class MarketRuntimeContext:
runtime_key: str
task: asyncio.Task | None
interval_seconds: int
symbol_provider: Callable[[], str | None]
screen: str | None
action: str
runtime_label: str | None
last_market_status: str | None = None
# Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
last_status_error_key: str | None = None
last_stream_state: str | None = None
last_stream_error_key: str | None = None
last_rest_state: str | None = None
last_rest_error_key: str | None = None
class MarketDataRunner:
_runtimes: dict[str, MarketRuntimeContext] = {}
# Global dedupe runtime-событий между start/stop runtime.
_global_runtime_event_timestamps: dict[str, float] = {}
# Минимальный интервал повторного логирования одного runtime-состояния.
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
_runtime_log_cooldown_seconds = 300
@classmethod
def _can_log_runtime_event(
cls,
event_key: str,
) -> bool:
now = time.monotonic()
last_logged_at = cls._global_runtime_event_timestamps.get(event_key)
if last_logged_at is not None:
if (
now - last_logged_at
) < cls._runtime_log_cooldown_seconds:
return False
cls._global_runtime_event_timestamps[event_key] = now
return True
@classmethod
def start(
cls,
*,
symbol_provider: Callable[[], str | None],
interval_seconds: int = 1,
runtime_key: str = "default",
screen: str | None = None,
action: str = "market_data",
runtime_label: str | None = None,
) -> None:
existing = cls._runtimes.get(runtime_key)
if (
existing is not None
and existing.task is not None
and not existing.task.done()
):
existing.symbol_provider = symbol_provider
existing.interval_seconds = interval_seconds
existing.screen = screen
existing.action = action
existing.runtime_label = runtime_label
return
context = MarketRuntimeContext(
runtime_key=runtime_key,
task=None,
interval_seconds=interval_seconds,
symbol_provider=symbol_provider,
screen=screen,
action=action,
runtime_label=runtime_label,
)
cls._runtimes[runtime_key] = context
cls._log_info(
context,
"market_monitor_started",
"Мониторинг рынка запущен.",
{"interval_seconds": interval_seconds},
)
context.task = asyncio.create_task(cls._worker(context))
@classmethod
def stop(cls, runtime_key: str | None = None) -> None:
if runtime_key is None:
for key in list(cls._runtimes.keys()):
cls.stop(key)
return
context = cls._runtimes.get(runtime_key)
if context is None:
return
if context.task is not None:
context.task.cancel()
context.task = None
cls._log_info(
context,
"market_monitor_stopped",
"Мониторинг рынка остановлен.",
)
cls._runtimes.pop(runtime_key, None)
@classmethod
async def _worker(cls, context: MarketRuntimeContext) -> None:
last_symbol: str | None = None
try:
while True:
symbol = context.symbol_provider()
if not symbol:
await asyncio.sleep(context.interval_seconds)
continue
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
if symbol != last_symbol:
last_symbol = symbol
if not cls._is_cache_symbol_used_by_other_runtime(
runtime_key=context.runtime_key,
cache_symbol=cache_symbol,
):
MarketPriceCache.clear(cache_symbol)
try:
market_status = ExchangeService().get_symbol_market_status(symbol)
except asyncio.CancelledError:
raise
except Exception as exc:
error_key = f"{type(exc).__name__}:{str(exc)}"
if context.last_status_error_key != error_key:
context.last_status_error_key = error_key
cls._log_warning(
context,
"market_status_unavailable",
"Статус рынка временно недоступен.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
await asyncio.sleep(context.interval_seconds)
continue
if context.last_status_error_key is not None:
context.last_status_error_key = None
cls._log_info(
context,
"market_status_restored",
"Статус рынка снова доступен.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
},
)
status_key = str(market_status.get("status") or "UNKNOWN")
if not bool(market_status.get("is_open")):
if context.last_market_status != status_key:
context.last_market_status = status_key
cls._log_warning(
context,
"market_closed",
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
{
"symbol": symbol,
"market_status": status_key,
"message": market_status.get("message"),
},
)
await asyncio.sleep(context.interval_seconds)
continue
context.last_market_status = status_key
try:
await cls._run_websocket(context, symbol)
except asyncio.CancelledError:
raise
except Exception as exc:
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
should_log_disconnected = (
(
context.last_stream_state != "DISCONNECTED"
or context.last_stream_error_key != error_key
)
and cls._can_log_runtime_event(
f"market_stream_disconnected:{symbol}:{error_key}"
)
)
context.last_stream_state = "DISCONNECTED"
context.last_stream_error_key = error_key
if should_log_disconnected:
cls._log_warning(
context,
"market_stream_disconnected",
"Live-поток рыночных данных отключён. Используется REST-режим.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
await cls._rest_fallback_once(context, symbol)
await asyncio.sleep(context.interval_seconds)
except asyncio.CancelledError:
# stop() уже пишет market_monitor_stopped.
# Здесь не логируем, чтобы в журнале не было дубля остановки.
raise
@classmethod
async def _run_websocket(
cls,
context: MarketRuntimeContext,
symbol: str,
) -> None:
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
payload_count = 0
async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol,
interval_seconds=context.interval_seconds,
):
if payload_count == 0:
should_log_connected = (
context.last_stream_state != "CONNECTED"
and cls._can_log_runtime_event(
f"market_stream_connected:{symbol}"
)
)
context.last_stream_state = "CONNECTED"
context.last_stream_error_key = None
context.last_rest_state = None
context.last_rest_error_key = None
if should_log_connected:
cls._log_info(
context,
"market_stream_connected",
"Live-поток рыночных данных подключён.",
{
"requested_symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"payload_keys": list(payload.keys()),
"payload_preview": cls._safe_payload_preview(payload),
},
)
payload_count += 1
current_symbol = context.symbol_provider()
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:
continue
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}",
runtime_key=context.runtime_key,
)
@classmethod
async def _rest_fallback_once(
cls,
context: MarketRuntimeContext,
symbol: str,
) -> None:
try:
await asyncio.to_thread(
ExchangeService().refresh_market_snapshot_cache,
symbol,
runtime_key=context.runtime_key,
)
should_log_rest_available = (
context.last_rest_state != "AVAILABLE"
and cls._can_log_runtime_event(
f"market_rest_fallback_available:{symbol}"
)
)
context.last_rest_state = "AVAILABLE"
context.last_rest_error_key = None
if should_log_rest_available:
cls._log_info(
context,
"market_rest_fallback_available",
"REST-режим рыночных данных доступен. Live-поток пока недоступен.",
{
"symbol": symbol,
"cache_symbol": cls._cache_symbol(symbol),
"ws_symbol": cls._ws_symbol(symbol),
},
)
except Exception as exc:
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
should_log_rest_unavailable = (
(
context.last_rest_state != "UNAVAILABLE"
or context.last_rest_error_key != error_key
)
and cls._can_log_runtime_event(
f"market_rest_fallback_unavailable:{symbol}:{error_key}"
)
)
context.last_rest_state = "UNAVAILABLE"
context.last_rest_error_key = error_key
if should_log_rest_unavailable:
cls._log_error(
context,
"market_rest_fallback_unavailable",
"Live-поток отключён. REST-режим рыночных данных недоступен.",
{
"symbol": symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
@classmethod
def _is_cache_symbol_used_by_other_runtime(
cls,
*,
runtime_key: str,
cache_symbol: str,
) -> bool:
for key, context in cls._runtimes.items():
if key == runtime_key:
continue
try:
symbol = context.symbol_provider()
except Exception:
continue
if symbol and cls._cache_symbol(symbol) == cache_symbol:
return True
return False
@classmethod
def _cache_symbol(cls, symbol: str) -> str:
try:
validation = ExchangeService().validate_symbol(symbol)
if validation.is_valid:
return validation.normalized_symbol
except Exception:
pass
return symbol
@classmethod
def _ws_symbol(cls, symbol: str) -> str:
return cls._cache_symbol(symbol)
@classmethod
def _extract_best_price(
cls,
payload: JsonDict,
side_key: str,
) -> float | None:
data = payload
inner = payload.get("payload")
if isinstance(inner, dict):
data = inner
values = data.get(side_key)
if not isinstance(values, list) or not values:
return None
first = values[0]
if isinstance(first, list) and first:
return cls._positive_float(first[0])
if isinstance(first, dict):
raw_price = (
first.get("price")
or first.get("p")
or first.get("bidPrice")
or first.get("askPrice")
)
return cls._positive_float(raw_price)
return None
@classmethod
def _positive_float(cls, value: NumericLike | None) -> float | None:
number = safe_float(value)
if number is None or number <= 0:
return None
return number
@classmethod
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
preview: JsonDict = {}
for key, value in payload.items():
if key in {"bids", "asks"} and isinstance(value, list):
preview[key] = value[:2]
elif key == "payload" and isinstance(value, dict):
inner_preview: JsonDict = {}
for inner_key, inner_value in value.items():
if (
inner_key in {"bids", "asks"}
and isinstance(inner_value, list)
):
inner_preview[inner_key] = inner_value[:2]
else:
inner_preview[inner_key] = inner_value
preview[key] = inner_preview
else:
preview[key] = value
return preview
@classmethod
def _message(
cls,
context: MarketRuntimeContext,
message: str,
) -> str:
return message
@classmethod
def _payload(
cls,
context: MarketRuntimeContext,
payload: JsonDict | None = None,
) -> JsonDict:
result: JsonDict = dict(payload or {})
result.setdefault("runtime_key", context.runtime_key)
if context.screen:
result.setdefault("runtime_screen", context.screen)
if context.runtime_label:
result.setdefault("runtime_label", context.runtime_label)
return result
@classmethod
def _log_info(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
JournalService().log_ui_info(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
)
return
JournalService().log_info(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass
@classmethod
def _log_warning(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
JournalService().log_ui_warning(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
)
return
JournalService().log_warning(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass
@classmethod
def _log_error(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
error_type = None
raw_error = None
if payload:
error_type = payload.get("error_type")
raw_error = payload.get("error")
if context.screen:
JournalService().log_ui_error(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
error_type=str(error_type) if error_type is not None else None,
raw_error=str(raw_error) if raw_error is not None else None,
)
return
JournalService().log_error(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass

View File

@@ -1,195 +0,0 @@
# app/src/integrations/exchange/market_stream.py
from __future__ import annotations
import asyncio
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.core.numbers import safe_float
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.trading.journal.service import JournalService
# безопасно форматирует timestamp биржи в локальное время
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
timestamp = safe_float(raw_timestamp)
if timestamp is None:
return None
try:
settings = load_settings()
dt_utc = datetime.fromtimestamp(
int(timestamp) / 1000,
tz=ZoneInfo("UTC"),
)
return dt_utc.astimezone(
ZoneInfo(settings.tz),
).strftime("%d.%m.%Y %H:%M:%S")
except Exception:
return None
# достаёт внутренний payload из websocket-сообщения
def _payload_from_message(payload: JsonDict) -> JsonDict | None:
event = payload.get("Payload") or payload.get("payload")
if isinstance(event, dict) and "Payload" in event:
event = event.get("Payload")
if not isinstance(event, dict):
return None
return dict(event)
# извлекает best bid / best ask из формата depth
def _extract_depth_prices(event: JsonDict) -> tuple[float | None, float | None]:
bids = event.get("bids")
asks = event.get("asks")
bid_price = _extract_first_price(bids)
ask_price = _extract_first_price(asks)
return bid_price, ask_price
# извлекает первую цену из списка стакана
def _extract_first_price(value: object) -> float | None:
if not isinstance(value, list) or not value:
return None
first = value[0]
if isinstance(first, list) and first:
return _positive_float(first[0])
if isinstance(first, dict):
return _positive_float(
first.get("price")
or first.get("p")
or first.get("bidPrice")
or first.get("askPrice")
)
return None
# безопасно приводит число к float и отсекает нулевые/отрицательные цены
def _positive_float(value: NumericLike | None) -> float | None:
number = safe_float(value)
if number is None or number <= 0:
return None
return number
# нормализует websocket-сообщение рынка в единый формат для MarketPriceCache
def _extract_market_event(payload: JsonDict) -> JsonDict | None:
event = _payload_from_message(payload)
if event is None:
return None
symbol = (
event.get("symbolName")
or event.get("symbol")
or payload.get("symbol")
)
bid_price = _positive_float(event.get("bid"))
ask_price = _positive_float(event.get("ofr") or event.get("ask"))
if bid_price is None or ask_price is None:
bid_price, ask_price = _extract_depth_prices(event)
if symbol is None or bid_price is None or ask_price is None:
return None
price = (bid_price + ask_price) / 2
return {
"symbol": str(symbol).upper(),
"price": price,
"bid_price": bid_price,
"ask_price": ask_price,
"updated_at": _format_timestamp(event.get("timestamp")),
}
# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache
async def start_market_stream() -> None:
settings = load_settings()
journal = JournalService()
if not settings.exchange_enabled:
return
while True:
try:
service = ExchangeService()
validation = service.validate_symbol(settings.default_symbol)
if not validation.is_valid:
await asyncio.sleep(10)
continue
symbol = validation.normalized_symbol
client = ExchangeWebSocketClient()
journal.log_info(
"market_ws_started",
"WebSocket market stream запущен.",
{"symbol": symbol},
)
async for message in client.stream_depth(symbol):
event = _extract_market_event(message)
if event is None:
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:
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",
runtime_key="default",
)
except asyncio.CancelledError:
raise
except Exception as exc:
try:
journal.log_warning(
"market_ws_reconnect",
f"WebSocket market stream будет переподключен: {exc}",
{"raw_error": str(exc)},
)
except Exception:
pass
await asyncio.sleep(5)

View File

@@ -1,11 +1,8 @@
# app/src/integrations/exchange/models.py
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
# Состояние публичного API биржи.
@dataclass(slots=True) @dataclass(slots=True)
class ExchangeHealth: class ExchangeHealth:
ok: bool ok: bool
@@ -13,19 +10,6 @@ class ExchangeHealth:
message: str message: str
# Состояние синхронизации времени сервера и биржи.
@dataclass(slots=True)
class TimeSyncStatus:
ok: bool
local_time: str
exchange_time: str | None
drift_seconds: float | None
hostname: str
local_ip: str | None
message: str
# Текущая рыночная цена инструмента.
@dataclass(slots=True) @dataclass(slots=True)
class TickerPrice: class TickerPrice:
symbol: str symbol: str
@@ -34,26 +18,6 @@ class TickerPrice:
updated_at: str updated_at: str
# Snapshot цен для execution layer.
@dataclass(slots=True)
class ExecutionPriceSnapshot:
symbol: str
last_price: float
bid_price: float
ask_price: float
updated_at: str
source: str
is_fresh: bool
age_seconds: float | None = None
freshness_status: str = "UNKNOWN"
spread_percent: float | None = None
# Баланс актива аккаунта.
@dataclass(slots=True) @dataclass(slots=True)
class BalanceSummary: class BalanceSummary:
currency: str currency: str
@@ -62,74 +26,28 @@ class BalanceSummary:
source: str source: str
# Информация о торговом инструменте биржи.
@dataclass(slots=True) @dataclass(slots=True)
class ExchangeSymbol: class ExchangeSymbol:
symbol: str symbol: str
name: str name: str
status: str status: str
base_asset: str base_asset: str
quote_asset: str quote_asset: str
market_modes: list[str] market_modes: list[str]
market_type: str market_type: str
tick_size: float | None tick_size: float | None
step_size: float | None
min_qty: float | None
min_notional: float | None
# Результат проверки символа.
@dataclass(slots=True) @dataclass(slots=True)
class SymbolValidationResult: class SymbolValidationResult:
requested_symbol: str requested_symbol: str
normalized_symbol: str normalized_symbol: str
is_valid: bool is_valid: bool
message: str message: str
symbol_info: ExchangeSymbol | None symbol_info: ExchangeSymbol | None
# Состояние приватного API аккаунта.
@dataclass(slots=True) @dataclass(slots=True)
class PrivateAuthHealth: class PrivateAuthHealth:
ok: bool ok: bool
message: str message: str
# Runtime-статус рынка инструмента.
@dataclass(slots=True)
class ExchangeMarketStatus:
symbol: str
is_open: bool
status: str
message: str
# Одна свеча OHLCV.
@dataclass(slots=True)
class Kline:
symbol: str
interval: str
open_time: int
open_price: float
high_price: float
low_price: float
close_price: float
volume: float
source: str
# Пакет свечей.
@dataclass(slots=True)
class KlineBatch:
symbol: str
interval: str
candles: list[Kline]
source: str

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/private_client.py
from __future__ import annotations from __future__ import annotations
from src.core.config import load_settings from src.core.config import load_settings

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/rest_client.py
from __future__ import annotations from __future__ import annotations
import json import json
@@ -22,60 +20,6 @@ class ExchangeRestClient:
self.base_url = self.settings.exchange_base_url.rstrip("/") self.base_url = self.settings.exchange_base_url.rstrip("/")
self.timeout = self.settings.exchange_timeout_sec self.timeout = self.settings.exchange_timeout_sec
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
query = f"?{urlencode(params)}" if params else ""
url = f"{self.base_url}{path}{query}"
request_headers = {
"Accept": "application/json",
"User-Agent": "dzentra-bot/2.0.0",
}
if headers:
request_headers.update(headers)
request = Request(
url=url,
method="GET",
headers=request_headers,
)
try:
with urlopen(request, timeout=self.timeout) as response:
status = getattr(response, "status", 200)
body = response.read().decode("utf-8")
except HTTPError as exc:
error_body = ""
try:
error_body = exc.read().decode("utf-8")
except Exception:
pass
message = f"HTTP {exc.code} from exchange: {exc.reason}"
if error_body:
message += f" | body: {error_body}"
raise ExchangeResponseError(message) from exc
except URLError as exc:
raise ExchangeConnectionError(
f"Network error while calling exchange: {exc.reason}"
) from exc
except TimeoutError as exc:
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
if status != 200:
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
def get_json( def get_json(
self, self,
path: str, path: str,

View File

@@ -1,296 +0,0 @@
# app/src/integrations/exchange/runtime_ui.py
from __future__ import annotations
from src.core.numbers import safe_float
from src.integrations.exchange.models import TimeSyncStatus
from src.integrations.exchange.status import (
ExchangeRuntimeStatus,
ExchangeStatusCode,
build_exchange_error_status,
)
def format_drift_seconds(value: float | int | None) -> str:
number = safe_float(value)
if number is None:
return ""
sign = "-" if number < 0 else "+"
total_seconds = abs(int(round(number)))
minutes = total_seconds // 60
seconds = total_seconds % 60
if minutes > 0:
return f"{sign} {minutes} мин. {seconds} сек."
return f"{sign} {seconds} сек."
def build_time_sync_details(sync: TimeSyncStatus) -> str:
lines = [
"Проверь настройки времени на:",
f"Сервер: {sync.hostname}",
]
if sync.local_ip:
lines.append(f"IP: {sync.local_ip}")
lines.append("")
lines.append(f"Время сервера: {sync.local_time}")
if sync.exchange_time:
lines.append(f"Время биржи: {sync.exchange_time}")
lines.append(f"Расхождение: {format_drift_seconds(sync.drift_seconds)}")
return "\n".join(lines)
def get_time_sync_status_from_service() -> TimeSyncStatus:
from src.integrations.exchange.service import ExchangeService
return ExchangeService().get_time_sync_status()
def build_time_sync_details_from_service() -> str:
return build_time_sync_details(get_time_sync_status_from_service())
def build_exchange_error_ui_parts(
exc: Exception,
) -> tuple[ExchangeRuntimeStatus, str, str]:
status = build_exchange_error_status(exc)
if status.code == ExchangeStatusCode.AUTH_ERROR:
return (
status,
"⛔️ Ошибка доступа к аккаунту",
"Проверь API-ключ, Secret Key, IP whitelist и права доступа.",
)
if status.code == ExchangeStatusCode.TIME_ERROR:
return (
status,
"⛔️ Ошибка времени биржи",
build_time_sync_details_from_service(),
)
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
return status, "⛔️ Биржа недоступна", ""
return status, status.ui_line, status.message
def build_runtime_exchange_status(exc: Exception) -> dict[str, object]:
status = build_exchange_error_status(exc)
if status.code == ExchangeStatusCode.TIME_ERROR:
sync = get_time_sync_status_from_service()
return {
"code": status.code.value,
"title": "Ошибка времени биржи",
"ui_line": "⛔️ Ошибка времени биржи",
"details": {
"hostname": sync.hostname,
"local_ip": sync.local_ip,
"local_time": sync.local_time,
"exchange_time": sync.exchange_time,
"drift_seconds": sync.drift_seconds,
},
"reason": status.reason,
"raw_error": status.raw_error,
}
_, title, details = build_exchange_error_ui_parts(exc)
return {
"code": status.code.value,
"title": title.replace("⛔️ ", "").strip(),
"ui_line": title,
"details": details,
"reason": status.reason,
"raw_error": status.raw_error,
}
def build_runtime_exchange_alerts(
*,
symbol: str | None = None,
exc: Exception | None = None,
include_exchange_unavailable: bool = True,
) -> list[dict[str, object]]:
from src.integrations.exchange.service import ExchangeService
alerts: list[dict[str, object]] = []
service = ExchangeService()
def add_alert(alert: dict[str, object] | None) -> None:
if not alert:
return
code = str(alert.get("code") or "")
reason = str(alert.get("reason") or "")
for existing in alerts:
if (
str(existing.get("code") or "") == code
and str(existing.get("reason") or "") == reason
):
return
alerts.append(alert)
if exc is not None:
add_alert(build_runtime_exchange_status(exc))
try:
runtime_status = service.get_symbol_runtime_status(symbol)
except Exception as status_exc:
if include_exchange_unavailable:
add_alert(build_runtime_exchange_status(status_exc))
else:
if include_exchange_unavailable and not runtime_status.is_available:
add_alert(
build_runtime_exchange_status(
Exception(runtime_status.raw_error or runtime_status.message)
)
)
try:
time_sync = service.get_time_sync_status()
except Exception as time_exc:
add_alert(build_runtime_exchange_status(time_exc))
else:
if not time_sync.ok:
add_alert(
{
"code": ExchangeStatusCode.TIME_ERROR.value,
"title": "Ошибка времени биржи",
"ui_line": "⛔️ Ошибка времени биржи",
"details": {
"hostname": time_sync.hostname,
"local_ip": time_sync.local_ip,
"local_time": time_sync.local_time,
"exchange_time": time_sync.exchange_time,
"drift_seconds": time_sync.drift_seconds,
},
"reason": "time_error",
"raw_error": time_sync.message,
}
)
try:
private_auth_health = service.get_private_auth_health()
except Exception as auth_exc:
add_alert(build_runtime_exchange_status(auth_exc))
else:
if not private_auth_health.ok:
add_alert(
build_runtime_exchange_status(
Exception(private_auth_health.message)
)
)
priority = {
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value: 10,
ExchangeStatusCode.TIME_ERROR.value: 20,
ExchangeStatusCode.AUTH_ERROR.value: 30,
}
alerts.sort(
key=lambda alert: priority.get(
str(alert.get("code") or ""),
999,
)
)
return alerts
def format_runtime_exchange_alert(alert: dict[str, object]) -> str:
title = str(
alert.get("ui_line")
or alert.get("title")
or "⛔️ Ошибка биржи"
).strip()
details = alert.get("details")
code = str(alert.get("code") or "")
lines = [title]
if isinstance(details, dict):
lines.append("Проверь настройки времени на:")
hostname = details.get("hostname")
local_ip = details.get("local_ip")
local_time = details.get("local_time")
exchange_time = details.get("exchange_time")
drift_seconds = details.get("drift_seconds")
if hostname:
lines.append(f"• Сервер: {hostname}")
if local_ip:
lines.append(f"• IP: {local_ip}")
if local_time:
lines.append(f"• Время сервера: {local_time}")
if exchange_time:
lines.append(f"• Время биржи: {exchange_time}")
lines.append(f"• Расхождение: {format_drift_seconds(drift_seconds)}")
return "\n".join(lines).strip()
if code == ExchangeStatusCode.AUTH_ERROR.value:
lines.extend([
"Проверь:",
"• API-ключ, Secret Key",
"• IP whitelist и права доступа",
])
return "\n".join(lines).strip()
details_text = str(details or "").strip()
if details_text:
lines.append(details_text)
return "\n".join(lines).strip()
def format_runtime_exchange_alerts(alerts: list[dict[str, object]]) -> str:
return "\n\n".join(
block
for block in (
format_runtime_exchange_alert(alert)
for alert in alerts
)
if block.strip()
).strip()
def build_runtime_exchange_alert_lines(
*,
symbol: str | None = None,
include_exchange_unavailable: bool = True,
) -> list[str]:
alerts = build_runtime_exchange_alerts(
symbol=symbol,
include_exchange_unavailable=include_exchange_unavailable,
)
lines: list[str] = []
for alert in alerts:
line = str(alert.get("ui_line") or alert.get("title") or "").strip()
if line and line not in lines:
lines.append(line)
return lines

File diff suppressed because it is too large Load Diff

View File

@@ -1,288 +0,0 @@
# app/src/integrations/exchange/status.py
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeResponseError,
)
class ExchangeStatusCode(StrEnum):
OPEN = "OPEN"
BREAK = "BREAK"
EXCHANGE_UNAVAILABLE = "EXCHANGE_UNAVAILABLE"
AUTH_ERROR = "AUTH_ERROR"
TIME_ERROR = "TIME_ERROR"
INVALID_SYMBOL = "INVALID_SYMBOL"
UNKNOWN = "UNKNOWN"
# app/src/integrations/exchange/status.py
@dataclass(slots=True)
class ExchangeRuntimeStatus:
code: ExchangeStatusCode
is_open: bool
is_available: bool
is_auth_ok: bool
title: str
message: str
ui_line: str
reason: str
symbol: str | None = None
raw_status: str | None = None
raw_error: str | None = None
# вернуть статус в dict для старого UI-кода на время миграции
def as_dict(self) -> dict[str, object]:
return {
"code": self.code.value,
"status": self.code.value,
"symbol": self.symbol,
"is_open": self.is_open,
"is_available": self.is_available,
"is_auth_ok": self.is_auth_ok,
"title": self.title,
"message": self.message,
"ui_line": self.ui_line,
"reason": self.reason,
"raw_status": self.raw_status,
"raw_error": self.raw_error,
}
# собрать статус mock-режима
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
is_open=True,
is_available=True,
is_auth_ok=True,
title="Mock exchange",
message="Mock market is open.",
ui_line="🟢 Mock биржа",
reason="mock_exchange",
symbol=symbol,
raw_status="OPEN",
)
# собрать статус ошибки авторизации аккаунта
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",
}
# определить единый runtime-статус по статусу инструмента биржи
def build_market_status_from_symbol_status(
*,
raw_status: str | None,
symbol: str,
) -> ExchangeRuntimeStatus:
normalized_status = str(raw_status or "").strip().upper()
if normalized_status in OPEN_STATUSES:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
is_open=True,
is_available=True,
is_auth_ok=True,
title="Биржа доступна",
message="Рынок открыт.",
ui_line="🟢 Биржа доступна",
reason="market_open",
raw_status=normalized_status,
symbol=symbol,
)
if normalized_status in BREAK_STATUSES:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Перерыв на бирже",
message="Торги по инструменту временно остановлены.",
ui_line="⏸️ Перерыв на бирже",
reason="market_break",
raw_status=normalized_status,
symbol=symbol,
)
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.UNKNOWN,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Статус рынка не определён",
message=f"Статус инструмента {symbol} не определён.",
ui_line="⏸️ Перерыв на бирже",
reason="market_status_unknown",
raw_status=normalized_status or None,
symbol=symbol,
)
# собрать единый статус для неверного торгового инструмента
def build_invalid_symbol_status(
*,
symbol: str,
message: str,
) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.INVALID_SYMBOL,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Инструмент недоступен",
message=message or f"Инструмент {symbol} недоступен.",
ui_line="⛔️ Инструмент недоступен",
reason="invalid_symbol",
raw_status="INVALID_SYMBOL",
symbol=symbol,
)
# собрать единый статус по ошибке exchange/API
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
error_type = classify_exchange_error(exc)
raw_error = str(exc)
if error_type == "auth":
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.AUTH_ERROR,
is_open=False,
is_available=True,
is_auth_ok=False,
title="Ошибка доступа к аккаунту",
message="Ошибка доступа к аккаунту.",
ui_line="⛔️ Ошибка доступа к аккаунту",
reason="auth_error",
raw_status="AUTH_ERROR",
raw_error=raw_error,
)
if error_type == "time":
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.TIME_ERROR,
is_open=False,
is_available=False,
is_auth_ok=True,
title="Ошибка времени",
message="Проверь синхронизацию времени.",
ui_line="⛔️ Ошибка времени биржи",
reason="time_error",
raw_status="TIME_ERROR",
raw_error=raw_error,
)
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.EXCHANGE_UNAVAILABLE,
is_open=False,
is_available=False,
is_auth_ok=True,
title="Биржа недоступна",
message="Не удалось получить данные с биржи.",
ui_line="⛔️ Биржа недоступна",
reason="exchange_unavailable",
raw_status="EXCHANGE_UNAVAILABLE",
raw_error=raw_error,
)
# классифицировать ошибку биржи для единого UI и логов
def classify_exchange_error(exc: Exception) -> str:
text = str(exc).lower()
if any(
marker in text
for marker in [
"invalid api key",
"invalid api-key",
"api key",
"api-key",
"signature",
"unauthorized",
"forbidden",
"permissions",
"expired",
]
):
return "auth"
if any(
marker in text
for marker in [
"-1021",
"server time",
"doesn't match server time",
"рассинхрон",
]
):
return "time"
if isinstance(exc, ExchangeConnectionError):
return "network"
if isinstance(exc, ExchangeResponseError):
if "404" in text:
return "network"
if any(
marker in text
for marker in [
"404",
"timeout",
"timed out",
"connection error",
"network error",
"name or service not known",
"nodename nor servname",
"temporary failure",
]
):
return "network"
return "generic"
# проверить, относится ли reason к unified exchange status layer
def is_exchange_status_reason(reason: str | None) -> bool:
if not reason:
return False
normalized = str(reason).strip().upper()
return normalized in {
ExchangeStatusCode.OPEN.value,
ExchangeStatusCode.BREAK.value,
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
ExchangeStatusCode.AUTH_ERROR.value,
ExchangeStatusCode.TIME_ERROR.value,
ExchangeStatusCode.INVALID_SYMBOL.value,
ExchangeStatusCode.UNKNOWN.value,
}

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/symbol_utils.py
from __future__ import annotations from __future__ import annotations

View File

@@ -1,126 +0,0 @@
# app/src/integrations/exchange/ws_client.py
from __future__ import annotations
import asyncio
import json
from typing import AsyncIterator, cast
from uuid import uuid4
import websockets
from websockets.typing import Subprotocol
from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
class ExchangeWebSocketClient:
def __init__(self) -> None:
self.settings = load_settings()
self.base_url = self._build_ws_base_url()
# собрать корректный websocket URL из настроек
def _build_ws_base_url(self) -> str:
raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url
if raw_url.startswith("https://"):
raw_url = raw_url.replace("https://", "wss://", 1)
elif raw_url.startswith("http://"):
raw_url = raw_url.replace("http://", "ws://", 1)
raw_url = raw_url.rstrip("/")
if raw_url.endswith("/connect"):
return raw_url
return f"{raw_url}/connect"
# безопасно нормализовать паузу между websocket-запросами
def _interval_seconds(self, value: NumericLike | None) -> float:
interval = safe_float(value)
if interval is None or interval <= 0:
return 1.0
return interval
# собрать headers для подключения к websocket
def _headers(self) -> dict[str, str]:
headers = {
"Origin": self.settings.exchange_base_url.rstrip("/"),
"Content-Type": "application/json",
}
if self.settings.exchange_api_key:
headers["X-MBX-APIKEY"] = self.settings.exchange_api_key
return headers
# собрать payload запроса стакана
def _depth_request(self, symbol: str) -> JsonDict:
return {
"correlationId": str(uuid4()),
"destination": "/api/v2/depth",
"payload": {
"limit": 5,
"symbol": symbol,
},
}
# безопасно разобрать JSON от websocket
def _loads_json(self, raw_message: str | bytes) -> JsonDict | JsonList | None:
try:
payload = json.loads(raw_message)
except json.JSONDecodeError:
return None
if isinstance(payload, dict):
return cast(JsonDict, payload)
if isinstance(payload, list):
return cast(JsonList, payload)
return None
# поток данных стакана по websocket
async def stream_depth(
self,
symbol: str,
*,
interval_seconds: NumericLike = 1.0,
) -> AsyncIterator[JsonDict]:
interval = self._interval_seconds(interval_seconds)
headers = self._headers()
async with websockets.connect(
self.base_url,
additional_headers=headers,
subprotocols=[Subprotocol("json")],
ping_interval=20,
open_timeout=self.settings.exchange_timeout_sec,
) as websocket:
while True:
request = self._depth_request(symbol)
await websocket.send(json.dumps(request))
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=self.settings.exchange_timeout_sec,
)
except asyncio.TimeoutError:
await asyncio.sleep(interval)
continue
if not isinstance(raw_message, (str, bytes)):
await asyncio.sleep(interval)
continue
payload = self._loads_json(raw_message)
if isinstance(payload, dict):
yield payload
await asyncio.sleep(interval)

View File

@@ -1,25 +1,10 @@
# app/src/main.py
import asyncio import asyncio
from src.bootstrap.app_factory import create_app from src.bootstrap.app_factory import create_app
async def main() -> None: async def main() -> None:
# создаём bot + dispatcher
bot, dispatcher = create_app() bot, dispatcher = create_app()
# WebSocket stream временно отключён.
# Причина: Dzengi Swagger содержит wss:/api/v2/* endpoints,
# но runtime probe не нашёл endpoint с WebSocket Upgrade 101.
#
# Когда Dzengi подтвердит рабочий WS endpoint,
# можно будет вернуть запуск:
#
# from src.integrations.exchange.market_stream import start_market_stream
# market_stream_task = asyncio.create_task(start_market_stream())
# запускаем Telegram polling
await dispatcher.start_polling(bot) await dispatcher.start_polling(bot)

View File

@@ -1,9 +0,0 @@
# app/src/notifications/__init__.py
from src.notifications.models import NotificationMessage
from src.notifications.targets import NotificationTargetRegistry
__all__ = [
"NotificationMessage",
"NotificationTargetRegistry",
]

View File

@@ -1,5 +0,0 @@
# app/src/notifications/channels/__init__.py
from src.notifications.channels.telegram import TelegramNotificationChannel
__all__ = ["TelegramNotificationChannel"]

View File

@@ -1,73 +0,0 @@
# app/src/notifications/channels/telegram.py
from __future__ import annotations
from aiogram.exceptions import TelegramRetryAfter
from src.notifications.models import NotificationMessage
from src.notifications.targets import NotificationTargetRegistry
from src.trading.journal.service import JournalService
class TelegramNotificationChannel:
async def send(self, message: NotificationMessage) -> bool:
bot = NotificationTargetRegistry.get_bot()
chat_id = NotificationTargetRegistry.get_default_chat_id()
if bot is None or chat_id is None:
JournalService().log_warning(
"notification_error",
"Не удалось отправить Telegram-уведомление: получатель не настроен.",
{
"title": message.title,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False
try:
await bot.send_message(
chat_id=chat_id,
text=message.text,
parse_mode=message.parse_mode,
)
JournalService().log_info(
"notification_sent",
"Telegram-уведомление отправлено.",
{
"title": message.title,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return True
except TelegramRetryAfter as exc:
JournalService().log_warning(
"notification_error",
"Не удалось отправить Telegram-уведомление: Telegram ограничил частоту отправки.",
{
"title": message.title,
"retry_after": exc.retry_after,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False
except Exception as exc:
JournalService().log_error(
"notification_error",
"Не удалось отправить Telegram-уведомление.",
{
"title": message.title,
"error": str(exc),
"error_type": type(exc).__name__,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False

View File

@@ -1,23 +0,0 @@
# app/src/notifications/dedupe.py
from __future__ import annotations
import time
class NotificationDedupe:
_sent_at_by_key: dict[str, float] = {}
@classmethod
def should_send(cls, key: str | None, *, ttl_seconds: int = 120) -> bool:
if not key:
return True
now = time.monotonic()
last_sent_at = cls._sent_at_by_key.get(key)
if last_sent_at is not None and now - last_sent_at < ttl_seconds:
return False
cls._sent_at_by_key[key] = now
return True

View File

@@ -1,14 +0,0 @@
# app/src/notifications/models.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class NotificationMessage:
title: str
text: str
priority: str = "normal"
parse_mode: str = "HTML"
dedupe_key: str | None = None

View File

@@ -1,29 +0,0 @@
# app/src/notifications/service.py
from __future__ import annotations
from src.notifications.channels.telegram import TelegramNotificationChannel
from src.notifications.dedupe import NotificationDedupe
from src.notifications.models import NotificationMessage
from src.notifications.templates.execution import build_execution_notification
from src.notifications.templates.signal import build_signal_notification
from src.runtime_events.models import RuntimeEvent
class NotificationService:
async def handle_runtime_event(self, event: RuntimeEvent) -> None:
message = self._build_message(event)
if message is None:
return
if not NotificationDedupe.should_send(message.dedupe_key):
return
await TelegramNotificationChannel().send(message)
def _build_message(self, event: RuntimeEvent) -> NotificationMessage | None:
return (
build_signal_notification(event)
or build_execution_notification(event)
)

View File

@@ -1,33 +0,0 @@
# app/src/notifications/targets.py
from __future__ import annotations
from aiogram import Bot
class NotificationTargetRegistry:
_bot: Bot | None = None
_chat_id: int | None = None
@classmethod
def set_bot(cls, bot: Bot) -> None:
cls._bot = bot
@classmethod
def set_default_chat(cls, *, bot: Bot | None = None, chat_id: int) -> None:
if bot is not None:
cls._bot = bot
cls._chat_id = chat_id
@classmethod
def get_bot(cls) -> Bot | None:
return cls._bot
@classmethod
def get_default_chat_id(cls) -> int | None:
return cls._chat_id
@classmethod
def is_ready(cls) -> bool:
return cls._bot is not None and cls._chat_id is not None

View File

@@ -1,9 +0,0 @@
# app/src/notifications/templates/__init__.py
from src.notifications.templates.execution import build_execution_notification
from src.notifications.templates.signal import build_signal_notification
__all__ = [
"build_execution_notification",
"build_signal_notification",
]

View File

@@ -1,324 +0,0 @@
# app/src/notifications/templates/execution.py
from __future__ import annotations
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
from src.core.numbers import safe_float
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
if event.event_type == RuntimeEventType.POSITION_OPENED:
return _build_position_opened(event)
if event.event_type == RuntimeEventType.POSITION_CLOSED:
return _build_position_closed(event)
if event.event_type == RuntimeEventType.POSITION_FLIPPED:
return _build_position_flipped(event)
if event.event_type == RuntimeEventType.POSITION_FLIP_BLOCKED:
return _build_flip_blocked(event)
return None
def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
strategy = str(payload.get("strategy") or "").title()
side_raw = str(payload.get("side") or "").upper()
side = side_raw.title()
leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
size = _format_size(payload.get("size"))
confidence = float(payload.get("confidence") or 0.0)
priority = _alert_priority(
confidence=confidence,
repeat_count=int(payload.get("repeat_count") or 0),
)
semantic_lines = payload.get("semantic_lines") or []
side_icon = "🟢" if side_raw == "LONG" else "🔴"
lines = [
"<b>🧾 Позиция открыта</b>",
"",
f"{side_icon} {symbol} · {strategy} · {side} {leverage}",
f"Вход: ${entry_price}",
f"Размер: {size}",
f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}",
"",
f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}",
]
if semantic_lines:
lines.extend(
str(line).strip().rstrip(".")
for line in semantic_lines
if str(line).strip()
)
return NotificationMessage(
title=event.title,
text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)
def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
side = str(payload.get("side") or "").title()
leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
exit_price = _format_price(payload.get("exit_price"))
size = _format_size(payload.get("size"))
pnl_value = float(payload.get("pnl") or 0.0)
pnl_text = _format_pnl_amount(pnl_value)
risk_reason = _human_close_reason(payload.get("risk_reason"))
pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
lines = [
"<b>🧾 Сделка закрыта</b>",
f"{pnl_icon} {pnl_label} · {pnl_text}",
"",
f"{symbol} · {side} {leverage}",
f"Вход: ${entry_price}",
f"Выход: ${exit_price}",
f"Размер: {size}",
]
if risk_reason:
lines.extend([
"",
f"Закрытие по {risk_reason}",
])
return NotificationMessage(
title=event.title,
text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)
def _format_pnl_amount(value: float) -> str:
amount = f"$ {abs(value):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
if value > 0:
return f"+{amount}"
if value < 0:
return f"{amount}"
return "$ 0"
def _human_close_reason(value: object) -> str:
mapping = {
"STOP_LOSS": "Stop Loss",
"TAKE_PROFIT": "Take Profit",
"MAX_LOSS": "Max Loss",
}
return mapping.get(str(value or ""), "")
def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
strategy = str(payload.get("strategy") or "").title()
old_side_raw = str(payload.get("old_side") or "").upper()
new_side_raw = str(
payload.get("new_side") or payload.get("side") or ""
).upper()
old_side = old_side_raw.title()
new_side = new_side_raw.title()
old_leverage = _format_leverage(
payload.get("old_leverage")
if payload.get("old_leverage") is not None
else payload.get("leverage")
)
new_leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
exit_price = _format_price(payload.get("exit_price"))
new_entry_price = _format_price(payload.get("new_entry_price"))
old_size = _format_size(payload.get("old_size"))
new_size = _format_size(payload.get("new_size"))
pnl_value = float(payload.get("pnl") or 0.0)
pnl_text = _format_pnl_amount(pnl_value)
pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
old_icon = "🟢" if old_side_raw == "LONG" else "🔴"
new_icon = "🟢" if new_side_raw == "LONG" else "🔴"
confidence = float(payload.get("confidence") or 0.0)
repeat_count = int(payload.get("repeat_count") or 0)
priority = _alert_priority(
confidence=confidence,
repeat_count=repeat_count,
)
semantic_lines = payload.get("semantic_lines") or []
lines = [
"<b>🧾 Сделка развернута</b>",
f"{pnl_label} {pnl_icon} {pnl_text}",
f"{symbol} · {strategy} {old_icon} {old_side}{new_icon} {new_side}",
"",
f"Закрыта {old_side} {old_leverage}",
f"Вход: ${entry_price}",
f"Выход: ${exit_price}",
f"Размер: {old_size}",
"",
f"Открыта {new_side} {new_leverage}",
f"Вход: ${new_entry_price}",
f"Размер: {new_size}",
(
"Объём: "
f"{_format_notional(entry_price=payload.get('new_entry_price'), size=payload.get('new_size'))}"
),
"",
f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}",
]
if semantic_lines:
lines.extend(
str(line).strip().rstrip(".")
for line in semantic_lines
if str(line).strip()
)
return NotificationMessage(
title=event.title,
text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)
def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
signal = str(payload.get("signal") or "").upper()
confidence = float(payload.get("confidence") or 0.0)
reason = str(payload.get("reason") or "Flip заблокирован")
position_side = str(payload.get("position_side") or "").title()
target_side = "Long" if signal == "BUY" else "Short" if signal == "SELL" else ""
icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else ""
text = (
f"<b>⚠️ Flip отменён</b>\n\n"
f"{icon} {symbol} · {target_side}\n"
f"Текущая позиция: {position_side}\n\n"
f"Недостаточно условий для разворота\n"
f"{reason}\n"
f"Сила сигнала: {confidence:.2f}"
)
return NotificationMessage(
title=event.title,
text=text,
priority=event.priority,
dedupe_key=event.dedupe_key,
)
def _format_symbol(value: object) -> str:
symbol = str(value or "")
if not symbol or symbol == "":
return ""
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_leverage(value: object) -> str:
number = safe_float(value)
if number is None:
return ""
return f"x{number:g}"
def _format_price(value: object) -> str:
number = safe_float(value)
if number is None:
return ""
return f"{number:,.2f}".replace(",", " ")
def _format_size(value: object) -> str:
number = safe_float(value)
if number is None:
return ""
return f"{number:.8f}".rstrip("0").rstrip(".")
def _alert_priority(*, confidence: float, repeat_count: int) -> str:
if confidence >= 0.8 and repeat_count >= 3:
return "HIGH"
if confidence >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
def _strength_label(priority: str) -> str:
mapping = {
"HIGH": "Сильный",
"MEDIUM": "Средний",
"LOW": "Слабый",
}
return mapping.get(priority.upper(), priority)
def _strength_bar(priority: str) -> str:
mapping = {
"HIGH": "●●●",
"MEDIUM": "●●○",
"LOW": "●○○",
}
return mapping.get(priority.upper(), "●○○")
def _format_notional(
*,
entry_price: object,
size: object,
) -> str:
entry = safe_float(entry_price)
amount = safe_float(size)
if entry is None or amount is None:
return ""
value = entry * amount
return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")

View File

@@ -1,230 +0,0 @@
# app/src/notifications/templates/signal.py
from __future__ import annotations
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None:
if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY:
return None
payload: JsonDict = event.payload
signal = str(payload.get("signal") or "").upper()
symbol = _format_symbol(str(payload.get("symbol") or ""))
confidence = safe_float(payload.get("confidence")) or 0.0
repeat_count = int(safe_float(payload.get("repeat_count")) or 0)
position_context = str(payload.get("position_context") or "NONE").upper()
semantic_lines = _as_json_list(payload.get("semantic_lines"))
priority = str(
event.priority
or _alert_priority(
confidence=confidence,
repeat_count=repeat_count,
)
).upper()
direction = _signal_direction(signal)
direction_key = direction.upper()
icon = _direction_icon(direction)
strength = _strength_label(priority)
strength_bar = _strength_bar(priority)
lines = [
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
]
position_line = _position_context_line(
signal=signal,
position_context=position_context,
)
if position_line:
lines.append(position_line)
price_lines = _market_price_lines(
direction=direction_key,
bid_price=payload.get("bid_price"),
ask_price=payload.get("ask_price"),
)
if price_lines:
lines.append("")
lines.extend(price_lines)
lines.extend([
"",
f"{strength_bar} {strength} · {confidence:.2f}",
])
if semantic_lines:
lines.extend(
str(line).strip().rstrip(".")
for line in semantic_lines
if str(line).strip()
)
return NotificationMessage(
title=event.title,
text="\n".join(lines),
priority=priority.lower(),
dedupe_key=event.dedupe_key or _dedupe_key(payload),
)
def _position_context_line(
*,
signal: str,
position_context: str,
) -> str:
if position_context in {"NONE", "", ""}:
return ""
if position_context == "LONG" and signal == "BUY":
return " В сторону открытой позиции"
if position_context == "SHORT" and signal == "SELL":
return " В сторону открытой позиции"
if position_context == "LONG" and signal == "SELL":
return "⚠️ Против открытой позиции"
if position_context == "SHORT" and signal == "BUY":
return "⚠️ Против открытой позиции"
return ""
def _market_price_lines(
*,
direction: str,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> list[str]:
bid = _format_price_usd(bid_price)
ask = _format_price_usd(ask_price)
if bid == "" and ask == "":
return []
if direction == "LONG":
return [
f"Цена входа Long · {ask} (Ask)",
f"Цена Bid · {bid}",
]
if direction == "SHORT":
return [
f"Цена входа Short · {bid} (Bid)",
f"Цена Ask · {ask}",
]
return [
f"Цена Bid · {bid}",
f"Цена Ask · {ask}",
]
def _format_price_usd(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return ""
return f"${number:,.2f}".replace(",", " ")
def _alert_priority(*, confidence: float, repeat_count: int) -> str:
if confidence >= 0.8 and repeat_count >= 3:
return "HIGH"
if confidence >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
def _strength_label(priority: str) -> str:
mapping = {
"HIGH": "Сильный",
"MEDIUM": "Средний",
"LOW": "Слабый",
}
return mapping.get(priority.upper(), priority)
def _strength_bar(priority: str) -> str:
mapping = {
"HIGH": "●●●",
"MEDIUM": "●●○",
"LOW": "●○○",
}
return mapping.get(priority.upper(), "●○○")
def _signal_direction(signal: str) -> str:
if signal == "BUY":
return "Long"
if signal == "SELL":
return "Short"
return ""
def _direction_icon(direction: str) -> str:
normalized = direction.upper()
if normalized == "LONG":
return "🟢"
if normalized == "SHORT":
return "🔴"
return ""
def _format_symbol(symbol: str) -> str:
if not symbol or symbol == "":
return ""
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_price(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return ""
return f"{number:,.2f}".replace(",", " ")
def _dedupe_key(payload: JsonDict) -> str:
confidence = safe_float(payload.get("confidence")) or 0.0
return (
f"auto_signal_ready:"
f"{payload.get('position_context')}:"
f"{payload.get('symbol')}:"
f"{payload.get('strategy')}:"
f"{payload.get('signal')}:"
f"{payload.get('repeat_count')}:"
f"{confidence:.2f}:"
f"{payload.get('decision_status')}:"
f"{payload.get('reason')}"
)
def _as_json_list(value: object) -> JsonList:
if isinstance(value, list):
return value
return []

View File

@@ -1,9 +0,0 @@
# app/src/runtime_events/__init__.py
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
__all__ = [
"RuntimeEvent",
"RuntimeEventType",
]

View File

@@ -1,18 +0,0 @@
# app/src/runtime_events/event_types.py
from __future__ import annotations
from enum import Enum
class RuntimeEventType(str, Enum):
AUTO_SIGNAL_READY = "auto_signal_ready"
POSITION_OPENED = "position_opened"
POSITION_CLOSED = "position_closed"
POSITION_FLIPPED = "position_flipped"
POSITION_FLIP_BLOCKED = "position_flip_blocked"
EXECUTION_BLOCKED = "execution_blocked"
RISK_ALERT = "risk_alert"
SYSTEM_ALERT = "system_alert"

View File

@@ -1,20 +0,0 @@
# app/src/runtime_events/models.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from src.runtime_events.event_types import RuntimeEventType
@dataclass(slots=True)
class RuntimeEvent:
event_type: RuntimeEventType
source: str
title: str
payload: dict[str, Any] = field(default_factory=dict)
priority: str = "normal"
dedupe_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -1,22 +0,0 @@
# app/src/runtime_events/publisher.py
from __future__ import annotations
import asyncio
from src.runtime_events.models import RuntimeEvent
class RuntimeEventPublisher:
@classmethod
def publish(cls, event: RuntimeEvent) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
# lazy import, чтобы не ловить circular import:
# runtime_events -> notifications -> runtime_events
from src.notifications.service import NotificationService
loop.create_task(NotificationService().handle_runtime_event(event))

View File

@@ -1,5 +1,3 @@
# app/src/storage/models.py
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -21,3 +19,15 @@ class JournalEventRecord:
event_type: str event_type: str
message: str message: str
payload_json: str | None payload_json: str | None
@dataclass(slots=True)
class OrderDraftRecord:
id: int | None
created_at: str
symbol: str
side: str
order_type: str
quantity: str
status: str
payload_json: str | None

View File

@@ -1 +0,0 @@
"""Package marker."""

View File

@@ -1,5 +1,3 @@
# app/src/storage/repositories/journal.py
from __future__ import annotations from __future__ import annotations
import json import json
@@ -17,19 +15,15 @@ class JournalRepository:
message: str, message: str,
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
) -> None: ) -> None:
payload_json = ( payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None
json.dumps(payload, ensure_ascii=False)
if payload is not None
else None
)
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" '''
INSERT INTO journal_events (level, event_type, message, payload_json) INSERT INTO journal_events (level, event_type, message, payload_json)
VALUES (%s, %s, %s, %s::jsonb) VALUES (%s, %s, %s, %s::jsonb)
""", ''',
( (
level.upper().strip(), level.upper().strip(),
event_type.strip(), event_type.strip(),
@@ -38,146 +32,32 @@ class JournalRepository:
), ),
) )
def _parse_payload(self, raw_payload: Any) -> dict[str, Any] | None: def list_recent_events(self, limit: int = 10) -> list[dict[str, str]]:
if raw_payload is None:
return None
if isinstance(raw_payload, dict):
return raw_payload
if isinstance(raw_payload, str):
try:
parsed = json.loads(raw_payload)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def list_recent_events(self, limit: int = 10) -> list[dict[str, Any]]:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" '''
SELECT id, created_at, level, event_type, message, payload_json SELECT id, created_at, level, event_type, message
FROM journal_events FROM journal_events
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
LIMIT %s LIMIT %s
""", ''',
(limit,), (limit,),
) )
rows = cursor.fetchall() rows = cursor.fetchall()
return [self._row_to_dict(row) for row in rows] items: list[dict[str, str]] = []
for row in rows:
def list_recent_with_offset( items.append(
self, {
limit: int, "id": str(row[0]),
offset: int, "created_at": str(row[1]),
) -> list[dict[str, Any]]: "level": str(row[2]),
with get_connection() as connection: "event_type": str(row[3]),
with connection.cursor() as cursor: "message": str(row[4]),
cursor.execute( }
"""
SELECT id, created_at, level, event_type, message, payload_json
FROM journal_events
ORDER BY created_at DESC, id DESC
LIMIT %s OFFSET %s
""",
(limit, offset),
)
rows = cursor.fetchall()
return [self._row_to_dict(row) for row in rows]
def list_export_rows(
self,
limit: int = 5000,
export_filter: str = "all",
) -> list[dict[str, Any]]:
where_sql = ""
if export_filter == "auto":
where_sql = """
WHERE
COALESCE(payload_json ->> 'screen', '') = 'auto'
OR event_type LIKE 'position_%%'
OR event_type LIKE 'trade_%%'
OR event_type LIKE 'runtime_%%'
OR event_type IN (
'signal_summary',
'signal_ready',
'signal_changed',
'signal_blocked',
'execution_blocked',
'execution_quality_changed'
)
"""
elif export_filter == "trades":
where_sql = """
WHERE
event_type IN (
'position_opened',
'position_closed',
'position_flipped',
'position_flip_blocked',
'trade_opened',
'trade_closed',
'trade_flipped'
)
OR event_type LIKE 'trade_%%'
"""
elif export_filter == "errors":
where_sql = """
WHERE
level IN ('ERROR', 'CRITICAL')
OR (
level = 'WARNING'
AND (
event_type LIKE '%%error%%'
OR event_type LIKE '%%blocked%%'
OR event_type LIKE '%%failed%%'
OR COALESCE(payload_json ->> 'error_type', '') <> ''
OR COALESCE(payload_json ->> 'raw_error', '') <> ''
)
)
"""
elif export_filter == "not_auto":
where_sql = """
WHERE NOT (
COALESCE(payload_json ->> 'screen', '') = 'auto'
OR event_type LIKE 'position_%%'
OR event_type LIKE 'trade_%%'
OR event_type LIKE 'runtime_%%'
OR event_type IN (
'signal_summary',
'signal_ready',
'signal_changed',
'signal_blocked',
'execution_blocked',
'execution_quality_changed'
)
) )
""" return items
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT id, created_at, level, event_type, message, payload_json
FROM journal_events
{where_sql}
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(limit,),
)
rows = cursor.fetchall()
return [self._row_to_dict(row) for row in rows]
def count_events(self) -> int: def count_events(self) -> int:
with get_connection() as connection: with get_connection() as connection:
@@ -187,34 +67,26 @@ class JournalRepository:
return int(row[0]) if row else 0 return int(row[0]) if row else 0
def delete_all(self) -> int: def list_recent_with_offset(self, limit: int, offset: int):
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM journal_events")
deleted_count = cursor.rowcount
return int(deleted_count or 0)
def delete_older_than_days(self, days: int) -> int:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
DELETE FROM journal_events SELECT created_at, level, event_type, message
WHERE created_at < NOW() - (%s * INTERVAL '1 day') FROM journal_events
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", """,
(days,), (limit, offset),
) )
deleted_count = cursor.rowcount rows = cursor.fetchall()
return int(deleted_count or 0) return [
{
def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]: "created_at": str(r[0]),
return { "level": r[1],
"id": str(row[0]), "event_type": r[2],
"created_at": str(row[1]), "message": r[3],
"level": str(row[2]), }
"event_type": str(row[3]), for r in rows
"message": str(row[4]), ]
"payload": self._parse_payload(row[5]),
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import json
from typing import Any
from src.storage.session import get_connection
class OrderDraftRepository:
def add_draft(
self,
*,
symbol: str,
side: str,
order_type: str,
quantity: str,
status: str = "draft",
payload: dict[str, Any] | None = None,
) -> None:
payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
'''
INSERT INTO order_drafts (symbol, side, order_type, quantity, status, payload_json)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
''',
(symbol, side, order_type, quantity, status, payload_json),
)
def list_recent_drafts(self, limit: int = 10) -> list[dict[str, str]]:
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
'''
SELECT created_at, symbol, side, order_type, quantity::text, status
FROM order_drafts
ORDER BY created_at DESC, id DESC
LIMIT %s
''',
(limit,),
)
rows = cursor.fetchall()
items: list[dict[str, str]] = []
for row in rows:
items.append(
{
"created_at": str(row[0]),
"symbol": str(row[1]),
"side": str(row[2]),
"order_type": str(row[3]),
"quantity": str(row[4]),
"status": str(row[5]),
}
)
return items
def count_drafts(self) -> int:
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM order_drafts")
row = cursor.fetchone()
return int(row[0]) if row else 0

View File

@@ -1,23 +1,15 @@
# app/src/storage/schema.py
from __future__ import annotations from __future__ import annotations
from psycopg import sql
from src.storage.session import get_connection from src.storage.session import get_connection
DDL = [
'''
# SQL-команды для первичной инициализации базы данных.
DDL: list[sql.SQL] = [
sql.SQL("""
CREATE TABLE IF NOT EXISTS balance_snapshots ( CREATE TABLE IF NOT EXISTS balance_snapshots (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL, source TEXT NOT NULL,
payload_json JSONB NOT NULL payload_json JSONB NOT NULL
) )
"""), ''',
sql.SQL(""" '''
CREATE TABLE IF NOT EXISTS journal_events ( CREATE TABLE IF NOT EXISTS journal_events (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -26,19 +18,28 @@ DDL: list[sql.SQL] = [
message TEXT NOT NULL, message TEXT NOT NULL,
payload_json JSONB payload_json JSONB
) )
"""), ''',
sql.SQL(""" '''
CREATE INDEX IF NOT EXISTS idx_journal_events_created_at CREATE INDEX IF NOT EXISTS idx_journal_events_created_at
ON journal_events (created_at DESC) ON journal_events (created_at DESC)
"""), ''',
sql.SQL(""" '''
CREATE INDEX IF NOT EXISTS idx_journal_events_event_type CREATE INDEX IF NOT EXISTS idx_journal_events_event_type
ON journal_events (event_type) ON journal_events (event_type)
"""), ''',
'''
CREATE TABLE IF NOT EXISTS order_drafts (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
symbol TEXT NOT NULL,
side TEXT NOT NULL,
order_type TEXT NOT NULL,
quantity NUMERIC(36, 18) NOT NULL,
status TEXT NOT NULL,
payload_json JSONB
)
'''
] ]
# создаёт таблицы и индексы, если они ещё не существуют
def init_schema() -> None: def init_schema() -> None:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:

View File

@@ -1,5 +1,3 @@
# app/src/storage/session.py
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager

View File

@@ -0,0 +1,14 @@
# app/src/telegram/handlers/auto.py
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import AUTO_TEXT
router = Router(name="auto")
@router.message(F.text == "🤖 Авто")
async def open_auto(message: Message) -> None:
await message.answer(AUTO_TEXT)

View File

@@ -1,11 +0,0 @@
from aiogram import Router
from src.telegram.handlers.auto.main import router as main_router
from src.telegram.handlers.auto.risk import router as risk_router
router = Router(name="auto")
router.include_router(main_router)
router.include_router(risk_router)
__all__ = ["router"]

View File

@@ -1,2 +0,0 @@
# app/src/telegram/handlers/auto/debug.py

View File

@@ -1,356 +0,0 @@
# app/src/telegram/handlers/auto/main.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InaccessibleMessage, Message
from src.telegram.handlers.auto.ui import (
auto_diagnostics_keyboard,
auto_keyboard,
build_auto_text,
is_auto_configured,
_auto_block_reason,
)
from src.telegram.handlers.system import open_auto_settings
from src.telegram.live.active_screen import ActiveScreenManager
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
async def render_auto_screen(
target_message: Message,
*,
edit_mode: bool,
) -> None:
text = build_auto_text()
if edit_mode:
try:
await target_message.edit_text(text, reply_markup=auto_keyboard())
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_text,
render_markup=auto_keyboard,
)
ActiveScreenManager.register(
screen="auto",
message=target_message,
)
return
sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_text,
render_markup=auto_keyboard,
)
ActiveScreenManager.register(
screen="auto",
message=sent_message,
)
async def _prepare_auto_from_message(message: Message) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_auto_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="auto",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
def build_auto_diagnostics_text() -> str:
service = AutoTradeService()
state = service.get_state()
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=is_auto_configured(state),
)
return SemanticDiagnosticFormatter().format(snapshot)
async def render_auto_diagnostics_screen(
target_message: Message,
) -> None:
text = build_auto_diagnostics_text()
try:
await target_message.edit_text(
text,
reply_markup=auto_diagnostics_keyboard(),
)
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message to edit not found" in error_text:
sent_message = await target_message.answer(
text,
reply_markup=auto_diagnostics_keyboard(),
)
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_diagnostics_text,
render_markup=auto_diagnostics_keyboard,
)
ActiveScreenManager.register(
screen="auto_diagnostics",
message=sent_message,
)
return
if "message is not modified" in error_text:
return
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_diagnostics_text,
render_markup=auto_diagnostics_keyboard,
)
ActiveScreenManager.register(
screen="auto_diagnostics",
message=target_message,
)
@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear()
if not await _prepare_auto_from_message(message):
return
await render_auto_screen(message, edit_mode=False)
@router.callback_query(F.data == "auto:home")
async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if not await _prepare_auto_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await render_auto_screen(message, edit_mode=True)
await callback.answer()
@router.callback_query(F.data == "auto:start_blocked")
async def auto_start_blocked(callback: CallbackQuery) -> None:
reason = _auto_block_reason() or "Запуск сейчас недоступен"
await callback.answer(
reason.replace("⛔️ ", ""),
show_alert=True,
)
@router.callback_query(F.data == "auto:start")
async def auto_start(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
block_reason = _auto_block_reason()
if block_reason:
await callback.answer(
block_reason.replace("⛔️ ", "").replace("", ""),
show_alert=True,
)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
return
_, message_text = service.start()
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message_text)
@router.callback_query(F.data == "auto:observe")
async def auto_observe(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
_, message_text = service.observe()
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message_text)
@router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService()
_, message_text = service.stop()
AutoTradeRunner.stop()
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:diagnostics")
async def open_auto_diagnostics(callback: CallbackQuery) -> None:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return
await ActiveScreenManager.prepare_new_screen(
screen="auto_diagnostics",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
await render_auto_diagnostics_screen(message)
await callback.answer()

View File

@@ -1,518 +0,0 @@
# app/src/telegram/handlers/auto/risk.py
from __future__ import annotations
import asyncio
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
CallbackQuery,
InlineKeyboardMarkup,
Message,
InaccessibleMessage,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
router = Router(name="auto_risk")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
class AutoRiskStates(StatesGroup):
waiting_stop_loss = State()
waiting_take_profit = State()
waiting_max_loss = State()
def _format_number(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return ""
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}"
return f"{number:.2f}".rstrip("0").rstrip(".")
def _format_percent(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return "off"
return f"{_format_number(number)}%"
def _format_usd(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return "off"
return f"{_format_number(number)} USD"
def _rule_icon(value: NumericLike | None) -> str:
return "" if safe_float(value) is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛑 SL", callback_data="auto:risk:set_sl")
builder.button(text="🎯 TP", callback_data="auto:risk:set_tp")
builder.button(text="💸 ML", callback_data="auto:risk:set_ml")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.button(text="♻️ Сбросить", callback_data="auto:risk:reset")
builder.adjust(3, 1, 2)
return builder.as_markup()
def _risk_text(status_message: str | None = None) -> str:
state = AutoTradeService().get_state()
active_count = sum(
value is not None
for value in (
state.stop_loss_percent,
state.take_profit_percent,
state.max_loss_usd,
)
)
status = "🟢 Активна" if active_count else "⚪ Выключена"
text = (
"<b>🧯 Защита позиции</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
f"Статус защиты: {status}\n"
f"Активных правил: {active_count}/3\n\n"
f"{_rule_icon(state.stop_loss_percent)} Stop Loss · {_format_percent(state.stop_loss_percent)}\n"
f"{_rule_icon(state.take_profit_percent)} Take Profit · {_format_percent(state.take_profit_percent)}\n"
f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n"
)
if status_message:
text += f"\n\n{status_message}"
return text
async def _render_risk_screen(
callback: CallbackQuery,
) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
_unregister_auto_screen_message(callback)
await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
await callback.answer()
async def _render_risk_screen_by_message(
message: Message,
*,
state: FSMContext,
status_message: str | None = None,
auto_clear: bool = False,
) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
bot = message.bot
if bot is None:
return
data: JsonDict = await state.get_data()
raw_chat_id = data.get("risk_chat_id")
raw_message_id = data.get("risk_message_id")
if not isinstance(raw_chat_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
if not isinstance(raw_message_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
chat_id = raw_chat_id
message_id = raw_message_id
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
if status_message and auto_clear:
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
async def _remember_risk_screen(
callback: CallbackQuery,
state: FSMContext,
) -> None:
message = _require_message(callback)
if message is None:
return
await state.update_data(
risk_chat_id=message.chat.id,
risk_message_id=message.message_id,
)
def _unregister_auto_screen_message(
callback: CallbackQuery,
) -> None:
message = _require_message(callback)
if message is None:
return
AutoTradeRunner.unregister_screen(
chat_id=message.chat.id,
message_id=message.message_id,
)
def _risk_payload(**values: object) -> JsonDict:
return dict(values)
def _parse_positive_or_none(
raw_text: str | None,
) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".")
if value_text.lower() in {
"0",
"0.0",
"off",
"-",
}:
return None
value = safe_float(value_text)
if value is None:
raise ValueError
if value <= 0:
return None
return value
def _validate_percent(
value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True
return 0 < number <= 100
def _validate_max_loss(
value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True
return 0 < number <= 10000
def _log_risk_updated(action: str) -> None:
state = AutoTradeService().get_state()
try:
JournalService().log_ui_info(
event_type="risk_settings_updated",
message=(
"Параметры защиты позиции изменены: "
f"SL={_format_percent(state.stop_loss_percent)}, "
f"TP={_format_percent(state.take_profit_percent)}, "
f"ML={_format_usd(state.max_loss_usd)}."
),
screen="auto",
action=action,
payload=_risk_payload(
stop_loss_percent=state.stop_loss_percent,
take_profit_percent=state.take_profit_percent,
max_loss_usd=state.max_loss_usd,
),
)
except Exception:
pass
@router.callback_query(F.data == "auto:risk")
async def open_auto_risk(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await _render_risk_screen(callback)
@router.callback_query(F.data == "settings:auto_risk_controls")
async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await _render_risk_screen(callback)
@router.callback_query(F.data == "auto:risk:set_sl")
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_stop_loss)
await _remember_risk_screen(callback, state)
await message.edit_text(
"<b>Stop Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Stop Loss в процентах.\n"
"Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@router.callback_query(F.data == "auto:risk:set_tp")
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_take_profit)
await _remember_risk_screen(callback, state)
await message.edit_text(
"<b>Take Profit</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Take Profit в процентах.\n"
"Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@router.callback_query(F.data == "auto:risk:set_ml")
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_max_loss)
await _remember_risk_screen(callback, state)
await message.edit_text(
"<b>Maximum Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите максимальный paper-убыток в USD.\n"
"Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@router.callback_query(F.data == "auto:risk:reset")
async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = AutoTradeService()
service.set_stop_loss_percent(None)
service.set_take_profit_percent(None)
service.set_max_loss_usd(None)
_log_risk_updated("risk_reset")
await message.edit_text(
_risk_text(status_message="✅ Risk Controls сброшены"),
reply_markup=_risk_keyboard(),
)
await callback.answer()
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
@router.message(AutoRiskStates.waiting_stop_loss)
async def set_stop_loss(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
await message.answer("Введите число. Например: 1, 0.5 или 0 для отключения.")
return
if not _validate_percent(value):
await message.answer("Stop Loss должен быть от 0 до 100%.")
return
AutoTradeService().set_stop_loss_percent(value)
_log_risk_updated("set_stop_loss")
await _render_risk_screen_by_message(
message,
state=state,
status_message=f"✅ Stop Loss обновлён: {_format_percent(value)}",
auto_clear=True,
)
await state.clear()
@router.message(AutoRiskStates.waiting_take_profit)
async def set_take_profit(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
await message.answer("Введите число. Например: 2, 1.5 или 0 для отключения.")
return
if not _validate_percent(value):
await message.answer("Take Profit должен быть от 0 до 100%.")
return
AutoTradeService().set_take_profit_percent(value)
_log_risk_updated("set_take_profit")
await _render_risk_screen_by_message(
message,
state=state,
status_message=f"✅ Take Profit обновлён: {_format_percent(value)}",
auto_clear=True,
)
await state.clear()
@router.message(AutoRiskStates.waiting_max_loss)
async def set_max_loss(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
await message.answer("Введите число. Например: 100, 50.5 или 0 для отключения.")
return
if not _validate_max_loss(value):
await message.answer("Max Loss должен быть от 0 до 10000 USD.")
return
AutoTradeService().set_max_loss_usd(value)
_log_risk_updated("set_max_loss")
await _render_risk_screen_by_message(
message,
state=state,
status_message=f"✅ Max Loss обновлён: {_format_usd(value)}",
auto_clear=True,
)
await state.clear()

File diff suppressed because it is too large Load Diff

View File

@@ -1,669 +0,0 @@
# app/src/telegram/handlers/debug.py
from __future__ import annotations
import math
import time
from aiogram import F, Router
from aiogram.types import Message
from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonList, NumericLike
from src.trading.debug.execution import DebugExecutionEngine
from src.trading.debug.service import DebugTradeService
from src.trading.debug.state import DebugTradeState
from src.trading.execution.models import ExecutionDecision
router = Router(name="debug")
def _debug_enabled() -> bool:
return bool(load_settings().debug_enabled)
def _debug_help_text() -> str:
return (
"<b>🧪 Debug commands</b>\n\n"
"<b>Isolated Debug Runtime:</b>\n"
"/debug_auto reset\n"
"/debug_auto off\n"
"/debug_auto hold 335\n"
"/debug_auto buy 12 0.74\n"
"/debug_auto buy_ready 0.88\n"
"/debug_auto sell 9 0.71\n"
"/debug_auto sell_ready 0.91\n"
"/debug_auto long\n"
"/debug_auto short\n"
"/debug_auto state\n\n"
"<b>Isolated Debug Execution:</b>\n"
"/debug_exec buy — открыть DEBUG LONG\n"
"/debug_exec sell — открыть DEBUG SHORT\n"
"/debug_exec flip — перевернуть DEBUG позицию\n"
"/debug_exec close — закрыть DEBUG позицию\n"
"/debug_exec process — один цикл DEBUG execution\n"
"/debug_exec update — обновить DEBUG PnL\n"
"/debug_exec state — состояние DEBUG runtime\n\n"
"<b>Legacy aliases:</b>\n"
"/debug_signal BUY 0.95 3\n"
"/debug_signal SELL 0.70 2\n"
"/debug_signal HOLD 0.00 1\n"
"/debug_ready\n"
"/debug_state\n\n"
"⚠️ Все команды работают в изолированном [DEBUG] runtime "
"и не меняют обычную автоторговлю."
)
@router.message(F.text == "/debug_help")
async def debug_help(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
await message.answer(_debug_help_text())
@router.message(F.text.startswith("/debug_auto"))
async def debug_auto(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
parts = (message.text or "").split()
command = parts[1].lower() if len(parts) > 1 else "help"
service = DebugTradeService()
if command in {"help", "-h", "--help"}:
await message.answer(_debug_help_text())
return
if command == "reset":
state = service.reset()
await message.answer(
"✅ [DEBUG] Runtime reset\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "off":
state = service.stop()
await message.answer(
"✅ [DEBUG] Runtime stopped\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "state":
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
return
if command == "hold":
seconds = _parse_int(parts, index=2, default=335)
state = service.set_signal_duration(
signal="HOLD",
seconds=seconds,
confidence=0.0,
force_ready=False,
)
await message.answer(
f"✅ [DEBUG] HOLD {seconds}s\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "buy":
seconds = _parse_int(parts, index=2, default=12)
confidence = _parse_float(parts, index=3, default=0.74)
state = service.set_signal_duration(
signal="BUY",
seconds=seconds,
confidence=confidence,
force_ready=False,
)
await message.answer(
f"✅ [DEBUG] BUY {seconds}s confidence={confidence:.2f}\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "buy_ready":
confidence = _parse_float(parts, index=2, default=0.88)
state = service.set_signal_duration(
signal="BUY",
seconds=15,
confidence=confidence,
force_ready=True,
)
await message.answer(
f"✅ [DEBUG] BUY READY confidence={confidence:.2f}\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "sell":
seconds = _parse_int(parts, index=2, default=9)
confidence = _parse_float(parts, index=3, default=0.71)
state = service.set_signal_duration(
signal="SELL",
seconds=seconds,
confidence=confidence,
force_ready=False,
)
await message.answer(
f"✅ [DEBUG] SELL {seconds}s confidence={confidence:.2f}\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "sell_ready":
confidence = _parse_float(parts, index=2, default=0.91)
state = service.set_signal_duration(
signal="SELL",
seconds=15,
confidence=confidence,
force_ready=True,
)
await message.answer(
f"✅ [DEBUG] SELL READY confidence={confidence:.2f}\n\n"
f"{_debug_state_text(state)}"
)
return
if command == "long":
state, result = service.open_long()
await message.answer(
_execution_result_text(
"OPEN LONG",
state,
result,
)
)
return
if command == "short":
state, result = service.open_short()
await message.answer(
_execution_result_text(
"OPEN SHORT",
state,
result,
)
)
return
await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_exec"))
async def debug_exec(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
parts = (message.text or "").split()
command = parts[1].lower() if len(parts) > 1 else "help"
service = DebugTradeService()
if command in {"help", "-h", "--help"}:
await message.answer(_debug_help_text())
return
if command == "state":
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
return
if command == "buy":
state, result = service.open_long()
await message.answer(
_execution_result_text(
"EXEC BUY / LONG",
state,
result,
)
)
return
if command == "sell":
state, result = service.open_short()
await message.answer(
_execution_result_text(
"EXEC SELL / SHORT",
state,
result,
)
)
return
if command == "flip":
state, result = service.flip()
await message.answer(
_execution_result_text(
"EXEC AUTO FLIP",
state,
result,
)
)
return
if command == "close":
state, result = service.close(reason="DEBUG_CLOSE")
await message.answer(
_execution_result_text(
"EXEC CLOSE",
state,
result,
)
)
return
if command == "process":
state, result = service.process()
await message.answer(
_execution_result_text(
"EXEC PROCESS",
state,
result,
)
)
return
if command == "update":
state = service.update_market()
await message.answer(
"✅ [DEBUG] Market update\n\n"
f"{_debug_state_text(state)}"
)
return
await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_live"))
async def debug_live(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
await message.answer(
"⚠️ /debug_live отключён в рамках развязки debug runtime.\n\n"
"Теперь debug больше не вмешивается в обычную автоторговлю.\n"
"Используйте:\n\n"
"/debug_exec buy\n"
"/debug_exec sell\n"
"/debug_exec flip\n"
"/debug_exec close\n\n"
"Live-мониторинг для изолированного debug будет добавлен "
"в следующем пакете через отдельный DebugTradeRunner "
"и отдельный Debug Auto экран."
)
@router.message(F.text.startswith("/debug_signal"))
async def debug_signal(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
signal, confidence, repeat_count, error = _parse_debug_signal_args(
message.text,
)
if error is not None:
await message.answer(
f"⛔️ {error}\n\n{_debug_help_text()}"
)
return
service = DebugTradeService()
state = service.set_signal(
signal=signal,
confidence=confidence,
repeat_count=repeat_count,
reason=(
f"[DEBUG] LEGACY FORCE "
f"{signal} {confidence:.2f} ×{repeat_count}"
),
force_ready=(
signal in {"BUY", "SELL"}
and repeat_count >= 2
),
)
await message.answer(
"✅ [DEBUG] Legacy signal forced\n\n"
f"{_debug_state_text(state)}"
)
@router.message(F.text == "/debug_ready")
async def debug_ready(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
service = DebugTradeService()
state = service.set_signal_duration(
signal="BUY",
seconds=15,
confidence=0.95,
force_ready=True,
)
await message.answer(
"✅ [DEBUG] Legacy READY created\n\n"
f"{_debug_state_text(state)}"
)
@router.message(F.text == "/debug_state")
async def debug_state(message: Message) -> None:
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
service = DebugTradeService()
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
def _debug_state_text(
state: DebugTradeState,
) -> str:
position = state.position
duration = _signal_duration_text(state)
pnl = position.unrealized_pnl_usd
return (
"<b>[DEBUG] Auto State</b>\n\n"
f"Status: {state.status}\n"
f"Symbol: {state.symbol}\n"
f"Strategy: {state.strategy}\n"
f"Risk: {state.risk_percent}\n"
f"Leverage: {state.leverage}\n"
f"Allocated: $ {_format_money_compact(state.allocated_balance_usd)}\n"
f"Realized PnL: {_format_signed_usd(state.realized_pnl_usd)}\n\n"
f"<b>Signal</b>\n"
f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n"
f"Duration: {duration}\n"
f"Repeats: {state.last_signal_repeat_count}\n"
f"Confidence: {safe_float(state.last_signal_confidence) or 0.0:.2f}\n"
f"Decision: {state.decision_status}\n"
f"Ready: {state.is_signal_ready}\n"
f"Reason: {state.last_signal_reason or ''}\n\n"
f"<b>Risk</b>\n"
f"SL: {_format_percent(state.stop_loss_percent)}\n"
f"TP: {_format_percent(state.take_profit_percent)}\n"
f"ML: {_format_usd_or_off(state.max_loss_usd)}\n"
f"Max Reserved: {_format_percent(state.max_reserved_balance_percent)}\n"
f"Block: {state.execution_block_reason or ''}\n"
f"Adjustment: {state.execution_size_adjustment_reason or ''}\n\n"
f"<b>Position</b>\n"
f"Side: {position.side}\n"
f"Entry: {_format_usd_or_dash(position.entry_price)}\n"
f"Size: {_format_crypto_size(position.size)}\n"
f"Leverage: {_format_leverage(position.leverage)}\n"
f"PnL: {_format_signed_usd(pnl)}\n"
f"Opened: {position.opened_at or ''}\n"
f"Updated: {position.updated_at or ''}\n\n"
"Runtime: isolated [DEBUG]"
)
def _execution_result_text(
title: str,
state: DebugTradeState,
result: ExecutionDecision,
) -> str:
return (
f"✅ [DEBUG] {title}\n\n"
f"Action: {result.action}\n"
f"Can execute: {result.can_execute}\n"
f"Reason: {result.reason}\n\n"
f"{_debug_state_text(state)}"
)
def _parse_debug_signal_args(
raw_text: str | None,
) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split()
signal = parts[1].upper() if len(parts) > 1 else "BUY"
if signal not in {"BUY", "SELL", "HOLD"}:
return (
"BUY",
0.9,
2,
"SIGNAL должен быть BUY, SELL или HOLD.",
)
confidence = _parse_float(parts, index=2, default=0.9)
if confidence < 0 or confidence > 1:
return (
"BUY",
0.9,
2,
"CONFIDENCE должен быть от 0.00 до 1.00.",
)
repeat_count = _parse_int(parts, index=3, default=2)
if repeat_count < 1:
return (
"BUY",
0.9,
2,
"REPEATS должен быть больше или равен 1.",
)
return signal, confidence, repeat_count, None
def _parse_int(
parts: JsonList,
*,
index: int,
default: int,
) -> int:
try:
value = parts[index]
except (IndexError, TypeError):
return default
number = safe_float(value)
if number is None:
return default
return int(number)
def _parse_float(
parts: JsonList,
*,
index: int,
default: float,
) -> float:
try:
value = parts[index]
except (IndexError, TypeError):
return default
number = safe_float(value)
if number is None:
return default
return number
def _signal_duration_text(
state: DebugTradeState,
) -> str:
started_at = safe_float(state.signal_started_at)
if started_at is not None:
total_seconds = max(
0,
int(time.monotonic() - started_at),
)
else:
repeats = state.last_signal_repeat_count or 0
total_seconds = max(0, int(repeats) * 5)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}ч {minutes:02d}м"
if minutes > 0:
return f"{minutes}м {seconds:02d}с"
return f"{seconds}с"
def _signal_icon(
signal: str | None,
) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
return mapping.get(signal or "", "")
def _format_leverage(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "x—"
return f"x{number:g}"
def _format_crypto_size(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return ""
return f"{number:.5f}".rstrip("0").rstrip(".")
def _format_percent(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "off"
if math.isclose(number, round(number), abs_tol=1e-9):
return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_money_compact(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return ""
if math.isclose(number, round(number), abs_tol=1e-9):
return f"{number:,.0f}".replace(",", " ")
return (
f"{number:,.2f}"
.replace(",", " ")
.rstrip("0")
.rstrip(".")
)
def _format_usd_or_dash(
value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return ""
return f"$ {_format_money_compact(value)}"
def _format_usd_or_off(
value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return "off"
return f"$ {_format_money_compact(value)}"
def _format_signed_usd(
value: NumericLike | None,
) -> str:
amount = safe_float(value)
if amount is None:
return ""
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"
if amount < 0:
return f"🔴 $ {_format_money_compact(abs(amount))}"
return "$ 0"

View File

@@ -1,212 +0,0 @@
# app/src/telegram/handlers/debug_auto/main.py
from __future__ import annotations
import time
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from src.telegram.handlers.debug_auto.ui import (
build_debug_auto_text,
debug_auto_keyboard,
)
from src.telegram.live.active_screen import ActiveScreenManager
from src.trading.debug.runner import DebugTradeRunner
from src.trading.debug.service import DebugTradeService
router = Router(name="debug_auto")
def _ensure_signal_started_at(state) -> None:
if state.signal_started_at is None:
state.signal_started_at = time.monotonic()
async def render_debug_auto_screen(
target_message: Message,
*,
edit_mode: bool,
) -> None:
text = build_debug_auto_text()
if edit_mode:
try:
await target_message.edit_text(
text,
reply_markup=debug_auto_keyboard(),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
DebugTradeRunner.register_screen(
bot=target_message.bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_debug_auto_text,
render_markup=debug_auto_keyboard,
)
ActiveScreenManager.register(
screen="debug_auto",
message=target_message,
)
return
sent_message = await target_message.answer(
text,
reply_markup=debug_auto_keyboard(),
)
DebugTradeRunner.register_screen(
bot=sent_message.bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_debug_auto_text,
render_markup=debug_auto_keyboard,
)
ActiveScreenManager.register(
screen="debug_auto",
message=sent_message,
)
async def _prepare_debug_auto_from_message(message: Message) -> None:
await ActiveScreenManager.prepare_new_screen(
screen="debug_auto",
bot=message.bot,
chat_id=message.chat.id,
)
async def _prepare_debug_auto_from_callback(callback: CallbackQuery) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="debug_auto",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
return True
@router.message(F.text.in_({"🧪 Debug Auto", "Debug Auto", "/debug_auto_screen"}))
async def open_debug_auto(message: Message, state: FSMContext) -> None:
await state.clear()
await _prepare_debug_auto_from_message(message)
DebugTradeRunner.set_current_screen("debug_auto")
await render_debug_auto_screen(message, edit_mode=False)
@router.callback_query(F.data == "debug_auto:start")
async def debug_auto_start(callback: CallbackQuery) -> None:
service = DebugTradeService()
state = service.get_state()
state.status = "RUNNING"
_ensure_signal_started_at(state)
DebugTradeRunner.set_current_screen("debug_auto")
DebugTradeRunner.start()
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer("[DEBUG] Мониторинг запущен.")
@router.callback_query(F.data == "debug_auto:stop")
async def debug_auto_stop(callback: CallbackQuery) -> None:
DebugTradeRunner.set_current_screen("debug_auto")
DebugTradeRunner.stop()
DebugTradeService().stop()
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer("[DEBUG] Мониторинг остановлен.")
@router.callback_query(F.data == "debug_auto:long")
async def debug_auto_long(callback: CallbackQuery) -> None:
service = DebugTradeService()
_, result = service.open_long()
DebugTradeRunner.set_current_screen("debug_auto")
DebugTradeRunner.start()
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer(result.reason, show_alert=False)
@router.callback_query(F.data == "debug_auto:short")
async def debug_auto_short(callback: CallbackQuery) -> None:
service = DebugTradeService()
_, result = service.open_short()
DebugTradeRunner.set_current_screen("debug_auto")
DebugTradeRunner.start()
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer(result.reason, show_alert=False)
@router.callback_query(F.data == "debug_auto:flip")
async def debug_auto_flip(callback: CallbackQuery) -> None:
service = DebugTradeService()
_, result = service.flip()
DebugTradeRunner.set_current_screen("debug_auto")
DebugTradeRunner.start()
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer(result.reason, show_alert=False)
@router.callback_query(F.data == "debug_auto:close")
async def debug_auto_close(callback: CallbackQuery) -> None:
service = DebugTradeService()
_, result = service.close(reason="DEBUG_SCREEN_CLOSE")
DebugTradeRunner.set_current_screen("debug_auto")
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer(result.reason, show_alert=False)
@router.callback_query(F.data == "debug_auto:reset")
async def debug_auto_reset(callback: CallbackQuery) -> None:
DebugTradeRunner.set_current_screen("debug_auto")
state = DebugTradeService().reset()
_ensure_signal_started_at(state)
if callback.message is not None:
await _prepare_debug_auto_from_callback(callback)
await render_debug_auto_screen(callback.message, edit_mode=True)
await callback.answer("[DEBUG] Runtime reset.")

View File

@@ -1,355 +0,0 @@
# app/src/telegram/handlers/debug_auto/ui.py
from __future__ import annotations
import time
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.service import ExchangeService
from src.trading.debug.service import DebugTradeService
def debug_auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="▶️ Start", callback_data="debug_auto:start")
builder.button(text="🛑 Stop", callback_data="debug_auto:stop")
builder.button(text="🟢 LONG", callback_data="debug_auto:long")
builder.button(text="🔴 SHORT", callback_data="debug_auto:short")
builder.button(text="🔁 Flip", callback_data="debug_auto:flip")
builder.button(text="❌ Close", callback_data="debug_auto:close")
builder.button(text="🔄 Reset", callback_data="debug_auto:reset")
builder.adjust(2, 3, 2)
return builder.as_markup()
def build_debug_auto_text() -> str:
state = DebugTradeService().get_state()
position = state.position
parts = [
"🧪 <b>[DEBUG] Автоторговля</b>",
"",
f"<b>Status</b> · {state.status}",
f"<b>Актив</b> · {_asset_symbol(state.symbol)}",
f"<b>Стратегия</b> · {state.strategy or ''}",
f"<b>Баланс</b> · $ {_format_money_compact(state.allocated_balance_usd)}",
f"<b>Realized PnL</b> · {_format_signed_usd(state.realized_pnl_usd)}",
"",
*_market_snapshot_lines(state.symbol),
"",
_signal_line(state),
]
if state.last_signal != "HOLD":
parts.append(f"<b>Уверенность</b> · {state.last_signal_confidence:.2f}")
parts.extend(
[
f"<b>Decision</b> · {state.decision_status}",
"",
"<b>Risk</b>",
f"SL · {_format_percent(state.stop_loss_percent)}",
f"TP · {_format_percent(state.take_profit_percent)}",
f"ML · {_format_usd_or_off(state.max_loss_usd)}",
f"Max Reserved · {_format_percent(state.max_reserved_balance_percent)}",
]
)
if state.execution_block_reason or state.execution_size_adjustment_reason:
parts.extend(
[
"",
f"Blocked · {state.execution_block_reason or ''}",
f"Adjusted · {state.execution_size_adjustment_reason or ''}",
]
)
parts.append("")
if position.side == "NONE":
parts.extend(
[
"📭 <b>Позиция не открыта</b>",
"",
"Debug runtime изолирован от обычной автоторговли.",
]
)
return "\n".join(parts)
side_icon = "🟢" if position.side == "LONG" else "🔴"
notional = None
if position.entry_price is not None and position.size is not None:
notional = position.entry_price * position.size
reserved = None
if notional is not None and position.leverage and position.leverage > 0:
reserved = notional / position.leverage
parts.extend(
[
f"{side_icon} <b>{position.side}</b> · {_asset_symbol(position.symbol)} · {_leverage_text(position.leverage)}",
"",
f"<b>Entry</b> · {_format_usd_or_dash(position.entry_price)}",
f"<b>Size</b> · {_format_crypto_size(position.size)}",
f"<b>Notional</b> · {_format_usd_or_dash(notional)}",
f"<b>Reserved</b> · {_format_usd_or_dash(reserved)}",
f"<b>PnL</b> · {_format_signed_usd(position.unrealized_pnl_usd)}",
f"<b>Opened</b> · {position.opened_at or ''}",
f"<b>Updated</b> · {position.updated_at or ''}",
"",
"Debug runtime изолирован от обычной автоторговли.",
]
)
return "\n".join(parts)
def _format_updated_at(value: object) -> str:
if not value:
return ""
text = str(value)
if " " in text:
return text.rsplit(" ", 1)[-1]
return text
def _market_snapshot_lines(symbol: str | None) -> list[str]:
if not symbol:
return [
"<b>Market</b>",
"Last · —",
"Bid · —",
"Ask · —",
"Source · —",
"Age · —",
"",
"<b>Execution</b>",
"Source · —",
"Age · —",
]
market = None
execution = None
error = None
try:
market = ExchangeService().get_market_snapshot(
symbol,
runtime_key="debug_auto",
)
except Exception as exc:
error = str(exc)
try:
execution = ExchangeService().get_execution_snapshot(
symbol,
runtime_key="debug_auto",
)
except Exception as exc:
if error is None:
error = str(exc)
if market is None and execution is None:
return [
"<b>Market</b>",
"Last · —",
"Bid · —",
"Ask · —",
"Source · error",
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
execution_source = getattr(execution, "source", "") if execution else ""
execution_age = getattr(execution, "age_seconds", None) if execution else None
execution_fresh = getattr(execution, "is_fresh", None) if execution else None
return [
"<b>Market</b>",
f"Last · {_format_usd_or_dash(last_price)}",
f"Bid · {_format_usd_or_dash(bid_price)}",
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)}",
"",
"<b>Execution</b>",
f"Source · {execution_source or ''}",
f"Quote age · {_format_age(execution_age)}",
f"Fresh · {_format_bool(execution_fresh)}",
]
def _signal_line(state) -> str:
signal = state.last_signal or "HOLD"
if signal in {"BUY", "SELL"} and state.is_signal_ready:
return f"<b>Сигнал</b> {_signal_icon(signal)} {signal} · READY"
return f"<b>Сигнал</b> {_signal_icon(signal)} {signal} · {_signal_duration_text(state)}"
def _signal_duration_text(state) -> str:
started_at = state.signal_started_at
if started_at is not None:
total_seconds = max(0, int(time.monotonic() - float(started_at)))
else:
total_seconds = max(0, (state.last_signal_repeat_count or 0) * 5)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}ч {minutes:02d}м"
if minutes > 0:
return f"{minutes}м {seconds:02d}с"
return f"{seconds}с"
def _signal_icon(signal: str | None) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
return mapping.get(signal or "", "")
def _asset_symbol(symbol: str | None) -> str:
if not symbol:
return ""
base = symbol.split("_", 1)[0].upper()
if "/" in base:
return base.split("/", 1)[0]
for suffix in ("USDT", "USD", "EUR", "BTC"):
if base.endswith(suffix) and len(base) > len(suffix):
return base[: -len(suffix)]
return base
def _leverage_text(value: float | int | None) -> str:
if value is None:
return "x—"
return f"x{float(value):g}"
def _format_percent(value: float | int | None) -> str:
if value is None:
return "off"
number = float(value)
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_crypto_size(value: float | int | None) -> str:
if value is None:
return ""
return f"{float(value):.5f}".rstrip("0").rstrip(".")
def _format_money_compact(value: float | int | None) -> str:
if value is None:
return ""
number = float(value)
if abs(number - round(number)) < 1e-9:
return f"{number:,.0f}".replace(",", " ")
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
def _format_usd_or_dash(value: float | int | None) -> str:
if value is None:
return ""
return f"$ {_format_money_compact(value)}"
def _format_usd_or_off(value: float | int | None) -> str:
if value is None:
return "off"
return f"$ {_format_money_compact(value)}"
def _format_signed_usd(value: float | int | None) -> str:
if value is None:
return ""
amount = float(value)
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"
if amount < 0:
return f"🔴 $ {_format_money_compact(abs(amount))}"
return "$ 0"
def _format_age(value: object) -> str:
if value is None:
return ""
try:
age = max(0.0, float(value))
except (TypeError, ValueError):
return ""
if age < 1:
return f"{age:.2f}с"
if age < 10:
return f"{age:.1f}с"
total_seconds = int(age)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}ч {minutes:02d}м"
if minutes > 0:
return f"{minutes}м {seconds:02d}с"
return f"{seconds}с"
def _format_bool(value: object) -> str:
if value is True:
return "yes"
if value is False:
return "no"
return ""

View File

@@ -1,48 +1,14 @@
# app/src/telegram/handlers/home.py # app/src/telegram/handlers/home.py
from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.menus import HOME_TEXT from src.telegram.menus import HOME_TEXT
router = Router(name="home") router = Router(name="home")
async def _prepare_home_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="home",
bot=bot,
chat_id=message.chat.id,
)
return True
@router.message(F.text == "🏠 Главная") @router.message(F.text == "🏠 Главная")
async def open_home( async def open_home(message: Message) -> None:
message: Message, await message.answer(HOME_TEXT)
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_home_from_message(message):
return
sent_message = await message.answer(HOME_TEXT)
ActiveScreenManager.register(
screen="home",
message=sent_message,
)

View File

@@ -3,174 +3,85 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
BufferedInputFile,
CallbackQuery,
InaccessibleMessage,
Message,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_actions_keyboard,
build_clear_confirm_keyboard,
build_export_format_keyboard,
build_keyboard,
render,
render_actions,
render_clear_confirm,
render_export_format,
)
from src.trading.journal.filters import normalize_journal_export_filter
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
router = Router(name="journal") router = Router(name="journal")
PAGE_SIZE = 3
def _require_message( LEVEL_ICONS = {
callback: CallbackQuery, "INFO": "",
) -> Message | None: "WARNING": "⚠️",
message = callback.message "ERROR": "",
"CRITICAL": "🚨",
if ( }
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _user_id_from_message(message: Message) -> int | None: def build_keyboard(page: int, total_pages: int):
return message.from_user.id if message.from_user else None kb = InlineKeyboardBuilder()
if page > 1:
kb.button(text="⏮️", callback_data="journal:1")
if page > 1:
kb.button(text="⬅️", callback_data=f"journal:{page-1}")
kb.button(text=f"{page}/{total_pages}", callback_data="noop")
if page < total_pages:
kb.button(text="➡️", callback_data=f"journal:{page+1}")
return kb.as_markup()
def _chat_id_from_message(message: Message) -> int: def render(events, page, total_pages):
return message.chat.id lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
def _user_id_from_callback(callback: CallbackQuery) -> int | None: lines.extend(
return callback.from_user.id if callback.from_user else None [
f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {level}",
def _chat_id_from_callback(callback: CallbackQuery) -> int | None: f"• время: {e['created_at']}",
message = _require_message(callback) f"• сообщение: {e['message']}",
"",
if message is None: ]
return None
return message.chat.id
def _parse_page(value: NumericLike | None) -> int | None:
number = safe_float(value)
if number is None:
return None
return int(number)
def _journal_payload(**values: object) -> JsonDict:
return dict(values)
def _register_journal_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,
)
ScreenRegistry.register_screen(
StaticScreen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
) )
)
ActiveScreenManager.register( return "\n".join(lines).rstrip()
screen="journal",
message=message,
)
async def _prepare_journal_from_message( @router.message(F.text == "📒 Журнал")
message: Message, async def open_journal(message: Message):
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_journal_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="journal",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _show_journal_page(
target_message: Message,
*,
page: int,
edit_mode: bool,
) -> None:
service = JournalService() service = JournalService()
total = service.get_total_count() total = service.get_total_count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
events = service.get_page(1, PAGE_SIZE)
text = render(events, 1, total_pages)
kb = build_keyboard(1, total_pages)
await message.answer(text, reply_markup=kb)
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery):
page = int(callback.data.split(":")[1])
service = JournalService()
total = service.get_total_count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(1, min(page, total_pages)) page = max(1, min(page, total_pages))
events = service.get_page(page, PAGE_SIZE) events = service.get_page(page, PAGE_SIZE)
@@ -178,401 +89,5 @@ async def _show_journal_page(
text = render(events, page, total_pages) text = render(events, page, total_pages)
kb = build_keyboard(page, total_pages) kb = build_keyboard(page, total_pages)
if edit_mode: await callback.message.edit_text(text, reply_markup=kb)
try:
await target_message.edit_text(
text,
reply_markup=kb,
)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
_register_journal_screen(target_message)
return
raise
_register_journal_screen(target_message)
return
sent_message = await target_message.answer(
text,
reply_markup=kb,
)
_register_journal_screen(sent_message)
@router.callback_query(F.data == "journal:actions")
async def journal_actions(callback: CallbackQuery) -> None:
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
try:
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data.startswith("journal:export_filter:"))
async def journal_export_filter(callback: CallbackQuery) -> None:
# Пользователь выбрал фильтр экспорта.
# Теперь показываем выбор формата: CSV или Excel.
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
raw_filter = (callback.data or "").rsplit(":", 1)[-1]
export_filter = normalize_journal_export_filter(raw_filter)
try:
await message.edit_text(
render_export_format(export_filter),
reply_markup=build_export_format_keyboard(export_filter),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
await callback.answer()
@router.message(F.text == "📒 Журнал")
async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear()
if not await _prepare_journal_from_message(message):
return
await _show_journal_page(
message,
page=1,
edit_mode=False,
)
@router.callback_query(F.data == "system:journal")
async def open_journal_from_system(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await _show_journal_page(
message,
page=1,
edit_mode=True,
)
await callback.answer()
@router.callback_query(F.data == "journal:noop")
async def journal_noop(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data.startswith("journal:export_csv"))
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю CSV…")
try:
data = service.export_csv(export_filter=export_filter)
document = BufferedInputFile(
data,
filename=service.build_export_filename(
"csv",
export_filter=export_filter,
),
)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
message="Журнал экспортирован в CSV.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
)
except TelegramNetworkError as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось отправить CSV файл журнала.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer(
"⛔️ Не удалось отправить CSV файл.\n"
"Попробуй ещё раз или уменьши объём журнала."
)
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось экспортировать журнал в CSV.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer("⛔️ Не удалось экспортировать CSV.")
@router.callback_query(F.data.startswith("journal:export_xlsx"))
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю Excel…")
try:
data = service.export_xlsx(export_filter=export_filter)
document = BufferedInputFile(
data,
filename=service.build_export_filename(
"xlsx",
export_filter=export_filter,
),
)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
message="Журнал экспортирован в XLSX.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
)
except TelegramNetworkError as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось отправить XLSX файл журнала.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer(
"⛔️ Не удалось отправить Excel файл.\n"
"Попробуй ещё раз или уменьши объём журнала."
)
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось экспортировать журнал в XLSX.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer("⛔️ Не удалось экспортировать Excel.")
@router.callback_query(F.data == "journal:clear_confirm")
async def clear_journal_confirm(callback: CallbackQuery) -> None:
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
total_count = service.get_total_count()
await message.edit_text(
render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None:
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_all()
total_count = service.get_total_count()
await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count,
),
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None:
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count()
await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count if deleted_count > 0 else None,
no_old_records_days=90 if deleted_count == 0 else None,
),
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None:
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
data = callback.data or ""
parts = data.split(":", 1)
if len(parts) < 2:
await callback.answer("Неизвестное действие", show_alert=True)
return
page = _parse_page(parts[1])
if page is None:
await callback.answer("Неизвестное действие", show_alert=True)
return
await _show_journal_page(
message,
page=page,
edit_mode=True,
)
await callback.answer() await callback.answer()

View File

@@ -1,313 +0,0 @@
# app/src/telegram/handlers/journal_ui.py
from __future__ import annotations
from datetime import datetime
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.event_titles import event_title
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
from src.trading.journal.filters import journal_export_filter_label
PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🆘",
}
TECH_TO_HUMAN_MESSAGES = {
"invalid api key": "Неверный API Key.",
"unauthorized": "Нет доступа к аккаунту.",
"forbidden": "Доступ запрещён.",
"network error": "Нет связи с биржей.",
"timeout": "Биржа не ответила вовремя.",
}
def build_keyboard(
page: int,
total_pages: int,
) -> InlineKeyboardMarkup:
current_page = max(1, int(page))
pages_count = max(1, int(total_pages))
kb = InlineKeyboardBuilder()
if current_page > 1:
kb.button(text="⏮️", callback_data="journal:1")
kb.button(text="⬅️", callback_data=f"journal:{current_page - 1}")
kb.button(text=f"{current_page}/{pages_count}", callback_data="journal:noop")
if current_page < pages_count:
kb.button(text="➡️", callback_data=f"journal:{current_page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="⬅️ Назад", callback_data="system:back")
nav_count = 1
if current_page > 1:
nav_count += 2
if current_page < pages_count:
nav_count += 1
kb.adjust(nav_count, 2, 1)
return kb.as_markup()
def build_actions_keyboard() -> InlineKeyboardMarkup:
# Первый экран экспорта: выбираем, что именно экспортировать.
kb = InlineKeyboardBuilder()
kb.button(text="🤖 Автоторговля", callback_data="journal:export_filter:auto")
kb.button(text="📈 Сделки", callback_data="journal:export_filter:trades")
kb.button(text="⛔ Ошибки", callback_data="journal:export_filter:errors")
kb.button(text="🧾 Без авто", callback_data="journal:export_filter:not_auto")
kb.button(text="📒 Всё", callback_data="journal:export_filter:all")
kb.button(text="⬅️ Назад", callback_data="journal:1")
kb.adjust(2, 2, 1, 1)
return kb.as_markup()
def build_export_format_keyboard(export_filter: str) -> InlineKeyboardMarkup:
# Второй экран экспорта: после выбора фильтра выбираем формат.
kb = InlineKeyboardBuilder()
kb.button(text="📄 CSV", callback_data=f"journal:export_csv:{export_filter}")
kb.button(text="📊 Excel", callback_data=f"journal:export_xlsx:{export_filter}")
kb.button(text="⬅️ Назад", callback_data="journal:actions")
kb.adjust(2, 1)
return kb.as_markup()
def render_actions() -> str:
return (
"<b>📤 Экспорт</b>\n\n"
"<b>СИСТЕМА · Журнал</b>\n\n"
"Что экспортировать?"
)
def render_export_format(export_filter: str) -> str:
# Показываем выбранный фильтр перед выбором CSV/XLSX.
label = journal_export_filter_label(export_filter)
return (
f"<b>📤 Экспорт · {label}</b>\n\n"
"<b>СИСТЕМА · Журнал</b>\n\n"
"Выберите формат:"
)
def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
kb.button(text="Очистить всё", callback_data="journal:clear")
kb.button(text="Старше 90 дней", callback_data="journal:clear_older:90")
kb.button(text="⬅️ Назад", callback_data="settings:journal")
kb.button(text="📒 Журнал", callback_data="journal:1")
kb.adjust(2, 2)
return kb.as_markup()
def _format_int(
value: NumericLike | None,
*,
default: int = 0,
) -> int:
number = safe_float(value)
if number is None:
return default
return int(number)
def render_clear_confirm(
*,
total_count: NumericLike,
deleted_count: NumericLike | None = None,
no_old_records_days: NumericLike | None = None,
) -> str:
total = _format_int(total_count)
lines = [
"<b>⚠️ Очистить журнал</b>",
"",
"<b>СИСТЕМА</b> · Настройки · Журнал",
"",
f"📄 Записей: {total}",
]
if deleted_count is not None:
lines.append(f"🧹 Удалено записей: {_format_int(deleted_count)}")
if no_old_records_days is not None:
lines.append(
f"📭 Нет записей старше {_format_int(no_old_records_days)} дней"
)
return "\n".join(lines)
def _parse_local_datetime(value: object) -> datetime | None:
try:
settings = load_settings()
raw_value = str(value or "")
dt = datetime.fromisoformat(raw_value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo(settings.tz))
except Exception:
return None
def _date_group_label(dt: datetime | None) -> str:
if dt is None:
return "Без даты"
settings = load_settings()
today = datetime.now(ZoneInfo(settings.tz)).date()
if dt.date() == today:
return "Сегодня"
if (today - dt.date()).days == 1:
return "Вчера"
return dt.strftime("%Y-%m-%d")
def _time_label(
dt: datetime | None,
raw_value: object,
) -> str:
if dt is None:
return str(raw_value or "")
return dt.strftime("%H:%M:%S")
def _event_title(event_type: object) -> str:
return event_title(str(event_type or ""))
def _humanize_message(message: object) -> str:
text = str(message or "")
lower = text.lower()
for technical_text, human_text in TECH_TO_HUMAN_MESSAGES.items():
if technical_text in lower:
return human_text
return text
def _payload(event: JsonDict) -> JsonDict:
payload = event.get("payload")
if isinstance(payload, dict):
return payload
return {}
def _render_auto_signal(
event: JsonDict,
created_time: str,
) -> 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"))
lines = [
f"{icon} <b>{level}</b> · {title}",
created_time,
]
if message:
lines.append(message)
return lines
def _render_default_event(
event: JsonDict,
created_time: str,
) -> 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"))
lines = [
f"{icon} <b>{level}</b> · {title}",
created_time,
]
if message:
lines.append(message)
return lines
def render(
events: JsonList,
page: NumericLike,
total_pages: NumericLike,
) -> str:
lines = [
"<b>📒 Журнал</b>",
"",
"<b>СИСТЕМА</b>",
"",
]
if not events:
lines.append("Событий пока нет.")
return "\n".join(lines)
current_group: str | None = None
for raw_event in events:
if not isinstance(raw_event, dict):
continue
event: JsonDict = raw_event
raw_created_at = event.get("created_at") or ""
dt = _parse_local_datetime(raw_created_at)
group_label = _date_group_label(dt)
created_time = _time_label(dt, raw_created_at)
if group_label != current_group:
current_group = group_label
lines.append(f"<b>{group_label}</b>")
lines.append("")
event_type = str(event.get("event_type") or "")
if event_type in {"signal_summary", "signal_ready"}:
lines.extend(_render_auto_signal(event, created_time))
else:
lines.extend(_render_default_event(event, created_time))
lines.append("")
return "\n".join(lines).rstrip()

View File

@@ -0,0 +1,150 @@
# app/src/telegram/handlers/market.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.trading.journal.service import JournalService
router = Router(name="market")
def _safe_log_info(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_info(event_type, message, payload)
except Exception:
pass
def _safe_log_warning(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_warning(event_type, message, payload)
except Exception:
pass
def _safe_log_error(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_error(event_type, message, payload)
except Exception:
pass
@router.message(F.text == "📈 Рынок")
async def open_market(message: Message) -> None:
service = ExchangeService()
journal = JournalService()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
requested_symbol = service.settings.default_symbol
_safe_log_info(
journal,
"user_open_market",
"Пользователь открыл экран рынка.",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
)
try:
validation = service.validate_symbol(requested_symbol)
if not validation.is_valid:
_safe_log_warning(
journal,
"market_symbol_invalid",
f"Символ не прошел проверку: {validation.message}",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
)
await message.answer(
"<b>📈 Рынок</b>\n\n"
f"Ошибка инструмента: {validation.message}"
)
return
ticker = service.get_price(validation.normalized_symbol)
except ExchangeError as exc:
_safe_log_error(
journal,
"market_open_error",
f"Не удалось открыть экран рынка: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
)
await message.answer(
"<b>📈 Рынок</b>\n\n"
"Не удалось получить цену с биржи.\n"
f"Ошибка: {exc}"
)
return
symbol_info = validation.symbol_info
symbol_status = symbol_info.status if symbol_info else "n/a"
market_type = symbol_info.market_type if symbol_info else "n/a"
market_modes = (
", ".join(symbol_info.market_modes)
if symbol_info and symbol_info.market_modes
else "n/a"
)
tick_size = (
f"{symbol_info.tick_size}"
if symbol_info and symbol_info.tick_size is not None
else "n/a"
)
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
text = (
"<b>📈 Рынок</b>\n\n"
f"Символ: <b>{ticker.symbol}</b>\n"
f"Название: {name}\n"
f"Цена: <b>{ticker.price:.2f}</b>\n"
f"Статус: {symbol_status}\n"
f"Тип рынка: {market_type}\n"
f"Режимы: {market_modes}\n"
f"Tick size: {tick_size}\n"
f"Источник: {ticker.source}\n"
f"Обновлено: {ticker.updated_at}"
)
_safe_log_info(
journal,
"market_open_success",
"Экран рынка успешно показан пользователю.",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": ticker.symbol,
"price": ticker.price,
},
)
await message.answer(text)

View File

@@ -1,34 +1,12 @@
# app/src/telegram/handlers/portfolio.py # app/src/telegram/handlers/portfolio.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.types import Message
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float from src.integrations.exchange.exceptions import ExchangeError
from src.core.types import NumericLike
from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.runtime_ui import (
build_runtime_exchange_alerts,
format_runtime_exchange_alerts,
)
from src.integrations.exchange.status import 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 (
balance_total,
estimate_balance_usd,
format_usd_amount,
is_zero_balance,
)
from src.trading.accounts.service import AccountsService from src.trading.accounts.service import AccountsService
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -36,6 +14,23 @@ from src.trading.journal.service import JournalService
router = Router(name="portfolio") router = Router(name="portfolio")
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
CURRENCY_ICONS = {
"USD": "💵",
"USDT": "💵",
"EUR": "💶",
"RUB": "",
"BYN": "Br",
"BTC": "",
"ETH": "Ξ",
"BNB": "🟡",
"SOL": "",
"ADA": "🔵",
"XRP": "",
"DOGE": "🐶",
}
PINNED_ORDER = { PINNED_ORDER = {
"USD": 1, "USD": 1,
"USDT": 2, "USDT": 2,
@@ -44,57 +39,21 @@ PINNED_ORDER = {
} }
# Получить доступное Telegram-сообщение из callback. def format_amount(currency: str, value: float) -> str:
def _require_message( if currency.upper() in FIAT_CURRENCIES:
callback: CallbackQuery, return f"{value:,.2f}".replace(",", " ")
) -> Message | None: return f"{value:,.8f}".replace(",", " ")
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
# Отформатировать количество актива компактно для портфеля. def get_currency_label(currency: str) -> str:
def _compact_amount(currency: str, value: NumericLike) -> str: icon = CURRENCY_ICONS.get(currency.upper(), "💰")
number = safe_float(value) or 0.0 return f"{icon} {currency.upper()}"
currency = currency.upper()
if currency in {"USD", "USDT", "EUR"}:
return format_usd_amount(number)
text = f"{number:.8f}"
if "." in text:
integer, frac = text.split(".")
integer = f"{int(integer):,}".replace(",", " ")
stripped = frac.rstrip("0")
if stripped == "":
return f"{integer}.00"
if len(stripped) <= 2:
return f"{integer}.{stripped.ljust(2, '0')}"
return f"{integer}.{stripped}"
return f"{int(text):,}".replace(",", " ")
# Клавиатура портфеля при частичных данных или ошибке. def is_zero_balance(item: BalanceSummary) -> bool:
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.adjust(1)
return builder.as_markup()
# Отсортировать балансы: сначала основные активы, потом остальные по алфавиту.
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]: def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper() currency = item.currency.upper()
@@ -104,360 +63,175 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
return sorted(items, key=sort_key) return sorted(items, key=sort_key)
# Собрать текст ошибки портфеля через единый exchange status layer. def split_balances(
def _build_portfolio_exchange_error_text(exc: Exception) -> str: items: list[BalanceSummary],
alerts = build_runtime_exchange_alerts(exc=exc) ) -> tuple[list[BalanceSummary], list[BalanceSummary]]:
body = format_runtime_exchange_alerts(alerts) major: list[BalanceSummary] = []
other: list[BalanceSummary] = []
if not body: for item in items:
body = "⛔️ Биржа недоступна" if item.currency.upper() in PINNED_ORDER:
major.append(item)
else:
other.append(item)
return ( return major, other
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
f"{body}\n\n"
f"{now_line()}"
)
def render_balance_block(item: BalanceSummary) -> list[str]:
total = item.available + item.locked
# Собрать live-текст портфеля. return [
# raise_errors=True используется при ручном открытии экрана, f"<b>{get_currency_label(item.currency)}</b>",
# чтобы handler смог записать ошибку в журнал и показать стандартный error UI. f"• доступно: {format_amount(item.currency, item.available)}",
def _build_portfolio_live_text( f"• заблокировано: {format_amount(item.currency, item.locked)}",
*, f"• всего: {format_amount(item.currency, total)}",
raise_errors: bool = False,
) -> tuple[str, InlineKeyboardMarkup | None]:
service = AccountsService()
exchange_service = ExchangeService()
try:
balances = service.get_live_balance_summary()
except Exception as exc:
if raise_errors:
raise
return _build_portfolio_exchange_error_text(exc), _portfolio_warning_keyboard()
if not balances:
text = (
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
"Нет данных по балансу.\n\n"
f"{now_line()}"
)
return text, None
visible_balances = [
item
for item in balances
if not is_zero_balance(item)
]
visible_balances = sort_balances(visible_balances)
if not visible_balances:
text = (
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
"Нет активов с балансом.\n\n"
f"{now_line()}"
)
return text, None
price_cache: dict[str, float | None] = {}
total_estimated_usd = 0.0
has_any_estimate = False
missing_estimate_assets: list[str] = []
lines: list[str] = [
"<b>💼 Портфель</b>",
mode_line().rstrip(),
"", "",
] ]
for item in visible_balances:
currency = item.currency.upper()
total = safe_float(balance_total(item)) or 0.0
locked = safe_float(item.locked) or 0.0
try: def _safe_log_info(
estimated_usd = estimate_balance_usd( journal: JournalService,
item, event_type: str,
exchange_service, message: str,
price_cache, payload: dict | None = None,
)
except Exception:
estimated_usd = None
if estimated_usd is not None:
total_estimated_usd += estimated_usd
has_any_estimate = True
elif total > 0:
missing_estimate_assets.append(currency)
line = f"{currency}: {_compact_amount(currency, total)}"
if locked > 0:
line += f" · locked {_compact_amount(currency, locked)}"
if estimated_usd is not None and currency not in {"USD", "USDT"}:
line += f" ≈ $ {format_usd_amount(estimated_usd)}"
if currency == "BTC" and any("USD:" in x or "USDT:" in x for x in lines):
lines.append("")
lines.append(line)
has_partial_data = len(missing_estimate_assets) > 0
if missing_estimate_assets:
lines.append("🟡 <b>Данные загружены частично</b>")
if has_any_estimate:
lines.insert(3, "")
lines.insert(
3,
f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>",
)
if missing_estimate_assets:
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
lines.extend(["", now_line()])
reply_markup = (
_portfolio_warning_keyboard()
if has_partial_data
else None
)
return "\n".join(lines).rstrip(), reply_markup
# Render-функция текста для live runner.
def _portfolio_live_text() -> str:
text, _ = _build_portfolio_live_text()
return text
# Render-функция клавиатуры для live runner.
def _portfolio_live_markup() -> InlineKeyboardMarkup | None:
_, markup = _build_portfolio_live_text()
return markup
def _register_portfolio_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="portfolio",
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_portfolio_live_text,
render_markup=_portfolio_live_markup,
interval_seconds=10,
)
)
LiveScreenRunner.start("portfolio")
# Подготовить новый экран портфеля из обычного сообщения.
async def _prepare_portfolio_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=bot,
chat_id=message.chat.id,
)
return True
# Подготовить экран портфеля из callback.
async def _prepare_portfolio_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="portfolio",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
# Отрисовать экран портфеля и зарегистрировать live обновления.
async def _render_portfolio_screen(
target_message: Message,
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
action: str,
) -> None: ) -> None:
journal = JournalService() try:
journal.log_info(event_type, message, payload)
except Exception:
pass
journal.log_ui_info(
event_type="portfolio_open_requested",
message="Запрошено открытие экрана портфеля.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
)
text, reply_markup = _build_portfolio_live_text(raise_errors=True) def _safe_log_warning(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_warning(event_type, message, payload)
except Exception:
pass
journal.log_ui_info(
event_type="portfolio_open_success",
message="Портфель загружен.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
)
if edit_mode: def _safe_log_error(
await target_message.edit_text(text, reply_markup=reply_markup) journal: JournalService,
_register_portfolio_live_screen(target_message) event_type: str,
ActiveScreenManager.register(screen="portfolio", message=target_message) message: str,
return payload: dict | None = None,
) -> None:
sent_message = await target_message.answer(text, reply_markup=reply_markup) try:
_register_portfolio_live_screen(sent_message) journal.log_error(event_type, message, payload)
ActiveScreenManager.register(screen="portfolio", message=sent_message) except Exception:
pass
@router.message(F.text == "💼 Портфель") @router.message(F.text == "💼 Портфель")
async def open_portfolio( async def open_portfolio(message: Message) -> None:
message: Message, service = AccountsService()
state: FSMContext, journal = JournalService()
) -> None:
await state.clear()
if not await _prepare_portfolio_from_message(message):
return
user_id = message.from_user.id if message.from_user else None user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id chat_id = message.chat.id if message.chat else None
_safe_log_info(
journal,
"user_open_portfolio",
"Пользователь открыл экран портфеля.",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
try: try:
await _render_portfolio_screen( balances = service.get_live_balance_summary()
message, except ExchangeError as exc:
user_id=user_id, _safe_log_error(
chat_id=chat_id, journal,
edit_mode=False, "portfolio_open_error",
action="open", f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
) )
await message.answer(
except Exception as exc: "<b>💼 Портфель</b>\n\n"
JournalService().log_ui_error( "Не удалось получить баланс с private API.\n"
event_type="portfolio_open_error", f"Ошибка: {exc}"
message="Не удалось загрузить портфель.",
screen="portfolio",
action="open",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
) )
sent_message = await message.answer(
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
ActiveScreenManager.register(
screen="portfolio",
message=sent_message,
)
@router.callback_query(F.data == "portfolio:retry")
async def retry_portfolio(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_portfolio_from_callback(callback):
return return
message = _require_message(callback) if not balances:
_safe_log_warning(
if message is None: journal,
await callback.answer("Сообщение недоступно", show_alert=True) "portfolio_empty",
"Портфель открыт, но баланс пуст.",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Баланс пуст."
)
return return
user_id = callback.from_user.id if callback.from_user else None visible_balances = [item for item in balances if not is_zero_balance(item)]
chat_id = message.chat.id visible_balances = sort_balances(visible_balances)
try: if not visible_balances:
await _render_portfolio_screen( _safe_log_warning(
message, journal,
user_id=user_id, "portfolio_zero_balances",
chat_id=chat_id, "Портфель открыт, но все балансы нулевые.",
edit_mode=True, {
action="retry", "user_id": user_id,
"chat_id": chat_id,
"assets_count": len(balances),
},
) )
await callback.answer() await message.answer(
"<b>💼 Портфель</b>\n\n"
except Exception as exc: "Все балансы нулевые."
JournalService().log_ui_error(
event_type="portfolio_retry_error",
message="Не удалось обновить портфель.",
screen="portfolio",
action="retry",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
) )
return
await message.edit_text( major_balances, other_balances = split_balances(visible_balances)
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
ActiveScreenManager.register( lines: list[str] = ["<b>💼 Портфель</b>", "", "<b>Баланс аккаунта</b>", ""]
screen="portfolio",
message=message,
)
await callback.answer() if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
if other_balances:
lines.append("<b>Прочие активы</b>")
lines.append("")
for item in other_balances:
lines.extend(render_balance_block(item))
lines.extend(
[
"<b>Итого</b>",
f"• активов с ненулевым балансом: {len(visible_balances)}",
]
)
_safe_log_info(
journal,
"portfolio_open_success",
"Портфель успешно показан пользователю.",
{
"user_id": user_id,
"chat_id": chat_id,
"assets_count": len(visible_balances),
},
)
text = "\n".join(lines).rstrip()
await message.answer(text)

View File

@@ -4,85 +4,34 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.core.system_status import build_system_text from src.core.system_status import build_system_text
from src.telegram.handlers.auto.main import render_auto_screen
from src.telegram.keyboards.reply import build_main_menu_keyboard from src.telegram.keyboards.reply import build_main_menu_keyboard
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.menus import MAIN_MENU_TEXT from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start") router = Router(name="start")
# показать только reply-меню без открытия live-экрана @router.message(Command("start"))
async def _show_main_menu( async def cmd_start(message: Message) -> None:
message: Message,
) -> None:
await message.answer( await message.answer(
MAIN_MENU_TEXT, MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
) )
# открыть главный рабочий экран проекта — Автоторговлю @router.message(Command("menu"))
async def _open_auto_start_screen( async def cmd_menu(message: Message) -> None:
message: Message,
) -> None:
bot = message.bot
if bot is None:
return
# сначала прикрепляем основное reply-меню,
# потому что экран Автоторговли использует inline-кнопки
await message.answer( await message.answer(
"Открываю Автоторговлю.", MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
) )
# очищаем предыдущий активный экран перед открытием Автоторговли
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
)
await render_auto_screen(
message,
edit_mode=False,
)
@router.message(Command("start"))
async def cmd_start(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await _open_auto_start_screen(message)
@router.message(Command("menu"))
async def cmd_menu(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await _show_main_menu(message)
@router.message(Command("help")) @router.message(Command("help"))
async def cmd_help( async def cmd_help(message: Message) -> None:
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await message.answer( await message.answer(
build_system_text(), build_system_text(),
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
@@ -90,10 +39,8 @@ async def cmd_help(
@router.message(F.text == "Меню") @router.message(F.text == "Меню")
async def menu_shortcut( async def menu_shortcut(message: Message) -> None:
message: Message, await message.answer(
state: FSMContext, MAIN_MENU_TEXT,
) -> None: reply_markup=build_main_menu_keyboard(),
await state.clear() )
await _show_main_menu(message)

View File

@@ -3,960 +3,14 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.types import Message
from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.config import load_settings from src.core.system_status import build_system_text
from src.core.constants import APP_NAME, APP_VERSION
from src.core.numbers import safe_float
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
from src.core.types import JsonDict, NumericLike
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
router = Router(name="system") router = Router(name="system")
def _require_message(callback: CallbackQuery) -> Message | None: @router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
message = callback.message async def open_system(message: Message) -> None:
await message.answer(build_system_text())
if message is None or isinstance(message, InaccessibleMessage):
return None
return message
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(2, 1)
return builder.as_markup()
def _system_alert_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="system:retry")
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(1, 2, 1)
return builder.as_markup()
def _register_system_screen(message: Message, screen: str = "system") -> 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,
)
ScreenRegistry.register_screen(
StaticScreen(
screen=screen,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
)
ActiveScreenManager.register(
screen=screen,
message=message,
)
async def _prepare_system_from_message(
message: Message,
screen: str = "system",
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen=screen,
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_system_from_callback(
callback: CallbackQuery,
screen: str = "system",
) -> 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=screen,
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _render_system_screen(
target_message: Message,
*,
edit_mode: bool,
user_id: int | None,
chat_id: int | None,
action: str,
) -> None:
journal = JournalService()
snapshot = get_system_snapshot()
is_alert = has_system_alerts(snapshot)
if is_alert:
journal.log_ui_warning(
event_type="system_open_alert",
message="Система загружена с предупреждениями.",
screen="system",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"has_alerts": True,
"components": [
{
"name": component.name,
"state": component.state,
"details": component.details,
}
for component in snapshot.components
if component.state != "🟢"
],
},
)
else:
journal.log_ui_info(
event_type="system_open_success",
message="Экран системы загружен.",
screen="system",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={"has_alerts": False},
)
text = build_system_text(include_updated_at=is_alert)
reply_markup = _system_alert_keyboard() if is_alert else _system_keyboard()
if edit_mode:
await target_message.edit_text(text, reply_markup=reply_markup)
_register_system_screen(target_message, screen="system")
return
sent_message = await target_message.answer(text, reply_markup=reply_markup)
_register_system_screen(sent_message, screen="system")
@router.message(F.text.in_({"🖥️ Система"}))
async def open_system(message: Message, state: FSMContext) -> None:
await state.clear()
if not await _prepare_system_from_message(message, screen="system"):
return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
await _render_system_screen(
message,
edit_mode=False,
user_id=user_id,
chat_id=chat_id,
action="open",
)
@router.callback_query(F.data == "system:retry")
async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if not await _prepare_system_from_callback(callback, screen="system"):
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
await _render_system_screen(
message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="retry",
)
await callback.answer()
@router.callback_query(F.data == "system:management")
async def open_system_management(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
return
text = (
"<b>🛠️ Настройки</b>\n\n"
"<b>СИСТЕМА</b>\n\n"
"Выберите раздел:"
)
builder = InlineKeyboardBuilder()
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
builder.button(text="🌍 Общие", callback_data="settings:general")
builder.button(text="📒 Журнал", callback_data="settings:journal")
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(1, 2, 1)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="system")
await callback.answer()
@router.callback_query(F.data == "settings:auto")
async def open_auto_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
state = AutoTradeService().get_state()
strategy_map = {
"TREND": "TREND FOLLOWING",
"GRID": "GRID TRADING",
"SCALP": "SCALPING",
}
strategy_ready = state.strategy is not None
symbol_ready = bool(state.symbol)
risk_ready = state.risk_percent is not None
leverage_ready = state.leverage is not None
is_trend_strategy = (state.strategy or "").upper() == "TREND"
sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
is_configured = (
strategy_ready
and symbol_ready
and risk_ready
and leverage_ready
and (not is_trend_strategy or sl_ready)
)
strategy = strategy_map.get(state.strategy or "", "")
symbol = _human_symbol(state.symbol)
risk = _format_number(state.risk_percent, suffix="%", default="")
leverage_value = safe_float(state.leverage)
leverage = f"x{leverage_value:g}" if leverage_value is not None else ""
max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
sl = _format_percent_setting(state.stop_loss_percent)
tp = _format_percent_setting(state.take_profit_percent)
ml_value = safe_float(state.max_loss_usd)
ml = f"{ml_value:g} USD" if ml_value is not None else "off"
strategy_icon = "" if strategy_ready else "⚠️"
symbol_icon = "" if symbol_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️"
leverage_icon = "" if leverage_ready else "⚠️"
sl_icon = "⛔️" if is_trend_strategy and not sl_ready else ("" if state.stop_loss_percent is not None else "⚠️")
tp_icon = "" if state.take_profit_percent is not None else "⚠️"
ml_icon = "" if state.max_loss_usd is not None else "⚠️"
settings_status_icon = "" if is_configured else "⛔️"
config_status = "" if is_configured else "\n\nНастрой все параметры"
text = (
"<b>🤖 Автоторговля</b>\n\n"
f"<b>СИСТЕМА</b> · Настройки {settings_status_icon}\n\n"
f"{strategy_icon} Стратегия: <b>{strategy}</b>\n"
f"{symbol_icon} Актив: <b>{symbol}</b>\n"
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n"
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · {sl}\n"
f"{tp_icon} Take Profit · {tp}\n"
f"{ml_icon} Max Loss · {ml}"
f"{config_status}"
)
builder = InlineKeyboardBuilder()
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved")
builder.button(text="🛡️ Риск", callback_data="settings:auto_risk")
builder.button(text="🧯 Защита", callback_data="auto:risk")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(2, 2, 2, 2)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data == "settings:auto_strategy")
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🧠 Стратегия</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите стратегию:"
)
builder = InlineKeyboardBuilder()
builder.button(text="📈 Trend", callback_data="settings:auto_strategy:trend")
builder.button(text="🧩 Grid", callback_data="settings:auto_strategy:grid")
builder.button(text="⚡ Scalp", callback_data="settings:auto_strategy:scalp")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
def _log_auto_setting_updated(
*,
event_type: str = "auto_settings_updated",
message: str,
action: str,
payload: JsonDict,
) -> None:
try:
JournalService().log_ui_info(
event_type=event_type,
message=message,
screen="settings_auto",
action=action,
payload=payload,
)
except Exception:
pass
def _human_symbol(symbol: str | None) -> str:
if not symbol:
return ""
base = symbol.split("_", 1)[0].upper()
if "/" in base:
return base.split("/", 1)[0]
for suffix in ("USDT", "USD", "EUR", "BTC"):
if base.endswith(suffix) and len(base) > len(suffix):
return base[: -len(suffix)]
return base
def _format_number(
value: NumericLike | None,
*,
suffix: str = "",
default: str = "",
) -> str:
number = safe_float(value)
if number is None:
return default
return f"{number:g}{suffix}"
def _format_percent_setting(value: NumericLike | None) -> str:
return _format_number(value, suffix="%", default="off")
def _parse_callback_float(value: object) -> float | None:
return safe_float(value)
@router.callback_query(F.data.startswith("settings:auto_strategy:"))
async def set_auto_strategy(callback: CallbackQuery) -> None:
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение стратегии", show_alert=True)
return
strategy = parts[2].upper()
service = AutoTradeService()
state = service.get_state()
previous_strategy = state.strategy
service.set_strategy(strategy)
if previous_strategy != strategy:
_log_auto_setting_updated(
message=f"Стратегия изменена: {strategy}.",
action="set_strategy",
payload={
"previous_strategy": previous_strategy,
"strategy": strategy,
},
)
await open_auto_settings(callback)
await callback.answer("Стратегия обновлена")
@router.callback_query(F.data == "settings:auto_symbol")
async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>💱 Актив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите актив:"
)
builder = InlineKeyboardBuilder()
builder.button(text="BTC", callback_data="settings:auto_symbol:BTC/USD_LEVERAGE")
builder.button(text="ETH", callback_data="settings:auto_symbol:ETH/USD_LEVERAGE")
builder.button(text="LTC", callback_data="settings:auto_symbol:LTC/USD_LEVERAGE")
builder.button(text="XRP", callback_data="settings:auto_symbol:XRP/USD_LEVERAGE")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_symbol:"))
async def set_auto_symbol(callback: CallbackQuery) -> None:
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение актива", show_alert=True)
return
symbol = parts[2]
service = AutoTradeService()
state = service.get_state()
previous_symbol = state.symbol
service.set_symbol(symbol)
if previous_symbol != symbol:
_log_auto_setting_updated(
message=f"Актив изменён: {_human_symbol(symbol)}.",
action="set_symbol",
payload={
"previous_symbol": previous_symbol,
"symbol": symbol,
},
)
await open_auto_settings(callback)
await callback.answer("Актив обновлён")
@router.callback_query(F.data == "settings:auto_risk")
async def open_auto_risk_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🛡️ Риск на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите риск на сделку:"
)
builder = InlineKeyboardBuilder()
builder.button(text="0.5%", callback_data="settings:auto_risk:0.5")
builder.button(text="1.0%", callback_data="settings:auto_risk:1.0")
builder.button(text="2.0%", callback_data="settings:auto_risk:2.0")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_risk:"))
async def set_auto_risk(callback: CallbackQuery) -> None:
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение риска", show_alert=True)
return
risk = _parse_callback_float(parts[2])
if risk is None:
await callback.answer("Некорректное значение риска", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
previous_risk = state.risk_percent
service.set_risk_percent(risk)
if previous_risk != risk:
_log_auto_setting_updated(
message=f"Риск на сделку изменён: {risk:g}%.",
action="set_risk_percent",
payload={
"previous_risk_percent": previous_risk,
"risk_percent": risk,
},
)
await open_auto_settings(callback)
await callback.answer("Риск обновлён")
@router.callback_query(F.data == "settings:auto_leverage")
async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>⚙️ Плечо</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите плечо:"
)
builder = InlineKeyboardBuilder()
builder.button(text="x1", callback_data="settings:auto_leverage:1")
builder.button(text="x2", callback_data="settings:auto_leverage:2")
builder.button(text="x3", callback_data="settings:auto_leverage:3")
builder.button(text="x5", callback_data="settings:auto_leverage:5")
builder.button(text="x10", callback_data="settings:auto_leverage:10")
builder.button(text="x20", callback_data="settings:auto_leverage:20")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 3, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_leverage:"))
async def set_auto_leverage(callback: CallbackQuery) -> None:
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
leverage = _parse_callback_float(parts[2])
if leverage is None:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
previous_leverage = state.leverage
service.set_leverage(leverage)
if previous_leverage != leverage:
_log_auto_setting_updated(
message=f"Плечо изменено: x{leverage:g}.",
action="set_leverage",
payload={
"previous_leverage": previous_leverage,
"leverage": leverage,
},
)
await open_auto_settings(callback)
await callback.answer("Плечо обновлено")
@router.callback_query(F.data == "settings:auto_max_reserved")
async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🏦 Лимит на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Максимальная доля баланса, которую можно зарезервировать под позицию:"
)
builder = InlineKeyboardBuilder()
builder.button(text="25%", callback_data="settings:auto_max_reserved:25")
builder.button(text="50%", callback_data="settings:auto_max_reserved:50")
builder.button(text="75%", callback_data="settings:auto_max_reserved:75")
builder.button(text="100%", callback_data="settings:auto_max_reserved:100")
builder.button(text="off", callback_data="settings:auto_max_reserved:off")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_max_reserved:"))
async def set_auto_max_reserved(callback: CallbackQuery) -> None:
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректные данные кнопки", show_alert=True)
return
raw_value = parts[2]
value = None if raw_value == "off" else _parse_callback_float(raw_value)
if raw_value != "off" and value is None:
await callback.answer("Некорректное значение лимита", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
previous_value = state.max_reserved_balance_percent
service.set_max_reserved_balance_percent(value)
if previous_value != value:
value_text = "off" if value is None else f"{value:g}%"
_log_auto_setting_updated(
message=f"Лимит на сделку изменён: {value_text}.",
action="set_max_reserved_balance_percent",
payload={
"previous_max_reserved_balance_percent": previous_value,
"max_reserved_balance_percent": value,
},
)
await open_auto_settings(callback)
await callback.answer("Лимит обновлён")
@router.callback_query(F.data == "settings:general")
async def open_general_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_general"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🌍 Общие</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
"Режим аккаунта: —\n"
"Часовой пояс: —\n"
"Язык интерфейса: ru\n\n"
"В разработке."
)
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_general")
await callback.answer()
@router.callback_query(F.data == "settings:journal")
async def open_journal_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
total = service.get_total_count()
text = (
"<b>📒 Журнал</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
f"📄 Записей: {total}\n"
"📦 Лимит: —\n"
"⏳ Хранение: —\n"
"🗄 Архив: —\n\n"
)
builder = InlineKeyboardBuilder()
builder.button(text="🗑 Очистка", callback_data="journal:clear_confirm")
builder.button(text="🗄 Архив", callback_data="settings:journal_archive")
builder.button(text="📦 Лимит", callback_data="settings:journal_limit")
builder.button(text="⏳ Хранение", callback_data="settings:journal_retention")
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2, 2, 2)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@router.callback_query(F.data == "settings:journal_archive")
async def open_journal_archive_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🗄 Архив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
"Автоматический архив журнала: —\n"
"Формат архива: —\n"
"Периодичность: —\n\n"
"В разработке."
)
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@router.callback_query(F.data == "settings:journal_limit")
async def open_journal_limit_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>📦 Лимит</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
"Текущий лимит: —\n\n"
"Доступные варианты:"
)
builder = InlineKeyboardBuilder()
builder.button(text="1 000", callback_data="settings:journal_limit_stub")
builder.button(text="5 000", callback_data="settings:journal_limit_stub")
builder.button(text="10 000", callback_data="settings:journal_limit_stub")
builder.button(text="", callback_data="settings:journal_limit_stub")
builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@router.callback_query(F.data == "settings:journal_retention")
async def open_journal_retention_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>⏳ Хранение</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
"Текущий срок хранения: —\n\n"
"Доступные варианты:"
)
builder = InlineKeyboardBuilder()
builder.button(text="7 дней", callback_data="settings:journal_retention_stub")
builder.button(text="30 дней", callback_data="settings:journal_retention_stub")
builder.button(text="90 дней", callback_data="settings:journal_retention_stub")
builder.button(text="", callback_data="settings:journal_retention_stub")
builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@router.callback_query(
F.data.in_(
{
"settings:journal_limit_stub",
"settings:journal_retention_stub",
}
)
)
async def journal_settings_stub(callback: CallbackQuery) -> None:
await callback.answer("Настройка скоро появится", show_alert=True)
@router.callback_query(F.data == "system:back")
async def back_to_system(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
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
await _render_system_screen(
message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="back",
)
await callback.answer()
@router.callback_query(F.data == "system:about")
async def open_system_about(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system_about"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
settings = load_settings()
journal = JournalService()
journal.log_ui_info(
event_type="system_about_opened",
message="Открыта информация о продукте.",
screen="system",
action="about",
user_id=callback.from_user.id if callback.from_user else None,
chat_id=message.chat.id,
)
text = (
"<b> Информация</b>\n\n"
"<b>СИСТЕМА</b>\n\n"
f"<b>{APP_NAME}</b>\n"
f"Версия: {APP_VERSION}\n"
f"Режим: {'DEMO' if 'demo' in settings.exchange_base_url.lower() else 'LIVE'}\n"
f"Часовой пояс: {settings.tz}\n\n"
"Торговый Telegram-бот для контроля рынка, портфеля, журнала событий "
"и автоторговли."
)
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(1)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="system_about")
await callback.answer()

View File

@@ -0,0 +1,104 @@
# app/src/telegram/handlers/trade/main.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.handlers.trade.new_order import (
show_recent_drafts,
start_new_order_draft,
)
router = Router(name="trade_main")
def _trade_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📝 Новый ордер", callback_data="trade:new_order")
builder.button(text="📂 Черновики", callback_data="trade:drafts")
builder.button(text="⚙️ Настройки ордера", callback_data="trade:settings")
builder.button(text=" Справка", callback_data="trade:help")
builder.adjust(2, 2)
return builder.as_markup()
def _trade_back_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ К торговле", callback_data="trade:home")
return builder.as_markup()
def _trade_home_text() -> str:
return (
"<b>⚡ Торговля</b>\n\n"
"<b>‼️ Режим черновика</b>"
)
@router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None:
await message.answer(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
@router.callback_query(F.data == "trade:home")
async def open_trade_home_callback(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
@router.callback_query(F.data == "trade:new_order")
async def open_new_order_from_trade(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await callback.answer()
if callback.message is not None:
await start_new_order_draft(callback.message, state, edit_mode=True)
@router.callback_query(F.data == "trade:drafts")
async def open_drafts_from_trade(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await show_recent_drafts(callback.message, edit_mode=True, page=1)
@router.callback_query(F.data == "trade:settings")
async def open_trade_settings(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Настройки ордера</b>\n\n"
"Раздел в разработке.\n\n"
"Планируется добавить:\n"
"• параметры ордера по умолчанию\n"
"• пресеты количества\n"
"• режим цены: Bid / Ask / Last",
reply_markup=_trade_back_keyboard(),
)
@router.callback_query(F.data == "trade:help")
async def open_trade_help(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Справка</b>\n\n"
"<b>Режим черновика</b> — ордер не отправляется на биржу.\n\n"
"Сейчас можно:\n"
"• собрать черновик ордера\n"
"• проверить параметры\n"
"• сохранить черновик в базу\n\n"
"Реальная отправка ордера появится позже.",
reply_markup=_trade_back_keyboard(),
)

View File

@@ -0,0 +1,631 @@
# app/src/telegram/handlers/trade/new_order.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.trading.orders.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates
router = Router(name="trade_new_order")
DRAFTS_PAGE_SIZE = 3
def _side_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🟢 BUY", callback_data="order_side:BUY")
builder.button(text="🔴 SELL", callback_data="order_side:SELL")
builder.button(text="⬅️ Назад", callback_data="trade:home")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 2)
return builder.as_markup()
def _type_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⚡ MARKET", callback_data="order_type:MARKET")
builder.button(text="🎯 LIMIT", callback_data="order_type:LIMIT")
builder.button(text="⬅️ Назад", callback_data="order_back:side")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 2)
return builder.as_markup()
def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
labels = ["25%", "50%", "75%", "100%"]
for label, value in zip(labels, presets):
builder.button(text=label, callback_data=f"order_qty:{value}")
builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual")
builder.button(text="⬅️ Назад", callback_data="order_back:type")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 2, 1, 2)
return builder.as_markup()
def _quantity_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_back:type")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2)
return builder.as_markup()
def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=f"Bid {bid:.2f}", callback_data=f"order_price:{bid}")
builder.button(text=f"Ask {ask:.2f}", callback_data=f"order_price:{ask}")
builder.button(text=f"Last {last:.2f}", callback_data=f"order_price:{last}")
builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual")
builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 2, 2)
return builder.as_markup()
def _price_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2)
return builder.as_markup()
def _trade_back_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ К торговле", callback_data="trade:home")
return builder.as_markup()
def _drafts_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if page > 1:
builder.button(text="⏮️", callback_data="drafts:1")
builder.button(text="⬅️", callback_data=f"drafts:{page - 1}")
builder.button(text=f"{page}/{total_pages}", callback_data="drafts:noop")
if page < total_pages:
builder.button(text="➡️", callback_data=f"drafts:{page + 1}")
first_row_count = 1
if page > 1:
first_row_count += 2
if page < total_pages:
first_row_count += 1
builder.button(text="⬅️ К торговле", callback_data="trade:home")
builder.adjust(first_row_count, 1)
return builder.as_markup()
def _render_draft_summary(
symbol: str,
side: str,
order_type: str,
quantity: str,
price: str | None,
) -> str:
lines = [
"<b>⚡ Торговля — Черновик ордера</b>",
"",
"<b>📝 Черновик создан</b>",
"",
f"• инструмент: {symbol}",
f"• сторона: {side}",
f"• тип: {order_type}",
f"• количество: {quantity}",
]
if price:
lines.append(f"• цена: {price}")
lines.extend(
[
"• статус: draft",
"",
"<i>Ордер не отправлялся на биржу</i>",
]
)
return "\n".join(lines)
def _render_validation_error(errors: list[str]) -> str:
lines = [
"<b>⚡ Торговля — Ошибка валидации</b>",
"",
"<b>❌ Черновик не сохранён</b>",
"",
"<b>Причины</b>",
"",
]
for item in errors:
lines.append(f"{item}")
return "\n".join(lines)
def _format_draft_time(value: str) -> str:
text = str(value).replace("T", " ")
if "+" in text:
text = text.split("+", 1)[0]
if "." in text:
text = text.split(".", 1)[0]
return text
def _format_draft_quantity(value: str) -> str:
text = str(value).rstrip("0").rstrip(".")
return text or "0"
async def show_recent_drafts(
message: Message,
edit_mode: bool = False,
page: int = 1,
) -> None:
service = OrderDraftsService()
all_drafts = service.list_recent_drafts(limit=100)
total = len(all_drafts)
total_pages = max(1, (total + DRAFTS_PAGE_SIZE - 1) // DRAFTS_PAGE_SIZE)
page = max(1, min(page, total_pages))
start = (page - 1) * DRAFTS_PAGE_SIZE
end = start + DRAFTS_PAGE_SIZE
drafts = all_drafts[start:end]
if not drafts:
text = (
"<b>⚡ Торговля — Черновики</b>\n\n"
"Черновиков пока нет."
)
if edit_mode:
await message.edit_text(text, reply_markup=_trade_back_home_keyboard())
else:
await message.answer(text)
return
lines = ["<b>⚡ Торговля — Черновики</b>", "", "<b>Последние записи</b>", ""]
for item in drafts:
quantity = _format_draft_quantity(item["quantity"])
created_at = _format_draft_time(item["created_at"])
lines.extend(
[
f"<b>{item['symbol']}</b> · {item['side']} · {item['order_type']}",
f"• количество: {quantity}",
f"• статус: {item['status']}",
f"• время: {created_at}",
"",
]
)
text = "\n".join(lines).rstrip()
keyboard = _drafts_keyboard(page, total_pages)
if edit_mode:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "drafts:noop")
async def drafts_noop(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data.startswith("drafts:"))
async def paginate_drafts(callback: CallbackQuery) -> None:
value = callback.data.split(":", 1)[1]
if value == "noop":
await callback.answer()
return
page = int(value)
await callback.answer()
if callback.message is not None:
await show_recent_drafts(callback.message, edit_mode=True, page=page)
@router.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(
"<b>⚡ Торговля</b>\n\n"
"Создание черновика отменено."
)
@router.callback_query(F.data == "order_cancel")
async def cancel_order_builder_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
await callback.message.edit_text(
"<b>⚡ Торговля</b>\n\n"
"Создание черновика отменено.",
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@router.message(Command("new_order"))
async def start_new_order_draft(
message: Message,
state: FSMContext,
edit_mode: bool = False,
) -> None:
await state.clear()
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 1/4\n"
"Выберите сторону:"
)
if edit_mode:
await message.edit_text(text, reply_markup=_side_keyboard())
else:
await message.answer(text, reply_markup=_side_keyboard())
@router.callback_query(F.data == "order_back:side")
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_state(NewOrderDraftStates.waiting_side)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 1/4\n"
"Выберите сторону:",
reply_markup=_side_keyboard(),
)
await callback.answer()
@router.callback_query(F.data == "order_back:type")
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_state(NewOrderDraftStates.waiting_type)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 2/4\n"
"Выберите тип ордера:",
reply_markup=_type_keyboard(),
)
await callback.answer()
@router.callback_query(F.data == "order_back:quantity")
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "MARKET")
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Выберите количество или введите его вручную:",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(
NewOrderDraftStates.waiting_side,
F.data.startswith("order_side:"),
)
async def process_order_side_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
side = callback.data.split(":", 1)[1]
await state.update_data(side=side)
await state.set_state(NewOrderDraftStates.waiting_type)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 2/4\n"
"Выберите тип ордера:",
reply_markup=_type_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_side)
async def process_order_side_text(message: Message) -> None:
await message.answer(
"Пожалуйста, используйте кнопки для выбора стороны.",
reply_markup=_side_keyboard(),
)
@router.callback_query(
NewOrderDraftStates.waiting_type,
F.data.startswith("order_type:"),
)
async def process_order_type_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
order_type = callback.data.split(":", 1)[1]
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
await state.update_data(order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
context = service.get_entry_context(side=side, order_type=order_type)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Выберите количество или введите его вручную:",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_type)
async def process_order_type_text(message: Message) -> None:
await message.answer(
"Пожалуйста, используйте кнопки для выбора типа ордера.",
reply_markup=_type_keyboard(),
)
@router.callback_query(
NewOrderDraftStates.waiting_quantity,
F.data.startswith("order_qty:"),
)
async def process_quantity_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
value = callback.data.split(":", 1)[1]
if value == "manual":
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
"Введите количество вручную, например: <b>0.001</b>",
reply_markup=_quantity_manual_keyboard(),
)
await callback.answer()
return
service = OrderDraftsService()
quantity = service.normalize_quantity(value)
if quantity is None:
await callback.answer("Некорректное значение количества.", show_alert=True)
return
data = await state.get_data()
order_type = data.get("order_type", "MARKET")
await state.update_data(quantity=quantity)
if order_type == "LIMIT":
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Выберите цену или введите её вручную:",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
await callback.answer()
return
draft = service.build_draft(
side=data["side"],
order_type=order_type,
quantity=quantity,
)
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await callback.message.edit_text(
_render_validation_error(errors),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_quantity)
async def process_order_quantity(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
quantity = service.normalize_quantity(message.text or "")
if quantity is None:
await message.answer("Введите корректное количество, например: 0.001")
return
data = await state.get_data()
order_type = data.get("order_type", "MARKET")
await state.update_data(quantity=quantity)
if order_type == "LIMIT":
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await message.answer(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Выберите цену или введите её вручную:",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
return
draft = service.build_draft(
side=data["side"],
order_type=order_type,
quantity=quantity,
)
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await message.answer(_render_validation_error(errors))
return
await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
)
)
@router.callback_query(
NewOrderDraftStates.waiting_price,
F.data.startswith("order_price:"),
)
async def process_price_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
value = callback.data.split(":", 1)[1]
if value == "manual":
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
"Введите цену вручную, например: <b>73000.123</b>",
reply_markup=_price_manual_keyboard(),
)
await callback.answer()
return
service = OrderDraftsService()
price = service.normalize_price(value)
if price is None:
await callback.answer("Некорректная цена.", show_alert=True)
return
data = await state.get_data()
draft = service.build_draft(
side=data["side"],
order_type=data["order_type"],
quantity=data["quantity"],
price=price,
)
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await callback.message.edit_text(
_render_validation_error(errors),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_price)
async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
price = service.normalize_price(message.text or "")
if price is None:
await message.answer("Введите корректную цену, например: 73000.123")
return
data = await state.get_data()
draft = service.build_draft(
side=data["side"],
order_type=data["order_type"],
quantity=data["quantity"],
price=price,
)
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await message.answer(_render_validation_error(errors))
return
await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
)
)
@router.message(Command("drafts"))
async def drafts_command(message: Message) -> None:
await show_recent_drafts(message, edit_mode=False, page=1)

View File

@@ -1,5 +1,3 @@
# app/src/telegram/keyboards/reply.py
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
@@ -7,11 +5,17 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[ [
KeyboardButton(text="🤖 Автоторговля"), KeyboardButton(text="🏠 Главная"),
KeyboardButton(text="📈 Рынок"),
KeyboardButton(text="💼 Портфель"), KeyboardButton(text="💼 Портфель"),
], ],
[ [
KeyboardButton(text="🖥️ Система"), KeyboardButton(text="⚡ Торговля"),
KeyboardButton(text="🤖 Авто"),
KeyboardButton(text="📒 Журнал"),
],
[
KeyboardButton(text="⚙️ Система"),
], ],
], ],
resize_keyboard=True, resize_keyboard=True,

View File

@@ -1 +0,0 @@
"""Package marker."""

View File

@@ -1,100 +0,0 @@
# app/src/telegram/live/active_screen.py
from __future__ import annotations
from dataclasses import dataclass
from aiogram import Bot
from aiogram.types import Message
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry
from src.trading.auto.runner import AutoTradeRunner
from src.trading.debug.runner import DebugTradeRunner
@dataclass(slots=True)
class ActiveScreen:
screen: str
chat_id: int
message_id: int
class ActiveScreenManager:
_screens: dict[int, ActiveScreen] = {}
@classmethod
async def prepare_new_screen(
cls,
*,
screen: str,
bot: Bot,
chat_id: int,
keep_message_id: int | None = None,
) -> None:
previous = cls._screens.get(chat_id)
await AutoTradeRunner.detach_screen(
delete_message=True,
bot=bot,
chat_id=chat_id,
keep_message_id=keep_message_id,
)
await DebugTradeRunner.detach_screen(
delete_message=True,
bot=bot,
chat_id=chat_id,
keep_message_id=keep_message_id,
)
await LiveScreenRunner.delete_chat_screens(
bot=bot,
chat_id=chat_id,
keep_message_id=keep_message_id,
)
await ScreenRegistry.delete_chat_screens(
bot=bot,
chat_id=chat_id,
keep_message_id=keep_message_id,
)
if previous is not None:
if keep_message_id is None or previous.message_id != keep_message_id:
try:
await bot.delete_message(
chat_id=previous.chat_id,
message_id=previous.message_id,
)
except Exception:
pass
cls._screens.pop(chat_id, None)
@classmethod
def register(
cls,
*,
screen: str,
message: Message,
) -> None:
cls._screens[message.chat.id] = ActiveScreen(
screen=screen,
chat_id=message.chat.id,
message_id=message.message_id,
)
@classmethod
def unregister(
cls,
*,
chat_id: int,
message_id: int,
) -> None:
current = cls._screens.get(chat_id)
if current is None:
return
if current.message_id == message_id:
cls._screens.pop(chat_id, None)

View File

@@ -1,293 +0,0 @@
# app/src/telegram/live/runner.py
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from collections.abc import Callable
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiogram.exceptions import TelegramBadRequest
@dataclass(slots=True)
class LiveScreen:
screen: str
bot: Bot
chat_id: int
message_id: int
render_text: Callable[[], str]
render_markup: Callable[[], InlineKeyboardMarkup | None]
interval_seconds: int = 5
@dataclass(slots=True)
class StaticScreen:
screen: str
bot: Bot
chat_id: int
message_id: int
class ScreenRegistry:
_screens: dict[str, list[StaticScreen]] = {}
@classmethod
def register_screen(cls, static_screen: StaticScreen) -> None:
screens = cls._screens.setdefault(static_screen.screen, [])
screens[:] = [
item
for item in screens
if not (
item.chat_id == static_screen.chat_id
and item.message_id == static_screen.message_id
)
]
screens.append(static_screen)
@classmethod
def unregister_message(cls, *, chat_id: int, message_id: int) -> None:
empty_screens: list[str] = []
for screen, screens in cls._screens.items():
screens[:] = [
item
for item in screens
if not (item.chat_id == chat_id and item.message_id == message_id)
]
if not screens:
empty_screens.append(screen)
for screen in empty_screens:
cls._screens.pop(screen, None)
@classmethod
async def delete_screen(cls, *, screen: str, bot: Bot, chat_id: int) -> None:
screens = cls._screens.get(screen, [])
remaining: list[StaticScreen] = []
for static_screen in screens:
if static_screen.chat_id != chat_id:
remaining.append(static_screen)
continue
try:
await bot.delete_message(
chat_id=static_screen.chat_id,
message_id=static_screen.message_id,
)
except Exception:
pass
if remaining:
cls._screens[screen] = remaining
else:
cls._screens.pop(screen, None)
@classmethod
async def delete_chat_screens(
cls,
*,
bot: Bot,
chat_id: int,
keep_message_id: int | None = None,
) -> None:
empty_screens: list[str] = []
for screen, screens in cls._screens.items():
remaining: list[StaticScreen] = []
for static_screen in screens:
if static_screen.chat_id != chat_id:
remaining.append(static_screen)
continue
if keep_message_id is not None and static_screen.message_id == keep_message_id:
remaining.append(static_screen)
continue
try:
await bot.delete_message(
chat_id=static_screen.chat_id,
message_id=static_screen.message_id,
)
except Exception:
pass
cls._screens[screen] = remaining
if not remaining:
empty_screens.append(screen)
for screen in empty_screens:
cls._screens.pop(screen, None)
class LiveScreenRunner:
_screens: dict[str, list[LiveScreen]] = {}
_tasks: dict[str, asyncio.Task] = {}
@classmethod
def register_screen(cls, live_screen: LiveScreen) -> None:
screens = cls._screens.setdefault(live_screen.screen, [])
screens[:] = [
item
for item in screens
if not (
item.chat_id == live_screen.chat_id
and item.message_id == live_screen.message_id
)
]
screens.append(live_screen)
@classmethod
def unregister_message(cls, *, chat_id: int, message_id: int) -> None:
empty_screens: list[str] = []
for screen, screens in cls._screens.items():
screens[:] = [
item
for item in screens
if not (item.chat_id == chat_id and item.message_id == message_id)
]
if not screens:
empty_screens.append(screen)
for screen in empty_screens:
cls._screens.pop(screen, None)
cls.stop(screen)
@classmethod
async def delete_screen(cls, *, screen: str, bot: Bot, chat_id: int) -> None:
screens = cls._screens.get(screen, [])
remaining: list[LiveScreen] = []
for live_screen in screens:
if live_screen.chat_id != chat_id:
remaining.append(live_screen)
continue
try:
await bot.delete_message(
chat_id=live_screen.chat_id,
message_id=live_screen.message_id,
)
except Exception:
pass
if remaining:
cls._screens[screen] = remaining
else:
cls._screens.pop(screen, None)
cls.stop(screen)
@classmethod
async def delete_chat_screens(
cls,
*,
bot: Bot,
chat_id: int,
keep_message_id: int | None = None,
) -> None:
empty_screens: list[str] = []
for screen, screens in cls._screens.items():
remaining: list[LiveScreen] = []
for live_screen in screens:
if live_screen.chat_id != chat_id:
remaining.append(live_screen)
continue
if keep_message_id is not None and live_screen.message_id == keep_message_id:
remaining.append(live_screen)
continue
try:
await bot.delete_message(
chat_id=live_screen.chat_id,
message_id=live_screen.message_id,
)
except Exception:
pass
cls._screens[screen] = remaining
if not remaining:
empty_screens.append(screen)
for screen in empty_screens:
cls._screens.pop(screen, None)
cls.stop(screen)
@classmethod
def start(cls, screen: str) -> None:
task = cls._tasks.get(screen)
if task is not None and not task.done():
return
cls._tasks[screen] = asyncio.create_task(cls._worker(screen))
@classmethod
def stop(cls, screen: str) -> None:
task = cls._tasks.get(screen)
if task is None:
return
task.cancel()
cls._tasks.pop(screen, None)
@classmethod
async def _worker(cls, screen: str) -> None:
while True:
if screen not in cls._screens:
cls._tasks.pop(screen, None)
return
await cls._refresh_screen(screen)
await asyncio.sleep(cls._screen_interval(screen))
@classmethod
def _screen_interval(cls, screen: str) -> int:
screens = cls._screens.get(screen, [])
if not screens:
return 5
return screens[0].interval_seconds
@classmethod
async def _refresh_screen(cls, screen: str) -> None:
screens = cls._screens.get(screen, [])
if not screens:
cls._screens.pop(screen, None)
return
alive_screens: list[LiveScreen] = []
for live_screen in screens:
try:
await live_screen.bot.edit_message_text(
chat_id=live_screen.chat_id,
message_id=live_screen.message_id,
text=live_screen.render_text(),
reply_markup=live_screen.render_markup(),
)
alive_screens.append(live_screen)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
alive_screens.append(live_screen)
continue
except Exception:
pass
if alive_screens:
cls._screens[screen] = alive_screens
else:
cls._screens.pop(screen, None)

View File

@@ -2,48 +2,31 @@
MAIN_MENU_TEXT = ( MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\n\n" "<b>Dzentra Bot</b>\n\n"
"Trading Runtime Terminal\n\n" "Новый каркас проекта успешно создан.\n\n"
"Доступные разделы:\n" "Выбери раздел через меню ниже."
"• Автоторговля\n"
"• Портфель\n"
"• Система"
) )
HOME_TEXT = ( HOME_TEXT = (
"<b>🏠 Главная</b>\n\n" "<b>🏠 Главная</b>\n\n"
"Главное меню Dzentra Bot.\n\n" "Это главный экран бота.\n\n"
"Используй кнопки ниже для перехода в нужный раздел." "Сейчас здесь отображается базовый статус:\n"
"- бот запущен\n"
"- меню подключено\n"
"- handlers работают\n"
"- проект на этапе Bootstrap v2\n"
) )
SYSTEM_TEXT = ( SYSTEM_TEXT = (
"<b>🖥 Система</b>\n\n" "<b> Система</b>\n\n"
"Системный runtime экран.\n\n" "Системный экран.\n\n"
"<b>Разделы</b>\n" "<b>Справка</b>\n"
"• Настройки\n"
"• Журнал\n"
"• Информация\n\n"
"<b>Команды</b>\n"
"/start — запуск\n" "/start — запуск\n"
"/menu — главное меню\n" "/menu — показать меню\n"
"/help — системная информация\n" "/help — краткая справка\n"
) )
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
PORTFOLIO_TEXT = ( PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
"<b>💼 Портфель</b>\n\n" TRADE_TEXT = "<b>⚡ Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
"Просмотр активов и баланса биржи." AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
) JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
AUTO_TEXT = (
"<b>🤖 Автоторговля</b>\n\n"
"Runtime экран автоторговли."
)
JOURNAL_TEXT = (
"<b>📒 Журнал</b>\n\n"
"Runtime события и execution logs."
)

View File

@@ -1,23 +1,23 @@
# app/src/telegram/routers.py
from aiogram import Dispatcher from aiogram import Dispatcher
from src.telegram.handlers.auto import router as auto_router from src.telegram.handlers.auto import router as auto_router
from src.telegram.handlers.debug import router as debug_router
from src.telegram.handlers.debug_auto.main import router as debug_auto_router
from src.telegram.handlers.home import router as home_router from src.telegram.handlers.home import router as home_router
from src.telegram.handlers.journal import router as journal_router from src.telegram.handlers.journal import router as journal_router
from src.telegram.handlers.market import router as market_router
from src.telegram.handlers.portfolio import router as portfolio_router from src.telegram.handlers.portfolio import router as portfolio_router
from src.telegram.handlers.start import router as start_router from src.telegram.handlers.start import router as start_router
from src.telegram.handlers.system import router as system_router from src.telegram.handlers.system import router as system_router
from src.telegram.handlers.trade.main import router as trade_main_router
from src.telegram.handlers.trade.new_order import router as trade_new_order_router
def setup_routers(dispatcher: Dispatcher) -> None: def setup_routers(dispatcher: Dispatcher) -> None:
dispatcher.include_router(start_router) dispatcher.include_router(start_router)
dispatcher.include_router(home_router) dispatcher.include_router(home_router)
dispatcher.include_router(market_router)
dispatcher.include_router(portfolio_router) dispatcher.include_router(portfolio_router)
dispatcher.include_router(trade_main_router)
dispatcher.include_router(trade_new_order_router)
dispatcher.include_router(auto_router) dispatcher.include_router(auto_router)
dispatcher.include_router(journal_router) dispatcher.include_router(journal_router)
dispatcher.include_router(debug_auto_router)
dispatcher.include_router(debug_router)
dispatcher.include_router(system_router) dispatcher.include_router(system_router)

View File

@@ -1,56 +0,0 @@
# app/src/telegram/ui/common.py
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.core.system_status import get_runtime_mode_label
def mode_line() -> str:
label = get_runtime_mode_label()
return f"🔸 <b>{label}</b>\n\n"
def breadcrumb_line(*items: str) -> str:
if not items:
return ""
first = items[0].upper()
rest = " · ".join(items[1:])
if rest:
return f"<b>{first}</b> · {rest}\n\n"
return f"<b>{first}</b>\n\n"
def screen_header(
*,
title: str,
path: list[str] | None = None,
show_mode: bool = False,
) -> str:
parts: list[str] = [f"<b>{title}</b>"]
if show_mode:
parts.append(f"🔸 <b>{get_runtime_mode_label()}</b>")
if path:
parts.append(f"<b>{''.join(path).upper()}</b>")
return "\n".join(parts) + "\n\n"
def now_line() -> str:
settings = load_settings()
tz_name = settings.tz or "UTC"
try:
local_dt = datetime.now(ZoneInfo(tz_name))
except Exception:
local_dt = datetime.utcnow()
return f"<i>Обновлено: {local_dt.strftime('%H:%M:%S')}</i>"

View File

@@ -1,217 +0,0 @@
# app/src/telegram/ui/currency_ui.py
from __future__ import annotations
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary, ExchangeSymbol
from src.integrations.exchange.service import ExchangeService
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
CURRENCY_ICONS = {
"USD": "$",
"USDT": "$",
"EUR": "",
"RUB": "",
"BYN": "Br",
"BTC": "",
"ETH": "Ξ",
"LTC": "LTC",
"BNB": "BNB",
"SOL": "SOL",
"ADA": "ADA",
"XRP": "XRP",
"DOGE": "DOGE",
}
def is_fiat_currency(currency: str) -> bool:
return currency.upper() in FIAT_CURRENCIES
def get_currency_icon(currency: str) -> str:
return CURRENCY_ICONS.get(currency.upper(), currency.upper())
def get_currency_label(currency: str) -> str:
return f"{get_currency_icon(currency)} {currency.upper()}"
def render_currency_title(currency: str) -> str:
return get_currency_label(currency)
def format_amount(currency: str, value: float) -> str:
if is_fiat_currency(currency):
return f"{value:,.2f}".replace(",", " ")
return f"{value:,.8f}".replace(",", " ")
def format_usd_amount(value: float) -> str:
return f"{value:,.2f}".replace(",", " ")
def format_usd_price(value: float | int | str | None) -> str:
if value is None:
return ""
try:
return f"{float(value):,.2f}".replace(",", " ")
except (TypeError, ValueError):
return ""
def format_usd_pnl(value: float | int | str | None) -> str:
if value is None:
return ""
try:
pnl = float(value)
except (TypeError, ValueError):
return ""
if pnl > 0:
return f"🟢 +{format_usd_price(pnl)} USD"
if pnl < 0:
return f"🔴 -{format_usd_price(abs(pnl))} USD"
return f"{format_usd_price(0)} USD"
def render_currency_line(
*,
currency: str,
value: float,
show_code: bool = True,
) -> str:
icon = get_currency_icon(currency)
amount = format_amount(currency, value)
if show_code:
return f"{icon} {currency.upper()} · {amount}"
return f"{icon} {amount}"
def balance_total(item: BalanceSummary) -> float:
return item.available + item.locked
def is_zero_balance(item: BalanceSummary) -> bool:
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]:
return (
_quote_priority(symbol_info.quote_asset),
_status_priority(symbol_info.status),
_market_type_priority(symbol_info.market_type),
symbol_info.symbol.upper(),
)
def _resolve_asset_quote_symbol(
exchange_service: ExchangeService,
asset: str,
) -> ExchangeSymbol | None:
asset_upper = asset.upper()
try:
symbols = exchange_service.get_exchange_symbols()
except ExchangeError:
return None
candidates: list[ExchangeSymbol] = []
for symbol_info in symbols:
base_asset = (symbol_info.base_asset or "").upper()
quote_asset = (symbol_info.quote_asset or "").upper()
if base_asset != asset_upper:
continue
if quote_asset not in {"USD", "USDT"}:
continue
candidates.append(symbol_info)
if not candidates:
return None
candidates.sort(key=_symbol_priority, reverse=True)
return candidates[0]
def get_asset_usd_rate(
exchange_service: ExchangeService,
currency: str,
price_cache: dict[str, float | None],
) -> float | None:
asset = currency.upper()
if asset in {"USD", "USDT"}:
return 1.0
if asset in price_cache:
return price_cache[asset]
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset)
if symbol_info is None:
price_cache[asset] = None
return None
try:
ticker = exchange_service.get_price(symbol_info.symbol)
rate = float(ticker.price)
# Пока считаем USDT ~= USD
price_cache[asset] = rate
return rate
except ExchangeError:
price_cache[asset] = None
return None
def estimate_balance_usd(
item: BalanceSummary,
exchange_service: ExchangeService,
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)
if rate is None:
return None
return total * rate

View File

@@ -1,207 +0,0 @@
# app/src/telegram/ui/exchange_error.py
from __future__ import annotations
from dataclasses import dataclass
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from src.integrations.exchange.runtime_ui import build_exchange_error_ui_parts
from src.telegram.ui.common import mode_line, now_line
@dataclass(slots=True)
class ExchangeErrorView:
headline: str
details: str
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
DEFAULT_AUTH_DETAILS = (
"Не удалось выполнить приватный запрос к аккаунту.\n"
"Проверь API-ключ, Secret Key, IP whitelist и права доступа."
)
DEFAULT_TIME_DETAILS = (
"Не удалось выполнить запрос к бирже.\n"
"Проверь синхронизацию времени и обнови экран."
)
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
# Собрать UI-представление ошибки через единый exchange status layer.
def _build_exchange_error_view(
*,
exc: Exception,
network_details: str,
auth_details: str,
time_details: str | None = None,
generic_details: str | None = None,
) -> ExchangeErrorView:
_, headline, details = build_exchange_error_ui_parts(exc)
return ExchangeErrorView(
headline=headline,
details=details or generic_details or DEFAULT_GENERIC_DETAILS,
)
# Отрисовать единый текст ошибки биржи для message/callback экранов.
def render_exchange_error(
*,
title: str,
exc: Exception,
network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None,
generic_details: str | None = None,
) -> str:
view = _build_exchange_error_view(
exc=exc,
network_details=network_details,
auth_details=auth_details,
time_details=time_details,
generic_details=generic_details,
)
return (
f"{title}\n"
f"{mode_line()}"
f"{view.headline}\n\n"
f"{view.details}\n\n"
f"{now_line()}"
)
# Собрать клавиатуру для экранов с ошибкой биржи.
def exchange_error_keyboard(
*,
retry_callback_data: str | None = None,
back_callback_data: str | None = None,
drafts_page: int | None = None,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
first_row: list[InlineKeyboardButton] = []
if retry_callback_data:
first_row.append(
InlineKeyboardButton(
text="🔁 Обновить",
callback_data=retry_callback_data,
)
)
if back_callback_data:
first_row.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=back_callback_data,
)
)
if first_row:
buttons.append(first_row)
if drafts_page is not None:
buttons.append(
[
InlineKeyboardButton(
text="📚 К черновикам",
callback_data=f"drafts:{drafts_page}",
)
]
)
elif back_callback_data is None:
buttons.append(
[
InlineKeyboardButton(
text="🤖 К автоторговле",
callback_data="auto:home",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
# Показать ошибку биржи через редактирование callback-сообщения.
async def show_callback_exchange_error(
callback: CallbackQuery,
*,
title: str,
exc: Exception,
network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None,
generic_details: str | None = None,
retry_callback_data: str | None = None,
back_callback_data: str | None = None,
drafts_page: int | None = None,
) -> None:
message = callback.message
if message is None or isinstance(message, InaccessibleMessage):
await callback.answer("Сообщение не найдено", show_alert=True)
return
text = render_exchange_error(
title=title,
exc=exc,
network_details=network_details,
auth_details=auth_details,
time_details=time_details,
generic_details=generic_details,
)
markup = exchange_error_keyboard(
retry_callback_data=retry_callback_data or callback.data,
back_callback_data=back_callback_data,
drafts_page=drafts_page,
)
try:
await message.edit_text(text, reply_markup=markup)
await callback.answer()
except TelegramBadRequest as tg_exc:
if "message is not modified" in str(tg_exc).lower():
await callback.answer("Ошибка всё ещё актуальна")
return
raise
# Показать ошибку биржи новым сообщением.
async def show_message_exchange_error(
message: Message,
*,
title: str,
exc: Exception,
network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None,
generic_details: str | None = None,
retry_callback_data: str | None = None,
back_callback_data: str | None = None,
drafts_page: int | None = None,
) -> None:
await message.answer(
render_exchange_error(
title=title,
exc=exc,
network_details=network_details,
auth_details=auth_details,
time_details=time_details,
generic_details=generic_details,
),
reply_markup=exchange_error_keyboard(
retry_callback_data=retry_callback_data,
back_callback_data=back_callback_data,
drafts_page=drafts_page,
),
)

View File

@@ -1,16 +1,8 @@
# app/src/trading/accounts/service.py
from __future__ import annotations from __future__ import annotations
from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import ( from src.storage.repositories.balance_snapshots import BalanceSnapshotRepository
ExchangeStatusCode,
build_exchange_error_status,
)
from src.storage.repositories.balance_snapshots import (
BalanceSnapshotRepository,
)
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -20,27 +12,12 @@ class AccountsService:
self.snapshot_repository = BalanceSnapshotRepository() self.snapshot_repository = BalanceSnapshotRepository()
self.journal = JournalService() self.journal = JournalService()
# получить live balance summary через typed exchange runtime layer
def get_live_balance_summary(self) -> list[BalanceSummary]: def get_live_balance_summary(self) -> list[BalanceSummary]:
try: balances = self.exchange_service.get_balance_summary()
balances = self.exchange_service.get_balance_summary()
except Exception as exc:
runtime_status = build_exchange_error_status(exc)
self._log_balance_runtime_error(runtime_status)
raise
self._save_snapshot(balances) self._save_snapshot(balances)
return balances return balances
# сохранить snapshot баланса def _save_snapshot(self, balances: list[BalanceSummary]) -> None:
def _save_snapshot(
self,
balances: list[BalanceSummary],
) -> None:
payload = { payload = {
"assets": [ "assets": [
{ {
@@ -58,62 +35,22 @@ class AccountsService:
source="portfolio_screen", source="portfolio_screen",
payload=payload, payload=payload,
) )
except Exception as exc: except Exception as exc:
try: try:
self.journal.log_warning( self.journal.log_warning(
"balance_snapshot_error", "balance_snapshot_error",
f"Не удалось сохранить snapshot баланса: {exc}", f"Не удалось сохранить snapshot баланса: {exc}",
{ {"assets_count": len(balances)},
"assets_count": len(balances),
},
) )
except Exception: except Exception:
pass pass
return return
try: try:
self.journal.log_info( self.journal.log_info(
"balance_snapshot_saved", "balance_snapshot_saved",
f"Snapshot баланса сохранён. Активов: {len(balances)}", f"Snapshot баланса сохранён. Активов: {len(balances)}",
{ {"assets_count": len(balances)},
"assets_count": len(balances),
},
) )
except Exception:
pass
# записать typed runtime exchange error для balances
def _log_balance_runtime_error(
self,
runtime_status,
) -> None:
try:
payload = {
"status_code": runtime_status.code.value,
"is_available": runtime_status.is_available,
"is_auth_ok": runtime_status.is_auth_ok,
"reason": runtime_status.reason,
"raw_status": runtime_status.raw_status,
"raw_error": runtime_status.raw_error,
}
if runtime_status.code == ExchangeStatusCode.AUTH_ERROR:
self.journal.log_warning(
"balance_auth_error",
runtime_status.message,
payload,
)
return
self.journal.log_warning(
"balance_exchange_error",
runtime_status.message,
payload,
)
except Exception: except Exception:
pass pass

View File

@@ -1,3 +0,0 @@
# app/src/trading/auto/__init__.py
"""Package marker."""

View File

@@ -1,555 +0,0 @@
# app/src/trading/auto/auto_lifecycle.py
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING
from datetime import datetime
from src.core.config import load_settings
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.execution.engine import ExecutionEngine
from src.trading.strategies.base import BaseStrategy, StrategyContext
from src.trading.strategies.registry import StrategyRegistry
from src.trading.auto.execution_quality import AutoExecutionQualityMixin
from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin
from src.trading.auto.market_runtime import AutoMarketRuntimeMixin
from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin
from src.trading.auto.position_health import AutoPositionHealthMixin
from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin
from src.trading.auto.autonomous_management import AutoAutonomousManagementMixin
from src.trading.journal.service import JournalService
if TYPE_CHECKING:
from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin
from src.trading.auto.position_health import AutoPositionHealthMixin
from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin
from src.trading.auto.market_runtime import AutoMarketRuntimeMixin
from src.trading.auto.execution_quality import AutoExecutionQualityMixin
from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin
class AutoLifecycleMixin(
AutoSignalRuntimeMixin,
AutoExecutionQualityMixin,
AutoMarketRuntimeMixin,
AutoPositionHealthMixin,
AutoPositionIntelligenceMixin,
AutoAutonomousManagementMixin,
AutoExecutionSemanticMixin,
):
_state: AutoTradeState
_loop_task: asyncio.Task | None
_loop_interval_seconds: int
_confirm_min_duration_seconds: int
_confirm_repeats: int
_execution_confidence_required_score: float
# Записать изменение режима автоторговли в журнал.
def _log_auto_status_changed(
self,
*,
previous_status: str,
new_status: str,
action: str,
message: str,
) -> None:
state = self.get_state()
JournalService().log_ui_info(
event_type="auto_status_changed",
message=message,
screen="auto",
action=action,
payload={
"previous_status": previous_status,
"new_status": new_status,
"symbol": state.symbol,
"strategy": state.strategy,
"cycle_number": state.cycle_number,
"risk_percent": state.risk_percent,
"leverage": state.leverage,
"allocated_balance_usd": state.allocated_balance_usd,
"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,
},
)
# установить капитал, выделенный под автоторговлю
def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
state = self.get_state()
numeric_value = safe_float(value)
if numeric_value is None or numeric_value <= 0:
numeric_value = 1000.0
state.allocated_balance_usd = numeric_value
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
return state
# получить текущее состояние автоторговли
def get_state(self) -> AutoTradeState:
if not self._state.symbol:
self._state.symbol = load_settings().default_symbol
return self._state
# проверить, запущен ли background loop
def is_loop_running(self) -> bool:
return self._loop_task is not None and not self._loop_task.done()
# запустить background loop, если он ещё не запущен
def start_loop(self) -> None:
if self.is_loop_running():
return
self._loop_task = asyncio.create_task(self._loop_worker())
# остановить background loop
def stop_loop(self) -> None:
if self._loop_task is None:
return
self._loop_task.cancel()
self._loop_task = None
# рабочий цикл автоторговли
async def _loop_worker(self) -> None:
while True:
state = self.get_state()
if state.status == "OFF":
break
self.run_cycle()
await asyncio.sleep(self._loop_interval_seconds)
# запустить активную торговлю
def start(self) -> tuple[AutoTradeState, str]:
state = self.get_state()
previous_status = state.status
if state.status == "RUNNING":
return state, "Автоторговля уже активна."
if state.status == "OBSERVING":
state.status = "RUNNING"
EventBus.emit(
"auto_status_changed",
{
"previous_status": previous_status,
"status": state.status,
},
)
self._log_auto_status_changed(
previous_status=previous_status,
new_status=state.status,
action="start",
message="Автоторговля активирована.",
)
return state, "Автоторговля активирована."
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_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()
EventBus.emit(
"auto_status_changed",
{
"previous_status": previous_status,
"status": state.status,
},
)
self._log_auto_status_changed(
previous_status=previous_status,
new_status=state.status,
action="start",
message="Автоторговля запущена.",
)
return state, "Автоторговля запущена."
# включить режим наблюдения
def observe(self) -> tuple[AutoTradeState, str]:
state = self.get_state()
previous_status = state.status
if previous_status == "OBSERVING":
return state, "Режим наблюдения уже включён."
state.status = "OBSERVING"
EventBus.emit(
"auto_status_changed",
{
"previous_status": previous_status,
"status": state.status,
},
)
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 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
self._log_auto_status_changed(
previous_status=previous_status,
new_status=state.status,
action="observe",
message="Включён режим наблюдения.",
)
return state, "Включён режим наблюдения."
self._log_auto_status_changed(
previous_status=previous_status,
new_status=state.status,
action="observe",
message="Автоторговля переведена в режим наблюдения.",
)
return state, "Автоторговля переведена в режим наблюдения."
# полностью выключить автоторговлю
def stop(self) -> tuple[AutoTradeState, str]:
state = self.get_state()
previous_status = state.status
if state.status == "OFF":
self.stop_loop()
return state, "Автоторговля уже выключена."
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
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
self.stop_loop()
EventBus.emit(
"auto_status_changed",
{
"previous_status": previous_status,
"status": state.status,
},
)
self._log_auto_status_changed(
previous_status=previous_status,
new_status=state.status,
action="stop",
message="Автоторговля выключена.",
)
return state, "Автоторговля выключена."
# установить инструмент
def set_symbol(self, symbol: str) -> AutoTradeState:
state = self.get_state()
previous_symbol = state.symbol
state.symbol = symbol
self._reset_signal_tracking()
StrategyRegistry.reset_runtime(symbol=previous_symbol)
StrategyRegistry.reset_runtime(symbol=symbol)
return state
# установить стратегию
def set_strategy(self, strategy: str) -> AutoTradeState:
state = self.get_state()
previous_strategy = state.strategy
normalized_strategy = strategy.strip().upper()
state.strategy = normalized_strategy
self._reset_signal_tracking()
StrategyRegistry.reset_runtime(previous_strategy)
StrategyRegistry.reset_runtime(normalized_strategy)
return state
# установить риск
def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState:
state = self.get_state()
state.risk_percent = safe_float(risk_percent)
return state
# установить плечо
def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
state = self.get_state()
state.leverage = safe_float(leverage)
return state
# установить stop loss в %
def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.stop_loss_percent = safe_float(value)
return state
# установить take profit в %
def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.take_profit_percent = safe_float(value)
return state
# установить max loss в USD
def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.max_loss_usd = safe_float(value)
return state
# установить максимальное использование баланса под маржу
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
return state
# сбросить внутренний трекинг сигналов и runtime state
def _reset_signal_tracking(self) -> None:
self._last_signal_key = None
self._last_signal_value = None
self._last_signal_reason = ""
self._last_signal_confidence = 0.0
self._last_signal_payload = None
self._last_signal_started_at = None
self._same_signal_count = 0
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.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_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_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.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.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
# собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext:
state = self.get_state()
return StrategyContext(
symbol=state.symbol,
status=state.status,
risk_percent=state.risk_percent,
)
# получить стратегию для текущего цикла
def _get_strategy(self) -> BaseStrategy:
state = self.get_state()
return StrategyRegistry.get(state.strategy)
# выполнить один полный runtime cycle автоторговли
def run_cycle(self) -> AutoTradeState:
state = self.get_state()
if state.status == "OFF":
return state
if not self._sync_market_availability_state(state):
state.last_check_at = datetime.now().strftime("%H:%M:%S")
self._sync_execution_semantic_state(state)
return state
self._expire_runtime_if_needed(state)
strategy = self._get_strategy()
context = self._build_strategy_context()
result = strategy.analyze(context)
self._sync_market_analysis_state(
state=state,
payload=result.payload,
)
self._sync_execution_quality_state(state)
state.last_check_at = datetime.now().strftime("%H:%M:%S")
self._log_signal_if_changed(
strategy_name=strategy.name,
state=state,
signal=result.signal.value,
reason=result.reason,
confidence=result.confidence,
payload=result.payload,
)
if state.execution_quality != "BLOCKED":
ExecutionEngine().process(state)
self._sync_position_health_state(state)
self._sync_position_intelligence_state(state)
self._sync_autonomous_trade_management(state)
if state.execution_quality != "BLOCKED":
ExecutionEngine().process_runtime_action(state)
self._sync_execution_semantic_state(state)
return state

View File

@@ -1,69 +0,0 @@
# app/src/trading/auto/autonomous_management.py
from __future__ import annotations
from src.core.numbers import safe_float
from src.trading.auto.state import AutoTradeState
class AutoAutonomousManagementMixin:
# синхронизировать автономное управление открытой позицией
def _sync_autonomous_trade_management(
self,
state: AutoTradeState,
) -> None:
if state.position_side == "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
return
exit_signal = str(state.position_exit_signal or "HOLD").upper()
exit_confidence = safe_float(state.position_exit_confidence) or 0.0
action = "HOLD"
reason = "позиция удерживается"
protect_required = False
reduce_required = False
exit_required = False
if exit_signal == "WATCH":
action = "WATCH"
reason = "позиция требует наблюдения"
elif exit_signal == "REDUCE_OR_PROTECT":
if state.position_pressure in {"HIGH_LOSS", "LOSS"}:
action = "REDUCE"
reduce_required = True
reason = "позиция должна быть уменьшена"
else:
action = "PROTECT"
protect_required = True
reason = "позиция требует защиты"
elif exit_signal == "EXIT":
action = "EXIT"
exit_required = True
reason = "позиция требует закрытия"
if (
state.position_adverse_momentum
and state.position_trend_alignment == "AGAINST"
and exit_confidence >= 0.65
):
action = "EXIT"
exit_required = True
reduce_required = False
protect_required = False
reason = "рынок агрессивно движется против позиции"
state.autonomous_action = action
state.autonomous_action_reason = reason
state.autonomous_action_confidence = exit_confidence
state.autonomous_protection_required = protect_required
state.autonomous_reduce_required = reduce_required
state.autonomous_exit_required = exit_required

View File

@@ -1,488 +0,0 @@
# app/src/trading/auto/execution_quality.py
from __future__ import annotations
import time
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import (
ExchangeRuntimeStatus,
ExchangeStatusCode,
build_exchange_error_status,
)
from src.trading.auto.state import AutoTradeState
from src.trading.journal.service import JournalService
class AutoExecutionQualityMixin:
_spread_thresholds_by_asset: dict[str, dict[str, float]]
_default_spread_thresholds: dict[str, float]
_max_snapshot_age_seconds: float
_warning_snapshot_age_seconds: float
_last_logged_execution_quality_key: str | None
# получить базовый asset из symbol для spread thresholds
def _asset_symbol(self, symbol: str | None) -> str:
if not symbol:
return ""
base = str(symbol).split("_", 1)[0].upper()
if "/" in base:
return base.split("/", 1)[0]
for suffix in ("USDT", "USD", "EUR", "BTC"):
if base.endswith(suffix) and len(base) > len(suffix):
return base[: -len(suffix)]
return base
# получить spread thresholds для конкретного инструмента
def _spread_thresholds(self, symbol: str | None) -> dict[str, float]:
asset = self._asset_symbol(symbol)
return self._spread_thresholds_by_asset.get(
asset,
self._default_spread_thresholds,
)
# синхронизировать единый статус биржи/торговой сессии в AutoTradeState
def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
try:
status = ExchangeService().get_symbol_runtime_status(state.symbol)
except Exception as exc:
status = build_exchange_error_status(exc)
state.market_is_open = status.is_open
state.market_status = status.code.value
state.market_status_message = status.ui_line
state.market_status_updated_at = time.monotonic()
if status.is_open:
self._clear_exchange_block_state(state)
return True
self._apply_exchange_block_state(
state=state,
status=status,
)
return False
# очистить старую блокировку биржи, если рынок снова доступен
def _clear_exchange_block_state(self, state: AutoTradeState) -> None:
if state.execution_quality_reason not in {
"MARKET_BREAK",
"EXCHANGE_UNAVAILABLE",
"AUTH_ERROR",
"TIME_ERROR",
"INVALID_SYMBOL",
"MARKET_CLOSED",
}:
return
state.execution_quality = None
state.execution_quality_reason = None
state.execution_quality_message = None
state.execution_block_reason = None
state.market_runtime_degraded = False
state.entry_block_reason = None
state.entry_block_message = None
# применить блокировку execution по единому ExchangeRuntimeStatus
def _apply_exchange_block_state(
self,
*,
state: AutoTradeState,
status: ExchangeRuntimeStatus,
) -> None:
reason = self._exchange_execution_reason(status)
message = status.ui_line or status.message
state.execution_quality = "BLOCKED"
state.execution_quality_reason = reason
state.execution_quality_message = message
state.execution_block_reason = message
state.market_runtime_degraded = True
state.entry_block_reason = reason
state.entry_block_message = message
state.decision_status = "WAITING"
state.decision_reason = message
state.is_signal_confirmed = False
state.is_signal_ready = False
self._log_exchange_availability_if_changed(
state=state,
status=status,
reason=reason,
)
# преобразовать typed exchange status в код причины execution layer
def _exchange_execution_reason(self, status: ExchangeRuntimeStatus) -> str:
if status.code == ExchangeStatusCode.BREAK:
return "MARKET_BREAK"
if status.code == ExchangeStatusCode.AUTH_ERROR:
return "AUTH_ERROR"
if status.code == ExchangeStatusCode.TIME_ERROR:
return "TIME_ERROR"
if status.code == ExchangeStatusCode.INVALID_SYMBOL:
return "INVALID_SYMBOL"
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
return "EXCHANGE_UNAVAILABLE"
return "MARKET_BREAK"
# залогировать изменение доступности биржи/рынка
def _log_exchange_availability_if_changed(
self,
*,
state: AutoTradeState,
status: ExchangeRuntimeStatus,
reason: str,
) -> None:
key = (
f"{state.status}:{state.symbol}:{state.strategy}:"
f"{status.code.value}:{reason}:{status.ui_line}"
)
if key == type(self)._last_logged_execution_quality_key:
return
type(self)._last_logged_execution_quality_key = key
try:
JournalService().log_ui_warning(
event_type="exchange_availability_changed",
message=status.ui_line,
screen="auto",
action="exchange_status",
payload={
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
"exchange_status_code": status.code.value,
"exchange_reason": status.reason,
"execution_reason": reason,
"is_open": status.is_open,
"is_available": status.is_available,
"is_auth_ok": status.is_auth_ok,
"message": status.message,
"raw_status": status.raw_status,
"raw_error": status.raw_error,
},
)
except Exception:
pass
# рассчитать качество исполнения на основе spread
def _spread_execution_quality(
self,
*,
state: AutoTradeState,
spread_percent: NumericLike | None,
) -> tuple[str | None, str | None, str | None, bool]:
spread = safe_float(spread_percent)
if spread is None:
return None, None, None, False
thresholds = self._spread_thresholds(state.symbol)
warning_enter = thresholds["warning_enter"]
warning_exit = thresholds["warning_exit"]
block_enter = thresholds["block_enter"]
block_exit = thresholds["block_exit"]
previous_quality = state.execution_quality
previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
if spread > block_exit:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread >= warning_enter:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
# синхронизировать runtime quality исполнения
def _sync_execution_quality_state(self, state: AutoTradeState) -> None:
try:
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
except Exception as exc:
fallback_price = None
try:
fallback_price = safe_float(
ExchangeService().get_price(
state.symbol,
runtime_key="auto",
).price
)
except Exception:
pass
state.snapshot_age_seconds = None
state.spread_percent = None
if fallback_price is not None and fallback_price > 0:
state.execution_quality = "WARNING"
state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE"
state.execution_quality_message = "нет depth snapshot"
state.market_runtime_degraded = True
else:
status = build_exchange_error_status(exc)
self._apply_exchange_block_state(
state=state,
status=status,
)
self._log_execution_quality_if_changed(
state=state,
payload={
"error": str(exc),
"error_type": type(exc).__name__,
"fallback_price_available": fallback_price is not None,
},
)
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 "")
self._sync_execution_pricing_state(
state,
snapshot,
)
state.snapshot_age_seconds = age_seconds
state.spread_percent = self._spread_percent(
bid_price=bid_price,
ask_price=ask_price,
)
if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds:
state.execution_quality = "BLOCKED"
state.execution_quality_reason = "STALE_SNAPSHOT"
state.execution_quality_message = "snapshot устарел"
state.market_runtime_degraded = True
elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds:
state.execution_quality = "WARNING"
state.execution_quality_reason = "AGING_SNAPSHOT"
state.execution_quality_message = "snapshot стареет"
state.market_runtime_degraded = not is_fresh
elif state.spread_percent is not None:
(
state.execution_quality,
state.execution_quality_reason,
state.execution_quality_message,
state.market_runtime_degraded,
) = self._spread_execution_quality(
state=state,
spread_percent=state.spread_percent,
)
else:
state.execution_quality = "GOOD"
state.execution_quality_reason = "MARKET_OK"
state.execution_quality_message = "рынок готов"
state.market_runtime_degraded = False
if state.execution_quality == "BLOCKED":
state.execution_block_reason = state.execution_quality_message
elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None
spread_thresholds = self._spread_thresholds(state.symbol)
self._log_execution_quality_if_changed(
state=state,
payload={
"symbol": state.symbol,
"strategy": state.strategy,
"bid_price": bid_price,
"ask_price": ask_price,
"last_price": last_price,
"snapshot_age_seconds": age_seconds,
"spread_percent": state.spread_percent,
"is_fresh": is_fresh,
"source": source,
"execution_quality": state.execution_quality,
"execution_quality_reason": state.execution_quality_reason,
"execution_quality_message": state.execution_quality_message,
"market_runtime_degraded": state.market_runtime_degraded,
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
"spread_asset": self._asset_symbol(state.symbol),
"spread_warning_enter_percent": spread_thresholds["warning_enter"],
"spread_warning_exit_percent": spread_thresholds["warning_exit"],
"spread_block_enter_percent": spread_thresholds["block_enter"],
"spread_block_exit_percent": spread_thresholds["block_exit"],
},
)
# рассчитать spread между bid/ask в процентах
def _spread_percent(
self,
*,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> float | None:
bid = safe_float(bid_price)
ask = safe_float(ask_price)
if bid is None or ask is None:
return None
if bid <= 0 or ask <= 0:
return None
mid_price = (bid + ask) / 2
if mid_price <= 0:
return None
spread = ask - bid
if spread < 0:
return None
return round((spread / mid_price) * 100, 5)
# синхронизировать execution pricing данные в state
def _sync_execution_pricing_state(
self,
state: AutoTradeState,
snapshot: dict[str, object],
) -> None:
age_seconds = safe_float(snapshot.get("age_seconds"))
state.execution_price_source = str(snapshot.get("source") or "")
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"))
if age_seconds is None:
state.execution_price_freshness = "UNKNOWN"
elif age_seconds <= 1:
state.execution_price_freshness = "FRESH"
elif age_seconds <= self._warning_snapshot_age_seconds:
state.execution_price_freshness = "AGING"
else:
state.execution_price_freshness = "STALE"
# записать событие изменения execution quality
def _log_execution_quality_if_changed(
self,
*,
state: AutoTradeState,
payload: dict[str, object],
) -> None:
quality = state.execution_quality
reason = state.execution_quality_reason
message = state.execution_quality_message
if not quality or not reason or not message:
return
key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}"
if key == type(self)._last_logged_execution_quality_key:
return
type(self)._last_logged_execution_quality_key = key
if quality == "GOOD":
return
try:
log_payload = {
**payload,
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
}
if quality == "BLOCKED":
JournalService().log_ui_warning(
event_type="execution_quality_changed",
message=f"Качество исполнения: {message}.",
screen="auto",
action="execution_quality",
payload=log_payload,
)
return
JournalService().log_ui_info(
event_type="execution_quality_changed",
message=f"Качество исполнения: {message}.",
screen="auto",
action="execution_quality",
payload=log_payload,
)
except Exception:
pass
# рассчитать confidence execution quality для общего execution confidence
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
quality = state.execution_quality
reason = state.execution_quality_reason
if quality == "GOOD":
return 1.0
if quality == "WARNING":
if reason == "WIDE_SPREAD":
return 0.65
if reason == "AGING_SNAPSHOT":
return 0.6
if reason == "SNAPSHOT_UNAVAILABLE":
return 0.55
return 0.6
if quality == "BLOCKED":
return 0.0
return 0.5

View File

@@ -1,120 +0,0 @@
# app/src/trading/auto/execution_semantic.py
from __future__ import annotations
from src.integrations.exchange.status import (
ExchangeStatusCode,
is_exchange_status_reason,
)
from src.trading.auto.state import AutoTradeState
class AutoExecutionSemanticMixin:
_execution_confidence_required_score: float
# синхронизировать semantic-статус execution слоя для UI
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
if state.execution_quality == "BLOCKED":
state.execution_semantic_status = "BLOCKED"
state.execution_semantic_message = self._execution_block_semantic_message(state)
state.execution_semantic_reason = state.execution_quality_reason
return
if state.decision_status == "BLOCKED":
state.execution_semantic_status = "BLOCKED"
if (
state.execution_confidence_score is not None
and state.execution_confidence_score < self._execution_confidence_required_score
):
state.execution_semantic_message = "⛔ Исполнение · низкая уверенность"
state.execution_semantic_reason = state.execution_confidence_reason
return
state.execution_semantic_message = "⛔ Исполнение · сигнал заблокирован"
state.execution_semantic_reason = state.decision_reason
return
if state.position_side != "NONE":
state.execution_semantic_status = "POSITION_OPEN"
state.execution_semantic_message = "📌 Исполнение · позиция открыта"
state.execution_semantic_reason = state.last_execution_reason
return
if state.decision_status == "READY" and state.is_signal_ready:
state.execution_semantic_status = "READY"
state.execution_semantic_message = "✅ Исполнение · готово"
state.execution_semantic_reason = state.decision_reason
return
if state.decision_status == "CONFIRMING":
state.execution_semantic_status = "WAITING_SIGNAL"
state.execution_semantic_message = "⏳ Исполнение · ждёт подтверждения"
state.execution_semantic_reason = state.decision_reason
return
if state.last_signal in {"BUY", "SELL"}:
state.execution_semantic_status = "WAITING_SIGNAL"
state.execution_semantic_message = "⏳ Исполнение · сигнал проверяется"
state.execution_semantic_reason = state.decision_reason
return
state.execution_semantic_status = "IDLE"
state.execution_semantic_message = ""
state.execution_semantic_reason = state.decision_reason
# вернуть человекочитаемое сообщение блокировки execution слоя
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
reason = str(state.execution_quality_reason or "")
message = str(state.execution_quality_message or "")
if self._is_exchange_unavailable(reason):
return "⛔ Исполнение · биржа недоступна"
if self._is_exchange_break(reason):
return "⏸️ Исполнение · перерыв на бирже"
if self._is_auth_error(reason):
return "⛔ Исполнение · неверный API Key"
if reason == "STALE_SNAPSHOT":
return "⛔ Исполнение · рынок неактуален"
if reason == "HIGH_SPREAD":
return "⛔ Исполнение · высокий spread"
if reason == "SNAPSHOT_ERROR":
return "⛔ Исполнение · нет данных рынка"
if reason == "SNAPSHOT_UNAVAILABLE":
return "⚠️ Исполнение · нет стакана"
if message:
return f"⛔ Исполнение · {message}"
return "⛔ Исполнение · заблокировано"
# проверить, что блокировка пришла из единого exchange status layer
def _is_exchange_unavailable(self, reason: str) -> bool:
return (
is_exchange_status_reason(reason)
and reason
in {
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
ExchangeStatusCode.TIME_ERROR.value,
}
)
# проверить, что причина блокировки — торговый перерыв, а не ошибка доступа
def _is_exchange_break(self, reason: str) -> bool:
return (
is_exchange_status_reason(reason)
and reason == ExchangeStatusCode.BREAK.value
)
# проверить ошибку приватного доступа / API key
def _is_auth_error(self, reason: str) -> bool:
return (
is_exchange_status_reason(reason)
and reason == ExchangeStatusCode.AUTH_ERROR.value
)

View File

@@ -1,274 +0,0 @@
# app/src/trading/auto/market_runtime.py
from __future__ import annotations
import time
from src.core.numbers import safe_float
from src.core.types import JsonDict
from src.trading.auto.state import AutoTradeState
from src.trading.journal.service import JournalService
class AutoMarketRuntimeMixin:
_last_logged_market_state: str | None
_last_logged_market_trend: str | None
_last_logged_market_volatility: str | None
_last_logged_entry_block_reason: str | None
_last_logged_entry_block_at: float | None = None
_entry_block_log_ttl_seconds: int = 900
# синхронизировать market analysis payload в AutoTradeState
def _sync_market_analysis_state(
self,
*,
state: AutoTradeState,
payload: JsonDict | None,
) -> None:
if not isinstance(payload, dict):
return
previous_market_state = state.market_state
previous_market_trend = state.market_trend
previous_market_volatility = state.market_volatility
state.market_state = str(payload.get("market_state") or "")
state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
state.market_trend_strength = str(payload.get("market_trend_strength") or "")
state.market_trend_quality = str(payload.get("market_trend_quality") or "")
state.market_phase = str(payload.get("market_phase") or "")
state.market_phase_direction = str(payload.get("market_phase_direction") or "")
state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
state.ema_distance_state = str(payload.get("ema_distance_state") or "")
state.entry_timing_state = str(payload.get("entry_timing_state") or "")
state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
state.ema_fast_slope_percent = safe_float(payload.get("ema_fast_slope_percent"))
state.ema_slow_slope_percent = safe_float(payload.get("ema_slow_slope_percent"))
state.candle_noise_score = safe_float(payload.get("candle_noise_score"))
state.price_position_score = safe_float(payload.get("price_position_score"))
state.htf_interval = str(payload.get("htf_interval") or "")
state.htf_atr_percent = safe_float(payload.get("htf_atr_percent"))
state.htf_atr_percent_baseline = safe_float(payload.get("htf_atr_percent_baseline"))
state.htf_volatility_ratio = safe_float(payload.get("htf_volatility_ratio"))
state.htf_volatility = str(payload.get("htf_volatility") or "")
state.market_analysis_interval = str(payload.get("interval") or payload.get("market_analysis_interval") or "")
state.market_analysis_reason = str(payload.get("reason") or payload.get("market_analysis_reason") or "")
state.momentum_state = str(payload.get("momentum_state") or "")
state.momentum_direction = str(payload.get("momentum_direction") or "")
state.momentum_change_percent = safe_float(payload.get("momentum_change_percent"))
state.momentum_strength = safe_float(payload.get("momentum_strength"))
state.breakout_level = safe_float(payload.get("breakout_level"))
state.breakout_distance_percent = safe_float(payload.get("breakout_distance_percent"))
state.breakout_reason = str(payload.get("breakout_reason") or "")
state.entry_block_reason = str(payload.get("entry_block_reason") or "")
state.entry_block_message = str(payload.get("entry_block_message") or "")
self._log_market_state_if_changed(
state=state,
payload=payload,
previous_market_state=previous_market_state,
previous_market_trend=previous_market_trend,
previous_market_volatility=previous_market_volatility,
)
self._log_entry_block_if_changed(
state=state,
payload=payload,
)
# записать entry-block событие, если причина изменилась или истёк TTL
def _log_entry_block_if_changed(
self,
*,
state: AutoTradeState,
payload: JsonDict,
) -> None:
reason = state.entry_block_reason
message = state.entry_block_message
if not reason or not message:
return
now = time.monotonic()
# status специально не входит в key:
# RUNNING / OBSERVING не должны создавать дубли одной и той же причины.
key = f"{state.symbol}:{state.strategy}:{reason}:{message}"
last_logged_at = type(self)._last_logged_entry_block_at
ttl_expired = (
last_logged_at is None
or now - last_logged_at >= type(self)._entry_block_log_ttl_seconds
)
if (
key == type(self)._last_logged_entry_block_reason
and not ttl_expired
):
return
type(self)._last_logged_entry_block_reason = key
type(self)._last_logged_entry_block_at = now
try:
JournalService().log_ui_info(
event_type="entry_blocked",
message=f"Вход в позицию не выполнен: {message}.",
screen="auto",
action="entry_diagnostics",
payload={
**payload,
"entry_block_reason": reason,
"entry_block_message": message,
"entry_block_key": key,
"entry_block_ttl_seconds": type(self)._entry_block_log_ttl_seconds,
"symbol": state.symbol,
"strategy": state.strategy,
"status": state.status,
"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,
"momentum_state": state.momentum_state,
"momentum_direction": state.momentum_direction,
"momentum_strength": state.momentum_strength,
"momentum_change_percent": state.momentum_change_percent,
"execution_quality": state.execution_quality,
"execution_quality_reason": state.execution_quality_reason,
"execution_confidence_score": state.execution_confidence_score,
"last_signal": state.last_signal,
"last_signal_confidence": state.last_signal_confidence,
"last_signal_reason": state.last_signal_reason,
},
)
except Exception:
pass
# записать market state / volatility событие, если состояние изменилось
def _log_market_state_if_changed(
self,
*,
state: AutoTradeState,
payload: JsonDict,
previous_market_state: str | None,
previous_market_trend: str | None,
previous_market_volatility: str | None,
) -> None:
market_state = state.market_state
market_trend = state.market_trend
market_volatility = state.market_volatility
if not market_state or market_state == "UNKNOWN":
return
state_changed = (
market_state != previous_market_state
and market_state != type(self)._last_logged_market_state
)
volatility_changed = (
market_volatility is not None
and market_volatility != previous_market_volatility
and market_volatility != type(self)._last_logged_market_volatility
)
if not state_changed and not volatility_changed:
return
journal_payload = {
**payload,
"previous_market_state": previous_market_state,
"previous_market_trend": previous_market_trend,
"previous_market_volatility": previous_market_volatility,
"current_market_state": market_state,
"current_market_trend": market_trend,
"current_market_volatility": market_volatility,
}
try:
if state_changed:
self._write_market_journal_event(
event_type="market_state_changed",
market_state=market_state,
message=self._market_state_message(market_state),
payload=journal_payload,
)
if volatility_changed:
self._write_market_journal_event(
event_type="market_volatility_changed",
market_state=market_state,
message=self._market_volatility_message(market_volatility),
payload=journal_payload,
)
except Exception:
pass
type(self)._last_logged_market_state = market_state
type(self)._last_logged_market_trend = market_trend
type(self)._last_logged_market_volatility = market_volatility
# записать market journal событие с нужным уровнем важности
def _write_market_journal_event(
self,
*,
event_type: str,
market_state: str,
message: str,
payload: JsonDict,
) -> None:
level = self._market_journal_level(market_state)
if level == "WARNING":
JournalService().log_ui_warning(
event_type=event_type,
message=message,
screen="auto",
action="market_analysis",
payload=payload,
)
return
JournalService().log_ui_info(
event_type=event_type,
message=message,
screen="auto",
action="market_analysis",
payload=payload,
)
# получить человекочитаемое сообщение по volatility
def _market_volatility_message(self, market_volatility: str | None) -> str:
messages = {
"LOW": "Волатильность изменена: низкая.",
"NORMAL": "Волатильность изменена: нормальная.",
"HIGH": "Волатильность изменена: высокая.",
}
return messages.get(str(market_volatility or ""), "Волатильность не определена.")
# определить уровень journal события для market state
def _market_journal_level(self, market_state: str | None) -> str:
if market_state == "HIGH_VOLATILITY":
return "WARNING"
return "INFO"
# получить человекочитаемое сообщение по market state
def _market_state_message(self, market_state: str) -> str:
messages = {
"TREND_UP": "Состояние рынка изменено: рост.",
"TREND_DOWN": "Состояние рынка изменено: снижение.",
"RANGE": "Состояние рынка изменено: нет выраженного направления.",
"HIGH_VOLATILITY": "Состояние рынка изменено: высокая волатильность.",
"LOW_VOLATILITY": "Состояние рынка изменено: низкая активность.",
}
return messages.get(market_state, "Состояние рынка анализируется.")

View File

@@ -1,318 +0,0 @@
# app/src/trading/auto/position_health.py
from __future__ import annotations
import time
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.trading.auto.state import AutoTradeState
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
return
pnl_percent = self._position_pnl_percent(state)
hold_seconds = self._position_hold_seconds(state)
trend_alignment = self._position_trend_alignment(state)
adverse_momentum = self._has_adverse_position_momentum(state)
pressure = self._position_pressure(
state=state,
pnl_percent=pnl_percent,
)
health_score = self._position_health_score(
state=state,
pnl_percent=pnl_percent,
trend_alignment=trend_alignment,
adverse_momentum=adverse_momentum,
)
risk_level, risk_reason = self._position_risk_level(
state=state,
pnl_percent=pnl_percent,
trend_alignment=trend_alignment,
adverse_momentum=adverse_momentum,
)
state.position_pnl_percent = pnl_percent
state.position_hold_seconds = hold_seconds
state.position_pressure = pressure
state.position_health_score = health_score
state.position_health_status = self._position_health_status(health_score)
state.position_health_reason = self._position_health_reason(
pressure=pressure,
trend_alignment=trend_alignment,
adverse_momentum=adverse_momentum,
)
state.position_risk_level = risk_level
state.position_risk_reason = risk_reason
state.position_trend_alignment = trend_alignment
state.position_adverse_momentum = adverse_momentum
state.position_exit_pressure = self._position_exit_pressure(
state=state,
pnl_percent=pnl_percent,
risk_level=risk_level,
)
# рассчитать PnL позиции в процентах от notional
def _position_pnl_percent(self, state: AutoTradeState) -> float | None:
entry_price = safe_float(state.entry_price)
size = safe_float(state.position_size)
pnl = safe_float(state.unrealized_pnl_usd)
if entry_price is None or entry_price <= 0:
return None
if size is None or size <= 0:
return None
if pnl is None:
return None
notional = entry_price * size
if notional <= 0:
return None
return round((pnl / notional) * 100, 4)
# рассчитать время удержания открытой позиции
def _position_hold_seconds(self, state: AutoTradeState) -> int | None:
opened_at = getattr(state, "position_opened_monotonic_at", None)
if opened_at is None:
return None
opened = safe_float(opened_at)
if opened is None:
return None
return max(0, int(time.monotonic() - opened))
# определить давление на позицию по PnL
def _position_pressure(
self,
*,
state: AutoTradeState,
pnl_percent: NumericLike | None,
) -> str:
pnl = safe_float(state.unrealized_pnl_usd) or 0.0
percent = safe_float(pnl_percent)
if percent is None:
if pnl < 0:
return "LOSS"
if pnl > 0:
return "PROFIT"
return "FLAT"
if percent <= -0.8:
return "HIGH_LOSS"
if percent <= -0.3:
return "LOSS"
if percent >= 0.8:
return "STRONG_PROFIT"
if percent >= 0.3:
return "PROFIT"
return "FLAT"
# определить alignment позиции относительно тренда
def _position_trend_alignment(self, state: AutoTradeState) -> str:
side = str(state.position_side or "NONE").upper()
market_state = str(state.market_state or "").upper()
trend = str(state.market_trend or "").upper()
if side == "NONE":
return "NONE"
if side == "LONG":
if market_state == "TREND_UP" or trend == "UP":
return "ALIGNED"
if market_state == "TREND_DOWN" or trend == "DOWN":
return "AGAINST"
if side == "SHORT":
if market_state == "TREND_DOWN" or trend == "DOWN":
return "ALIGNED"
if market_state == "TREND_UP" or trend == "UP":
return "AGAINST"
return "NEUTRAL"
# проверить, направлен ли momentum против позиции
def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool:
side = str(state.position_side or "NONE").upper()
momentum_direction = str(state.momentum_direction or "").upper()
momentum_state = str(state.momentum_state or "").upper()
if side == "LONG":
return (
momentum_direction == "DOWN"
or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
)
if side == "SHORT":
return (
momentum_direction == "UP"
or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
)
return False
# рассчитать health score позиции
def _position_health_score(
self,
*,
state: AutoTradeState,
pnl_percent: NumericLike | None,
trend_alignment: str,
adverse_momentum: bool,
) -> int:
score = 100
percent = safe_float(pnl_percent)
if percent is not None:
if percent <= -1.0:
score -= 35
elif percent <= -0.5:
score -= 22
elif percent < 0:
score -= 10
elif percent >= 0.8:
score += 5
if trend_alignment == "AGAINST":
score -= 25
elif trend_alignment == "NEUTRAL":
score -= 8
if adverse_momentum:
score -= 20
if state.execution_quality == "BLOCKED":
score -= 15
elif state.execution_quality == "WARNING":
score -= 8
if state.market_runtime_degraded:
score -= 10
return max(0, min(100, score))
# классифицировать health status по score
def _position_health_status(self, score: int | None) -> str:
if score is None:
return "UNKNOWN"
if score >= 80:
return "HEALTHY"
if score >= 55:
return "WATCH"
if score >= 35:
return "PRESSURE"
return "DANGER"
# сформировать человекочитаемую причину health состояния
def _position_health_reason(
self,
*,
pressure: str,
trend_alignment: str,
adverse_momentum: bool,
) -> str:
if trend_alignment == "AGAINST" and adverse_momentum:
return "тренд и momentum против позиции"
if trend_alignment == "AGAINST":
return "тренд против позиции"
if adverse_momentum:
return "momentum против позиции"
if pressure in {"HIGH_LOSS", "LOSS"}:
return "позиция под давлением"
if pressure in {"PROFIT", "STRONG_PROFIT"}:
return "позиция в прибыли"
return "позиция стабильна"
# определить runtime risk level позиции
def _position_risk_level(
self,
*,
state: AutoTradeState,
pnl_percent: NumericLike | None,
trend_alignment: str,
adverse_momentum: bool,
) -> tuple[str, str]:
percent = safe_float(pnl_percent)
if state.execution_quality == "BLOCKED":
return "HIGH", "исполнение заблокировано"
if percent is not None and percent <= -1.0:
return "HIGH", "сильная просадка позиции"
if trend_alignment == "AGAINST" and adverse_momentum:
return "HIGH", "рынок движется против позиции"
if percent is not None and percent < 0:
if trend_alignment == "AGAINST" or adverse_momentum:
return "ELEVATED", "убыток усиливается рыночным контекстом"
return "MODERATE", "позиция в минусе"
if adverse_momentum:
return "MODERATE", "momentum против позиции"
return "LOW", "критичных рисков нет"
# определить давление на выход из позиции
def _position_exit_pressure(
self,
*,
state: AutoTradeState,
pnl_percent: NumericLike | None,
risk_level: str,
) -> str:
percent = safe_float(pnl_percent)
if risk_level == "HIGH":
return "HIGH"
if risk_level == "ELEVATED":
return "WATCH"
if percent is not None and percent <= -0.5:
return "WATCH"
return "LOW"

View File

@@ -1,420 +0,0 @@
# app/src/trading/auto/position_intelligence.py
from __future__ import annotations
from src.core.numbers import safe_float
from src.trading.auto.state import AutoTradeState
class AutoPositionIntelligenceMixin:
# синхронизировать intelligence-состояние открытой позиции
def _sync_position_intelligence_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
return
lifecycle_stage = self._position_lifecycle_stage(state)
hold_quality = self._position_hold_quality(state)
decay_state = self._position_decay_state(state)
self._sync_advanced_position_analytics(
state=state,
lifecycle_stage=lifecycle_stage,
hold_quality=hold_quality,
decay_state=decay_state,
)
exit_confidence = self._position_exit_confidence(
state=state,
hold_quality=hold_quality,
decay_state=decay_state,
)
exit_signal = self._position_exit_signal(exit_confidence)
state.position_lifecycle_stage = lifecycle_stage
state.position_hold_quality = hold_quality
state.position_decay_state = decay_state
state.position_exit_confidence = exit_confidence
state.position_exit_signal = exit_signal
state.position_intelligence_reason = self._position_intelligence_reason(
state=state,
hold_quality=hold_quality,
decay_state=decay_state,
exit_signal=exit_signal,
)
state.position_recommended_action = self._position_recommended_action(
exit_signal
)
# определить lifecycle stage позиции по времени удержания
def _position_lifecycle_stage(self, state: AutoTradeState) -> str:
hold_seconds = state.position_hold_seconds
if hold_seconds is None:
return "UNKNOWN"
if hold_seconds < 60:
return "NEW"
if hold_seconds < 300:
return "ACTIVE"
if hold_seconds < 900:
return "MATURE"
return "AGED"
# определить качество удержания позиции
def _position_hold_quality(self, state: AutoTradeState) -> str:
health_status = str(state.position_health_status or "").upper()
pressure = str(state.position_pressure or "").upper()
trend_alignment = str(state.position_trend_alignment or "").upper()
if health_status == "DANGER":
return "BAD"
if pressure == "HIGH_LOSS":
return "BAD"
if trend_alignment == "AGAINST" and state.position_adverse_momentum:
return "BAD"
if health_status == "PRESSURE":
return "WEAK"
if pressure == "LOSS":
return "WEAK"
if pressure in {"PROFIT", "STRONG_PROFIT"} and trend_alignment == "ALIGNED":
return "GOOD"
if health_status == "HEALTHY":
return "GOOD"
return "NEUTRAL"
# определить тип ухудшения позиции
def _position_decay_state(self, state: AutoTradeState) -> str:
pressure = str(state.position_pressure or "").upper()
trend_alignment = str(state.position_trend_alignment or "").upper()
lifecycle = str(state.position_lifecycle_stage or "").upper()
if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum:
return "ACCELERATING_LOSS"
if trend_alignment == "AGAINST" and state.position_adverse_momentum:
return "CONTEXT_DECAY"
if pressure == "PROFIT" and state.position_adverse_momentum:
return "PROFIT_DECAY"
if lifecycle == "AGED" and pressure == "FLAT":
return "TIME_DECAY"
return "NONE"
# рассчитать confidence для выхода из позиции
def _position_exit_confidence(
self,
*,
state: AutoTradeState,
hold_quality: str,
decay_state: str,
) -> float:
score = 0.0
risk_level = str(state.position_risk_level or "").upper()
exit_pressure = str(state.position_exit_pressure or "").upper()
if risk_level == "HIGH":
score += 0.45
elif risk_level == "ELEVATED":
score += 0.30
elif risk_level == "MODERATE":
score += 0.15
if exit_pressure == "HIGH":
score += 0.30
elif exit_pressure == "WATCH":
score += 0.15
if hold_quality == "BAD":
score += 0.25
elif hold_quality == "WEAK":
score += 0.15
if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}:
score += 0.25
elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}:
score += 0.15
if state.execution_quality == "BLOCKED":
score += 0.10
return round(max(0.0, min(1.0, score)), 3)
# определить semantic exit signal по confidence
def _position_exit_signal(self, exit_confidence: float | None) -> str:
if exit_confidence is None:
return "NONE"
if exit_confidence >= 0.75:
return "EXIT"
if exit_confidence >= 0.50:
return "REDUCE_OR_PROTECT"
if exit_confidence >= 0.30:
return "WATCH"
return "HOLD"
# сформировать объяснение position intelligence
def _position_intelligence_reason(
self,
*,
state: AutoTradeState,
hold_quality: str,
decay_state: str,
exit_signal: str,
) -> str:
if exit_signal == "EXIT":
return "позиция требует выхода"
if exit_signal == "REDUCE_OR_PROTECT":
return "позицию нужно защитить или уменьшить"
if decay_state != "NONE":
return "качество удержания ухудшается"
if hold_quality == "GOOD":
return "позицию можно удерживать"
if hold_quality == "WEAK":
return "позиция требует наблюдения"
return "критичных признаков выхода нет"
# определить рекомендуемое действие по exit signal
def _position_recommended_action(self, exit_signal: str | None) -> str:
if exit_signal == "EXIT":
return "CLOSE"
if exit_signal == "REDUCE_OR_PROTECT":
return "PROTECT"
if exit_signal == "WATCH":
return "WATCH"
return "HOLD"
# синхронизировать advanced analytics позиции
def _sync_advanced_position_analytics(
self,
*,
state: AutoTradeState,
lifecycle_stage: str,
hold_quality: str,
decay_state: str,
) -> None:
pnl = safe_float(state.unrealized_pnl_usd)
pnl_percent = safe_float(state.position_pnl_percent)
peak_pnl = safe_float(state.position_peak_pnl_usd)
peak_pnl_percent = safe_float(state.position_peak_pnl_percent)
if pnl is not None:
if peak_pnl is None or pnl > peak_pnl:
state.position_peak_pnl_usd = pnl
if pnl_percent is not None:
if peak_pnl_percent is None or pnl_percent > peak_pnl_percent:
state.position_peak_pnl_percent = pnl_percent
state.position_mfe_percent = self._position_mfe_percent(state)
state.position_mae_percent = self._position_mae_percent(state)
state.position_giveback_percent = self._position_giveback_percent(state)
fatigue_score = self._position_fatigue_score(
state=state,
lifecycle_stage=lifecycle_stage,
hold_quality=hold_quality,
decay_state=decay_state,
)
state.position_fatigue_score = fatigue_score
state.position_fatigue_state = self._position_fatigue_state(fatigue_score)
state.position_conviction_state = self._position_conviction_state(state)
state.position_exit_urgency = self._position_exit_urgency(state)
state.position_reversal_risk = self._position_reversal_risk(state)
# рассчитать maximum favorable excursion позиции
def _position_mfe_percent(self, state: AutoTradeState) -> float | None:
peak = safe_float(state.position_peak_pnl_percent)
if peak is None:
return None
return round(max(0.0, peak), 4)
# рассчитать maximum adverse excursion позиции
def _position_mae_percent(self, state: AutoTradeState) -> float | None:
current = safe_float(state.position_pnl_percent)
if current is None:
return None
return round(min(0.0, current), 4)
# рассчитать процент отдачи прибыли от peak pnl
def _position_giveback_percent(self, state: AutoTradeState) -> float | None:
peak = safe_float(state.position_peak_pnl_percent)
current = safe_float(state.position_pnl_percent)
if peak is None or current is None:
return None
if peak <= 0:
return 0.0
giveback = peak - current
if giveback <= 0:
return 0.0
return round((giveback / peak) * 100, 2)
# рассчитать fatigue score позиции
def _position_fatigue_score(
self,
*,
state: AutoTradeState,
lifecycle_stage: str,
hold_quality: str,
decay_state: str,
) -> float:
score = 0.0
giveback = safe_float(state.position_giveback_percent) or 0.0
hold_seconds = safe_float(state.position_hold_seconds) or 0.0
if lifecycle_stage == "AGED":
score += 0.25
elif lifecycle_stage == "MATURE":
score += 0.15
if hold_quality == "BAD":
score += 0.30
elif hold_quality == "WEAK":
score += 0.18
if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}:
score += 0.30
elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}:
score += 0.18
if giveback >= 70:
score += 0.30
elif giveback >= 45:
score += 0.20
elif giveback >= 25:
score += 0.10
if hold_seconds >= 1800:
score += 0.15
elif hold_seconds >= 900:
score += 0.08
if state.position_adverse_momentum:
score += 0.15
return round(max(0.0, min(1.0, score)), 3)
# определить fatigue state позиции
def _position_fatigue_state(self, score: float | None) -> str:
value = safe_float(score)
if value is None:
return "UNKNOWN"
if value >= 0.75:
return "EXHAUSTED"
if value >= 0.50:
return "TIRED"
if value >= 0.25:
return "WATCH"
return "FRESH"
# определить conviction state позиции
def _position_conviction_state(self, state: AutoTradeState) -> str:
health = str(state.position_health_status or "").upper()
fatigue = str(state.position_fatigue_state or "").upper()
alignment = str(state.position_trend_alignment or "").upper()
if health == "DANGER" or fatigue == "EXHAUSTED":
return "BROKEN"
if alignment == "AGAINST" or fatigue == "TIRED":
return "WEAKENING"
if health == "HEALTHY" and alignment == "ALIGNED":
return "STRONG"
return "NEUTRAL"
# определить срочность выхода из позиции
def _position_exit_urgency(self, state: AutoTradeState) -> str:
exit_signal = str(state.position_exit_signal or "").upper()
fatigue = str(state.position_fatigue_state or "").upper()
risk = str(state.position_risk_level or "").upper()
if exit_signal == "EXIT" or risk == "HIGH":
return "IMMEDIATE"
if fatigue == "EXHAUSTED":
return "HIGH"
if exit_signal == "REDUCE_OR_PROTECT" or fatigue == "TIRED":
return "MEDIUM"
if exit_signal == "WATCH":
return "LOW"
return "NONE"
# определить риск разворота позиции
def _position_reversal_risk(self, state: AutoTradeState) -> str:
giveback = safe_float(state.position_giveback_percent) or 0.0
fatigue = str(state.position_fatigue_state or "").upper()
adverse = bool(state.position_adverse_momentum)
if adverse and giveback >= 45:
return "HIGH"
if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25:
return "ELEVATED"
if adverse:
return "MODERATE"
return "LOW"

View File

@@ -1,841 +0,0 @@
# app/src/trading/auto/runner.py
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from typing import ClassVar
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
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.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.telegram.handlers.auto.ui import build_auto_notification_text
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
_task: ClassVar[asyncio.Task | None] = None
_bot: ClassVar[Bot | None] = None
_chat_id: ClassVar[int | None] = None
_message_id: ClassVar[int | None] = None
_render_text: ClassVar[staticmethod | None] = None
_render_markup: ClassVar[staticmethod | None] = None
_current_screen: ClassVar[str | None] = None
_analysis_interval_seconds = 5
_ui_interval_seconds = 30
_last_text: ClassVar[str | None] = None
_last_semantic_text: ClassVar[str | None] = None
_last_ui_refresh_at: ClassVar[float] = 0.0
_last_event_version: ClassVar[int] = 0
_retry_after_until: ClassVar[float] = 0.0
_last_screen_state_key: ClassVar[str | None] = None
_position_aligned_signal_log_interval_seconds = 900
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
@classmethod
def register_screen(
cls,
*,
bot: Bot,
chat_id: int,
message_id: int,
render_text: Callable[[], str],
render_markup: Callable[[], object],
) -> None:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._render_text = staticmethod(render_text)
cls._render_markup = staticmethod(render_markup)
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
chat_id=chat_id,
)
@classmethod
async def delete_registered_screen(
cls,
*,
bot: Bot,
chat_id: int,
) -> None:
if cls._chat_id is None or cls._message_id is None:
return
if cls._chat_id != chat_id:
return
try:
await bot.delete_message(
chat_id=cls._chat_id,
message_id=cls._message_id,
)
except Exception:
pass
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
def unregister_screen(
cls,
*,
chat_id: int,
message_id: int,
) -> None:
if cls._chat_id != chat_id or cls._message_id != message_id:
return
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
async def detach_screen(
cls,
*,
delete_message: bool = False,
bot: Bot | None = None,
chat_id: int | None = None,
keep_message_id: int | None = None,
) -> None:
if (
delete_message
and bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._chat_id == chat_id
and cls._message_id != keep_message_id
):
try:
await bot.delete_message(
chat_id=cls._chat_id,
message_id=cls._message_id,
)
except Exception:
pass
cls._bot = None
cls._chat_id = None
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._current_screen = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
def set_current_screen(cls, screen: str) -> None:
cls._current_screen = screen
@classmethod
def start(cls) -> None:
service = AutoTradeService()
MarketDataRunner.start(
symbol_provider=lambda: service.get_state().symbol,
interval_seconds=1,
runtime_key="auto",
screen="auto",
action="market_data",
runtime_label="[AUTO]",
)
if cls._task is not None and not cls._task.done():
return
cls._task = asyncio.create_task(cls._worker())
@classmethod
def stop(cls) -> None:
MarketDataRunner.stop("auto")
if cls._task is None:
return
cls._task.cancel()
cls._task = None
@classmethod
async def _worker(cls) -> None:
service = AutoTradeService()
while True:
state = service.get_state()
if state.status == "OFF":
cls._task = None
MarketDataRunner.stop("auto")
break
try:
service.run_cycle()
except Exception as exc:
cls._log_refresh_error(
"auto_run_cycle_error",
{
"error": str(exc),
"error_type": type(exc).__name__,
"symbol": state.symbol,
"strategy": state.strategy,
"status": state.status,
},
)
state = service.get_state()
current_event_version = EventBus.version()
has_important_event = current_event_version != cls._last_event_version
screen_state_key = cls._screen_state_key(state)
has_screen_state_changed = screen_state_key != cls._last_screen_state_key
if has_screen_state_changed:
cls._last_screen_state_key = screen_state_key
force_refresh = False
if has_important_event:
cls._last_event_version = current_event_version
event_type, _ = EventBus.last_event()
force_refresh = event_type in {
"paper_position_opened",
"paper_position_closed",
"paper_position_flipped",
}
try:
await cls._handle_important_event(state)
except Exception as exc:
cls._log_refresh_error(
"auto_event_handler_error",
{
"error": str(exc),
"error_type": type(exc).__name__,
},
)
try:
await cls._refresh_screen(
force=force_refresh or has_screen_state_changed
)
except Exception as exc:
cls._log_refresh_error(
"auto_refresh_loop_error",
{
"error": str(exc),
"error_type": type(exc).__name__,
},
)
await asyncio.sleep(cls._analysis_interval_seconds)
@classmethod
async def process_last_event_now(cls) -> None:
state = AutoTradeService().get_state()
await cls._handle_important_event(state)
@classmethod
async def _handle_important_event(
cls,
state,
) -> None:
event_type, payload = EventBus.last_event()
if not isinstance(payload, dict):
payload = {}
if event_type == "auto_decision_changed":
if payload.get("decision_status") != "READY":
return
signal = str(payload.get("signal", "")).upper()
if signal not in {"BUY", "SELL"}:
return
# Если сигнал совпадает с открытой позицией, не публикуем событие,
# чтобы не создавать избыточные уведомления
#if cls._is_position_aligned_signal(state=state, signal=signal):
# cls._log_position_aligned_signal_suppressed(
# state=state,
# payload=payload,
# signal=signal,
# )
# return
cls._publish_strong_signal_event(state=state, payload=payload)
return
if event_type in {
"paper_position_opened",
"paper_position_closed",
"paper_position_flipped",
"paper_flip_blocked",
}:
cls._publish_execution_event(
state=state,
event_type=str(event_type),
payload=payload,
)
return
@classmethod
def _notification_reason_lines(
cls,
state,
) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=True,
)
return SemanticDiagnosticFormatter().build_notification_reason_lines(
snapshot,
limit=2,
)
@classmethod
def _is_position_aligned_signal(cls, *, state, signal: str) -> bool:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
if position_side == "LONG" and signal == "BUY":
return True
if position_side == "SHORT" and signal == "SELL":
return True
return False
@classmethod
def _log_position_aligned_signal_suppressed(
cls,
*,
state,
payload: JsonDict,
signal: str,
) -> None:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
confidence = safe_float(
payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
log_key = (
f"{position_side}:"
f"{symbol}:"
f"{strategy}:"
f"{signal}"
)
now = time.monotonic()
last_logged_at = cls._last_position_aligned_signal_log_at_by_key.get(log_key)
if (
last_logged_at is not None
and now - last_logged_at < cls._position_aligned_signal_log_interval_seconds
):
return
cls._last_position_aligned_signal_log_at_by_key[log_key] = now
try:
JournalService().log_ui_info(
event_type="auto_position_aligned_signal_suppressed",
message=(
f"Сигнал {signal} совпадает с открытой позицией "
f"{position_side}; уведомление подавлено."
),
screen="auto",
action="signal_notification",
payload={
"symbol": symbol,
"strategy": strategy,
"signal": signal,
"position_side": position_side,
"confidence": confidence,
"repeat_count": repeat_count,
"reason": payload.get("reason") or state.last_signal_reason,
"suppression_interval_seconds": cls._position_aligned_signal_log_interval_seconds,
},
)
except Exception:
pass
@classmethod
def _signal_price_payload(
cls,
*,
state,
payload: JsonDict,
signal: str,
) -> JsonDict:
bid_price = safe_float(payload.get("bid_price"))
ask_price = safe_float(payload.get("ask_price"))
last_price = safe_float(payload.get("last_price"))
if bid_price is None:
bid_price = safe_float(getattr(state, "execution_bid_price", None))
if ask_price is None:
ask_price = safe_float(getattr(state, "execution_ask_price", None))
if last_price is None:
last_price = safe_float(getattr(state, "execution_last_price", None))
signal_price = None
signal_price_role = "last"
if signal == "BUY":
signal_price = ask_price or last_price or bid_price
signal_price_role = "ask"
elif signal == "SELL":
signal_price = bid_price or last_price or ask_price
signal_price_role = "bid"
else:
signal_price = last_price or ask_price or bid_price
return {
"bid_price": bid_price,
"ask_price": ask_price,
"last_price": last_price,
"signal_price": signal_price,
"signal_price_role": signal_price_role,
"signal_price_source": (
payload.get("price_source")
or getattr(state, "execution_price_source", None)
),
"signal_price_age_seconds": (
payload.get("price_age_seconds")
or getattr(state, "execution_price_age_seconds", None)
),
}
@classmethod
def _publish_strong_signal_event(
cls,
*,
state,
payload: JsonDict,
) -> None:
signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
confidence = safe_float(payload.get("confidence"))
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
leverage = (
payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
)
reason = str(payload.get("reason") or state.last_signal_reason or "")
position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper()
is_aligned_signal = cls._is_position_aligned_signal(
state=state,
signal=signal,
)
price_payload = cls._signal_price_payload(
state=state,
payload=payload,
signal=signal,
)
semantic_lines = cls._notification_reason_lines(state)
priority = cls._alert_priority(
confidence=confidence,
repeat_count=repeat_count,
)
RuntimeEventPublisher.publish(
RuntimeEvent(
event_type=RuntimeEventType.AUTO_SIGNAL_READY,
source="auto_trade_runner",
title=f"Auto strong signal {signal}",
payload={
"symbol": symbol,
"strategy": strategy,
"signal": signal,
"repeat_count": repeat_count,
"confidence": confidence,
"leverage": leverage,
"reason": reason,
"position_context": position_context,
"position_side": position_context,
"is_position_aligned_signal": is_aligned_signal,
"decision_status": state.decision_status,
"semantic_lines": semantic_lines,
**price_payload,
},
priority=priority.lower(),
dedupe_key=(
f"auto_signal_ready:"
f"{position_context}:"
f"{symbol}:"
f"{strategy}:"
f"{signal}:"
f"{repeat_count}:"
f"{confidence:.2f}:"
f"{state.decision_status}:"
f"{reason}:"
f"aligned={is_aligned_signal}"
),
)
)
@classmethod
def _publish_execution_event(
cls,
*,
state,
event_type: str,
payload: JsonDict,
) -> None:
runtime_event_type = cls._runtime_execution_event_type(event_type)
if runtime_event_type is None:
return
symbol = str(payload.get("symbol") or state.symbol or "")
side = str(payload.get("side") or getattr(state, "position_side", "") or "")
old_side = str(payload.get("old_side") or "")
new_side = str(payload.get("new_side") or side or "")
semantic_lines = cls._notification_reason_lines(state)
RuntimeEventPublisher.publish(
RuntimeEvent(
event_type=runtime_event_type,
source="auto_trade_runner",
title=cls._execution_event_title(runtime_event_type),
payload={
**payload,
"source_event_type": event_type,
"symbol": symbol,
"side": side,
"old_side": old_side,
"new_side": new_side,
"leverage": (
payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
),
"strategy": state.strategy,
"semantic_lines": semantic_lines,
},
priority="normal",
dedupe_key=cls._execution_dedupe_key(
runtime_event_type=runtime_event_type,
payload=payload,
),
)
)
@classmethod
def _runtime_execution_event_type(cls, event_type: str) -> RuntimeEventType | None:
mapping = {
"paper_position_opened": RuntimeEventType.POSITION_OPENED,
"paper_position_closed": RuntimeEventType.POSITION_CLOSED,
"paper_position_flipped": RuntimeEventType.POSITION_FLIPPED,
"paper_flip_blocked": RuntimeEventType.POSITION_FLIP_BLOCKED,
}
return mapping.get(event_type)
@classmethod
def _execution_event_title(cls, event_type: RuntimeEventType) -> str:
mapping = {
RuntimeEventType.POSITION_OPENED: "Paper position opened",
RuntimeEventType.POSITION_CLOSED: "Paper position closed",
RuntimeEventType.POSITION_FLIPPED: "Paper position flipped",
RuntimeEventType.POSITION_FLIP_BLOCKED: "Flip blocked",
}
return mapping.get(event_type, "Paper execution event")
@classmethod
def _execution_dedupe_key(
cls,
*,
runtime_event_type: RuntimeEventType,
payload: JsonDict,
) -> str:
return (
f"{runtime_event_type.value}:"
f"{payload.get('symbol')}:"
f"{payload.get('side')}:"
f"{payload.get('old_side')}:"
f"{payload.get('new_side')}:"
f"{payload.get('entry_price')}:"
f"{payload.get('exit_price')}:"
f"{payload.get('new_entry_price')}:"
f"{payload.get('size')}:"
f"{payload.get('old_size')}:"
f"{payload.get('new_size')}:"
f"{payload.get('pnl')}:"
f"{payload.get('risk_reason')}:"
f"{payload.get('is_forced')}"
)
@classmethod
def _alert_priority(
cls,
*,
confidence: NumericLike,
repeat_count: int,
) -> str:
confidence_value = safe_float(confidence) or 0.0
if confidence_value >= 0.8 and repeat_count >= 3:
return "HIGH"
if confidence_value >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
@classmethod
def _log_refresh_skip(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
return
@classmethod
def _log_refresh_success(
cls,
payload: JsonDict | None = None,
) -> None:
return
@classmethod
def _log_refresh_error(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
try:
JournalService().log_error(
"auto_screen_refresh_error",
f"Auto screen refresh error: {reason}",
payload or {},
)
except Exception:
pass
@classmethod
def _screen_state_key(
cls,
state,
) -> str:
return "|".join(
str(value)
for value in [
getattr(state, "status", None),
getattr(state, "symbol", None),
getattr(state, "strategy", None),
getattr(state, "last_signal", None),
#getattr(state, "last_signal_repeat_count", None),
#getattr(state, "last_signal_confidence", None),
getattr(state, "decision_status", None),
getattr(state, "decision_reason", None),
getattr(state, "market_state", None),
getattr(state, "market_trend", None),
getattr(state, "market_volatility", None),
getattr(state, "market_trend_strength", None),
getattr(state, "market_trend_quality", None),
getattr(state, "market_phase", None),
getattr(state, "market_phase_direction", None),
getattr(state, "entry_block_reason", None),
getattr(state, "entry_block_message", None),
getattr(state, "execution_quality", None),
getattr(state, "execution_quality_reason", None),
getattr(state, "execution_semantic_status", None),
getattr(state, "execution_semantic_message", None),
getattr(state, "execution_confidence_score", None),
getattr(state, "adaptive_size_multiplier", None),
getattr(state, "adaptive_size_reason", None),
getattr(state, "effective_target_risk_usd", None),
getattr(state, "position_side", None),
getattr(state, "entry_price", None),
getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None),
getattr(state, "cycle_closed_trades", None),
getattr(state, "cycle_realized_pnl_usd", None),
getattr(state, "cycle_winning_trades", None),
getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None),
]
)
@classmethod
async def _refresh_screen(cls, *, force: bool = False) -> None:
now = time.monotonic()
if now < cls._retry_after_until:
cls._log_refresh_skip(
"retry_after_active",
{"retry_after_until": cls._retry_after_until, "now": now},
)
return
if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds:
cls._log_refresh_skip(
"ui_interval_not_reached",
{
"elapsed": round(now - cls._last_ui_refresh_at, 2),
"interval": cls._ui_interval_seconds,
},
)
return
if not all(
[
cls._bot,
cls._chat_id,
cls._message_id,
cls._render_text,
cls._render_markup,
]
):
cls._log_refresh_skip(
"screen_not_registered",
{
"has_bot": cls._bot is not None,
"chat_id": cls._chat_id,
"message_id": cls._message_id,
"has_render_text": cls._render_text is not None,
"has_render_markup": cls._render_markup is not None,
},
)
return
render_text = cls._render_text
render_markup = cls._render_markup
bot = cls._bot
if (
render_text is None
or render_markup is None
or bot is None
):
return
text = render_text()
semantic_text = build_auto_notification_text()
if semantic_text == cls._last_semantic_text:
cls._log_refresh_skip("text_not_changed")
return
try:
await bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
text=text,
reply_markup=render_markup(),
)
cls._last_text = text
cls._last_semantic_text = semantic_text
cls._last_ui_refresh_at = now
cls._log_refresh_success(
{
"chat_id": cls._chat_id,
"message_id": cls._message_id,
"text_length": len(text),
}
)
except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 15
cls._last_ui_refresh_at = time.monotonic()
return
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message is not modified" in error_text:
cls._last_text = text
cls._last_semantic_text = semantic_text
cls._last_ui_refresh_at = now
cls._log_refresh_skip("telegram_message_not_modified")
return
if "message to edit not found" in error_text:
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._log_refresh_error(
"telegram_message_to_edit_not_found",
{"error": str(exc)},
)
return
cls._log_refresh_error(
"telegram_bad_request",
{"error": str(exc)},
)
except Exception as exc:
cls._log_refresh_error(
"unexpected_refresh_error",
{"error": str(exc)},
)

View File

@@ -1,188 +0,0 @@
# app/src/trading/auto/service.py
from __future__ import annotations
import asyncio
from src.core.types import JsonDict
from src.trading.auto.auto_lifecycle import AutoLifecycleMixin
from src.trading.auto.state import AutoTradeState
class AutoTradeService(AutoLifecycleMixin):
# =========================================================
# GLOBAL SERVICE STATE
# =========================================================
# единый runtime state автоторговли
# хранит:
# - сигналы
# - market context
# - execution context
# - pnl
# - lifecycle
# - protection state
_state = AutoTradeState()
# background asyncio loop task
# нужен для continuous auto-trading worker
# None -> loop не запущен
_loop_task: asyncio.Task | None = None
# интервал между auto-trading циклами
# run_cycle() вызывается каждые N секунд
_loop_interval_seconds = 5
# =========================================================
# SIGNAL CONFIRMATION ENGINE
# =========================================================
# минимальное количество одинаковых BUY/SELL подряд
# чтобы сигнал считался подтвержденным
_confirm_repeats = 2
# минимальное время удержания сигнала
# перед execution
_confirm_min_duration_seconds = 10
# =========================================================
# EXECUTION CONFIDENCE RULES
# =========================================================
# минимальный confidence для READY state
# ниже -> сигнал не считается готовым
_ready_confidence = 0.3
# минимальный execution confidence
# для реального допуска execution engine
_execution_confidence_required_score = 0.55
# =========================================================
# RUNTIME TTL
# =========================================================
# время жизни signal runtime
# после ttl сигнал считается устаревшим
_signal_ttl_seconds = 90
# время жизни market analysis runtime
# после ttl market context считается stale
_market_analysis_ttl_seconds = 180
# последний logged runtime expiration key
# нужен чтобы не спамить одинаковыми логами
_last_logged_runtime_expired_key: str | None = None
# =========================================================
# SIGNAL MEMORY
# =========================================================
# уникальный ключ последнего сигнала
# используется для deduplication
_last_signal_key: str | None = None
# последнее signal значение:
# BUY / SELL / HOLD
_last_signal_value: str | None = None
# последнее объяснение сигнала
_last_signal_reason: str = ""
# confidence последнего сигнала
_last_signal_confidence: float = 0.0
# полный payload последнего signal анализа
# содержит market metrics / indicators / runtime info
_last_signal_payload: JsonDict | None = None
# monotonic timestamp начала сигнала
# нужен для confirmation timing
_last_signal_started_at: float | None = None
# =========================================================
# MARKET STATE LOG MEMORY
# =========================================================
# последние logged market states
# нужны чтобы не дублировать одинаковые runtime logs
_last_logged_market_state: str | None = None
_last_logged_market_trend: str | None = None
_last_logged_market_volatility: str | None = None
# последнее logged reason блокировки входа
_last_logged_entry_block_reason: str | None = None
# количество одинаковых сигналов подряд
# используется confirmation engine
_same_signal_count = 0
# =========================================================
# EXECUTION SNAPSHOT VALIDATION
# =========================================================
# максимальный допустимый возраст execution snapshot
# старше -> snapshot stale
_max_snapshot_age_seconds = 5.0
# warning threshold snapshot age
# выше -> degraded execution quality
_warning_snapshot_age_seconds = 2.0
# =========================================================
# SPREAD RISK THRESHOLDS
# =========================================================
# asset-specific spread thresholds
#
# warning_enter:
# warning при входе
#
# warning_exit:
# warning при выходе
#
# block_enter:
# полный блок входа
#
# block_exit:
# полный блок выхода
_spread_thresholds_by_asset: dict[str, dict[str, float]] = {
"BTC": {
"warning_enter": 0.08,
"warning_exit": 0.06,
"block_enter": 0.15,
"block_exit": 0.12,
},
"ETH": {
"warning_enter": 0.10,
"warning_exit": 0.08,
"block_enter": 0.18,
"block_exit": 0.15,
},
"LTC": {
"warning_enter": 0.18,
"warning_exit": 0.14,
"block_enter": 0.35,
"block_exit": 0.28,
},
"XRP": {
"warning_enter": 0.20,
"warning_exit": 0.16,
"block_enter": 0.40,
"block_exit": 0.32,
},
}
# default spread thresholds
# используются если asset не найден
_default_spread_thresholds: dict[str, float] = {
"warning_enter": 0.12,
"warning_exit": 0.09,
"block_enter": 0.25,
"block_exit": 0.20,
}
# последний logged execution quality key
# нужен чтобы не спамить одинаковыми warning/block logs
_last_logged_execution_quality_key: str | None = None

View File

@@ -1,814 +0,0 @@
# app/src/trading/auto/signal_runtime.py
from __future__ import annotations
import time
from typing import Callable, cast
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.trading.auto.state import AutoTradeState
from src.trading.journal.service import JournalService
class AutoSignalRuntimeMixin:
_loop_interval_seconds: int
_confirm_repeats: int
_confirm_min_duration_seconds: int
_ready_confidence: float
_execution_confidence_required_score: float
_signal_ttl_seconds: int
_market_analysis_ttl_seconds: int
_last_logged_runtime_expired_key: str | None
_last_signal_key: str | None
_last_signal_value: str | None
_last_signal_reason: str
_last_signal_confidence: float
_last_signal_payload: JsonDict | None
_last_signal_started_at: float | None
_same_signal_count: int
# получить state из основного AutoTradeService
def get_state(self) -> AutoTradeState:
raise NotImplementedError
# сбросить runtime tracking в основном AutoTradeService
def _reset_signal_tracking(self) -> None:
raise NotImplementedError
# debug: принудительно выставить сигнал и decision
def debug_force_signal(
self,
*,
signal: str,
confidence: NumericLike = 0.9,
repeat_count: int = 2,
reason: str = "DEBUG SIGNAL",
) -> AutoTradeState:
state = self.get_state()
confidence_value = safe_float(confidence) or 0.0
normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
normalized_signal = "HOLD"
previous_signal = state.last_signal
previous_decision_status = state.decision_status
if previous_signal != normalized_signal or state.signal_started_at is None:
state.signal_started_at = time.monotonic()
state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count
state.last_signal_confidence = confidence_value
state.last_signal_reason = reason
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_missing_repeats = 0
state.signal_confirmation_progress = 1.0
state.signal_confirmation_reason = "debug confirmation"
if normalized_signal == "HOLD":
state.decision_status = "WAITING"
state.decision_reason = "Debug HOLD."
state.is_signal_confirmed = False
state.is_signal_ready = False
else:
state.decision_status = "READY"
state.decision_reason = "Debug READY signal."
state.is_signal_confirmed = True
state.is_signal_ready = True
signal_intent = self._signal_intent(
state=state,
signal=state.last_signal,
)
EventBus.emit(
"auto_decision_changed",
{
"previous_signal": previous_signal,
"previous_decision_status": previous_decision_status,
"decision_status": state.decision_status,
"signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence,
"symbol": state.symbol,
"strategy": state.strategy,
"leverage": state.leverage,
"reason": state.last_signal_reason,
"debug": True,
},
)
return state
# определить смысл сигнала с учетом открытой позиции
def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str:
normalized_signal = (signal or "HOLD").upper()
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
if normalized_signal == "HOLD":
return "HOLD_MARKET"
if normalized_signal not in {"BUY", "SELL"}:
return "NOISE"
if position_side == "NONE":
return "ENTRY_CANDIDATE"
if position_side == "LONG" and normalized_signal == "BUY":
return "REINFORCE_POSITION"
if position_side == "SHORT" and normalized_signal == "SELL":
return "REINFORCE_POSITION"
if position_side == "LONG" and normalized_signal == "SELL":
return "REVERSAL_CANDIDATE"
if position_side == "SHORT" and normalized_signal == "BUY":
return "REVERSAL_CANDIDATE"
return "NOISE"
# обновить статус решения по текущему сигналу
def _update_decision_state(
self,
*,
state: AutoTradeState,
signal: str,
confidence: float,
) -> None:
state.is_signal_confirmed = False
state.is_signal_ready = False
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
if signal == "HOLD":
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.decision_status = "WAITING"
state.decision_reason = "Нет торгового направления."
return
now = time.monotonic()
if state.signal_started_at is None:
signal_age_seconds = 0
else:
signal_started = safe_float(state.signal_started_at)
signal_age_seconds = (
max(0, int(now - signal_started))
if signal_started is not None
else 0
)
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
missing_seconds = max(
0,
self._confirm_min_duration_seconds - signal_age_seconds,
)
repeat_progress = min(
1.0,
self._same_signal_count / max(1, self._confirm_repeats),
)
time_progress = min(
1.0,
signal_age_seconds / max(1, self._confirm_min_duration_seconds),
)
confirmation_progress = min(repeat_progress, time_progress)
state.signal_confirmation_seconds = signal_age_seconds
state.signal_confirmation_missing_repeats = missing_repeats
state.signal_confirmation_progress = round(confirmation_progress, 3)
if missing_repeats > 0 or missing_seconds > 0:
state.decision_status = "CONFIRMING"
state.signal_confirmation_reason = (
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с"
)
state.decision_reason = (
f"Сигнал {signal} подтверждается: "
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с."
)
return
state.is_signal_confirmed = True
state.signal_confirmation_reason = "сигнал подтверждён"
if confidence < self._ready_confidence:
state.decision_status = "BLOCKED"
state.decision_reason = (
f"Сигнал {signal} подтверждён, но уверенность низкая: "
f"{confidence:.2f} < {self._ready_confidence:.2f}."
)
return
self._sync_execution_confidence_state(
state=state,
signal=signal,
confidence=confidence,
)
if (
state.execution_confidence_score is not None
and state.execution_confidence_score < self._execution_confidence_required_score
):
state.decision_status = "BLOCKED"
state.decision_reason = (
f"Execution confidence низкий: "
f"{state.execution_confidence_score:.2f} < "
f"{self._execution_confidence_required_score:.2f}."
)
return
state.is_signal_ready = True
state.signal_confirmation_progress = 1.0
state.decision_status = "READY"
state.decision_reason = (
f"Сигнал {signal} подтверждён по повторам и времени удержания."
)
# записать новый сигнал и итог предыдущей серии при смене сигнала
def _log_signal_if_changed(
self,
*,
strategy_name: str,
state: AutoTradeState,
signal: str,
reason: str,
confidence: float,
payload: JsonDict | None,
) -> None:
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value
previous_count = self._same_signal_count
is_same_signal = signal_key == self._last_signal_key
now = time.monotonic()
if is_same_signal:
self._same_signal_count += 1
self._last_signal_reason = reason
self._last_signal_confidence = confidence
self._last_signal_payload = payload
self._update_signal_state_fields(
state=state,
signal=signal,
reason=reason,
confidence=confidence,
)
return
if previous_signal is not None and previous_signal != signal:
if previous_count > 1:
self._log_signal_summary(
strategy_name=strategy_name,
state=state,
previous_signal=previous_signal,
previous_count=previous_count,
next_signal=signal,
reason=self._last_signal_reason,
confidence=self._last_signal_confidence,
payload=self._last_signal_payload,
duration_seconds=self._signal_duration_seconds(now=now),
)
else:
self._log_signal_event(
strategy_name=strategy_name,
state=state,
signal=previous_signal,
reason=f"{previous_signal} завершился без серии.",
confidence=self._last_signal_confidence,
payload={
"previous_signal": previous_signal,
"next_signal": signal,
},
)
self._last_signal_key = signal_key
self._last_signal_value = signal
self._last_signal_reason = reason
self._last_signal_confidence = confidence
self._last_signal_payload = payload
self._last_signal_started_at = now
self._same_signal_count = 1
self._update_signal_state_fields(
state=state,
signal=signal,
reason=reason,
confidence=confidence,
)
# рассчитать длительность текущей серии сигналов
def _signal_duration_seconds(self, *, now: float) -> int:
if self._last_signal_started_at is None:
return max(0, int(self._same_signal_count * self._loop_interval_seconds))
return max(0, int(now - self._last_signal_started_at))
# отформатировать длительность для журнала
def _format_duration(self, total_seconds: int) -> str:
total_seconds = max(0, int(total_seconds))
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}ч {minutes:02d}м {seconds:02d}с"
if minutes > 0:
return f"{minutes}м {seconds:02d}с"
return f"{seconds}с"
# обновить поля state для экрана автоторговли
def _update_signal_state_fields(
self,
*,
state: AutoTradeState,
signal: str,
reason: str,
confidence: float,
) -> None:
previous_signal = state.last_signal
previous_decision_status = state.decision_status
if previous_signal != signal or state.signal_started_at is None:
state.signal_started_at = time.monotonic()
state.last_signal = signal
state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = confidence
state.last_signal_reason = reason
state.signal_updated_at = time.monotonic()
state.runtime_expired_reason = None
state.runtime_expired_message = None
self._update_decision_state(
state=state,
signal=signal,
confidence=confidence,
)
signal_intent = self._signal_intent(
state=state,
signal=state.last_signal,
)
if (
previous_decision_status != state.decision_status
and state.decision_status == "READY"
):
self._log_ready_signal(
state=state,
signal=state.last_signal,
reason=state.last_signal_reason or reason,
confidence=state.last_signal_confidence,
signal_intent=signal_intent,
)
if previous_signal != state.last_signal:
EventBus.emit(
"auto_signal_changed",
{
"previous_signal": previous_signal,
"signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence,
},
)
if previous_decision_status != state.decision_status:
EventBus.emit(
"auto_decision_changed",
{
"previous_decision_status": previous_decision_status,
"decision_status": state.decision_status,
"signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence,
"symbol": state.symbol,
"strategy": state.strategy,
"leverage": state.leverage,
"reason": state.last_signal_reason,
},
)
# одиночные BUY / SELL больше не пишем в журнал как полезные события
def _log_signal_event(
self,
*,
strategy_name: str,
state: AutoTradeState,
signal: str,
reason: str,
confidence: float,
payload: JsonDict | None,
) -> None:
return
# записать итог серии одинаковых сигналов при смене сигнала
def _log_signal_summary(
self,
*,
strategy_name: str,
state: AutoTradeState,
previous_signal: str,
previous_count: int,
next_signal: str,
reason: str,
confidence: float,
payload: JsonDict | None,
duration_seconds: int,
) -> None:
if previous_signal != "HOLD":
return
duration_text = self._format_duration(duration_seconds)
signal_intent = "HOLD_MARKET"
try:
JournalService().log_ui_info(
event_type="signal_summary",
message=(
f"HOLD длился {duration_text} и завершился сигналом {next_signal}."
),
screen="auto",
action="signal_summary",
payload={
"strategy": strategy_name,
"status": state.status,
"symbol": state.symbol,
"signal": previous_signal,
"next_signal": next_signal,
"signal_intent": signal_intent,
"repeat_count": previous_count,
"duration_seconds": duration_seconds,
"duration_text": duration_text,
"confidence": confidence,
"reason": reason,
"is_strong_signal": False,
"is_aggregated": True,
"payload": payload or {},
},
)
except Exception:
pass
# записать событие готовности сигнала к исполнению
def _log_ready_signal(
self,
*,
state: AutoTradeState,
signal: str | None,
reason: str,
confidence: float,
signal_intent: str,
) -> None:
normalized_signal = (signal or "HOLD").upper()
if normalized_signal not in {"BUY", "SELL"}:
return
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
try:
JournalService().log_ui_info(
event_type="signal_ready",
message=(
f"Сигнал {normalized_signal} подтверждён и готов к исполнению."
),
screen="auto",
action="signal_ready",
payload={
"strategy": state.strategy,
"status": state.status,
"symbol": state.symbol,
"signal": normalized_signal,
"signal_intent": signal_intent,
"confidence": confidence,
"reason": reason,
"repeat_count": state.last_signal_repeat_count,
"position_side": state.position_side,
"decision_status": state.decision_status,
"is_strong_signal": confidence > self._ready_confidence,
"is_aggregated": False,
"confirmation_seconds": state.signal_confirmation_seconds,
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
"confirmation_progress": state.signal_confirmation_progress,
"bid_price": snapshot.get("bid_price"),
"ask_price": snapshot.get("ask_price"),
"last_price": snapshot.get("last_price"),
},
)
except Exception:
pass
# сбросить устаревшие signal / market runtime данные
def _expire_runtime_if_needed(self, state: AutoTradeState) -> None:
now = time.monotonic()
signal_updated_at = getattr(state, "signal_updated_at", None)
if signal_updated_at is not None:
signal_updated = safe_float(signal_updated_at)
if signal_updated is None:
return
signal_age = now - signal_updated
if signal_age > self._signal_ttl_seconds:
previous_signal = state.last_signal
self._reset_signal_tracking()
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
state.runtime_expired_message = "сигнал устарел и был сброшен"
self._log_runtime_expired_if_changed(
state=state,
reason="SIGNAL_TTL_EXPIRED",
message="Сигнал устарел и был сброшен.",
payload={
"previous_signal": previous_signal,
"signal_age_seconds": int(signal_age),
"signal_ttl_seconds": self._signal_ttl_seconds,
},
)
return
market_updated_at = getattr(state, "market_analysis_updated_at", None)
if market_updated_at is not None:
market_updated = safe_float(market_updated_at)
if market_updated is None:
return
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.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.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
state.momentum_strength = None
state.breakout_level = None
state.breakout_distance_percent = None
state.breakout_reason = None
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
state.runtime_expired_message = "анализ рынка устарел"
self._log_runtime_expired_if_changed(
state=state,
reason="MARKET_ANALYSIS_TTL_EXPIRED",
message="Анализ рынка устарел и был сброшен.",
payload={
"market_age_seconds": int(market_age),
"market_analysis_ttl_seconds": self._market_analysis_ttl_seconds,
},
)
# записать событие устаревания runtime данных
def _log_runtime_expired_if_changed(
self,
*,
state: AutoTradeState,
reason: str,
message: str,
payload: JsonDict,
) -> None:
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
if key == type(self)._last_logged_runtime_expired_key:
return
type(self)._last_logged_runtime_expired_key = key
try:
JournalService().log_ui_warning(
event_type="runtime_expired",
message=message,
screen="auto",
action="runtime_expiration",
payload={
**payload,
"symbol": state.symbol,
"strategy": state.strategy,
"status": state.status,
"runtime_expired_reason": reason,
},
)
except Exception:
pass
# синхронизировать итоговый execution confidence
def _sync_execution_confidence_state(
self,
*,
state: AutoTradeState,
signal: str,
confidence: float,
) -> None:
if signal not in {"BUY", "SELL"}:
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
return
signal_score = self._clamp_score(confidence)
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
market_score = self._market_confidence_score(state)
execution_quality_confidence_score = cast(
Callable[[AutoTradeState], float],
getattr(self, "_execution_quality_confidence_score"),
)
execution_score = execution_quality_confidence_score(state)
score = (
signal_score * 0.35
+ confirmation_score * 0.20
+ market_score * 0.25
+ execution_score * 0.20
)
score = round(self._clamp_score(score), 3)
state.execution_confidence_score = score
state.execution_confidence_required_score = self._execution_confidence_required_score
state.execution_confidence_level = self._execution_confidence_level(score)
state.execution_confidence_reason = self._execution_confidence_reason(state)
state.execution_confidence_factors = {
"signal_score": round(signal_score, 3),
"confirmation_score": round(confirmation_score, 3),
"market_score": round(market_score, 3),
"execution_score": round(execution_score, 3),
"required_score": self._execution_confidence_required_score,
"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,
"execution_quality": state.execution_quality,
"execution_quality_reason": state.execution_quality_reason,
"spread_percent": state.spread_percent,
"momentum_state": getattr(state, "momentum_state", None),
"momentum_direction": getattr(state, "momentum_direction", None),
"momentum_change_percent": getattr(state, "momentum_change_percent", None),
"momentum_strength": getattr(state, "momentum_strength", None),
"breakout_level": getattr(state, "breakout_level", None),
"breakout_distance_percent": getattr(state, "breakout_distance_percent", None),
"breakout_reason": getattr(state, "breakout_reason", None),
}
# рассчитать market confidence для итогового execution confidence
def _market_confidence_score(self, state: AutoTradeState) -> float:
market_state = state.market_state
strength = state.market_trend_strength
quality = state.market_trend_quality
phase = state.market_phase
ema_distance_state = state.ema_distance_state
entry_timing_state = state.entry_timing_state
trend_quality_score = safe_float(state.trend_quality_score)
if market_state in {
"HIGH_VOLATILITY",
"LOW_VOLATILITY",
"RANGE",
"UNKNOWN",
None,
"",
}:
return 0.25
score = 0.65
if strength == "STRONG":
score += 0.2
elif strength == "NORMAL":
score += 0.1
elif strength == "WEAK":
score -= 0.25
if quality == "CLEAN":
score += 0.12
elif quality == "NORMAL":
score += 0.04
elif quality == "NOISY":
score -= 0.25
if phase == "IMPULSE":
score += 0.1
elif phase == "PULLBACK":
score -= 0.25
elif phase in {"RANGE", "SQUEEZE"}:
score -= 0.3
if ema_distance_state == "HEALTHY":
score += 0.08
elif ema_distance_state == "EXTENDED":
score -= 0.08
elif ema_distance_state == "COMPRESSED":
score -= 0.18
elif ema_distance_state == "OVEREXTENDED":
score -= 0.35
if entry_timing_state == "NORMAL":
score += 0.08
elif entry_timing_state == "EARLY":
score -= 0.05
elif entry_timing_state == "LATE":
score -= 0.2
elif entry_timing_state == "CHASING":
score -= 0.35
if trend_quality_score is not None:
if trend_quality_score >= 0.7:
score += 0.08
elif trend_quality_score < 0.45:
score -= 0.15
return self._clamp_score(score)
# определить уровень execution confidence
def _execution_confidence_level(self, score: float) -> str:
if score >= 0.75:
return "HIGH"
if score >= self._execution_confidence_required_score:
return "NORMAL"
return "LOW"
# сформировать причину execution confidence
def _execution_confidence_reason(self, state: AutoTradeState) -> str:
score = state.execution_confidence_score
if score is None:
return "execution confidence не рассчитан"
if score < self._execution_confidence_required_score:
return "низкая совокупная уверенность входа"
if state.execution_confidence_level == "HIGH":
return "высокая совокупная уверенность входа"
return "достаточная совокупная уверенность входа"
# ограничить score диапазоном 0.0..1.0
def _clamp_score(self, value: NumericLike | None) -> float:
if value is None:
return 0.0
numeric = safe_float(value)
if numeric is None:
return 0.0
return max(0.0, min(1.0, numeric))

View File

@@ -1,416 +0,0 @@
# app/src/trading/auto/state.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class AutoTradeState:
# текущее состояние: OFF / OBSERVING / RUNNING
status: str = "OFF"
# выбранная стратегия: TREND / GRID / SCALP
strategy: str | None = "TREND"
# торговый инструмент
symbol: str = "BTC/USD_LEVERAGE"
# риск на одну сделку в %
risk_percent: float | None = 1.0
# текущий PnL
pnl_usd: float = 0.0
# время последней проверки
last_check_at: str | None = None
# последний сырой сигнал стратегии
last_signal: str | None = None
# количество одинаковых сигналов подряд
last_signal_repeat_count: int = 0
# уверенность последнего сигнала от 0.0 до 1.0
last_signal_confidence: float = 0.0
# причина последнего сигнала
last_signal_reason: str | None = None
# время начала текущего сигнала, monotonic timestamp
signal_started_at: float | None = None
# статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED
decision_status: str = "WAITING"
# человекочитаемое объяснение решения
decision_reason: str | None = None
# сигнал подтверждён по количеству повторов
is_signal_confirmed: bool = False
# сигнал готов к будущему execution
is_signal_ready: bool = False
# текущая позиция: NONE / LONG / SHORT
position_side: str = "NONE"
# цена входа
entry_price: float | None = None
# размер позиции
position_size: float | None = None
# monotonic timestamp открытия текущей позиции
position_opened_monotonic_at: float | None = None
# нереализованный PnL
unrealized_pnl_usd: float | None = None
# position health / runtime risk
position_pnl_percent: float | None = None
position_hold_seconds: int | None = None
position_pressure: str | None = None
position_health_score: int | None = None
position_health_status: str | None = None
position_health_reason: str | None = None
position_risk_level: str | None = None
position_risk_reason: str | None = None
position_trend_alignment: str | None = None
position_adverse_momentum: bool = False
position_exit_pressure: str | None = None
# position intelligence layer
position_lifecycle_stage: str | None = None
position_hold_quality: str | None = None
position_decay_state: str | None = None
position_exit_confidence: float | None = None
position_exit_signal: str | None = None
position_intelligence_reason: str | None = None
position_recommended_action: str | None = None
# advanced lifecycle analytics
position_peak_pnl_usd: float | None = None
position_peak_pnl_percent: float | None = None
position_mfe_percent: float | None = None
position_mae_percent: float | None = None
position_fatigue_score: float | None = None
position_fatigue_state: str | None = None
position_giveback_percent: float | None = None
# position behavioral state
position_conviction_state: str | None = None
position_exit_urgency: str | None = None
position_reversal_risk: str | None = None
# autonomous trade management
autonomous_action: str | None = None
autonomous_action_reason: str | None = None
autonomous_action_confidence: float | None = None
autonomous_protection_required: bool = False
autonomous_reduce_required: bool = False
autonomous_exit_required: bool = False
# runtime position action execution
autonomous_last_action: str | None = None
autonomous_last_action_reason: str | None = None
autonomous_last_action_at: float | None = None
# loss cooldown tracking
last_loss_monotonic_at: float | None = None
# runtime protection engine
position_protection_status: str | None = None
position_protection_reason: str | None = None
# break-even protection
break_even_armed: bool = False
break_even_price: float | None = None
# trailing protection
trailing_stop_active: bool = False
trailing_stop_price: float | None = None
# locked profit protection
profit_lock_active: bool = False
profit_lock_price: float | None = None
# runtime protection execution
runtime_protection_action: str | None = None
runtime_protection_reason: str | None = None
runtime_protection_updated_at: float | None = None
# максимальная просадка
max_drawdown_usd: float | None = None
# плечо
leverage: float | None = 2.0
# stop loss по движению цены в %
stop_loss_percent: float | None = 1.0
# take profit по движению цены в %
take_profit_percent: float | None = None
# максимальный допустимый paper-убыток в USD
max_loss_usd: float | None = None
# максимальная доля баланса, которую можно зарезервировать под позицию
max_reserved_balance_percent: float | None = 50.0
# последняя причина блокировки execution
execution_block_reason: str | None = None
# причина авто-уменьшения размера позиции
execution_size_adjustment_reason: str | None = None
# капитал, выделенный только под AutoTrade
allocated_balance_usd: float = 1000.0
# зафиксированный результат закрытых paper-сделок
realized_pnl_usd: float = 0.0
# cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0
# количество закрытых сделок в текущем цикле
cycle_closed_trades: int = 0
# количество прибыльных закрытых сделок
cycle_winning_trades: int = 0
# время запуска текущего цикла
cycle_started_at: float | None = None
# время последней adaptive size корректировки
adaptive_size_changed_at: float | None = None
# данные последнего flip
last_flip_old_side: str | None = None
last_flip_new_side: str | None = None
last_flip_pnl_usd: float | None = None
last_flip_reason: str | None = None
# monotonic timestamp последнего flip
last_flip_monotonic_at: float | None = None
# последнее execution-действие
last_execution_action: str | None = None
# последняя execution-причина
last_execution_reason: str | None = None
# последняя причина блокировки flip
last_flip_block_reason: str | None = None
# время последнего успешного flip
last_flip_at: str | None = None
# состояние рынка по Market State Engine
market_state: str | None = None
# направление тренда: UP / DOWN / FLAT / UNKNOWN
market_trend: str | None = None
# волатильность: LOW / NORMAL / HIGH / UNKNOWN
market_volatility: str | None = None
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
market_trend_strength: str | None = None
# качество тренда: CLEAN / NORMAL / NOISY / UNKNOWN
market_trend_quality: str | None = None
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
market_phase: str | None = None
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
market_phase_direction: str | None = None
# advanced trend quality metrics
market_trend_gap_percent: float | None = None
market_trend_consistency: float | None = None
market_trend_efficiency: float | None = None
ema_distance_atr_ratio: float | None = None
ema_fast_slope_percent: float | None = None
ema_slow_slope_percent: float | None = None
candle_noise_score: float | None = None
price_position_score: float | None = None
# advanced trend quality semantic states
trend_quality_score: float | None = None
ema_distance_state: str | None = None
entry_timing_state: str | None = None
entry_timing_reason: str | None = None
# higher timeframe volatility context
htf_interval: str | None = None
htf_atr_percent: float | None = None
htf_atr_percent_baseline: float | None = None
htf_volatility_ratio: float | None = None
htf_volatility: str | None = None
# состояние momentum/breakout semantic engine
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
momentum_state: str | None = None
# направление momentum
momentum_direction: str | None = None
# изменение цены в momentum window
momentum_change_percent: float | None = None
# сила momentum относительно threshold
momentum_strength: float | None = None
# breakout уровень
breakout_level: float | None = None
# расстояние от breakout уровня в %
breakout_distance_percent: float | None = None
# причина breakout/momentum классификации
breakout_reason: str | None = None
# таймфрейм анализа рынка
market_analysis_interval: str | None = None
# объяснение последнего анализа рынка
market_analysis_reason: str | None = None
# код причины, почему вход в позицию сейчас не выполнен
entry_block_reason: str | None = None
# человекочитаемое объяснение причины не входа
entry_block_message: str | None = None
# время последнего обновления сигнала, monotonic timestamp
signal_updated_at: float | None = None
# время последнего обновления market analysis, monotonic timestamp
market_analysis_updated_at: float | None = None
# причина runtime expiration
runtime_expired_reason: str | None = None
# человекочитаемое сообщение runtime expiration
runtime_expired_message: str | None = None
# возраст последнего market snapshot в секундах
snapshot_age_seconds: float | None = None
# spread между bid/ask в %
spread_percent: float | None = None
# качество рынка для исполнения: GOOD / WARNING / BLOCKED / UNKNOWN
execution_quality: str | None = None
# код причины качества исполнения
execution_quality_reason: str | None = None
# человекочитаемое объяснение качества исполнения
execution_quality_message: str | None = None
# источник execution pricing snapshot
execution_price_source: str | None = None
# возраст execution snapshot
execution_price_age_seconds: float | None = None
# bid execution price
execution_bid_price: float | None = None
# ask execution price
execution_ask_price: float | None = None
# last execution price
execution_last_price: float | None = None
# pricing freshness status:
# FRESH / AGING / STALE / UNKNOWN
execution_price_freshness: str | None = None
# признак деградации runtime market data
market_runtime_degraded: bool = False
# сколько секунд текущий BUY / SELL сигнал удерживается
signal_confirmation_seconds: int = 0
# сколько секунд нужно удерживать BUY / SELL сигнал для подтверждения
signal_confirmation_required_seconds: int = 10
# сколько повторов ещё не хватает до подтверждения
signal_confirmation_missing_repeats: int = 0
# прогресс подтверждения сигнала от 0.0 до 1.0
signal_confirmation_progress: float = 0.0
# человекочитаемая причина текущего confirmation status
signal_confirmation_reason: str | None = None
# semantic-статус execution слоя:
# IDLE / WAITING_SIGNAL / READY / BLOCKED / EXECUTED / POSITION_OPEN / PROTECTED
execution_semantic_status: str | None = None
# короткая строка для UI
execution_semantic_message: str | None = None
# техническая детализация для логов / отладки
execution_semantic_reason: str | None = None
# итоговая execution confidence от 0.0 до 1.0
execution_confidence_score: float | None = None
# уровень confidence: LOW / NORMAL / HIGH
execution_confidence_level: str | None = None
# минимальный score для допуска execution
execution_confidence_required_score: float | None = None
# человекочитаемая причина confidence-оценки
execution_confidence_reason: str | None = None
# детализация факторов confidence для логов / отладки
execution_confidence_factors: dict | None = None
# итоговый риск после adaptive sizing
effective_risk_percent: float | None = None
# итоговый риск в USD после adaptive sizing
effective_target_risk_usd: float | None = None
# базовый размер позиции до adaptive sizing
adaptive_size_base: float | None = None
# итоговый размер позиции после adaptive sizing
adaptive_size_final: float | None = None
# итоговый множитель adaptive sizing
adaptive_size_multiplier: float | None = None
# человекочитаемая причина adaptive sizing
adaptive_size_reason: str | None = None
# факторы adaptive sizing для логов / отладки
adaptive_size_factors: dict | None = None
# статус торговой сессии инструмента
market_is_open: bool | None = None
market_status: str | None = None
market_status_message: str | None = None
market_status_updated_at: float | None = None
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
cycle_number: int = 0
# уникальный номер сделки внутри runtime
trade_sequence: int = 0
# id текущей открытой сделки
current_trade_id: str | None = None
# номер цикла, в котором открыта текущая сделка
current_trade_cycle_number: int | None = None

View File

@@ -1 +0,0 @@
from __future__ import annotations

View File

@@ -1,443 +0,0 @@
# app/src/trading/debug/execution.py
from __future__ import annotations
import math
from datetime import datetime
from src.integrations.exchange.service import ExchangeService
from src.trading.debug.state import DebugPositionState, DebugTradeState
from src.trading.execution.models import ExecutionDecision
class DebugExecutionEngine:
_size_precision = 5
def process(self, state: DebugTradeState) -> ExecutionDecision:
if state.status != "RUNNING":
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Execution доступен только в режиме RUNNING.",
)
self.update_unrealized_pnl(state)
risk_decision = self.risk_close_decision(state)
if risk_decision is not None:
return risk_decision
if state.decision_status != "READY" or not state.is_signal_ready:
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Сигнал ещё не готов к execution.",
)
if self._should_flip_position(state):
return self.flip_position(state)
if state.last_signal == "BUY":
return self.open_position_if_empty(state=state, side="LONG")
if state.last_signal == "SELL":
return self.open_position_if_empty(state=state, side="SHORT")
return ExecutionDecision("NONE", False, "[DEBUG] Нет торгового действия.")
def open_position_if_empty(
self,
*,
state: DebugTradeState,
side: str,
) -> ExecutionDecision:
if state.position.side != "NONE":
return ExecutionDecision("NONE", False, "[DEBUG] Позиция уже открыта.")
try:
entry_price = self._entry_price_for_side(state.symbol, side)
except Exception as exc:
return ExecutionDecision(
"NONE",
False,
f"[DEBUG] Не удалось получить цену входа: {exc}",
)
size = self.calculate_position_size(state, entry_price=entry_price)
if size <= 0:
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Позиция не открыта: невозможно рассчитать size.",
)
size = self.adjust_size_by_margin_limit(
state=state,
entry_price=entry_price,
size=size,
)
size = self._round_size(size)
if size <= 0:
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Позиция не открыта: итоговый size равен 0.",
)
now = self._now_time()
state.position = DebugPositionState(
side=side,
symbol=state.symbol,
entry_price=entry_price,
size=size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
opened_at=now,
updated_at=now,
)
return ExecutionDecision(
f"DEBUG_OPEN_{side}",
True,
f"[DEBUG] Paper позиция открыта: {side}.",
)
def flip_position(self, state: DebugTradeState) -> ExecutionDecision:
position = state.position
if position.side == "NONE":
return ExecutionDecision("NONE", False, "[DEBUG] Нет позиции для flip.")
new_side = self._target_side_from_signal(state.last_signal)
if new_side is None:
return ExecutionDecision("NONE", False, "[DEBUG] Нет направления для flip.")
try:
exit_price = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
new_entry_price = self._entry_price_for_side(state.symbol, new_side)
except Exception as exc:
return ExecutionDecision("NONE", False, f"[DEBUG] Ошибка цены для flip: {exc}")
pnl = self.calculate_pnl(state, exit_price)
new_size = self.calculate_position_size(state, entry_price=new_entry_price)
if new_size <= 0:
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Flip отменён: невозможно рассчитать new size.",
)
new_size = self.adjust_size_by_margin_limit(
state=state,
entry_price=new_entry_price,
size=new_size,
)
new_size = self._round_size(new_size)
if new_size <= 0:
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Flip отменён: итоговый new size равен 0.",
)
state.realized_pnl_usd += pnl
now = self._now_time()
old_side = position.side
state.position = DebugPositionState(
side=new_side,
symbol=state.symbol,
entry_price=new_entry_price,
size=new_size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
opened_at=now,
updated_at=now,
)
return ExecutionDecision(
f"DEBUG_FLIP_{old_side}_TO_{new_side}",
True,
f"[DEBUG] Flip выполнен: {old_side}{new_side}.",
)
def close_position(
self,
state: DebugTradeState,
*,
forced_reason: str | None = None,
) -> ExecutionDecision:
position = state.position
if position.side == "NONE":
return ExecutionDecision(
"NONE",
False,
"[DEBUG] Нет открытой позиции для закрытия.",
)
try:
exit_price = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
except Exception as exc:
return ExecutionDecision("NONE", False, f"[DEBUG] Ошибка цены закрытия: {exc}")
pnl = self.calculate_pnl(state, exit_price)
state.realized_pnl_usd += pnl
old_side = position.side
state.position = DebugPositionState()
action = f"DEBUG_CLOSE_{forced_reason}" if forced_reason else "DEBUG_CLOSE"
return ExecutionDecision(
action,
True,
f"[DEBUG] Позиция закрыта: {old_side}. PnL: {pnl:.4f}",
)
def risk_close_decision(self, state: DebugTradeState) -> ExecutionDecision | None:
position = state.position
if position.side == "NONE":
return None
try:
current_price = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
except Exception:
return None
price_move_percent = self.calculate_price_move_percent(state, current_price)
unrealized_pnl = self.calculate_pnl(state, current_price)
if self._is_max_loss_hit(state, unrealized_pnl):
return self.close_position(state, forced_reason="MAX_LOSS")
if self._is_stop_loss_hit(state, price_move_percent):
return self.close_position(state, forced_reason="STOP_LOSS")
if self._is_take_profit_hit(state, price_move_percent):
return self.close_position(state, forced_reason="TAKE_PROFIT")
return None
def update_unrealized_pnl(self, state: DebugTradeState) -> None:
position = state.position
if position.side == "NONE":
position.unrealized_pnl_usd = None
return
try:
current_price = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
except Exception:
return
position.unrealized_pnl_usd = self.calculate_pnl(state, current_price)
position.updated_at = self._now_time()
def calculate_position_size(
self,
state: DebugTradeState,
*,
entry_price: float | None = None,
) -> float:
if state.risk_percent is None or state.risk_percent <= 0:
return 0.0
if state.stop_loss_percent is None or state.stop_loss_percent <= 0:
return 0.0
price = entry_price
if price is None:
price = self._signal_entry_price(state)
if price <= 0:
return 0.0
target_risk_usd = state.allocated_balance_usd * (state.risk_percent / 100)
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
if stop_loss_distance_usd <= 0:
return 0.0
return self._round_size(target_risk_usd / stop_loss_distance_usd)
def adjust_size_by_margin_limit(
self,
*,
state: DebugTradeState,
entry_price: float,
size: float,
) -> float:
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
max_percent = state.max_reserved_balance_percent
if max_percent is None or max_percent <= 0:
return self._round_size(size)
leverage = state.leverage or 1.0
if leverage <= 0 or entry_price <= 0:
state.execution_block_reason = "[DEBUG] Invalid leverage or entry price."
return 0.0
max_reserved_usd = state.allocated_balance_usd * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / entry_price
if size <= max_size:
return self._round_size(size)
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
return self._round_size(max_size)
def calculate_price_move_percent(
self,
state: DebugTradeState,
current_price: float,
) -> float:
position = state.position
entry = position.entry_price or 0.0
if entry <= 0:
return 0.0
if position.side == "LONG":
return round(((current_price - entry) / entry) * 100, 4)
if position.side == "SHORT":
return round(((entry - current_price) / entry) * 100, 4)
return 0.0
def calculate_pnl(self, state: DebugTradeState, current_price: float) -> float:
position = state.position
entry = position.entry_price or 0.0
size = position.size or 0.0
if position.side == "LONG":
return round((current_price - entry) * size, 4)
if position.side == "SHORT":
return round((entry - current_price) * size, 4)
return 0.0
def _should_flip_position(self, state: DebugTradeState) -> bool:
if state.position.side == "LONG" and state.last_signal == "SELL":
return True
if state.position.side == "SHORT" and state.last_signal == "BUY":
return True
return False
def _target_side_from_signal(self, signal: str | None) -> str | None:
if signal == "BUY":
return "LONG"
if signal == "SELL":
return "SHORT"
return None
def _is_stop_loss_hit(self, state: DebugTradeState, price_move_percent: float) -> bool:
if state.stop_loss_percent is None:
return False
return price_move_percent <= -abs(state.stop_loss_percent)
def _is_take_profit_hit(self, state: DebugTradeState, price_move_percent: float) -> bool:
if state.take_profit_percent is None:
return False
return price_move_percent >= abs(state.take_profit_percent)
def _is_max_loss_hit(self, state: DebugTradeState, unrealized_pnl: float) -> bool:
if state.max_loss_usd is None:
return False
return unrealized_pnl <= -abs(state.max_loss_usd)
def _signal_entry_price(self, state: DebugTradeState) -> float:
if state.last_signal == "BUY":
return self._entry_price_for_side(state.symbol, "LONG")
if state.last_signal == "SELL":
return self._entry_price_for_side(state.symbol, "SHORT")
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)
if side == "LONG":
return self._snapshot_price(snapshot, "ask_price", "last_price")
if side == "SHORT":
return self._snapshot_price(snapshot, "bid_price", "last_price")
return self._snapshot_price(snapshot, "last_price")
def _exit_price_for_side(self, symbol: str, side: str) -> float:
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
if side == "LONG":
return self._snapshot_price(snapshot, "bid_price", "last_price")
if side == "SHORT":
return self._snapshot_price(snapshot, "ask_price", "last_price")
return self._snapshot_price(snapshot, "last_price")
def _market_last_price(self, symbol: str) -> float:
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
return self._snapshot_price(snapshot, "last_price")
def _snapshot_price(
self,
snapshot: dict[str, object],
primary_key: str,
fallback_key: str | None = None,
) -> float:
raw_price = snapshot.get(primary_key)
if raw_price is None and fallback_key is not None:
raw_price = snapshot.get(fallback_key)
if raw_price is None:
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
price = float(raw_price)
if price <= 0:
raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
return price
def _round_size(self, size: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(size) * factor) / factor
def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S")

View File

@@ -1,269 +0,0 @@
# app/src/trading/debug/runner.py
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from typing import ClassVar, Protocol
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from src.core.telegram_errors import (
is_message_not_modified,
is_message_to_edit_not_found,
)
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry
from src.trading.debug.service import DebugTradeService
class RenderText(Protocol):
def __call__(self) -> str: ...
class RenderMarkup(Protocol):
def __call__(self) -> InlineKeyboardMarkup | None: ...
class DebugTradeRunner:
_task: ClassVar[asyncio.Task[None] | None] = None
_bot: ClassVar[Bot | None] = None
_chat_id: ClassVar[int | None] = None
_message_id: ClassVar[int | None] = None
_text_renderer: ClassVar[RenderText | None] = None
_markup_renderer: ClassVar[RenderMarkup | None] = None
_current_screen: ClassVar[str | None] = None
_interval_seconds: ClassVar[int] = 5
_market_interval_seconds: ClassVar[int] = 1
_last_text: ClassVar[str | None] = None
_last_refresh_at: ClassVar[float] = 0.0
_retry_after_until: ClassVar[float] = 0.0
@classmethod
def register_screen(
cls,
*,
bot: Bot,
chat_id: int,
message_id: int,
render_text: RenderText,
render_markup: RenderMarkup,
) -> None:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._text_renderer = render_text
cls._markup_renderer = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
chat_id=chat_id,
)
@classmethod
def _reset_screen(cls) -> None:
cls._message_id = None
cls._text_renderer = None
cls._markup_renderer = None
cls._last_text = None
@classmethod
def _reset_runtime(cls) -> None:
cls._bot = None
cls._chat_id = None
cls._current_screen = None
cls._reset_screen()
@classmethod
def _is_screen_ready(cls) -> bool:
return (
cls._bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._text_renderer is not None
and cls._markup_renderer is not None
)
@classmethod
async def delete_registered_screen(
cls,
*,
bot: Bot,
chat_id: int,
) -> None:
if cls._chat_id is None or cls._message_id is None:
return
if cls._chat_id != chat_id:
return
try:
await bot.delete_message(
chat_id=cls._chat_id,
message_id=cls._message_id,
)
except Exception:
pass
cls._reset_screen()
@classmethod
async def detach_screen(
cls,
*,
delete_message: bool = False,
bot: Bot | None = None,
chat_id: int | None = None,
keep_message_id: int | None = None,
) -> None:
if (
delete_message
and bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._chat_id == chat_id
and cls._message_id != keep_message_id
):
try:
await bot.delete_message(
chat_id=cls._chat_id,
message_id=cls._message_id,
)
except Exception:
pass
cls._reset_runtime()
@classmethod
def set_current_screen(cls, screen: str) -> None:
cls._current_screen = screen
@classmethod
def start(cls) -> None:
service = DebugTradeService()
state = service.get_state()
state.status = "RUNNING"
MarketDataRunner.start(
symbol_provider=lambda: DebugTradeService().get_state().symbol,
interval_seconds=cls._market_interval_seconds,
runtime_key="debug_auto",
screen="debug_auto",
action="market_data",
runtime_label="[DEBUG]",
)
cls._last_text = None
if cls._task is not None and not cls._task.done():
return
cls._task = asyncio.create_task(cls._worker())
@classmethod
def stop(cls) -> None:
MarketDataRunner.stop("debug_auto")
if cls._task is None:
return
cls._task.cancel()
cls._task = None
@classmethod
async def _worker(cls) -> None:
service = DebugTradeService()
while True:
state = service.get_state()
if state.status == "OFF":
cls._task = None
MarketDataRunner.stop("debug_auto")
break
service.process()
await cls.refresh_screen(force=False)
await asyncio.sleep(cls._interval_seconds)
@classmethod
async def refresh_screen(
cls,
*,
force: bool = False,
) -> None:
if cls._current_screen != "debug_auto":
return
now = time.monotonic()
if now < cls._retry_after_until:
return
if (
not force
and now - cls._last_refresh_at < cls._interval_seconds
):
return
if not cls._is_screen_ready():
return
bot = cls._bot
chat_id = cls._chat_id
message_id = cls._message_id
text_renderer = cls._text_renderer
markup_renderer = cls._markup_renderer
if (
bot is None
or chat_id is None
or message_id is None
or text_renderer is None
or markup_renderer is None
):
return
text = text_renderer()
if text == cls._last_text:
return
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=markup_renderer(),
)
cls._last_text = text
cls._last_refresh_at = now
except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc:
if is_message_not_modified(exc):
cls._last_text = text
cls._last_refresh_at = now
return
if is_message_to_edit_not_found(exc):
cls._reset_screen()
return
except Exception:
pass

View File

@@ -1,219 +0,0 @@
# app/src/trading/debug/service.py
from __future__ import annotations
import time
from datetime import datetime
from src.core.config import load_settings
from src.trading.debug.execution import DebugExecutionEngine
from src.trading.debug.state import DebugPositionState, DebugTradeState
from src.trading.execution.models import ExecutionDecision
class DebugTradeService:
_state = DebugTradeState()
_confirm_repeats = 2
_ready_confidence = 0.3
def get_state(self) -> DebugTradeState:
if not self._state.symbol:
self._state.symbol = load_settings().default_symbol
return self._state
def reset(self) -> DebugTradeState:
state = self.get_state()
state.status = "RUNNING"
state.last_signal = "HOLD"
state.last_signal_confidence = 0.0
state.last_signal_repeat_count = 1
state.last_signal_reason = "[DEBUG] RESET HOLD"
state.signal_started_at = time.monotonic()
state.decision_status = "WAITING"
state.decision_reason = "[DEBUG] Reset."
state.is_signal_confirmed = False
state.is_signal_ready = False
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
state.position = DebugPositionState()
return state
def stop(self) -> DebugTradeState:
state = self.get_state()
state.status = "OFF"
return state
def set_signal(
self,
*,
signal: str,
confidence: float = 0.0,
repeat_count: int = 1,
reason: str | None = None,
force_ready: bool = False,
) -> DebugTradeState:
state = self.get_state()
normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
normalized_signal = "HOLD"
previous_signal = state.last_signal
state.status = "RUNNING"
state.last_signal = normalized_signal
state.last_signal_confidence = max(0.0, min(1.0, confidence))
state.last_signal_repeat_count = max(1, int(repeat_count))
state.last_signal_reason = reason or f"[DEBUG] SIGNAL {normalized_signal}"
if previous_signal != normalized_signal or state.signal_started_at is None:
state.signal_started_at = time.monotonic()
self._update_decision_state(state, force_ready=force_ready)
return state
def set_signal_duration(
self,
*,
signal: str,
seconds: int,
confidence: float = 0.0,
force_ready: bool = False,
) -> DebugTradeState:
repeat_count = max(1, int(max(0, seconds) / 5))
state = self.set_signal(
signal=signal,
confidence=confidence,
repeat_count=repeat_count,
reason=f"[DEBUG] {signal.upper()} {seconds}s",
force_ready=force_ready,
)
state.signal_started_at = time.monotonic() - max(0, seconds)
return state
def open_long(self) -> tuple[DebugTradeState, ExecutionDecision]:
state = self.set_signal(
signal="BUY",
confidence=0.95,
repeat_count=3,
reason="[DEBUG] OPEN LONG",
force_ready=True,
)
result = DebugExecutionEngine().process(state)
return state, result
def open_short(self) -> tuple[DebugTradeState, ExecutionDecision]:
state = self.set_signal(
signal="SELL",
confidence=0.95,
repeat_count=3,
reason="[DEBUG] OPEN SHORT",
force_ready=True,
)
result = DebugExecutionEngine().process(state)
return state, result
def flip(self) -> tuple[DebugTradeState, ExecutionDecision]:
state = self.get_state()
if state.position.side == "LONG":
target_signal = "SELL"
elif state.position.side == "SHORT":
target_signal = "BUY"
else:
return state, ExecutionDecision(
"NONE",
False,
"[DEBUG] Flip невозможен: нет открытой позиции.",
)
state = self.set_signal(
signal=target_signal,
confidence=0.95,
repeat_count=3,
reason="[DEBUG] AUTO FLIP",
force_ready=True,
)
result = DebugExecutionEngine().process(state)
return state, result
def close(self, *, reason: str = "DEBUG_CLOSE") -> tuple[DebugTradeState, ExecutionDecision]:
state = self.get_state()
result = DebugExecutionEngine().close_position(state, forced_reason=reason)
return state, result
def update_market(self) -> DebugTradeState:
state = self.get_state()
state.last_check_at = datetime.now().strftime("%H:%M:%S")
if state.status != "RUNNING":
return state
DebugExecutionEngine().update_unrealized_pnl(state)
return state
def process(self) -> tuple[DebugTradeState, ExecutionDecision]:
state = self.get_state()
state.last_check_at = datetime.now().strftime("%H:%M:%S")
result = DebugExecutionEngine().process(state)
return state, result
def _update_decision_state(
self,
state: DebugTradeState,
*,
force_ready: bool = False,
) -> None:
state.is_signal_confirmed = False
state.is_signal_ready = False
if state.last_signal == "HOLD":
state.decision_status = "WAITING"
state.decision_reason = "[DEBUG] Нет торгового направления."
return
if force_ready:
state.is_signal_confirmed = True
state.is_signal_ready = True
state.decision_status = "READY"
state.decision_reason = "[DEBUG] Signal forced READY."
return
if state.last_signal_repeat_count < self._confirm_repeats:
state.decision_status = "CONFIRMING"
state.decision_reason = (
f"[DEBUG] Сигнал {state.last_signal} подтверждается: "
f"{state.last_signal_repeat_count}/{self._confirm_repeats}."
)
return
state.is_signal_confirmed = True
if state.last_signal_confidence < self._ready_confidence:
state.decision_status = "BLOCKED"
state.decision_reason = (
f"[DEBUG] Confidence низкая: "
f"{state.last_signal_confidence:.2f} < {self._ready_confidence:.2f}."
)
return
state.is_signal_ready = True
state.decision_status = "READY"
state.decision_reason = "[DEBUG] Signal ready."

View File

@@ -1,57 +0,0 @@
# app/src/trading/debug/state.py
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class DebugPositionState:
side: str = "NONE"
symbol: str = ""
entry_price: float | None = None
size: float | None = None
leverage: float | None = None
unrealized_pnl_usd: float | None = None
opened_at: str | None = None
updated_at: str | None = None
@dataclass(slots=True)
class DebugTradeState:
status: str = "OFF"
strategy: str | None = "TREND"
symbol: str = "BTC/USD_LEVERAGE"
allocated_balance_usd: float = 1000.0
realized_pnl_usd: float = 0.0
risk_percent: float | None = 1.0
leverage: float | None = 2.0
stop_loss_percent: float | None = 1.0
take_profit_percent: float | None = None
max_loss_usd: float | None = None
max_reserved_balance_percent: float | None = 50.0
last_signal: str | None = "HOLD"
last_signal_confidence: float = 0.0
last_signal_repeat_count: int = 0
last_signal_reason: str | None = None
signal_started_at: float | None = None
decision_status: str = "WAITING"
decision_reason: str | None = None
is_signal_confirmed: bool = False
is_signal_ready: bool = False
execution_block_reason: str | None = None
execution_size_adjustment_reason: str | None = None
position: DebugPositionState = field(default_factory=DebugPositionState)
last_check_at: str | None = None

View File

@@ -1 +0,0 @@
from __future__ import annotations

File diff suppressed because it is too large Load Diff

View File

@@ -1,238 +0,0 @@
# app/src/trading/diagnostics/semantic_runtime.py
from __future__ import annotations
import time
from typing import Any
from src.trading.auto.state import AutoTradeState
class SemanticRuntimeDiagnostics:
def build(self, state: AutoTradeState) -> dict[str, Any]:
now = time.monotonic()
return {
"snapshot_type": "SEMANTIC_RUNTIME_DIAGNOSTIC",
"snapshot_version": "07.4.4.1.10.1",
"built_at_monotonic": now,
"status": self._status_section(state),
"signal": self._signal_section(state, now),
"market": self._market_section(state, now),
"momentum": self._momentum_section(state),
"execution": self._execution_section(state),
"adaptive_size": self._adaptive_size_section(state),
"position": self._position_section(state),
"runtime_health": self._runtime_health_section(state, now),
"summary": self._summary_section(state),
}
def _status_section(self, state: AutoTradeState) -> dict[str, Any]:
return {
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
"last_check_at": state.last_check_at,
"is_configured": self._is_configured(state),
}
def _signal_section(
self,
state: AutoTradeState,
now: float,
) -> dict[str, Any]:
signal_age_seconds = self._age_seconds(
started_at=state.signal_started_at,
now=now,
)
return {
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"reason": state.last_signal_reason,
"repeat_count": state.last_signal_repeat_count,
"started_at": state.signal_started_at,
"updated_at": state.signal_updated_at,
"age_seconds": signal_age_seconds,
"decision_status": state.decision_status,
"decision_reason": state.decision_reason,
"is_confirmed": state.is_signal_confirmed,
"is_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,
}
def _market_section(
self,
state: AutoTradeState,
now: float,
) -> dict[str, Any]:
market_age_seconds = self._age_seconds(
started_at=state.market_analysis_updated_at,
now=now,
)
return {
"state": state.market_state,
"trend": state.market_trend,
"volatility": state.market_volatility,
"trend_strength": state.market_trend_strength,
"trend_quality": state.market_trend_quality,
"phase": state.market_phase,
"phase_direction": state.market_phase_direction,
"interval": state.market_analysis_interval,
"reason": state.market_analysis_reason,
"updated_at": state.market_analysis_updated_at,
"age_seconds": market_age_seconds,
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
}
def _momentum_section(self, state: AutoTradeState) -> dict[str, Any]:
return {
"state": state.momentum_state,
"direction": state.momentum_direction,
"change_percent": state.momentum_change_percent,
"strength": state.momentum_strength,
"breakout_level": state.breakout_level,
"breakout_distance_percent": state.breakout_distance_percent,
"breakout_reason": state.breakout_reason,
"is_breakout": state.momentum_state in {
"BREAKOUT_UP",
"BREAKOUT_DOWN",
},
"is_momentum": state.momentum_state in {
"MOMENTUM_UP",
"MOMENTUM_DOWN",
"BREAKOUT_UP",
"BREAKOUT_DOWN",
},
}
def _execution_section(self, state: AutoTradeState) -> dict[str, Any]:
return {
"quality": state.execution_quality,
"quality_reason": state.execution_quality_reason,
"quality_message": state.execution_quality_message,
"semantic_status": state.execution_semantic_status,
"semantic_message": state.execution_semantic_message,
"semantic_reason": state.execution_semantic_reason,
"confidence_score": state.execution_confidence_score,
"confidence_level": state.execution_confidence_level,
"confidence_required_score": state.execution_confidence_required_score,
"confidence_reason": state.execution_confidence_reason,
"confidence_factors": state.execution_confidence_factors,
"block_reason": state.execution_block_reason,
"snapshot_age_seconds": state.snapshot_age_seconds,
"spread_percent": state.spread_percent,
"market_runtime_degraded": state.market_runtime_degraded,
}
def _adaptive_size_section(self, state: AutoTradeState) -> dict[str, Any]:
return {
"base_size": state.adaptive_size_base,
"final_size": state.adaptive_size_final,
"multiplier": state.adaptive_size_multiplier,
"reason": state.adaptive_size_reason,
"factors": state.adaptive_size_factors,
"effective_risk_percent": state.effective_risk_percent,
"effective_target_risk_usd": state.effective_target_risk_usd,
"size_adjustment_reason": state.execution_size_adjustment_reason,
}
def _position_section(self, state: AutoTradeState) -> dict[str, Any]:
return {
"side": state.position_side,
"entry_price": state.entry_price,
"size": state.position_size,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"realized_pnl_usd": state.realized_pnl_usd,
"last_execution_action": state.last_execution_action,
"last_execution_reason": state.last_execution_reason,
"last_flip_block_reason": state.last_flip_block_reason,
"last_flip_at": state.last_flip_at,
}
def _runtime_health_section(
self,
state: AutoTradeState,
now: float,
) -> dict[str, Any]:
signal_age_seconds = self._age_seconds(
started_at=state.signal_updated_at,
now=now,
)
market_age_seconds = self._age_seconds(
started_at=state.market_analysis_updated_at,
now=now,
)
return {
"runtime_expired_reason": state.runtime_expired_reason,
"runtime_expired_message": state.runtime_expired_message,
"signal_age_seconds": signal_age_seconds,
"market_age_seconds": market_age_seconds,
"has_market_data": state.market_state is not None,
"has_execution_quality": state.execution_quality is not None,
"has_signal": state.last_signal is not None,
"has_momentum_data": state.momentum_state is not None,
"is_runtime_degraded": bool(state.market_runtime_degraded),
}
def _summary_section(self, state: AutoTradeState) -> dict[str, Any]:
blockers = []
if state.entry_block_message:
blockers.append(state.entry_block_message)
if state.execution_quality == "BLOCKED":
blockers.append(state.execution_quality_message or "execution blocked")
if state.decision_status == "BLOCKED":
blockers.append(state.decision_reason or "decision blocked")
return {
"mode": state.status,
"signal": state.last_signal,
"market": state.market_state,
"phase": state.market_phase,
"momentum": state.momentum_state,
"execution": state.execution_semantic_status,
"position": state.position_side,
"is_trade_candidate": state.last_signal in {"BUY", "SELL"},
"is_ready": bool(state.is_signal_ready),
"is_blocked": bool(blockers),
"blockers": blockers,
}
def _is_configured(self, state: AutoTradeState) -> bool:
if not state.symbol:
return False
if not state.strategy:
return False
if state.risk_percent is None:
return False
if state.strategy.upper() == "TREND":
return (
state.stop_loss_percent is not None
and state.stop_loss_percent > 0
)
return True
def _age_seconds(
self,
*,
started_at: float | None,
now: float,
) -> int | None:
if started_at is None:
return None
return max(0, int(now - float(started_at)))

View File

@@ -1,818 +0,0 @@
# app/src/trading/diagnostics/snapshot.py
from __future__ import annotations
import time
from typing import Any
from src.trading.auto.state import AutoTradeState
from src.core.numbers import safe_float
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
class SemanticDiagnosticSnapshotBuilder:
def build(self, state: AutoTradeState, *, is_configured: bool) -> dict[str, Any]:
now = time.monotonic()
signal_age_seconds = self._age_seconds(
now=now,
started_at=state.signal_started_at,
)
market_age_seconds = self._age_seconds(
now=now,
started_at=state.market_analysis_updated_at,
)
blockers = self._blockers(state)
health_score = self._health_score(state=state, blockers=blockers)
severity = self._severity(
state=state,
health_score=health_score,
blockers=blockers,
)
position_current_price = self._position_current_price(state)
position_health = self._position_health(
state=state,
current_price=position_current_price,
)
runtime_exchange_alerts = self._runtime_exchange_alerts(state)
return {
"status": {
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
"is_configured": is_configured,
},
"signal": {
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"decision_status": state.decision_status,
"is_confirmed": state.is_signal_confirmed,
"is_ready": state.is_signal_ready,
"repeat_count": state.last_signal_repeat_count,
"confirmation_progress": state.signal_confirmation_progress,
"age_seconds": signal_age_seconds,
"reason": state.last_signal_reason,
},
"market": {
"state": state.market_state,
"trend": state.market_trend,
"volatility": state.market_volatility,
"trend_strength": state.market_trend_strength,
"trend_quality": state.market_trend_quality,
"phase": state.market_phase,
"phase_direction": state.market_phase_direction,
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds,
"market_is_open": state.market_is_open,
"market_status": state.market_status,
"market_status_message": state.market_status_message,
"market_status_updated_at": state.market_status_updated_at,
"trend_gap_percent": state.market_trend_gap_percent,
"trend_consistency": state.market_trend_consistency,
"trend_efficiency": state.market_trend_efficiency,
"trend_quality_score": state.trend_quality_score,
"ema_distance_atr_ratio": state.ema_distance_atr_ratio,
"ema_distance_state": state.ema_distance_state,
"entry_timing_state": state.entry_timing_state,
"entry_timing_reason": state.entry_timing_reason,
"ema_fast_slope_percent": state.ema_fast_slope_percent,
"ema_slow_slope_percent": state.ema_slow_slope_percent,
"candle_noise_score": state.candle_noise_score,
"price_position_score": state.price_position_score,
"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,
},
"momentum": {
"state": getattr(state, "momentum_state", None),
"direction": getattr(state, "momentum_direction", None),
"strength": getattr(state, "momentum_strength", None),
"change_percent": getattr(state, "momentum_change_percent", None),
"breakout_level": getattr(state, "breakout_level", None),
"breakout_distance_percent": getattr(
state,
"breakout_distance_percent",
None,
),
"is_breakout": getattr(state, "momentum_state", None)
in {"BREAKOUT_UP", "BREAKOUT_DOWN"},
"breakout_reason": getattr(state, "breakout_reason", None),
},
"execution": {
"quality": state.execution_quality,
"quality_reason": state.execution_quality_reason,
"quality_message": state.execution_quality_message,
"semantic_status": state.execution_semantic_status,
"semantic_message": state.execution_semantic_message,
"semantic_reason": state.execution_semantic_reason,
"confidence_score": state.execution_confidence_score,
"confidence_level": state.execution_confidence_level,
"confidence_reason": state.execution_confidence_reason,
"spread_percent": state.spread_percent,
"snapshot_age_seconds": state.snapshot_age_seconds,
"market_runtime_degraded": state.market_runtime_degraded,
},
"adaptive_size": {
"base": state.adaptive_size_base,
"final": state.adaptive_size_final,
"multiplier": state.adaptive_size_multiplier,
"effective_risk_percent": state.effective_risk_percent,
"effective_target_risk_usd": state.effective_target_risk_usd,
"reason": state.adaptive_size_reason,
"factors": state.adaptive_size_factors,
},
"position": {
"side": state.position_side,
"entry_price": state.entry_price,
"size": state.position_size,
"leverage": state.leverage,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"realized_pnl_usd": state.realized_pnl_usd,
"cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
"last_execution_action": state.last_execution_action,
"last_execution_reason": state.last_execution_reason,
"last_flip_old_side": state.last_flip_old_side,
"last_flip_new_side": state.last_flip_new_side,
"last_flip_pnl_usd": state.last_flip_pnl_usd,
"last_flip_reason": state.last_flip_reason,
"last_flip_monotonic_at": state.last_flip_monotonic_at,
"current_price": position_current_price,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"stop_loss_usd": state.effective_target_risk_usd,
"take_profit_usd": self._take_profit_usd(state),
"max_loss_usd": state.max_loss_usd,
"opened_age_seconds": self._age_seconds(
now=now,
started_at=state.position_opened_monotonic_at,
),
"price_move_percent": position_health.get("price_move_percent"),
"risk_used_percent": position_health.get("risk_used_percent"),
"health_state": position_health.get("health_state"),
"health_score": position_health.get("health_score"),
"health_message": position_health.get("health_message"),
"pressure_state": position_health.get("pressure_state"),
"trend_alignment": position_health.get("trend_alignment"),
"adverse_momentum": position_health.get("adverse_momentum"),
},
"runtime_health": {
"exchange_statuses": runtime_exchange_alerts,
"exchange_status": (
runtime_exchange_alerts[0]
if runtime_exchange_alerts
else None
),
"health_score": health_score,
"severity": severity,
"is_runtime_degraded": self._is_runtime_degraded(state),
"signal_age_seconds": signal_age_seconds,
"market_age_seconds": market_age_seconds,
"runtime_expired_reason": state.runtime_expired_reason,
"runtime_expired_message": state.runtime_expired_message,
"has_market_data": state.market_state is not None,
"has_momentum_data": getattr(state, "momentum_state", None) is not None,
"position_health_state": position_health.get("health_state"),
"position_health_score": position_health.get("health_score"),
},
"summary": {
"health_score": health_score,
"severity": severity,
"assessment": self._assessment(severity),
"mode": self._display_mode(
severity=severity,
blockers=blockers,
state=state,
),
"headline_mode": (
"POSITION"
if state.position_side != "NONE"
else "ENTRY"
),
"main_message": self._main_message(state=state, blockers=blockers),
"market": state.market_state,
"phase": state.market_phase,
"momentum": getattr(state, "momentum_state", None),
"execution": state.execution_semantic_status,
"position": state.position_side,
"position_health": position_health.get("health_state"),
"is_ready": state.is_signal_ready,
"is_blocked": bool(blockers),
"blockers": blockers,
"symbol": state.symbol,
},
}
def _position_current_price(
self,
state: AutoTradeState,
) -> float | None:
if state.position_side == "NONE":
return None
try:
from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
side = str(state.position_side or "").upper()
price = snapshot.get("last_price")
if side == "LONG":
price = snapshot.get("bid_price") or price
elif side == "SHORT":
price = snapshot.get("ask_price") or price
return safe_float(price)
except Exception:
return None
def _age_seconds(
self,
*,
now: float,
started_at: float | None,
) -> int | None:
started = safe_float(started_at)
if started is None:
return None
return max(0, int(now - started))
def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
return bool(
state.market_runtime_degraded
or state.execution_quality == "BLOCKED"
or state.runtime_expired_reason
)
def _health_score(
self,
*,
state: AutoTradeState,
blockers: list[str],
) -> int:
score = 100
if state.status != "RUNNING":
score -= 10
if blockers:
score -= min(35, len(blockers) * 12)
if state.execution_quality == "BLOCKED":
score -= 30
elif state.execution_quality == "WARNING":
score -= 15
if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}:
score -= 15
if state.market_trend_strength == "WEAK":
score -= 10
if state.market_trend_quality == "NOISY":
score -= 10
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10
if state.ema_distance_state == "COMPRESSED":
score -= 10
if state.ema_distance_state == "EXTENDED":
score -= 8
if state.ema_distance_state == "OVEREXTENDED":
score -= 25
if state.entry_timing_state == "LATE":
score -= 18
if state.entry_timing_state == "CHASING":
score -= 30
trend_quality_score = safe_float(state.trend_quality_score)
if trend_quality_score is not None:
if trend_quality_score < 0.45:
score -= 12
elif trend_quality_score >= 0.7:
score += 5
if state.market_runtime_degraded:
score -= 15
if state.runtime_expired_reason:
score -= 20
if state.is_signal_ready:
score += 10
return max(0, min(100, score))
def _severity(
self,
*,
state: AutoTradeState,
health_score: int,
blockers: list[str],
) -> str:
signal = str(state.last_signal or "HOLD").upper()
has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE"
if state.market_is_open is False:
return "RED"
has_waiting_data_blocker = any(
str(item).strip().lower()
in {
"мало данных",
"мало live-данных",
"недостаточно live-данных",
}
for item in blockers
)
if has_waiting_data_blocker:
return "WAITING"
if (
state.execution_quality == "BLOCKED"
or state.decision_status == "BLOCKED"
or state.runtime_expired_reason
):
return "RED"
if has_position:
if health_score < 45:
return "RED"
if blockers or state.execution_quality == "WARNING" or health_score < 75:
return "YELLOW"
return "GREEN"
if signal == "HOLD" and not has_ready_signal:
return "WAITING"
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
return "RED"
if state.market_trend_quality == "NOISY":
return "RED"
return "YELLOW"
if health_score < 45:
return "YELLOW"
if blockers or state.execution_quality == "WARNING" or health_score < 75:
return "YELLOW"
return "GREEN"
def _assessment(self, severity: str) -> str:
if severity == "GREEN":
return "стабильно"
if severity == "WAITING":
return "ожидание"
if severity == "YELLOW":
return "осторожно"
return "вход нежелателен"
def _display_mode(
self,
*,
severity: str,
blockers: list[str],
state: AutoTradeState | None = None,
) -> str:
if state is not None and state.position_side != "NONE":
return "EXPANDED"
if severity == "GREEN" and not blockers:
return "COMPACT"
return "EXPANDED"
def _main_message(
self,
*,
state: AutoTradeState,
blockers: list[str],
) -> str:
if state.market_is_open is False:
return state.market_status_message or "Биржа временно недоступна для торговли."
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
return "Ожидание: рынок без направления."
return "Осторожно: рынок не подходит."
if state.execution_quality == "BLOCKED":
reason = str(state.execution_quality_reason or "")
if reason == "HIGH_SPREAD":
return "Вход нежелателен: спред мешает входу."
if reason == "STALE_SNAPSHOT":
return "Вход нежелателен: данные рынка устарели."
if reason in {"SNAPSHOT_ERROR", "SNAPSHOT_UNAVAILABLE"}:
return "Вход нежелателен: нет надёжных данных рынка."
return "Вход нежелателен: исполнение заблокировано."
if state.entry_block_message:
return f"Рынок не готов: {state.entry_block_message}."
if state.execution_quality == "WARNING":
return "Вход рискованный: качество исполнения снижено."
if state.is_signal_ready:
return "Сигнал готов, вход разрешён."
if state.last_signal in {"BUY", "SELL"}:
return "Сигнал есть, идёт подтверждение."
if blockers:
return f"Есть ограничения: {', '.join(blockers)}."
return "Критичных ограничений нет."
def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = []
if state.market_is_open is False:
blockers.append(
state.market_status_message
or "рынок закрыт"
)
return blockers
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления")
elif state.entry_block_message:
blockers.append(str(state.entry_block_message))
else:
blockers.append("рынок не подходит")
if state.ema_distance_state == "COMPRESSED":
blockers.append("EMA слишком сжаты")
if state.ema_distance_state == "OVEREXTENDED":
blockers.append("тренд перерастянут")
if state.entry_timing_state == "LATE":
blockers.append("поздний вход")
if state.entry_timing_state == "CHASING":
blockers.append("вход запрещён: chasing move")
if state.entry_block_message:
blockers.append(str(state.entry_block_message))
if state.execution_quality == "BLOCKED":
blockers.append(str(state.execution_quality_message or "исполнение заблокировано"))
if state.decision_status == "BLOCKED":
blockers.append(str(state.decision_reason or "решение заблокировано"))
if state.runtime_expired_message:
blockers.append(str(state.runtime_expired_message))
result: list[str] = []
for item in blockers:
if item and item not in result:
result.append(item)
return result
def _position_health(
self,
*,
state: AutoTradeState,
current_price: float | None,
) -> dict[str, Any]:
if state.position_side == "NONE":
return {
"health_state": "NONE",
"health_score": None,
"health_message": None,
"price_move_percent": None,
"risk_used_percent": None,
"pressure_state": "NONE",
"trend_alignment": "NONE",
"adverse_momentum": False,
}
entry_price = safe_float(state.entry_price)
pnl = safe_float(state.unrealized_pnl_usd)
stop_loss_usd = safe_float(state.effective_target_risk_usd)
max_loss_usd = safe_float(state.max_loss_usd)
price_move_percent = self._position_price_move_percent(
side=state.position_side,
entry_price=entry_price,
current_price=current_price,
)
risk_used_percent = self._position_risk_used_percent(
pnl=pnl,
stop_loss_usd=stop_loss_usd,
max_loss_usd=max_loss_usd,
)
trend_alignment = self._position_trend_alignment(state)
adverse_momentum = self._has_adverse_momentum(state)
pressure_state = self._position_pressure_state(
pnl=pnl,
risk_used_percent=risk_used_percent,
adverse_momentum=adverse_momentum,
)
opened_age_seconds = self._age_seconds(
now=time.monotonic(),
started_at=state.position_opened_monotonic_at,
)
health_score = self._position_health_score(
pnl=pnl,
risk_used_percent=risk_used_percent,
trend_alignment=trend_alignment,
adverse_momentum=adverse_momentum,
pressure_state=pressure_state,
opened_age_seconds=opened_age_seconds,
)
health_state = self._position_health_state(health_score)
health_message = self._position_health_message(
health_state=health_state,
pressure_state=pressure_state,
trend_alignment=trend_alignment,
adverse_momentum=adverse_momentum,
)
return {
"health_state": health_state,
"health_score": health_score,
"health_message": health_message,
"price_move_percent": price_move_percent,
"risk_used_percent": risk_used_percent,
"pressure_state": pressure_state,
"trend_alignment": trend_alignment,
"adverse_momentum": adverse_momentum,
}
def _position_price_move_percent(
self,
*,
side: str | None,
entry_price: float | None,
current_price: float | None,
) -> float | None:
if entry_price is None or current_price is None:
return None
if entry_price <= 0 or current_price <= 0:
return None
normalized_side = str(side or "").upper()
if normalized_side == "LONG":
return round(((current_price - entry_price) / entry_price) * 100, 4)
if normalized_side == "SHORT":
return round(((entry_price - current_price) / entry_price) * 100, 4)
return None
def _position_risk_used_percent(
self,
*,
pnl: float | None,
stop_loss_usd: float | None,
max_loss_usd: float | None,
) -> float | None:
if pnl is None or pnl >= 0:
return 0.0
candidates = [
value
for value in [stop_loss_usd, max_loss_usd]
if value is not None and value > 0
]
if not candidates:
return None
risk_base = min(candidates)
if risk_base is None or risk_base <= 0:
return None
return round(min(999.0, (abs(pnl) / abs(risk_base)) * 100), 1)
def _position_trend_alignment(self, state: AutoTradeState) -> str:
side = str(state.position_side or "").upper()
market_state = str(state.market_state or "").upper()
trend = str(state.market_trend or "").upper()
if side == "LONG":
if market_state == "TREND_UP" or trend == "UP":
return "ALIGNED"
if market_state == "TREND_DOWN" or trend == "DOWN":
return "AGAINST"
if side == "SHORT":
if market_state == "TREND_DOWN" or trend == "DOWN":
return "ALIGNED"
if market_state == "TREND_UP" or trend == "UP":
return "AGAINST"
return "NEUTRAL"
def _has_adverse_momentum(self, state: AutoTradeState) -> bool:
side = str(state.position_side or "").upper()
momentum_direction = str(state.momentum_direction or "").upper()
momentum_state = str(state.momentum_state or "").upper()
if side == "LONG":
return (
momentum_direction == "DOWN"
or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
)
if side == "SHORT":
return (
momentum_direction == "UP"
or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
)
return False
def _position_pressure_state(
self,
*,
pnl: float | None,
risk_used_percent: float | None,
adverse_momentum: bool,
) -> str:
if pnl is None:
return "UNKNOWN"
if pnl >= 0:
if adverse_momentum:
return "PROFIT_UNDER_PRESSURE"
return "PROFIT"
if risk_used_percent is None:
return "LOSS"
if risk_used_percent >= 80:
return "DANGER"
if risk_used_percent >= 50:
return "PRESSURE"
return "LOSS"
def _position_health_score(
self,
*,
pnl: float | None,
risk_used_percent: float | None,
trend_alignment: str,
adverse_momentum: bool,
pressure_state: str,
opened_age_seconds: int | None,
) -> int:
score = 100
if pnl is not None:
if pnl < 0:
score -= 20
elif pnl > 0:
score += 5
if risk_used_percent is not None:
if risk_used_percent >= 80:
score -= 45
elif risk_used_percent >= 50:
score -= 30
elif risk_used_percent >= 25:
score -= 15
if trend_alignment == "AGAINST":
score -= 25
elif trend_alignment == "ALIGNED":
score += 8
if adverse_momentum:
score -= 20
if pressure_state == "DANGER":
score -= 20
elif pressure_state == "PRESSURE":
score -= 10
if opened_age_seconds is not None:
if opened_age_seconds >= 7200:
score -= 20
elif opened_age_seconds >= 3600:
score -= 10
return max(0, min(100, score))
def _position_health_state(self, score: int | None) -> str:
if score is None:
return "UNKNOWN"
if score >= 75:
return "HEALTHY"
if score >= 50:
return "WATCH"
if score >= 30:
return "PRESSURE"
return "DANGER"
def _position_health_message(
self,
*,
health_state: str,
pressure_state: str,
trend_alignment: str,
adverse_momentum: bool,
) -> str:
if health_state == "HEALTHY":
return "Позиция выглядит устойчиво."
if health_state == "WATCH":
return "Позиция требует наблюдения."
if health_state == "PRESSURE":
if trend_alignment == "AGAINST":
return "Позиция под давлением: рынок против направления."
if adverse_momentum:
return "Позиция под давлением: импульс против позиции."
return "Позиция под давлением."
if health_state == "DANGER":
return "Высокий риск по открытой позиции."
return "Состояние позиции не определено."
def _take_profit_usd(self, state: AutoTradeState) -> float | None:
take_profit_percent = safe_float(state.take_profit_percent)
position_size = safe_float(state.position_size)
entry_price = safe_float(state.entry_price)
if (
take_profit_percent is None
or position_size is None
or position_size <= 0
or entry_price is None
or entry_price <= 0
):
return None
move = entry_price * (take_profit_percent / 100)
return move * position_size
def _runtime_exchange_alerts(
self,
state: AutoTradeState,
) -> list[dict[str, Any]]:
return build_runtime_exchange_alerts(symbol=state.symbol)

View File

@@ -1,142 +0,0 @@
# app/src/trading/execution/calculations.py
from __future__ import annotations
from datetime import datetime
from typing import Protocol
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.trading.position.state import PositionState
class _ExecutionCalculationsProtocol(Protocol):
"""
Protocol для доступа к shared position state.
"""
_position: PositionState
class ExecutionCalculationsMixin(
_ExecutionCalculationsProtocol,
):
"""
Execution math/calculation helpers.
Отвечает за:
- pnl calculations
- price move calculations
- shared execution math helpers
- execution timestamps
"""
# =========================================================
# PRICE MOVE %
# =========================================================
def _calculate_price_move_percent(
self,
current_price: NumericLike | None,
) -> float:
"""
Рассчитать изменение цены относительно entry.
LONG:
(current - entry) / entry
SHORT:
(entry - current) / entry
"""
position = type(self)._position
price = safe_float(current_price) or 0.0
entry = safe_float(
position.entry_price
) or 0.0
if entry <= 0:
return 0.0
# -----------------------------------------------------
# LONG
# -----------------------------------------------------
if position.side == "LONG":
return round(
((price - entry) / entry) * 100,
4,
)
# -----------------------------------------------------
# SHORT
# -----------------------------------------------------
if position.side == "SHORT":
return round(
((entry - price) / entry) * 100,
4,
)
return 0.0
# =========================================================
# PNL
# =========================================================
def _calculate_pnl(
self,
current_price: NumericLike | None,
) -> float:
"""
Рассчитать unrealized pnl позиции.
"""
position = type(self)._position
price = safe_float(current_price) or 0.0
entry = safe_float(
position.entry_price
) or 0.0
size = safe_float(
position.size
) or 0.0
# -----------------------------------------------------
# LONG
# -----------------------------------------------------
if position.side == "LONG":
return round(
(price - entry) * size,
4,
)
# -----------------------------------------------------
# SHORT
# -----------------------------------------------------
if position.side == "SHORT":
return round(
(entry - price) * size,
4,
)
return 0.0
# =========================================================
# TIME
# =========================================================
def _now_time(self) -> str:
"""
Current execution timestamp.
"""
return datetime.now().strftime(
"%H:%M:%S"
)

View File

@@ -1,128 +0,0 @@
# app/src/trading/execution/engine.py
from __future__ import annotations
import time
#import math
#from dataclasses import dataclass
#from datetime import datetime
#from src.core.event_bus import EventBus
#from src.integrations.exchange.service import ExchangeService
from src.trading.auto.state import AutoTradeState
from src.trading.execution.models import ExecutionDecision
#from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState
#from src.core.numbers import safe_float
#from src.core.types import NumericLike
from src.trading.execution.pricing import ExecutionPricingMixin
from src.trading.execution.position_runtime import ExecutionPositionRuntimeMixin
from src.trading.execution.position_intelligence import ExecutionPositionIntelligenceMixin
from src.trading.execution.position_protection import ExecutionPositionProtectionMixin
from src.trading.execution.supervisor import ExecutionSupervisorMixin
from src.trading.execution.sizing import ExecutionSizingMixin
from src.trading.execution.risk_close import ExecutionRiskCloseMixin
from src.trading.execution.flip import ExecutionFlipMixin
from src.trading.execution.position_actions import ExecutionPositionActionsMixin
from src.trading.execution.runtime_actions import ExecutionRuntimeActionsMixin
from src.trading.execution.calculations import ExecutionCalculationsMixin
from src.trading.execution.resets import ExecutionResetsMixin
class ExecutionEngine(
ExecutionCalculationsMixin,
ExecutionResetsMixin,
ExecutionPricingMixin,
ExecutionPositionRuntimeMixin,
ExecutionPositionIntelligenceMixin,
ExecutionSizingMixin,
ExecutionPositionActionsMixin,
ExecutionPositionProtectionMixin,
ExecutionSupervisorMixin,
ExecutionRiskCloseMixin,
ExecutionFlipMixin,
ExecutionRuntimeActionsMixin,
):
_position = PositionState()
_size_precision = 5
_min_flip_confidence = 0.75
_min_flip_repeat_count = 3
_min_flip_hold_seconds = 60
_flip_cooldown_seconds = 45
_loss_flip_confidence = 0.9
_last_flip_block_key: str | None = None
_runtime_action_cooldown_seconds = 30
_last_runtime_action_key: str | None = None
_emergency_halt_drawdown_usd = 250.0
_emergency_halt_loss_streak = 5
_execution_cooldown_after_loss_seconds = 90
_max_execution_snapshot_age_seconds = 5
_degraded_market_block_states = {
"HIGH_VOLATILITY",
"CHAOTIC",
"LIQUIDITY_VOID",
}
_conflict_execution_block = True
_last_supervisor_block_key: str | None = None
def process(self, state: AutoTradeState) -> ExecutionDecision:
self._sync_state_from_position(state)
if state.status != "RUNNING":
return ExecutionDecision("NONE", False, "Execution доступен только в режиме RUNNING.")
self._update_unrealized_pnl(state)
risk_decision = self._risk_close_decision(state)
if risk_decision is not None:
return risk_decision
protection_decision = self._process_runtime_protection(state)
if protection_decision is not None:
return protection_decision
supervisor_decision = self._process_execution_supervisor(state)
if supervisor_decision is not None:
return supervisor_decision
if state.decision_status != "READY" or not state.is_signal_ready:
return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.")
position = type(self)._position
# Не пытаемся повторно открыть позицию в ту же сторону.
# Сигнал остаётся валидным для UI/Telegram, но execution не дублируется.
if position.side == "LONG" and state.last_signal == "BUY":
return ExecutionDecision(
"NONE",
False,
"Сигнал BUY совпадает с уже открытой LONG позицией.",
)
if position.side == "SHORT" and state.last_signal == "SELL":
return ExecutionDecision(
"NONE",
False,
"Сигнал SELL совпадает с уже открытой SHORT позицией.",
)
if self._should_flip_position(state):
flip_block_reason = self._flip_block_reason(state)
if flip_block_reason is not None:
return self._block_flip(state, flip_block_reason)
return self._flip_position(state)
if state.last_signal == "BUY":
return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG")
if state.last_signal == "SELL":
return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT")
return ExecutionDecision("NONE", False, "Нет торгового действия.")

View File

@@ -1,446 +0,0 @@
# app/src/trading/execution/flip.py
from __future__ import annotations
import time
from typing import Protocol
from src.core.event_bus import EventBus
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.journal.service import JournalService
from src.trading.position.state import PositionState
from src.trading.execution.pricing import ExecutionPrice
class _ExecutionFlipProtocol(Protocol):
_position: PositionState
_min_flip_confidence: float
_min_flip_repeat_count: int
_min_flip_hold_seconds: int
_flip_cooldown_seconds: int
_loss_flip_confidence: float
_last_flip_block_key: str | None
def _create_trade_id(self, state: AutoTradeState, side: str) -> str: ...
# получить exit price для текущей стороны позиции
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
# получить entry price для новой стороны позиции
def _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
# рассчитать размер позиции
def _calculate_position_size(
self,
state: AutoTradeState,
*,
entry_price: float | None = None,
) -> float: ...
# ограничить размер позиции margin-limit правилом
def _adjust_size_by_margin_limit(
self,
*,
state: AutoTradeState,
entry_price: float,
size: float,
) -> float: ...
# пересчитать effective risk после margin-limit
def _sync_effective_risk_after_margin_limit(
self,
state: AutoTradeState,
*,
base_size: float,
final_size: float,
) -> None: ...
# округлить размер позиции
def _round_size(self, size) -> float: ...
# рассчитать PnL позиции
def _calculate_pnl(self, current_price) -> float: ...
# синхронизировать AutoTradeState с PositionState
def _sync_state_from_position(self, state: AutoTradeState) -> None: ...
# посчитать время удержания позиции
def _position_hold_seconds(self, position: PositionState) -> int | None: ...
# получить текущее время строкой
def _now_time(self) -> str: ...
class ExecutionFlipMixin(_ExecutionFlipProtocol):
# записать отказ flip execution в журнал
def _log_flip_rejected(
self,
*,
state: AutoTradeState,
reason: str,
) -> None:
position = type(self)._position
payload: JsonDict = {
"execution_type": "FLIP_REJECTED",
"symbol": state.symbol,
"position_side": position.side,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"reject_reason": reason,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"opened_at": position.opened_at,
"updated_at": position.updated_at,
}
JournalService().log_ui_warning(
event_type="position_flip_rejected",
message=f"Flip позиции отклонён: {reason}",
screen="auto",
action="paper_execution",
payload=payload,
)
# проверить, нужен ли flip позиции по текущему сигналу
def _should_flip_position(self, state: AutoTradeState) -> bool:
position = type(self)._position
if position.side == "NONE":
return False
if position.side == "LONG" and state.last_signal == "SELL":
return True
if position.side == "SHORT" and state.last_signal == "BUY":
return True
return False
# определить причину блокировки flip, если flip сейчас опасен
def _flip_block_reason(self, state: AutoTradeState) -> 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)
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
hold_seconds = self._position_hold_seconds(position)
momentum_direction = getattr(state, "momentum_direction", None)
momentum_state = getattr(state, "momentum_state", None)
signal = (state.last_signal or "").upper()
if confidence < self._min_flip_confidence:
return (
"уверенность сигнала ниже порога "
f"({confidence:.2f} < {self._min_flip_confidence:.2f})"
)
if repeat_count < self._min_flip_repeat_count:
return (
"сигнал ещё не подтверждён нужным количеством повторов "
f"({repeat_count} < {self._min_flip_repeat_count})"
)
if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds:
return (
"позиция открыта слишком недавно "
f"({hold_seconds}с < {self._min_flip_hold_seconds}с)"
)
if self._flip_cooldown_active(state):
return (
"flip cooldown активен "
f"(< {self._flip_cooldown_seconds}с)"
)
if signal == "BUY" and momentum_direction == "DOWN":
return "momentum направлен против BUY сигнала"
if signal == "SELL" and momentum_direction == "UP":
return "momentum направлен против SELL сигнала"
if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
if confidence < 0.85:
return (
"flip заблокирован во время breakout impulse "
f"({confidence:.2f} < 0.85)"
)
if unrealized_pnl < 0 and confidence < self._loss_flip_confidence:
return (
"позиция сейчас в минусе, а сигнал недостаточно сильный "
f"({confidence:.2f} < {self._loss_flip_confidence:.2f})"
)
return None
# записать блокировку flip в state, journal и event bus
def _block_flip(
self,
state: AutoTradeState,
reason: str,
) -> ExecutionDecision:
position = type(self)._position
confidence = safe_float(state.last_signal_confidence) or 0.0
state.execution_block_reason = reason
state.last_flip_block_reason = reason
state.last_execution_action = "FLIP_BLOCKED"
state.last_execution_reason = reason
block_key = (
f"{position.side}:"
f"{state.last_signal}:"
f"{state.last_signal_repeat_count}:"
f"{confidence:.2f}:"
f"{reason}"
)
if block_key != type(self)._last_flip_block_key:
type(self)._last_flip_block_key = block_key
payload: JsonDict = {
"execution_type": "FLIP_BLOCKED",
"symbol": state.symbol,
"position_side": position.side,
"signal": state.last_signal,
"confidence": confidence,
"repeat_count": state.last_signal_repeat_count,
"reason": reason,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"opened_at": position.opened_at,
"updated_at": position.updated_at,
}
JournalService().log_ui_warning(
event_type="position_flip_blocked",
message=f"Смена направления позиции заблокирована: {reason}.",
screen="auto",
action="paper_execution",
payload=payload,
)
EventBus.emit("paper_flip_blocked", payload)
return ExecutionDecision("NONE", False, reason)
# проверить, активен ли cooldown после последнего flip
def _flip_cooldown_active(
self,
state: AutoTradeState,
) -> bool:
ts = getattr(state, "last_flip_monotonic_at", None)
if ts is None:
return False
return (
time.monotonic() - float(ts)
) < self._flip_cooldown_seconds
# определить сторону позиции по сигналу BUY / SELL
def _target_side_from_signal(self, signal: str | None) -> str | None:
if signal == "BUY":
return "LONG"
if signal == "SELL":
return "SHORT"
return None
# закрыть текущую позицию и открыть новую в противоположную сторону
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
position = type(self)._position
if position.side == "NONE":
self._sync_state_from_position(state)
reason = "Нет позиции для flip."
self._log_flip_rejected(state=state, reason=reason)
return ExecutionDecision("NONE", False, reason)
new_side = self._target_side_from_signal(state.last_signal)
if new_side is None:
reason = "Нет направления для flip."
self._log_flip_rejected(state=state, reason=reason)
return ExecutionDecision("NONE", False, reason)
try:
exit_execution = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
entry_execution = self._entry_price_for_side(
state.symbol,
new_side,
)
exit_price = exit_execution.price
new_entry_price = entry_execution.price
except Exception as exc:
reason = f"Ошибка получения цены для flip: {exc}"
self._log_flip_rejected(state=state, reason=reason)
return ExecutionDecision("NONE", False, reason)
now = self._now_time()
opened_monotonic_at = time.monotonic()
pnl = self._calculate_pnl(exit_price)
new_size = self._calculate_position_size(
state,
entry_price=new_entry_price,
)
if new_size <= 0:
reason = "Flip отменён: невозможно рассчитать adaptive size."
self._log_flip_rejected(state=state, reason=reason)
return ExecutionDecision("NONE", False, reason)
new_size = self._adjust_size_by_margin_limit(
state=state,
entry_price=new_entry_price,
size=new_size,
)
self._sync_effective_risk_after_margin_limit(
state,
base_size=state.adaptive_size_base or 0.0,
final_size=new_size,
)
new_size = self._round_size(new_size)
if new_size <= 0:
reason = "Flip отменён: итоговый size равен 0."
self._log_flip_rejected(state=state, reason=reason)
return ExecutionDecision("NONE", False, reason)
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
old_side = position.side
old_entry_price = position.entry_price
old_size = position.size
old_leverage = position.leverage
old_opened_at = position.opened_at
state.last_flip_old_side = old_side
state.last_flip_new_side = new_side
state.last_flip_pnl_usd = pnl
state.last_flip_reason = state.last_signal_reason
state.last_flip_monotonic_at = time.monotonic()
old_trade_id = position.trade_id or state.current_trade_id
old_trade_sequence = position.trade_sequence or state.trade_sequence
old_trade_cycle_number = (
position.trade_cycle_number
or state.current_trade_cycle_number
or state.cycle_number
)
new_trade_id = self._create_trade_id(state, new_side)
state.current_trade_id = new_trade_id
state.current_trade_cycle_number = state.cycle_number
type(self)._position = PositionState(
trade_id=new_trade_id,
trade_cycle_number=state.current_trade_cycle_number,
trade_sequence=state.trade_sequence,
side=new_side,
symbol=state.symbol,
entry_price=new_entry_price,
size=new_size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
opened_at=now,
opened_monotonic_at=opened_monotonic_at,
updated_at=now,
)
self._sync_state_from_position(state)
state.execution_block_reason = None
state.last_flip_block_reason = None
state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}"
state.last_execution_reason = "Направление позиции изменено."
state.last_flip_at = now
type(self)._last_flip_block_key = None
payload: JsonDict = {
"trade_id": old_trade_id,
"closed_trade_id": old_trade_id,
"new_trade_id": new_trade_id,
"trade_sequence": old_trade_sequence,
"trade_cycle_number": old_trade_cycle_number,
"closed_trade_sequence": old_trade_sequence,
"closed_trade_cycle_number": old_trade_cycle_number,
"new_trade_sequence": state.trade_sequence,
"new_trade_cycle_number": state.current_trade_cycle_number,
"execution_type": "FLIP",
"action": f"FLIP_{old_side}_TO_{new_side}",
"symbol": state.symbol,
"old_side": old_side,
"new_side": new_side,
"side": new_side,
"entry_price": old_entry_price,
"exit_price": exit_price,
"new_entry_price": new_entry_price,
"old_size": old_size,
"new_size": new_size,
"size": new_size,
"old_leverage": old_leverage,
"leverage": state.leverage,
"pnl": pnl,
"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,
"opened_at": old_opened_at,
"new_opened_monotonic_at": opened_monotonic_at,
"closed_at": now,
"new_opened_at": now,
"pricing": "exit_by_side_then_entry_by_side",
"exit_pricing_role": exit_execution.pricing_role,
"exit_price_source": exit_execution.source,
"exit_price_age_seconds": exit_execution.age_seconds,
"exit_price_updated_at": exit_execution.updated_at,
"entry_pricing_role": entry_execution.pricing_role,
"entry_price_source": entry_execution.source,
"entry_price_age_seconds": entry_execution.age_seconds,
"entry_price_updated_at": entry_execution.updated_at,
}
JournalService().log_ui_info(
event_type="position_flipped",
message=f"Направление позиции изменено: {old_side}{new_side}.",
screen="auto",
action="paper_execution",
payload=payload,
)
EventBus.emit("paper_position_flipped", payload)
return ExecutionDecision(
f"FLIP_{old_side}_TO_{new_side}",
True,
f"Направление позиции изменено: {old_side}{new_side}.",
)

View File

@@ -1,17 +0,0 @@
# app/src/trading/execution/models.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class ExecutionDecision:
# действие: NONE / OPEN_LONG / OPEN_SHORT
action: str
# можно ли выполнить действие
can_execute: bool
# причина решения
reason: str

View File

@@ -1,478 +0,0 @@
# app/src/trading/execution/position_actions.py
from __future__ import annotations
import time
from typing import Protocol
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.execution.models import ExecutionDecision
from src.trading.execution.pricing import ExecutionPrice
from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState
class _ExecutionPositionActionsProtocol(Protocol):
_position: PositionState
_last_flip_block_key: str | None
# создать trade id
def _create_trade_id(
self,
state: AutoTradeState,
side: str,
) -> str: ...
# получить entry execution price
def _entry_price_for_side(
self,
symbol: str,
side: str,
) -> ExecutionPrice: ...
# получить exit execution price
def _exit_price_for_side(
self,
symbol: str,
side: str,
) -> ExecutionPrice: ...
# рассчитать adaptive size
def _calculate_position_size(
self,
state: AutoTradeState,
*,
entry_price: float | None = None,
) -> float: ...
# ограничить size margin limit
def _adjust_size_by_margin_limit(
self,
*,
state: AutoTradeState,
entry_price: float,
size: float,
) -> float: ...
# обновить effective risk после margin limit
def _sync_effective_risk_after_margin_limit(
self,
state: AutoTradeState,
*,
base_size: float,
final_size: float,
) -> None: ...
# округлить size
def _round_size(self, size: NumericLike | None) -> float: ...
# синхронизировать state с position
def _sync_state_from_position(
self,
state: AutoTradeState,
) -> None: ...
# посчитать pnl
def _calculate_pnl(
self,
current_price: NumericLike | None,
) -> float: ...
# получить текущее время
def _now_time(self) -> str: ...
# reset runtime protection state
def _reset_runtime_protection_state(
self,
state: AutoTradeState,
) -> None: ...
class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
# создать новый trade_id для связки open -> close
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
state.trade_sequence = int(state.trade_sequence or 0) + 1
cycle_number = int(state.cycle_number or 0)
return (
f"trade-{cycle_number}-"
f"{state.trade_sequence}-"
f"{side.lower()}-"
f"{int(time.time())}"
)
# записать отказ открытия позиции в журнал
def _log_position_open_rejected(
self,
*,
state: AutoTradeState,
side: str,
action: str,
reason: str,
) -> None:
payload: JsonDict = {
"execution_type": "ENTRY_REJECTED",
"action": action,
"symbol": state.symbol,
"side": side,
"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,
"reject_reason": reason,
}
JournalService().log_ui_warning(
event_type="position_open_rejected",
message=f"Открытие позиции {side} отклонено: {reason}",
screen="auto",
action="paper_execution",
payload=payload,
)
# открыть позицию, если сейчас позиции нет
def _open_position_if_empty(
self,
*,
state: AutoTradeState,
side: str,
action: str,
) -> ExecutionDecision:
position = type(self)._position
if position.side != "NONE":
self._sync_state_from_position(state)
if position.side == side:
reason = f"Позиция {side} уже открыта."
return ExecutionDecision("NONE", False, reason)
reason = (
f"Позиция уже открыта в другом направлении: "
f"{position.side}, новый запрос: {side}."
)
self._log_position_open_rejected(
state=state,
side=side,
action=action,
reason=reason,
)
return ExecutionDecision("NONE", False, reason)
try:
entry = self._entry_price_for_side(state.symbol, side)
entry_price = entry.price
except Exception as exc:
reason = f"Не удалось получить цену для paper execution: {exc}"
self._log_position_open_rejected(
state=state,
side=side,
action=action,
reason=reason,
)
return ExecutionDecision("NONE", False, reason)
now = self._now_time()
opened_monotonic_at = time.monotonic()
size = self._calculate_position_size(
state,
entry_price=entry_price,
)
if size <= 0:
reason = "Позиция не открыта: невозможно рассчитать adaptive size."
self._log_position_open_rejected(
state=state,
side=side,
action=action,
reason=reason,
)
return ExecutionDecision("NONE", False, reason)
size = self._adjust_size_by_margin_limit(
state=state,
entry_price=entry_price,
size=size,
)
self._sync_effective_risk_after_margin_limit(
state,
base_size=state.adaptive_size_base or 0.0,
final_size=size,
)
size = self._round_size(size)
if size <= 0:
reason = "Позиция не открыта: итоговый size равен 0."
self._log_position_open_rejected(
state=state,
side=side,
action=action,
reason=reason,
)
return ExecutionDecision("NONE", False, reason)
trade_id = self._create_trade_id(state, side)
state.current_trade_id = trade_id
state.current_trade_cycle_number = state.cycle_number
type(self)._position = PositionState(
trade_id=trade_id,
trade_cycle_number=state.current_trade_cycle_number,
trade_sequence=state.trade_sequence,
side=side,
symbol=state.symbol,
entry_price=entry_price,
size=size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
opened_at=now,
opened_monotonic_at=opened_monotonic_at,
updated_at=now,
)
self._sync_state_from_position(state)
state.execution_block_reason = None
state.last_flip_block_reason = None
state.last_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта."
payload: JsonDict = {
"trade_id": trade_id,
"trade_sequence": state.trade_sequence,
"trade_cycle_number": state.current_trade_cycle_number,
"execution_type": "ENTRY",
"action": action,
"symbol": state.symbol,
"side": side,
"entry_price": entry_price,
"size": size,
"leverage": state.leverage,
"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,
"opened_at": now,
"opened_monotonic_at": opened_monotonic_at,
"pricing": "ask_for_long_bid_for_short",
"pricing_role": entry.pricing_role,
"price_source": entry.source,
"price_age_seconds": entry.age_seconds,
"price_updated_at": entry.updated_at,
}
JournalService().log_ui_info(
event_type="position_opened",
message=f"Позиция {side} открыта: {state.symbol}.",
screen="auto",
action="paper_execution",
payload=payload,
)
EventBus.emit("paper_position_opened", payload)
return ExecutionDecision(action, True, f"Позиция {side} открыта.")
# закрыть открытую позицию
def _close_position(
self,
state: AutoTradeState,
*,
forced_reason: str | None = None,
forced_exit_price: NumericLike | None = None,
forced_pnl: NumericLike | None = None,
forced_price_meta: ExecutionPrice | None = None,
) -> ExecutionDecision:
position = type(self)._position
if position.side == "NONE":
self._sync_state_from_position(state)
return ExecutionDecision(
"NONE",
False,
"Нет открытой позиции для закрытия.",
)
if forced_exit_price is not None:
exit_price = safe_float(forced_exit_price) or 0.0
exit_execution = forced_price_meta
else:
try:
exit_execution = self._exit_price_for_side(
position.symbol or state.symbol,
position.side,
)
exit_price = exit_execution.price
except Exception as exc:
return ExecutionDecision(
"NONE",
False,
f"Ошибка получения цены для закрытия: {exc}",
)
pnl = (
safe_float(forced_pnl)
if forced_pnl is not None
else self._calculate_pnl(exit_price)
)
if pnl is None:
pnl = 0.0
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
if pnl < 0:
state.last_loss_monotonic_at = time.monotonic()
now = self._now_time()
trade_id = (
position.trade_id
or state.current_trade_id
)
payload: JsonDict = {
"trade_id": trade_id,
"trade_sequence": position.trade_sequence or state.trade_sequence,
"trade_cycle_number": (
position.trade_cycle_number
or state.current_trade_cycle_number
),
"execution_type": "EXIT",
"action": "CLOSE",
"symbol": state.symbol,
"side": position.side,
"entry_price": position.entry_price,
"exit_price": exit_price,
"size": position.size,
"leverage": position.leverage,
"pnl": pnl,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"risk_reason": forced_reason,
"is_forced": forced_reason is not None,
"opened_at": position.opened_at,
"closed_at": now,
"pricing": "bid_for_long_exit_ask_for_short_exit",
"pricing_role": (
exit_execution.pricing_role
if exit_execution
else None
),
"price_source": (
exit_execution.source
if exit_execution
else None
),
"price_age_seconds": (
exit_execution.age_seconds
if exit_execution
else None
),
"price_updated_at": (
exit_execution.updated_at
if exit_execution
else None
),
}
close_reason = forced_reason or "MANUAL"
JournalService().log_ui_info(
event_type="position_closed",
message=f"Позиция {position.side} закрыта: {close_reason}.",
screen="auto",
action="paper_execution",
payload=payload,
)
EventBus.emit(
"paper_position_closed",
payload,
)
type(self)._position = PositionState()
self._sync_state_from_position(state)
state.position_opened_monotonic_at = None
state.current_trade_id = None
state.current_trade_cycle_number = None
self._reset_runtime_protection_state(state)
state.execution_block_reason = None
state.last_flip_block_reason = None
state.last_execution_action = (
f"FORCE_CLOSE_{forced_reason}"
if forced_reason is not None
else "CLOSE"
)
state.last_execution_reason = (
f"Позиция закрыта по правилу защиты: {forced_reason}."
if forced_reason is not None
else "Позиция закрыта."
)
type(self)._last_flip_block_key = None
if forced_reason is not None:
return ExecutionDecision(
f"FORCE_CLOSE_{forced_reason}",
True,
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
return ExecutionDecision(
"CLOSE",
True,
"Позиция закрыта.",
)

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