Compare commits
75 Commits
00ba553ca9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e6392e28 | |||
| f9a25e7671 | |||
| 06ea376cb5 | |||
| 2c75f95b46 | |||
| 8e1c09ad66 | |||
| 2a9e95540e | |||
| 4a34338041 | |||
| 8b83055e6a | |||
| 1aa8f6c407 | |||
| 2be7d92660 | |||
| 0dbb609b5a | |||
| fc50cadabf | |||
| 5325ea3855 | |||
| 9ba1297c46 | |||
| eb40ecc4dd | |||
| ec9904f91d | |||
| fe33e0c026 | |||
| e17f847603 | |||
| 363719cc8e | |||
| b5d931bbb7 | |||
| c07a1a4dff | |||
| ef7cec68cc | |||
| 8024cd9d9a | |||
| 1692cb4d81 | |||
| e72e2e51db | |||
| 38e8472942 | |||
| c3cf446143 | |||
| 7e5ecc2ed9 | |||
| 3181ac680c | |||
| e97dcd372b | |||
| 71cf206e32 | |||
| df76490783 | |||
| ee78f9774a | |||
| b1513a28ef | |||
| 3c3f0e846a | |||
| 163e8efe82 | |||
| 8dd6298712 | |||
| 1253cda003 | |||
| d8c077d066 | |||
| 75ba87c6d1 | |||
| 8adfab7220 | |||
| af2d27761f | |||
| 24c910fade | |||
| bd6b40fcb2 | |||
| 38c8686a9b | |||
| ec8e53c416 | |||
| 80f29443d4 | |||
| 7c8895c3a5 | |||
| 41c332d9cb | |||
| 51659037bb | |||
| 861f98024c | |||
| 93cdd164ae | |||
| b2801d8a19 | |||
| d639137855 | |||
| 83ab842f6e | |||
| b48d9c7f35 | |||
| cea74da4c4 | |||
| f6fc300e84 | |||
| 1fb72ced58 | |||
| 2a9ef16524 | |||
| c36e43f5e8 | |||
| cec7c761be | |||
| 39b35d742a | |||
| 2be2ac1d30 | |||
| e9fd3ea4a0 | |||
| 604a8c0069 | |||
| b1b9beef78 | |||
| f48effd9b5 | |||
| f662ff1901 | |||
| 76fc122955 | |||
| 2c49bb70c0 | |||
| c35deeaefa | |||
| 1deb676585 | |||
| 96998ee998 | |||
| 8e3f240558 |
@@ -4,6 +4,12 @@ APP_ENV=dev
|
||||
LOG_LEVEL=INFO
|
||||
TZ=Europe/Minsk
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=dzentra_bot
|
||||
DB_USER=dzentra_bot
|
||||
DB_PASSWORD=change_me
|
||||
|
||||
EXCHANGE_ENABLED=true
|
||||
EXCHANGE_NAME=dzengi
|
||||
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
# app/requirements.txt
|
||||
|
||||
aiogram==3.13.1
|
||||
python-dotenv==1.0.1
|
||||
psycopg[binary]==3.2.9
|
||||
openpyxl==3.1.5
|
||||
websockets==13.1
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/bootstrap/app_factory.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
@@ -5,18 +7,58 @@ from aiogram.client.default import DefaultBotProperties
|
||||
|
||||
from src.bootstrap.logging import setup_logging
|
||||
from src.core.config import load_settings
|
||||
from src.notifications.targets import NotificationTargetRegistry
|
||||
from src.storage.schema import init_schema
|
||||
from src.telegram.routers import setup_routers
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
def create_app() -> tuple[Bot, Dispatcher]:
|
||||
settings = load_settings()
|
||||
|
||||
setup_logging(settings.log_level)
|
||||
|
||||
journal = JournalService()
|
||||
|
||||
try:
|
||||
init_schema()
|
||||
except Exception as exc:
|
||||
try:
|
||||
journal.log_critical(
|
||||
"app_bootstrap_failed",
|
||||
f"Не удалось инициализировать схему БД: {exc}",
|
||||
{
|
||||
"env": settings.app_env,
|
||||
"exchange_name": settings.exchange_name,
|
||||
"default_symbol": settings.default_symbol,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
try:
|
||||
journal.log_info(
|
||||
"app_started",
|
||||
"Приложение запущено",
|
||||
{
|
||||
"env": settings.app_env,
|
||||
"exchange_name": settings.exchange_name,
|
||||
"default_symbol": settings.default_symbol,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot = Bot(
|
||||
token=settings.bot_token,
|
||||
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
|
||||
)
|
||||
|
||||
NotificationTargetRegistry.set_bot(bot)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
|
||||
setup_routers(dispatcher)
|
||||
|
||||
return bot, dispatcher
|
||||
return bot, dispatcher
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/bootstrap/logging.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -7,4 +9,4 @@ def setup_logging(log_level: str) -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
)
|
||||
@@ -1,52 +1,113 @@
|
||||
# app/src/core/config.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
# корень проекта
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# .env файл
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
|
||||
# загружаем переменные окружения
|
||||
load_dotenv(ENV_FILE)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
# Telegram
|
||||
bot_token: str
|
||||
bot_parse_mode: str
|
||||
|
||||
# App
|
||||
app_env: str
|
||||
log_level: str
|
||||
tz: str
|
||||
|
||||
# Exchange
|
||||
exchange_enabled: bool
|
||||
exchange_name: str
|
||||
exchange_base_url: str
|
||||
exchange_ws_url: str
|
||||
exchange_api_key: str
|
||||
exchange_api_secret: str
|
||||
exchange_timeout_sec: int
|
||||
exchange_testnet: bool
|
||||
default_symbol: str
|
||||
|
||||
# Database
|
||||
db_host: str
|
||||
db_port: int
|
||||
db_name: str
|
||||
db_user: str
|
||||
db_password: str
|
||||
|
||||
# Debag helper
|
||||
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:
|
||||
value = (raw_value or "").strip().lower()
|
||||
if not value:
|
||||
return default
|
||||
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# parse int
|
||||
def _parse_int(raw_value: str, default: int) -> int:
|
||||
value = (raw_value or "").strip()
|
||||
if not value:
|
||||
return default
|
||||
|
||||
return int(value)
|
||||
|
||||
|
||||
# load all settings
|
||||
def load_settings() -> Settings:
|
||||
bot_token = os.getenv("BOT_TOKEN", "").strip()
|
||||
|
||||
if not bot_token:
|
||||
raise RuntimeError("BOT_TOKEN is not set in app/.env")
|
||||
|
||||
return Settings(
|
||||
# Telegram
|
||||
bot_token=bot_token,
|
||||
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
|
||||
|
||||
# App
|
||||
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
||||
tz=os.getenv("TZ", "Europe/Madrid").strip() or "Europe/Madrid",
|
||||
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_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
||||
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_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
|
||||
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
||||
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE",
|
||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip()
|
||||
or "BTC/USD_LEVERAGE",
|
||||
|
||||
# Database
|
||||
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
||||
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
|
||||
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
||||
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
||||
db_password=os.getenv("DB_PASSWORD", "").strip(),
|
||||
)
|
||||
@@ -1,2 +1,4 @@
|
||||
# app/src/core/constants.py
|
||||
|
||||
APP_NAME = "Dzentra Bot"
|
||||
APP_VERSION = "2.0.0"
|
||||
APP_VERSION = "2.0.0"
|
||||
28
app/src/core/event_bus.py
Normal file
28
app/src/core/event_bus.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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)
|
||||
96
app/src/core/event_titles.py
Normal file
96
app/src/core/event_titles.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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, "Событие")
|
||||
23
app/src/core/numbers.py
Normal file
23
app/src/core/numbers.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
@@ -1,128 +1,262 @@
|
||||
# app/src/core/system_status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.core.config import load_settings
|
||||
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.status import build_exchange_error_status
|
||||
from src.storage.session import check_database_health
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ComponentStatus:
|
||||
name: str
|
||||
state: str
|
||||
details: str
|
||||
details: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SystemSnapshot:
|
||||
app_name: str
|
||||
app_version: str
|
||||
app_env: str
|
||||
python_version: str
|
||||
os_name: str
|
||||
db_label: str
|
||||
timezone_name: str
|
||||
exchange_enabled: bool
|
||||
exchange_name: str
|
||||
mode_label: str
|
||||
default_symbol: str
|
||||
symbol_validation_message: str
|
||||
components: list[ComponentStatus]
|
||||
|
||||
|
||||
def get_system_snapshot() -> SystemSnapshot:
|
||||
settings = load_settings()
|
||||
def _build_exchange_alert_components(
|
||||
*,
|
||||
default_symbol: str,
|
||||
) -> list[ComponentStatus]:
|
||||
exchange_service = ExchangeService()
|
||||
|
||||
try:
|
||||
symbol_validation = exchange_service.validate_symbol(settings.default_symbol)
|
||||
runtime_status = exchange_service.get_symbol_runtime_status(default_symbol)
|
||||
except Exception as exc:
|
||||
symbol_validation = None
|
||||
symbol_validation_message = f"Не удалось проверить символ: {exc}"
|
||||
else:
|
||||
symbol_validation_message = symbol_validation.message
|
||||
runtime_status = build_exchange_error_status(exc)
|
||||
|
||||
exchange_health = exchange_service.get_health()
|
||||
if not runtime_status.is_available:
|
||||
return [
|
||||
ComponentStatus(
|
||||
name="Биржа",
|
||||
state=runtime_status.ui_line,
|
||||
details=runtime_status.message,
|
||||
)
|
||||
]
|
||||
|
||||
if exchange_health.ok and exchange_health.mode == "mock":
|
||||
exchange_state = "🟡 mock mode"
|
||||
elif exchange_health.ok:
|
||||
exchange_state = "🟢 API OK"
|
||||
else:
|
||||
exchange_state = "🔴 ошибка"
|
||||
alerts = build_runtime_exchange_alerts(symbol=default_symbol)
|
||||
|
||||
symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка"
|
||||
|
||||
components = [
|
||||
ComponentStatus(
|
||||
name="Бот",
|
||||
state="🟢 работает",
|
||||
details="Процесс бота запущен и обрабатывает команды.",
|
||||
),
|
||||
ComponentStatus(
|
||||
name="Telegram",
|
||||
state="🟢 OK",
|
||||
details="Polling активен, базовая маршрутизация подключена.",
|
||||
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=exchange_state,
|
||||
details=exchange_health.message,
|
||||
),
|
||||
ComponentStatus(
|
||||
name="Символ",
|
||||
state=symbol_state,
|
||||
details=symbol_validation_message,
|
||||
),
|
||||
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:
|
||||
if not raw:
|
||||
return "PostgreSQL"
|
||||
|
||||
match = re.search(r"PostgreSQL\s+(\d+(?:\.\d+)?)", raw)
|
||||
if match:
|
||||
return f"PostgreSQL {match.group(1)}"
|
||||
|
||||
return "PostgreSQL"
|
||||
|
||||
|
||||
# проверить подключение к БД и вернуть компонент + подпись версии
|
||||
def _build_database_status() -> tuple[ComponentStatus, str]:
|
||||
db_ok, db_message = check_database_health()
|
||||
db_label = _extract_postgres_version(db_message)
|
||||
|
||||
if db_ok:
|
||||
return (
|
||||
ComponentStatus(
|
||||
name="База данных",
|
||||
state="🟢",
|
||||
),
|
||||
db_label,
|
||||
)
|
||||
|
||||
return (
|
||||
ComponentStatus(
|
||||
name="База данных",
|
||||
state="🟡 не подключена",
|
||||
details="Слой хранения пока только подготовлен структурно.",
|
||||
state="🔴 База данных недоступна",
|
||||
),
|
||||
db_label,
|
||||
)
|
||||
|
||||
|
||||
# проверить доступность журнала событий
|
||||
def _build_journal_status() -> ComponentStatus:
|
||||
ok, _ = JournalService().get_journal_health()
|
||||
|
||||
if ok:
|
||||
return ComponentStatus(
|
||||
name="Журнал",
|
||||
state="🟢",
|
||||
)
|
||||
|
||||
return ComponentStatus(
|
||||
name="Журнал",
|
||||
state="🔴 Журнал недоступен",
|
||||
)
|
||||
|
||||
|
||||
# определить runtime-режим по base_url биржи
|
||||
def get_runtime_mode_key() -> str:
|
||||
settings = load_settings()
|
||||
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:
|
||||
settings = load_settings()
|
||||
|
||||
database_status, db_label = _build_database_status()
|
||||
journal_status = _build_journal_status()
|
||||
|
||||
exchange_components = _build_exchange_alert_components(
|
||||
default_symbol=settings.default_symbol,
|
||||
)
|
||||
|
||||
components = [
|
||||
ComponentStatus(name="Приложение", state="🟢"),
|
||||
database_status,
|
||||
ComponentStatus(name="Telegram", state="🟢"),
|
||||
*exchange_components,
|
||||
journal_status,
|
||||
]
|
||||
|
||||
return SystemSnapshot(
|
||||
app_name=APP_NAME,
|
||||
app_version=APP_VERSION,
|
||||
app_env=settings.app_env,
|
||||
python_version=platform.python_version(),
|
||||
os_name=f"{platform.system()} {platform.release()}",
|
||||
db_label=db_label,
|
||||
timezone_name=settings.tz,
|
||||
exchange_enabled=settings.exchange_enabled,
|
||||
exchange_name=settings.exchange_name,
|
||||
mode_label=get_runtime_mode_label(),
|
||||
default_symbol=settings.default_symbol,
|
||||
symbol_validation_message=symbol_validation_message,
|
||||
components=components,
|
||||
)
|
||||
|
||||
|
||||
def build_system_text() -> str:
|
||||
# определить, есть ли системные предупреждения
|
||||
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
|
||||
return any(component.state != "🟢" for component in snapshot.components)
|
||||
|
||||
|
||||
# отрендерить одну строку компонента системы
|
||||
def _render_component(component: ComponentStatus) -> str:
|
||||
if component.state == "🟢":
|
||||
return f"{component.state} {component.name}"
|
||||
|
||||
return component.state
|
||||
|
||||
|
||||
# получить текущее локальное время для подписи обновления
|
||||
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()
|
||||
|
||||
component_lines = []
|
||||
for component in snapshot.components:
|
||||
component_lines.append(
|
||||
f"{component.state} <b>{component.name}</b>\n"
|
||||
f"— {component.details}"
|
||||
)
|
||||
|
||||
components_block = "\n\n".join(component_lines)
|
||||
|
||||
return (
|
||||
"<b>⚙️ Система</b>\n\n"
|
||||
"<b>Статус компонентов</b>\n"
|
||||
f"{components_block}\n\n"
|
||||
"<b>Окружение</b>\n"
|
||||
f"- приложение: {snapshot.app_name} {snapshot.app_version}\n"
|
||||
f"- env: {snapshot.app_env}\n"
|
||||
f"- python: {snapshot.python_version}\n"
|
||||
f"- os: {snapshot.os_name}\n"
|
||||
f"- timezone: {snapshot.timezone_name}\n"
|
||||
f"- exchange_enabled: {snapshot.exchange_enabled}\n"
|
||||
f"- exchange_name: {snapshot.exchange_name}\n"
|
||||
f"- default_symbol: {snapshot.default_symbol}\n\n"
|
||||
"<b>Справка</b>\n"
|
||||
"/start — стартовый экран\n"
|
||||
"/menu — показать меню\n"
|
||||
"/help — открыть системную справку"
|
||||
components_block = "\n".join(
|
||||
_render_component(component)
|
||||
for component in snapshot.components
|
||||
)
|
||||
|
||||
text = (
|
||||
"<b>🖥️ Система</b>\n"
|
||||
f"🔸 <b>{snapshot.mode_label}</b>\n\n"
|
||||
f"{components_block}"
|
||||
)
|
||||
|
||||
if include_updated_at:
|
||||
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
|
||||
|
||||
return text
|
||||
18
app/src/core/telegram_errors.py
Normal file
18
app/src/core/telegram_errors.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
)
|
||||
12
app/src/core/types.py
Normal file
12
app/src/core/types.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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]
|
||||
25
app/src/integrations/exchange/auth.py
Normal file
25
app/src/integrations/exchange/auth.py
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
|
||||
class ExchangeAuth:
|
||||
def __init__(self, api_key: str, api_secret: str):
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret.encode()
|
||||
|
||||
def sign(self, query: str) -> str:
|
||||
return hmac.new(self.api_secret, query.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def build_headers(self):
|
||||
return {
|
||||
"X-MBX-APIKEY": self.api_key,
|
||||
}
|
||||
|
||||
def build_signed_params(self, params: dict):
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
signature = self.sign(query)
|
||||
params["signature"] = signature
|
||||
return params
|
||||
67
app/src/integrations/exchange/balance_parser.py
Normal file
67
app/src/integrations/exchange/balance_parser.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.models import BalanceSummary
|
||||
|
||||
|
||||
def _safe_float(value: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def parse_account_balances(payload: dict) -> list[BalanceSummary]:
|
||||
# expected shapes seen in real APIs vary a lot
|
||||
# support:
|
||||
# 1) {"balances": [...]}
|
||||
# 2) {"payload": {"balances": [...]}}
|
||||
# 3) {"payload": [...]}
|
||||
# 4) {"assets": [...]}
|
||||
|
||||
balances_raw = None
|
||||
|
||||
if isinstance(payload.get("balances"), list):
|
||||
balances_raw = payload["balances"]
|
||||
else:
|
||||
inner = payload.get("payload")
|
||||
if isinstance(inner, dict) and isinstance(inner.get("balances"), list):
|
||||
balances_raw = inner["balances"]
|
||||
elif isinstance(inner, list):
|
||||
balances_raw = inner
|
||||
elif isinstance(payload.get("assets"), list):
|
||||
balances_raw = payload["assets"]
|
||||
|
||||
if not isinstance(balances_raw, list):
|
||||
return []
|
||||
|
||||
items: list[BalanceSummary] = []
|
||||
|
||||
for item in balances_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
currency = (
|
||||
str(item.get("asset") or item.get("currency") or item.get("code") or "")
|
||||
.strip()
|
||||
.upper()
|
||||
)
|
||||
if not currency:
|
||||
continue
|
||||
|
||||
available = _safe_float(
|
||||
item.get("free", item.get("available", item.get("amount", 0.0)))
|
||||
)
|
||||
locked = _safe_float(
|
||||
item.get("locked", item.get("hold", item.get("reserved", 0.0)))
|
||||
)
|
||||
|
||||
items.append(
|
||||
BalanceSummary(
|
||||
currency=currency,
|
||||
available=available,
|
||||
locked=locked,
|
||||
source="dzengi-private-api",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/integrations/exchange/exceptions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -11,3 +13,41 @@ class ExchangeConnectionError(ExchangeError):
|
||||
|
||||
class ExchangeResponseError(ExchangeError):
|
||||
"""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 "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."
|
||||
111
app/src/integrations/exchange/market_cache.py
Normal file
111
app/src/integrations/exchange/market_cache.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# 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)
|
||||
623
app/src/integrations/exchange/market_data_runner.py
Normal file
623
app/src/integrations/exchange/market_data_runner.py
Normal file
@@ -0,0 +1,623 @@
|
||||
# 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
|
||||
195
app/src/integrations/exchange/market_stream.py
Normal file
195
app/src/integrations/exchange/market_stream.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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)
|
||||
@@ -1,8 +1,11 @@
|
||||
# app/src/integrations/exchange/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Состояние публичного API биржи.
|
||||
@dataclass(slots=True)
|
||||
class ExchangeHealth:
|
||||
ok: bool
|
||||
@@ -10,6 +13,19 @@ class ExchangeHealth:
|
||||
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)
|
||||
class TickerPrice:
|
||||
symbol: str
|
||||
@@ -18,6 +34,26 @@ class TickerPrice:
|
||||
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)
|
||||
class BalanceSummary:
|
||||
currency: str
|
||||
@@ -26,22 +62,74 @@ class BalanceSummary:
|
||||
source: str
|
||||
|
||||
|
||||
# Информация о торговом инструменте биржи.
|
||||
@dataclass(slots=True)
|
||||
class ExchangeSymbol:
|
||||
symbol: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
base_asset: str
|
||||
quote_asset: str
|
||||
|
||||
market_modes: list[str]
|
||||
market_type: str
|
||||
|
||||
tick_size: float | None
|
||||
step_size: float | None
|
||||
min_qty: float | None
|
||||
min_notional: float | None
|
||||
|
||||
|
||||
# Результат проверки символа.
|
||||
@dataclass(slots=True)
|
||||
class SymbolValidationResult:
|
||||
requested_symbol: str
|
||||
normalized_symbol: str
|
||||
|
||||
is_valid: bool
|
||||
message: str
|
||||
|
||||
symbol_info: ExchangeSymbol | None
|
||||
|
||||
|
||||
# Состояние приватного API аккаунта.
|
||||
@dataclass(slots=True)
|
||||
class PrivateAuthHealth:
|
||||
ok: bool
|
||||
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
|
||||
29
app/src/integrations/exchange/private_client.py
Normal file
29
app/src/integrations/exchange/private_client.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# app/src/integrations/exchange/private_client.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.integrations.exchange.auth import ExchangeAuth
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
|
||||
|
||||
class ExchangePrivateClient:
|
||||
def __init__(self) -> None:
|
||||
settings = load_settings()
|
||||
self.client = ExchangeRestClient()
|
||||
self.auth = ExchangeAuth(
|
||||
api_key=settings.exchange_api_key,
|
||||
api_secret=settings.exchange_api_secret,
|
||||
)
|
||||
|
||||
def get_account_info(self, show_zero_balance: bool = False) -> dict:
|
||||
params = {
|
||||
"showZeroBalance": str(show_zero_balance).lower(),
|
||||
}
|
||||
signed = self.auth.build_signed_params(params)
|
||||
|
||||
return self.client.get_json(
|
||||
"/api/v2/account",
|
||||
params=signed,
|
||||
headers=self.auth.build_headers(),
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/integrations/exchange/rest_client.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -20,17 +22,27 @@ class ExchangeRestClient:
|
||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||
self.timeout = self.settings.exchange_timeout_sec
|
||||
|
||||
def get_json(self, path: str, params: dict[str, str] | None = None) -> dict:
|
||||
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={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "dzentra-bot/2.0.0",
|
||||
},
|
||||
headers=request_headers,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -38,9 +50,71 @@ class ExchangeRestClient:
|
||||
status = getattr(response, "status", 200)
|
||||
body = response.read().decode("utf-8")
|
||||
except HTTPError as exc:
|
||||
raise ExchangeResponseError(
|
||||
f"HTTP {exc.code} from exchange: {exc.reason}"
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
message = f"HTTP {exc.code} from exchange: {exc.reason}"
|
||||
if error_body:
|
||||
message += f" | body: {error_body}"
|
||||
|
||||
raise ExchangeResponseError(message) from exc
|
||||
except URLError as exc:
|
||||
raise ExchangeConnectionError(
|
||||
f"Network error while calling exchange: {exc.reason}"
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
|
||||
|
||||
if status != 200:
|
||||
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
|
||||
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
|
||||
|
||||
def get_json(
|
||||
self,
|
||||
path: str,
|
||||
params: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict:
|
||||
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}"
|
||||
@@ -59,4 +133,4 @@ class ExchangeRestClient:
|
||||
if not isinstance(payload, dict):
|
||||
raise ExchangeResponseError("Exchange response is not a JSON object.")
|
||||
|
||||
return payload
|
||||
return payload
|
||||
296
app/src/integrations/exchange/runtime_ui.py
Normal file
296
app/src/integrations/exchange/runtime_ui.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# 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
288
app/src/integrations/exchange/status.py
Normal file
288
app/src/integrations/exchange/status.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# 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,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/integrations/exchange/symbol_utils.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
126
app/src/integrations/exchange/ws_client.py
Normal file
126
app/src/integrations/exchange/ws_client.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# 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)
|
||||
@@ -1,12 +1,27 @@
|
||||
# app/src/main.py
|
||||
|
||||
import asyncio
|
||||
|
||||
from src.bootstrap.app_factory import create_app
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# создаём bot + dispatcher
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
9
app/src/notifications/__init__.py
Normal file
9
app/src/notifications/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# app/src/notifications/__init__.py
|
||||
|
||||
from src.notifications.models import NotificationMessage
|
||||
from src.notifications.targets import NotificationTargetRegistry
|
||||
|
||||
__all__ = [
|
||||
"NotificationMessage",
|
||||
"NotificationTargetRegistry",
|
||||
]
|
||||
5
app/src/notifications/channels/__init__.py
Normal file
5
app/src/notifications/channels/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# app/src/notifications/channels/__init__.py
|
||||
|
||||
from src.notifications.channels.telegram import TelegramNotificationChannel
|
||||
|
||||
__all__ = ["TelegramNotificationChannel"]
|
||||
73
app/src/notifications/channels/telegram.py
Normal file
73
app/src/notifications/channels/telegram.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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
|
||||
23
app/src/notifications/dedupe.py
Normal file
23
app/src/notifications/dedupe.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
14
app/src/notifications/models.py
Normal file
14
app/src/notifications/models.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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
|
||||
29
app/src/notifications/service.py
Normal file
29
app/src/notifications/service.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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)
|
||||
)
|
||||
33
app/src/notifications/targets.py
Normal file
33
app/src/notifications/targets.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
9
app/src/notifications/templates/__init__.py
Normal file
9
app/src/notifications/templates/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# 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",
|
||||
]
|
||||
324
app/src/notifications/templates/execution.py
Normal file
324
app/src/notifications/templates/execution.py
Normal file
@@ -0,0 +1,324 @@
|
||||
# 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(".")
|
||||
230
app/src/notifications/templates/signal.py
Normal file
230
app/src/notifications/templates/signal.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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 []
|
||||
9
app/src/runtime_events/__init__.py
Normal file
9
app/src/runtime_events/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# app/src/runtime_events/__init__.py
|
||||
|
||||
from src.runtime_events.event_types import RuntimeEventType
|
||||
from src.runtime_events.models import RuntimeEvent
|
||||
|
||||
__all__ = [
|
||||
"RuntimeEvent",
|
||||
"RuntimeEventType",
|
||||
]
|
||||
18
app/src/runtime_events/event_types.py
Normal file
18
app/src/runtime_events/event_types.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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"
|
||||
20
app/src/runtime_events/models.py
Normal file
20
app/src/runtime_events/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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))
|
||||
22
app/src/runtime_events/publisher.py
Normal file
22
app/src/runtime_events/publisher.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# 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))
|
||||
23
app/src/storage/models.py
Normal file
23
app/src/storage/models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# app/src/storage/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BalanceSnapshotRecord:
|
||||
id: int | None
|
||||
created_at: str
|
||||
source: str
|
||||
payload_json: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class JournalEventRecord:
|
||||
id: int | None
|
||||
created_at: str
|
||||
level: str
|
||||
event_type: str
|
||||
message: str
|
||||
payload_json: str | None
|
||||
1
app/src/storage/repositories/__init__.py
Normal file
1
app/src/storage/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package marker."""
|
||||
60
app/src/storage/repositories/balance_snapshots.py
Normal file
60
app/src/storage/repositories/balance_snapshots.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from src.storage.session import get_connection
|
||||
|
||||
|
||||
class BalanceSnapshotRepository:
|
||||
def add_snapshot(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'''
|
||||
INSERT INTO balance_snapshots (source, payload_json)
|
||||
VALUES (%s, %s::jsonb)
|
||||
''',
|
||||
(source, payload_json),
|
||||
)
|
||||
|
||||
def count_snapshots(self) -> int:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM balance_snapshots")
|
||||
row = cursor.fetchone()
|
||||
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def list_recent_snapshots(self, limit: int = 5) -> list[dict[str, str]]:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT created_at, source, payload_json::text
|
||||
FROM balance_snapshots
|
||||
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]),
|
||||
"source": str(row[1]),
|
||||
"payload_json": str(row[2]),
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
220
app/src/storage/repositories/journal.py
Normal file
220
app/src/storage/repositories/journal.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# app/src/storage/repositories/journal.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from src.storage.session import get_connection
|
||||
|
||||
|
||||
class JournalRepository:
|
||||
def add_event(
|
||||
self,
|
||||
*,
|
||||
level: str,
|
||||
event_type: str,
|
||||
message: str,
|
||||
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 journal_events (level, event_type, message, payload_json)
|
||||
VALUES (%s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
level.upper().strip(),
|
||||
event_type.strip(),
|
||||
message.strip(),
|
||||
payload_json,
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_payload(self, raw_payload: Any) -> dict[str, Any] | None:
|
||||
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 connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, created_at, level, event_type, message, payload_json
|
||||
FROM journal_events
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
|
||||
def list_recent_with_offset(
|
||||
self,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
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'
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
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:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM journal_events")
|
||||
row = cursor.fetchone()
|
||||
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def delete_all(self) -> 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 connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM journal_events
|
||||
WHERE created_at < NOW() - (%s * INTERVAL '1 day')
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
return int(deleted_count or 0)
|
||||
|
||||
def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"created_at": str(row[1]),
|
||||
"level": str(row[2]),
|
||||
"event_type": str(row[3]),
|
||||
"message": str(row[4]),
|
||||
"payload": self._parse_payload(row[5]),
|
||||
}
|
||||
46
app/src/storage/schema.py
Normal file
46
app/src/storage/schema.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/src/storage/schema.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from psycopg import sql
|
||||
|
||||
from src.storage.session import get_connection
|
||||
|
||||
|
||||
# SQL-команды для первичной инициализации базы данных.
|
||||
DDL: list[sql.SQL] = [
|
||||
sql.SQL("""
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source TEXT NOT NULL,
|
||||
payload_json JSONB NOT NULL
|
||||
)
|
||||
"""),
|
||||
sql.SQL("""
|
||||
CREATE TABLE IF NOT EXISTS journal_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
level TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
payload_json JSONB
|
||||
)
|
||||
"""),
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_events_created_at
|
||||
ON journal_events (created_at DESC)
|
||||
"""),
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_events_event_type
|
||||
ON journal_events (event_type)
|
||||
"""),
|
||||
]
|
||||
|
||||
|
||||
# создаёт таблицы и индексы, если они ещё не существуют
|
||||
def init_schema() -> None:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
for statement in DDL:
|
||||
cursor.execute(statement)
|
||||
44
app/src/storage/session.py
Normal file
44
app/src/storage/session.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# app/src/storage/session.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg
|
||||
|
||||
from src.core.config import load_settings
|
||||
|
||||
|
||||
def build_dsn() -> str:
|
||||
settings = load_settings()
|
||||
password_part = settings.db_password.replace("@", "%40")
|
||||
return (
|
||||
f"postgresql://{settings.db_user}:{password_part}"
|
||||
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
connection = psycopg.connect(build_dsn(), autocommit=False)
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def check_database_health() -> tuple[bool, str]:
|
||||
try:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT version()")
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return False, "PostgreSQL ping returned no rows."
|
||||
|
||||
version = str(row[0]).strip()
|
||||
except Exception as exc:
|
||||
return False, f"PostgreSQL error: {exc}"
|
||||
|
||||
return True, version
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
11
app/src/telegram/handlers/auto/__init__.py
Normal file
11
app/src/telegram/handlers/auto/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
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"]
|
||||
2
app/src/telegram/handlers/auto/debug.py
Normal file
2
app/src/telegram/handlers/auto/debug.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/src/telegram/handlers/auto/debug.py
|
||||
|
||||
356
app/src/telegram/handlers/auto/main.py
Normal file
356
app/src/telegram/handlers/auto/main.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# 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()
|
||||
518
app/src/telegram/handlers/auto/risk.py
Normal file
518
app/src/telegram/handlers/auto/risk.py
Normal file
@@ -0,0 +1,518 @@
|
||||
# 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()
|
||||
1370
app/src/telegram/handlers/auto/ui.py
Normal file
1370
app/src/telegram/handlers/auto/ui.py
Normal file
File diff suppressed because it is too large
Load Diff
669
app/src/telegram/handlers/debug.py
Normal file
669
app/src/telegram/handlers/debug.py
Normal file
@@ -0,0 +1,669 @@
|
||||
# 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"
|
||||
212
app/src/telegram/handlers/debug_auto/main.py
Normal file
212
app/src/telegram/handlers/debug_auto/main.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# 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.")
|
||||
355
app/src/telegram/handlers/debug_auto/ui.py
Normal file
355
app/src/telegram/handlers/debug_auto/ui.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# 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 "—"
|
||||
@@ -1,12 +1,48 @@
|
||||
# app/src/telegram/handlers/home.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
|
||||
from src.telegram.live.active_screen import ActiveScreenManager
|
||||
from src.telegram.menus import HOME_TEXT
|
||||
|
||||
|
||||
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 == "🏠 Главная")
|
||||
async def open_home(message: Message) -> None:
|
||||
await message.answer(HOME_TEXT)
|
||||
async def open_home(
|
||||
message: Message,
|
||||
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,
|
||||
)
|
||||
@@ -1,12 +1,578 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message
|
||||
# app/src/telegram/handlers/journal.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.telegram.menus import JOURNAL_TEXT
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
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
|
||||
|
||||
|
||||
router = Router(name="journal")
|
||||
|
||||
|
||||
def _require_message(
|
||||
callback: CallbackQuery,
|
||||
) -> Message | None:
|
||||
message = callback.message
|
||||
|
||||
if (
|
||||
message is None
|
||||
or isinstance(message, InaccessibleMessage)
|
||||
):
|
||||
return None
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _user_id_from_message(message: Message) -> int | None:
|
||||
return message.from_user.id if message.from_user else None
|
||||
|
||||
|
||||
def _chat_id_from_message(message: Message) -> int:
|
||||
return message.chat.id
|
||||
|
||||
|
||||
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
return callback.from_user.id if callback.from_user else None
|
||||
|
||||
|
||||
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
message = _require_message(callback)
|
||||
|
||||
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(
|
||||
screen="journal",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
async def _prepare_journal_from_message(
|
||||
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()
|
||||
|
||||
total = service.get_total_count()
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(1, min(page, total_pages))
|
||||
|
||||
events = service.get_page(page, PAGE_SIZE)
|
||||
|
||||
text = render(events, page, total_pages)
|
||||
kb = build_keyboard(page, total_pages)
|
||||
|
||||
if edit_mode:
|
||||
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) -> None:
|
||||
await message.answer(JOURNAL_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()
|
||||
313
app/src/telegram/handlers/journal_ui.py
Normal file
313
app/src/telegram/handlers/journal_ui.py
Normal file
@@ -0,0 +1,313 @@
|
||||
# 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()
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
router = Router(name="market")
|
||||
|
||||
|
||||
@router.message(F.text == "📈 Рынок")
|
||||
async def open_market(message: Message) -> None:
|
||||
service = ExchangeService()
|
||||
|
||||
try:
|
||||
validation = service.validate_symbol(service.settings.default_symbol)
|
||||
if not validation.is_valid:
|
||||
await message.answer(
|
||||
"<b>📈 Рынок</b>\n\n"
|
||||
f"Ошибка символа: {validation.message}"
|
||||
)
|
||||
return
|
||||
|
||||
ticker = service.get_price(validation.normalized_symbol)
|
||||
except ExchangeError as exc:
|
||||
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}"
|
||||
)
|
||||
|
||||
await message.answer(text)
|
||||
@@ -1,12 +1,463 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message
|
||||
# app/src/telegram/handlers/portfolio.py
|
||||
from __future__ import annotations
|
||||
|
||||
from src.telegram.menus import PORTFOLIO_TEXT
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InaccessibleMessage,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.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.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="portfolio")
|
||||
|
||||
|
||||
PINNED_ORDER = {
|
||||
"USD": 1,
|
||||
"USDT": 2,
|
||||
"BTC": 3,
|
||||
"ETH": 4,
|
||||
}
|
||||
|
||||
|
||||
# Получить доступное Telegram-сообщение из callback.
|
||||
def _require_message(
|
||||
callback: CallbackQuery,
|
||||
) -> Message | None:
|
||||
message = callback.message
|
||||
|
||||
if (
|
||||
message is None
|
||||
or isinstance(message, InaccessibleMessage)
|
||||
):
|
||||
return None
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# Отформатировать количество актива компактно для портфеля.
|
||||
def _compact_amount(currency: str, value: NumericLike) -> str:
|
||||
number = safe_float(value) or 0.0
|
||||
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 _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||
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_key(item: BalanceSummary) -> tuple[int, str]:
|
||||
currency = item.currency.upper()
|
||||
priority = PINNED_ORDER.get(currency, 999)
|
||||
return (priority, currency)
|
||||
|
||||
return sorted(items, key=sort_key)
|
||||
|
||||
|
||||
# Собрать текст ошибки портфеля через единый exchange status layer.
|
||||
def _build_portfolio_exchange_error_text(exc: Exception) -> str:
|
||||
alerts = build_runtime_exchange_alerts(exc=exc)
|
||||
body = format_runtime_exchange_alerts(alerts)
|
||||
|
||||
if not body:
|
||||
body = "⛔️ Биржа недоступна"
|
||||
|
||||
return (
|
||||
"<b>💼 Портфель</b>\n"
|
||||
f"{mode_line()}"
|
||||
f"{body}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Собрать live-текст портфеля.
|
||||
# raise_errors=True используется при ручном открытии экрана,
|
||||
# чтобы handler смог записать ошибку в журнал и показать стандартный error UI.
|
||||
def _build_portfolio_live_text(
|
||||
*,
|
||||
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:
|
||||
estimated_usd = estimate_balance_usd(
|
||||
item,
|
||||
exchange_service,
|
||||
price_cache,
|
||||
)
|
||||
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:
|
||||
journal = JournalService()
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
await target_message.edit_text(text, reply_markup=reply_markup)
|
||||
_register_portfolio_live_screen(target_message)
|
||||
ActiveScreenManager.register(screen="portfolio", message=target_message)
|
||||
return
|
||||
|
||||
sent_message = await target_message.answer(text, reply_markup=reply_markup)
|
||||
_register_portfolio_live_screen(sent_message)
|
||||
ActiveScreenManager.register(screen="portfolio", message=sent_message)
|
||||
|
||||
|
||||
@router.message(F.text == "💼 Портфель")
|
||||
async def open_portfolio(message: Message) -> None:
|
||||
await message.answer(PORTFOLIO_TEXT)
|
||||
async def open_portfolio(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
) -> 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
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_portfolio_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=False,
|
||||
action="open",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="portfolio_open_error",
|
||||
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
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_portfolio_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
await message.edit_text(
|
||||
_build_portfolio_exchange_error_text(exc),
|
||||
reply_markup=_portfolio_warning_keyboard(),
|
||||
)
|
||||
|
||||
ActiveScreenManager.register(
|
||||
screen="portfolio",
|
||||
message=message,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -1,35 +1,88 @@
|
||||
# app/src/telegram/handlers/start.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 Message
|
||||
|
||||
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.live.active_screen import ActiveScreenManager
|
||||
from src.telegram.menus import MAIN_MENU_TEXT
|
||||
|
||||
|
||||
router = Router(name="start")
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def cmd_start(message: Message) -> None:
|
||||
# показать только reply-меню без открытия live-экрана
|
||||
async def _show_main_menu(
|
||||
message: Message,
|
||||
) -> None:
|
||||
await message.answer(
|
||||
MAIN_MENU_TEXT,
|
||||
reply_markup=build_main_menu_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
# открыть главный рабочий экран проекта — Автоторговлю
|
||||
async def _open_auto_start_screen(
|
||||
message: Message,
|
||||
) -> None:
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
# сначала прикрепляем основное reply-меню,
|
||||
# потому что экран Автоторговли использует inline-кнопки
|
||||
await message.answer(
|
||||
"Открываю Автоторговлю.",
|
||||
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) -> None:
|
||||
await message.answer(
|
||||
MAIN_MENU_TEXT,
|
||||
reply_markup=build_main_menu_keyboard(),
|
||||
)
|
||||
async def cmd_menu(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
await _show_main_menu(message)
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message) -> None:
|
||||
async def cmd_help(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
build_system_text(),
|
||||
reply_markup=build_main_menu_keyboard(),
|
||||
@@ -37,8 +90,10 @@ async def cmd_help(message: Message) -> None:
|
||||
|
||||
|
||||
@router.message(F.text == "Меню")
|
||||
async def menu_shortcut(message: Message) -> None:
|
||||
await message.answer(
|
||||
MAIN_MENU_TEXT,
|
||||
reply_markup=build_main_menu_keyboard(),
|
||||
)
|
||||
async def menu_shortcut(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
await _show_main_menu(message)
|
||||
@@ -1,14 +1,962 @@
|
||||
# app/src/telegram/handlers/system.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.system_status import build_system_text
|
||||
from src.core.config import load_settings
|
||||
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.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
|
||||
async def open_system(message: Message) -> None:
|
||||
await message.answer(build_system_text())
|
||||
def _require_message(callback: CallbackQuery) -> Message | None:
|
||||
message = callback.message
|
||||
|
||||
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()
|
||||
@@ -1,12 +0,0 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message
|
||||
|
||||
from src.telegram.menus import TRADE_TEXT
|
||||
|
||||
|
||||
router = Router(name="trade")
|
||||
|
||||
|
||||
@router.message(F.text == "⚡ Торговля")
|
||||
async def open_trade(message: Message) -> None:
|
||||
await message.answer(TRADE_TEXT)
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/telegram/keyboards/reply.py
|
||||
|
||||
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
|
||||
|
||||
|
||||
@@ -5,19 +7,13 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="🏠 Главная"),
|
||||
KeyboardButton(text="📈 Рынок"),
|
||||
KeyboardButton(text="🤖 Автоторговля"),
|
||||
KeyboardButton(text="💼 Портфель"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="⚡ Торговля"),
|
||||
KeyboardButton(text="🤖 Авто"),
|
||||
KeyboardButton(text="📒 Журнал"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="⚙️ Система"),
|
||||
KeyboardButton(text="🖥️ Система"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выбери раздел...",
|
||||
)
|
||||
)
|
||||
1
app/src/telegram/live/__init__.py
Normal file
1
app/src/telegram/live/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package marker."""
|
||||
100
app/src/telegram/live/active_screen.py
Normal file
100
app/src/telegram/live/active_screen.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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)
|
||||
293
app/src/telegram/live/runner.py
Normal file
293
app/src/telegram/live/runner.py
Normal file
@@ -0,0 +1,293 @@
|
||||
# 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)
|
||||
@@ -1,30 +1,49 @@
|
||||
# app/src/telegram/menus.py
|
||||
|
||||
MAIN_MENU_TEXT = (
|
||||
"<b>Dzentra Bot</b>\n\n"
|
||||
"Новый каркас проекта успешно создан.\n\n"
|
||||
"Выбери раздел через меню ниже."
|
||||
"Trading Runtime Terminal\n\n"
|
||||
"Доступные разделы:\n"
|
||||
"• Автоторговля\n"
|
||||
"• Портфель\n"
|
||||
"• Система"
|
||||
)
|
||||
|
||||
|
||||
HOME_TEXT = (
|
||||
"<b>🏠 Главная</b>\n\n"
|
||||
"Это главный экран бота.\n\n"
|
||||
"Сейчас здесь отображается базовый статус:\n"
|
||||
"- бот запущен\n"
|
||||
"- меню подключено\n"
|
||||
"- handlers работают\n"
|
||||
"- проект на этапе Bootstrap v2\n"
|
||||
"Главное меню Dzentra Bot.\n\n"
|
||||
"Используй кнопки ниже для перехода в нужный раздел."
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_TEXT = (
|
||||
"<b>⚙️ Система</b>\n\n"
|
||||
"Системный экран.\n\n"
|
||||
"<b>Справка</b>\n"
|
||||
"<b>🖥️ Система</b>\n\n"
|
||||
"Системный runtime экран.\n\n"
|
||||
"<b>Разделы</b>\n"
|
||||
"• Настройки\n"
|
||||
"• Журнал\n"
|
||||
"• Информация\n\n"
|
||||
"<b>Команды</b>\n"
|
||||
"/start — запуск\n"
|
||||
"/menu — показать меню\n"
|
||||
"/help — краткая справка\n"
|
||||
"/menu — главное меню\n"
|
||||
"/help — системная информация\n"
|
||||
)
|
||||
|
||||
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
|
||||
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
|
||||
TRADE_TEXT = "<b>⚡ Торговля</b>\n\nРаздел пока в разработке."
|
||||
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
|
||||
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
|
||||
|
||||
PORTFOLIO_TEXT = (
|
||||
"<b>💼 Портфель</b>\n\n"
|
||||
"Просмотр активов и баланса биржи."
|
||||
)
|
||||
|
||||
|
||||
AUTO_TEXT = (
|
||||
"<b>🤖 Автоторговля</b>\n\n"
|
||||
"Runtime экран автоторговли."
|
||||
)
|
||||
|
||||
|
||||
JOURNAL_TEXT = (
|
||||
"<b>📒 Журнал</b>\n\n"
|
||||
"Runtime события и execution logs."
|
||||
)
|
||||
@@ -1,21 +1,23 @@
|
||||
# app/src/telegram/routers.py
|
||||
|
||||
from aiogram import Dispatcher
|
||||
|
||||
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.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.start import router as start_router
|
||||
from src.telegram.handlers.system import router as system_router
|
||||
from src.telegram.handlers.trade import router as trade_router
|
||||
|
||||
|
||||
def setup_routers(dispatcher: Dispatcher) -> None:
|
||||
dispatcher.include_router(start_router)
|
||||
dispatcher.include_router(home_router)
|
||||
dispatcher.include_router(market_router)
|
||||
dispatcher.include_router(portfolio_router)
|
||||
dispatcher.include_router(trade_router)
|
||||
dispatcher.include_router(auto_router)
|
||||
dispatcher.include_router(journal_router)
|
||||
dispatcher.include_router(system_router)
|
||||
dispatcher.include_router(debug_auto_router)
|
||||
dispatcher.include_router(debug_router)
|
||||
dispatcher.include_router(system_router)
|
||||
1
app/src/telegram/ui/__init__.py
Normal file
1
app/src/telegram/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package marker."""
|
||||
56
app/src/telegram/ui/common.py
Normal file
56
app/src/telegram/ui/common.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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>"
|
||||
217
app/src/telegram/ui/currency_ui.py
Normal file
217
app/src/telegram/ui/currency_ui.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# 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
|
||||
207
app/src/telegram/ui/exchange_error.py
Normal file
207
app/src/telegram/ui/exchange_error.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# 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,
|
||||
),
|
||||
)
|
||||
0
app/src/telegram/ui/runtime_status.py
Normal file
0
app/src/telegram/ui/runtime_status.py
Normal file
119
app/src/trading/accounts/service.py
Normal file
119
app/src/trading/accounts/service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# app/src/trading/accounts/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.models import BalanceSummary
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
)
|
||||
from src.storage.repositories.balance_snapshots import (
|
||||
BalanceSnapshotRepository,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
class AccountsService:
|
||||
def __init__(self) -> None:
|
||||
self.exchange_service = ExchangeService()
|
||||
self.snapshot_repository = BalanceSnapshotRepository()
|
||||
self.journal = JournalService()
|
||||
|
||||
# получить live balance summary через typed exchange runtime layer
|
||||
def get_live_balance_summary(self) -> list[BalanceSummary]:
|
||||
try:
|
||||
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)
|
||||
|
||||
return balances
|
||||
|
||||
# сохранить snapshot баланса
|
||||
def _save_snapshot(
|
||||
self,
|
||||
balances: list[BalanceSummary],
|
||||
) -> None:
|
||||
payload = {
|
||||
"assets": [
|
||||
{
|
||||
"currency": item.currency,
|
||||
"available": item.available,
|
||||
"locked": item.locked,
|
||||
"source": item.source,
|
||||
}
|
||||
for item in balances
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
self.snapshot_repository.add_snapshot(
|
||||
source="portfolio_screen",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.journal.log_warning(
|
||||
"balance_snapshot_error",
|
||||
f"Не удалось сохранить snapshot баланса: {exc}",
|
||||
{
|
||||
"assets_count": len(balances),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
self.journal.log_info(
|
||||
"balance_snapshot_saved",
|
||||
f"Snapshot баланса сохранён. Активов: {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:
|
||||
pass
|
||||
3
app/src/trading/auto/__init__.py
Normal file
3
app/src/trading/auto/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# app/src/trading/auto/__init__.py
|
||||
|
||||
"""Package marker."""
|
||||
555
app/src/trading/auto/auto_lifecycle.py
Normal file
555
app/src/trading/auto/auto_lifecycle.py
Normal file
@@ -0,0 +1,555 @@
|
||||
# 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
|
||||
69
app/src/trading/auto/autonomous_management.py
Normal file
69
app/src/trading/auto/autonomous_management.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
488
app/src/trading/auto/execution_quality.py
Normal file
488
app/src/trading/auto/execution_quality.py
Normal file
@@ -0,0 +1,488 @@
|
||||
# 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
|
||||
120
app/src/trading/auto/execution_semantic.py
Normal file
120
app/src/trading/auto/execution_semantic.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# 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
|
||||
)
|
||||
274
app/src/trading/auto/market_runtime.py
Normal file
274
app/src/trading/auto/market_runtime.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# 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, "Состояние рынка анализируется.")
|
||||
318
app/src/trading/auto/position_health.py
Normal file
318
app/src/trading/auto/position_health.py
Normal file
@@ -0,0 +1,318 @@
|
||||
# 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"
|
||||
420
app/src/trading/auto/position_intelligence.py
Normal file
420
app/src/trading/auto/position_intelligence.py
Normal file
@@ -0,0 +1,420 @@
|
||||
# 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"
|
||||
841
app/src/trading/auto/runner.py
Normal file
841
app/src/trading/auto/runner.py
Normal file
@@ -0,0 +1,841 @@
|
||||
# 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)},
|
||||
)
|
||||
188
app/src/trading/auto/service.py
Normal file
188
app/src/trading/auto/service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# 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
|
||||
814
app/src/trading/auto/signal_runtime.py
Normal file
814
app/src/trading/auto/signal_runtime.py
Normal file
@@ -0,0 +1,814 @@
|
||||
# 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))
|
||||
416
app/src/trading/auto/state.py
Normal file
416
app/src/trading/auto/state.py
Normal file
@@ -0,0 +1,416 @@
|
||||
# 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
|
||||
1
app/src/trading/debug/__init__.py
Normal file
1
app/src/trading/debug/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
443
app/src/trading/debug/execution.py
Normal file
443
app/src/trading/debug/execution.py
Normal file
@@ -0,0 +1,443 @@
|
||||
# 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")
|
||||
269
app/src/trading/debug/runner.py
Normal file
269
app/src/trading/debug/runner.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# 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
|
||||
219
app/src/trading/debug/service.py
Normal file
219
app/src/trading/debug/service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
# 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."
|
||||
57
app/src/trading/debug/state.py
Normal file
57
app/src/trading/debug/state.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
1
app/src/trading/diagnostics/__init__.py
Normal file
1
app/src/trading/diagnostics/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
2015
app/src/trading/diagnostics/formatter.py
Normal file
2015
app/src/trading/diagnostics/formatter.py
Normal file
File diff suppressed because it is too large
Load Diff
238
app/src/trading/diagnostics/semantic_runtime.py
Normal file
238
app/src/trading/diagnostics/semantic_runtime.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# 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)))
|
||||
818
app/src/trading/diagnostics/snapshot.py
Normal file
818
app/src/trading/diagnostics/snapshot.py
Normal file
@@ -0,0 +1,818 @@
|
||||
# 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)
|
||||
142
app/src/trading/execution/calculations.py
Normal file
142
app/src/trading/execution/calculations.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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"
|
||||
)
|
||||
128
app/src/trading/execution/engine.py
Normal file
128
app/src/trading/execution/engine.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# 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, "Нет торгового действия.")
|
||||
446
app/src/trading/execution/flip.py
Normal file
446
app/src/trading/execution/flip.py
Normal file
@@ -0,0 +1,446 @@
|
||||
# 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}.",
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user