07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
This commit is contained in:
@@ -16,18 +16,19 @@ EVENT_TITLES = {
|
|||||||
|
|
||||||
# Настройки
|
# Настройки
|
||||||
"auto_settings_updated": "Автоторговля",
|
"auto_settings_updated": "Автоторговля",
|
||||||
|
"auto_status_changed": "Автоторговля",
|
||||||
"risk_settings_updated": "Защита",
|
"risk_settings_updated": "Защита",
|
||||||
|
|
||||||
# Аналитика рынка
|
# Аналитика автоторговли
|
||||||
"market_state_changed": "Рынок",
|
"market_state_changed": "Автоторговля",
|
||||||
"market_volatility_changed": "Рынок",
|
"market_volatility_changed": "Автоторговля",
|
||||||
|
|
||||||
# Мониторинг рынка
|
# Рыночные данные runtime
|
||||||
"market_monitor_started": "Рынок",
|
"market_monitor_started": "Автоторговля",
|
||||||
"market_monitor_stopped": "Рынок",
|
"market_monitor_stopped": "Автоторговля",
|
||||||
"market_stream_connected": "Рынок",
|
"market_stream_connected": "Автоторговля",
|
||||||
"market_stream_disconnected": "Рынок",
|
"market_stream_disconnected": "Автоторговля",
|
||||||
"market_symbol_changed": "Рынок",
|
"market_symbol_changed": "Автоторговля",
|
||||||
|
|
||||||
# Мониторинг позиций
|
# Мониторинг позиций
|
||||||
"entry_blocked": "Вход в позицию",
|
"entry_blocked": "Вход в позицию",
|
||||||
@@ -61,10 +62,6 @@ EVENT_TITLES = {
|
|||||||
"system_retry": "Система",
|
"system_retry": "Система",
|
||||||
"system_about_opened": "Система",
|
"system_about_opened": "Система",
|
||||||
|
|
||||||
"market_open_requested": "Рынок",
|
|
||||||
"market_open_success": "Рынок",
|
|
||||||
"market_open_error": "Рынок",
|
|
||||||
|
|
||||||
"portfolio_open_requested": "Портфель",
|
"portfolio_open_requested": "Портфель",
|
||||||
"portfolio_open_success": "Портфель",
|
"portfolio_open_success": "Портфель",
|
||||||
"portfolio_open_error": "Портфель",
|
"portfolio_open_error": "Портфель",
|
||||||
@@ -72,10 +69,21 @@ EVENT_TITLES = {
|
|||||||
|
|
||||||
"exchange_request_error": "Биржа",
|
"exchange_request_error": "Биржа",
|
||||||
|
|
||||||
|
"exchange_auth_error": "Аккаунт",
|
||||||
|
"exchange_auth_restored": "Аккаунт",
|
||||||
|
"exchange_time_sync_error": "Время биржи",
|
||||||
|
"exchange_time_sync_restored": "Время биржи",
|
||||||
|
|
||||||
"balance_summary_loaded": "Баланс",
|
"balance_summary_loaded": "Баланс",
|
||||||
"balance_summary_error": "Баланс",
|
"balance_summary_error": "Баланс",
|
||||||
|
|
||||||
"runtime_expired": "Runtime",
|
"runtime_expired": "Runtime",
|
||||||
|
|
||||||
|
"market_status_unavailable": "Автоторговля",
|
||||||
|
"market_status_restored": "Автоторговля",
|
||||||
|
"market_closed": "Автоторговля",
|
||||||
|
"market_rest_fallback_available": "Автоторговля",
|
||||||
|
"market_rest_fallback_unavailable": "Автоторговля",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.constants import APP_NAME, APP_VERSION
|
from src.core.constants import APP_NAME, APP_VERSION
|
||||||
|
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.integrations.exchange.status import build_exchange_error_status
|
||||||
from src.storage.session import check_database_health
|
from src.storage.session import check_database_health
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
@@ -32,6 +34,96 @@ class SystemSnapshot:
|
|||||||
components: list[ComponentStatus]
|
components: list[ComponentStatus]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_exchange_alert_components(
|
||||||
|
*,
|
||||||
|
default_symbol: str,
|
||||||
|
) -> list[ComponentStatus]:
|
||||||
|
exchange_service = ExchangeService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime_status = exchange_service.get_symbol_runtime_status(default_symbol)
|
||||||
|
except Exception as exc:
|
||||||
|
runtime_status = build_exchange_error_status(exc)
|
||||||
|
|
||||||
|
if not runtime_status.is_available:
|
||||||
|
return [
|
||||||
|
ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state=runtime_status.ui_line,
|
||||||
|
details=runtime_status.message,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
alerts = build_runtime_exchange_alerts(symbol=default_symbol)
|
||||||
|
|
||||||
|
exchange_unavailable_alert = next(
|
||||||
|
(
|
||||||
|
alert
|
||||||
|
for alert in alerts
|
||||||
|
if str(alert.get("code") or "") == "EXCHANGE_UNAVAILABLE"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if exchange_unavailable_alert is not None:
|
||||||
|
return [
|
||||||
|
ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state=str(
|
||||||
|
exchange_unavailable_alert.get("ui_line")
|
||||||
|
or exchange_unavailable_alert.get("title")
|
||||||
|
or "⛔️ Биржа недоступна"
|
||||||
|
),
|
||||||
|
details=str(exchange_unavailable_alert.get("details") or ""),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
components: list[ComponentStatus] = [
|
||||||
|
ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state="🟢",
|
||||||
|
details=runtime_status.message,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
has_account_alert = False
|
||||||
|
|
||||||
|
for alert in alerts:
|
||||||
|
code = str(alert.get("code") or "")
|
||||||
|
state = str(
|
||||||
|
alert.get("ui_line")
|
||||||
|
or alert.get("title")
|
||||||
|
or "⛔️ Ошибка биржи"
|
||||||
|
)
|
||||||
|
|
||||||
|
if code == "AUTH_ERROR":
|
||||||
|
has_account_alert = True
|
||||||
|
name = "Аккаунт"
|
||||||
|
elif code == "TIME_ERROR":
|
||||||
|
name = "Время биржи"
|
||||||
|
else:
|
||||||
|
name = "Биржа"
|
||||||
|
|
||||||
|
components.append(
|
||||||
|
ComponentStatus(
|
||||||
|
name=name,
|
||||||
|
state=state,
|
||||||
|
details=str(alert.get("details") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_account_alert:
|
||||||
|
components.append(
|
||||||
|
ComponentStatus(
|
||||||
|
name="Аккаунт",
|
||||||
|
state="🟢",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
# извлечь короткую версию PostgreSQL из строки health-check
|
||||||
def _extract_postgres_version(raw: str) -> str:
|
def _extract_postgres_version(raw: str) -> str:
|
||||||
if not raw:
|
if not raw:
|
||||||
return "PostgreSQL"
|
return "PostgreSQL"
|
||||||
@@ -43,99 +135,72 @@ def _extract_postgres_version(raw: str) -> str:
|
|||||||
return "PostgreSQL"
|
return "PostgreSQL"
|
||||||
|
|
||||||
|
|
||||||
def _build_exchange_status(
|
# проверить подключение к БД и вернуть компонент + подпись версии
|
||||||
exchange_service: ExchangeService,
|
|
||||||
default_symbol: str,
|
|
||||||
) -> ComponentStatus:
|
|
||||||
try:
|
|
||||||
symbol_validation = exchange_service.validate_symbol(default_symbol)
|
|
||||||
except Exception as exc:
|
|
||||||
return ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state="🔴",
|
|
||||||
details=_humanize_error_message(str(exc)),
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange_health = exchange_service.get_health()
|
|
||||||
|
|
||||||
if exchange_health.ok and symbol_validation.is_valid:
|
|
||||||
return ComponentStatus(name="Биржа", state="🟢")
|
|
||||||
|
|
||||||
if not exchange_health.ok:
|
|
||||||
return ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state="🔴",
|
|
||||||
details=_humanize_error_message(exchange_health.message or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
return ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state="🔴",
|
|
||||||
details=symbol_validation.message or "Инструмент не прошёл проверку.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus:
|
|
||||||
private_auth_health = exchange_service.get_private_auth_health()
|
|
||||||
if private_auth_health.ok:
|
|
||||||
return ComponentStatus(name="Аккаунт", state="🟢")
|
|
||||||
|
|
||||||
return ComponentStatus(
|
|
||||||
name="Аккаунт",
|
|
||||||
state="🔴",
|
|
||||||
details=_humanize_error_message(private_auth_health.message or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_database_status() -> tuple[ComponentStatus, str]:
|
def _build_database_status() -> tuple[ComponentStatus, str]:
|
||||||
db_ok, db_message = check_database_health()
|
db_ok, db_message = check_database_health()
|
||||||
db_label = _extract_postgres_version(db_message)
|
db_label = _extract_postgres_version(db_message)
|
||||||
|
|
||||||
if db_ok:
|
if db_ok:
|
||||||
return ComponentStatus(name="База данных", state="🟢"), db_label
|
return (
|
||||||
|
ComponentStatus(
|
||||||
|
name="База данных",
|
||||||
|
state="🟢",
|
||||||
|
),
|
||||||
|
db_label,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
ComponentStatus(
|
ComponentStatus(
|
||||||
name="База данных",
|
name="База данных",
|
||||||
state="🔴",
|
state="🔴 База данных недоступна",
|
||||||
details=db_message or "Ошибка подключения к БД.",
|
|
||||||
),
|
),
|
||||||
db_label,
|
db_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# проверить доступность журнала событий
|
||||||
def _build_journal_status() -> ComponentStatus:
|
def _build_journal_status() -> ComponentStatus:
|
||||||
ok, message = JournalService().get_journal_health()
|
ok, _ = JournalService().get_journal_health()
|
||||||
|
|
||||||
if ok:
|
if ok:
|
||||||
return ComponentStatus(name="Журнал", state="🟢")
|
return ComponentStatus(
|
||||||
|
name="Журнал",
|
||||||
|
state="🟢",
|
||||||
|
)
|
||||||
|
|
||||||
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
return ComponentStatus(
|
||||||
|
name="Журнал",
|
||||||
|
state="🔴 Журнал недоступен",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# определить runtime-режим по base_url биржи
|
||||||
def get_runtime_mode_key() -> str:
|
def get_runtime_mode_key() -> str:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
|
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
|
||||||
|
|
||||||
|
|
||||||
|
# вернуть человекочитаемую подпись runtime-режима
|
||||||
def get_runtime_mode_label() -> str:
|
def get_runtime_mode_label() -> str:
|
||||||
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
|
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
|
||||||
|
|
||||||
|
|
||||||
|
# собрать полный snapshot системного экрана
|
||||||
def get_system_snapshot() -> SystemSnapshot:
|
def get_system_snapshot() -> SystemSnapshot:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
exchange_service = ExchangeService()
|
|
||||||
|
|
||||||
database_status, db_label = _build_database_status()
|
database_status, db_label = _build_database_status()
|
||||||
exchange_status = _build_exchange_status(exchange_service, settings.default_symbol)
|
|
||||||
account_status = _build_account_status(exchange_service)
|
|
||||||
journal_status = _build_journal_status()
|
journal_status = _build_journal_status()
|
||||||
|
|
||||||
|
exchange_components = _build_exchange_alert_components(
|
||||||
|
default_symbol=settings.default_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
components = [
|
components = [
|
||||||
ComponentStatus(name="Приложение", state="🟢"),
|
ComponentStatus(name="Приложение", state="🟢"),
|
||||||
database_status,
|
database_status,
|
||||||
ComponentStatus(name="Telegram", state="🟢"),
|
ComponentStatus(name="Telegram", state="🟢"),
|
||||||
exchange_status,
|
*exchange_components,
|
||||||
account_status,
|
|
||||||
journal_status,
|
journal_status,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -150,19 +215,20 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# определить, есть ли системные предупреждения
|
||||||
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
|
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
|
||||||
return any(component.state != "🟢" for component in snapshot.components)
|
return any(component.state != "🟢" for component in snapshot.components)
|
||||||
|
|
||||||
|
|
||||||
|
# отрендерить одну строку компонента системы
|
||||||
def _render_component(component: ComponentStatus) -> str:
|
def _render_component(component: ComponentStatus) -> str:
|
||||||
line = f"{component.state} {component.name}"
|
if component.state == "🟢":
|
||||||
|
return f"{component.state} {component.name}"
|
||||||
|
|
||||||
if component.state == "🟢" or not component.details:
|
return component.state
|
||||||
return line
|
|
||||||
|
|
||||||
return f"{line}\n— {component.details}"
|
|
||||||
|
|
||||||
|
|
||||||
|
# получить текущее локальное время для подписи обновления
|
||||||
def _now_hhmmss() -> str:
|
def _now_hhmmss() -> str:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
tz_name = settings.tz or "UTC"
|
tz_name = settings.tz or "UTC"
|
||||||
@@ -175,50 +241,22 @@ def _now_hhmmss() -> str:
|
|||||||
return local_dt.strftime("%H:%M:%S")
|
return local_dt.strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
# собрать текст экрана "Система"
|
||||||
def build_system_text(*, include_updated_at: bool = False) -> str:
|
def build_system_text(*, include_updated_at: bool = False) -> str:
|
||||||
snapshot = get_system_snapshot()
|
snapshot = get_system_snapshot()
|
||||||
|
|
||||||
components_block = "\n".join(
|
components_block = "\n".join(
|
||||||
_render_component(component) for component in snapshot.components
|
_render_component(component)
|
||||||
|
for component in snapshot.components
|
||||||
)
|
)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"<b>🖥️ Система</b>\n"
|
"<b>🖥️ Система</b>\n"
|
||||||
f"🔸 <b>{snapshot.mode_label}</b>\n\n"
|
f"🔸 <b>{snapshot.mode_label}</b>\n\n"
|
||||||
# f"⏱️ {snapshot.timezone_name}\n\n"
|
|
||||||
f"{components_block}"
|
f"{components_block}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_updated_at:
|
if include_updated_at:
|
||||||
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
|
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _humanize_error_message(text: str) -> str:
|
|
||||||
t = text.lower()
|
|
||||||
|
|
||||||
# сеть
|
|
||||||
if "nodename nor servname" in t or "name or service not known" in t:
|
|
||||||
return "Нет связи с биржей"
|
|
||||||
|
|
||||||
if "timeout" in t or "timed out" in t:
|
|
||||||
return "Биржа не отвечает (таймаут)"
|
|
||||||
|
|
||||||
if "network error" in t or "connection error" in t:
|
|
||||||
return "Ошибка сети при обращении к бирже"
|
|
||||||
|
|
||||||
# API / доступ
|
|
||||||
if "private api error" in t:
|
|
||||||
return "Ошибка доступа к аккаунту"
|
|
||||||
|
|
||||||
if "invalid api key" in t or "api key" in t:
|
|
||||||
return "Неверный API ключ"
|
|
||||||
|
|
||||||
if "forbidden" in t or "unauthorized" in t:
|
|
||||||
return "Нет доступа к аккаунту"
|
|
||||||
|
|
||||||
# время
|
|
||||||
if "-1021" in t or "doesn't match server time" in t:
|
|
||||||
return "Ошибка времени (рассинхронизация)"
|
|
||||||
|
|
||||||
return "Не удалось получить данные с биржи"
|
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
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.market_cache import MarketPriceCache
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||||
@@ -24,10 +27,42 @@ class MarketRuntimeContext:
|
|||||||
runtime_label: str | None
|
runtime_label: str | None
|
||||||
last_market_status: str | None = 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:
|
class MarketDataRunner:
|
||||||
_runtimes: dict[str, MarketRuntimeContext] = {}
|
_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
|
@classmethod
|
||||||
def start(
|
def start(
|
||||||
cls,
|
cls,
|
||||||
@@ -41,7 +76,11 @@ class MarketDataRunner:
|
|||||||
) -> None:
|
) -> None:
|
||||||
existing = cls._runtimes.get(runtime_key)
|
existing = cls._runtimes.get(runtime_key)
|
||||||
|
|
||||||
if existing is not None and existing.task is not None and not existing.task.done():
|
if (
|
||||||
|
existing is not None
|
||||||
|
and existing.task is not None
|
||||||
|
and not existing.task.done()
|
||||||
|
):
|
||||||
existing.symbol_provider = symbol_provider
|
existing.symbol_provider = symbol_provider
|
||||||
existing.interval_seconds = interval_seconds
|
existing.interval_seconds = interval_seconds
|
||||||
existing.screen = screen
|
existing.screen = screen
|
||||||
@@ -97,73 +136,143 @@ class MarketDataRunner:
|
|||||||
async def _worker(cls, context: MarketRuntimeContext) -> None:
|
async def _worker(cls, context: MarketRuntimeContext) -> None:
|
||||||
last_symbol: str | None = None
|
last_symbol: str | None = None
|
||||||
|
|
||||||
while True:
|
try:
|
||||||
symbol = context.symbol_provider()
|
while True:
|
||||||
|
symbol = context.symbol_provider()
|
||||||
|
|
||||||
if not symbol:
|
if not symbol:
|
||||||
await asyncio.sleep(context.interval_seconds)
|
await asyncio.sleep(context.interval_seconds)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cache_symbol = cls._cache_symbol(symbol)
|
cache_symbol = cls._cache_symbol(symbol)
|
||||||
ws_symbol = cls._ws_symbol(symbol)
|
ws_symbol = cls._ws_symbol(symbol)
|
||||||
|
|
||||||
if symbol != last_symbol:
|
if symbol != last_symbol:
|
||||||
previous_symbol = last_symbol
|
last_symbol = symbol
|
||||||
last_symbol = symbol
|
|
||||||
|
|
||||||
if not cls._is_cache_symbol_used_by_other_runtime(
|
if not cls._is_cache_symbol_used_by_other_runtime(
|
||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
cache_symbol=cache_symbol,
|
cache_symbol=cache_symbol,
|
||||||
):
|
):
|
||||||
MarketPriceCache.clear(cache_symbol)
|
MarketPriceCache.clear(cache_symbol)
|
||||||
|
|
||||||
market_status = ExchangeService().get_symbol_market_status(symbol)
|
try:
|
||||||
status_key = str(market_status.get("status") or "UNKNOWN")
|
market_status = ExchangeService().get_symbol_market_status(symbol)
|
||||||
|
|
||||||
if not bool(market_status.get("is_open")):
|
except asyncio.CancelledError:
|
||||||
if context.last_market_status != status_key:
|
raise
|
||||||
context.last_market_status = status_key
|
|
||||||
|
|
||||||
cls._log_warning(
|
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,
|
context,
|
||||||
"market_closed",
|
"market_status_restored",
|
||||||
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
|
"Статус рынка снова доступен.",
|
||||||
{
|
{
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"market_status": status_key,
|
"cache_symbol": cache_symbol,
|
||||||
"message": market_status.get("message"),
|
"ws_symbol": ws_symbol,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.sleep(context.interval_seconds)
|
status_key = str(market_status.get("status") or "UNKNOWN")
|
||||||
continue
|
|
||||||
|
|
||||||
context.last_market_status = status_key
|
if not bool(market_status.get("is_open")):
|
||||||
|
if context.last_market_status != status_key:
|
||||||
|
context.last_market_status = status_key
|
||||||
|
|
||||||
try:
|
cls._log_warning(
|
||||||
await cls._run_websocket(context, symbol)
|
context,
|
||||||
except asyncio.CancelledError:
|
"market_closed",
|
||||||
raise
|
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
|
||||||
except Exception as exc:
|
{
|
||||||
cls._log_warning(
|
"symbol": symbol,
|
||||||
context,
|
"market_status": status_key,
|
||||||
"market_stream_disconnected",
|
"message": market_status.get("message"),
|
||||||
"Поток рыночных данных отключён. Используется резервный 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)
|
||||||
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
|
@classmethod
|
||||||
async def _run_websocket(cls, context: MarketRuntimeContext, symbol: str) -> None:
|
async def _run_websocket(
|
||||||
|
cls,
|
||||||
|
context: MarketRuntimeContext,
|
||||||
|
symbol: str,
|
||||||
|
) -> None:
|
||||||
cache_symbol = cls._cache_symbol(symbol)
|
cache_symbol = cls._cache_symbol(symbol)
|
||||||
ws_symbol = cls._ws_symbol(symbol)
|
ws_symbol = cls._ws_symbol(symbol)
|
||||||
|
|
||||||
@@ -174,19 +283,33 @@ class MarketDataRunner:
|
|||||||
interval_seconds=context.interval_seconds,
|
interval_seconds=context.interval_seconds,
|
||||||
):
|
):
|
||||||
if payload_count == 0:
|
if payload_count == 0:
|
||||||
cls._log_info(
|
should_log_connected = (
|
||||||
context,
|
context.last_stream_state != "CONNECTED"
|
||||||
"market_stream_connected",
|
and cls._can_log_runtime_event(
|
||||||
"Поток рыночных данных подключён.",
|
f"market_stream_connected:{symbol}"
|
||||||
{
|
)
|
||||||
"requested_symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
"payload_keys": list(payload.keys()),
|
|
||||||
"payload_preview": cls._safe_payload_preview(payload),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
payload_count += 1
|
||||||
|
|
||||||
current_symbol = context.symbol_provider()
|
current_symbol = context.symbol_provider()
|
||||||
@@ -209,28 +332,78 @@ class MarketDataRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _rest_fallback_once(cls, context: MarketRuntimeContext, symbol: str) -> None:
|
async def _rest_fallback_once(
|
||||||
|
cls,
|
||||||
|
context: MarketRuntimeContext,
|
||||||
|
symbol: str,
|
||||||
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
ExchangeService().refresh_market_snapshot_cache,
|
ExchangeService().refresh_market_snapshot_cache,
|
||||||
symbol,
|
symbol,
|
||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
cls._log_error(
|
should_log_rest_available = (
|
||||||
context,
|
context.last_rest_state != "AVAILABLE"
|
||||||
"market_stream_disconnected",
|
and cls._can_log_runtime_event(
|
||||||
"Поток рыночных данных отключён. Резервный REST-режим недоступен.",
|
f"market_rest_fallback_available:{symbol}"
|
||||||
{
|
)
|
||||||
"symbol": symbol,
|
|
||||||
"error": str(exc),
|
|
||||||
"error_type": type(exc).__name__,
|
|
||||||
"traceback": traceback.format_exc(limit=5),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
@classmethod
|
||||||
def _is_cache_symbol_used_by_other_runtime(cls, *, runtime_key: str, cache_symbol: str) -> bool:
|
def _is_cache_symbol_used_by_other_runtime(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
runtime_key: str,
|
||||||
|
cache_symbol: str,
|
||||||
|
) -> bool:
|
||||||
for key, context in cls._runtimes.items():
|
for key, context in cls._runtimes.items():
|
||||||
if key == runtime_key:
|
if key == runtime_key:
|
||||||
continue
|
continue
|
||||||
@@ -251,6 +424,7 @@ class MarketDataRunner:
|
|||||||
validation = ExchangeService().validate_symbol(symbol)
|
validation = ExchangeService().validate_symbol(symbol)
|
||||||
if validation.is_valid:
|
if validation.is_valid:
|
||||||
return validation.normalized_symbol
|
return validation.normalized_symbol
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -261,7 +435,11 @@ class MarketDataRunner:
|
|||||||
return cls._cache_symbol(symbol)
|
return cls._cache_symbol(symbol)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_best_price(cls, payload: dict, side_key: str) -> float | None:
|
def _extract_best_price(
|
||||||
|
cls,
|
||||||
|
payload: JsonDict,
|
||||||
|
side_key: str,
|
||||||
|
) -> float | None:
|
||||||
data = payload
|
data = payload
|
||||||
|
|
||||||
inner = payload.get("payload")
|
inner = payload.get("payload")
|
||||||
@@ -276,53 +454,71 @@ class MarketDataRunner:
|
|||||||
first = values[0]
|
first = values[0]
|
||||||
|
|
||||||
if isinstance(first, list) and first:
|
if isinstance(first, list) and first:
|
||||||
return cls._safe_float(first[0])
|
return cls._positive_float(first[0])
|
||||||
|
|
||||||
if isinstance(first, dict):
|
if isinstance(first, dict):
|
||||||
return cls._safe_float(
|
raw_price = (
|
||||||
first.get("price")
|
first.get("price")
|
||||||
or first.get("p")
|
or first.get("p")
|
||||||
or first.get("bidPrice")
|
or first.get("bidPrice")
|
||||||
or first.get("askPrice")
|
or first.get("askPrice")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return cls._positive_float(raw_price)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _safe_float(cls, value: object) -> float | None:
|
def _positive_float(cls, value: NumericLike | None) -> float | None:
|
||||||
try:
|
number = safe_float(value)
|
||||||
number = float(value)
|
|
||||||
except (TypeError, ValueError):
|
if number is None or number <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return number if number > 0 else None
|
return number
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _safe_payload_preview(cls, payload: dict) -> dict:
|
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
|
||||||
preview: dict = {}
|
preview: JsonDict = {}
|
||||||
|
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
if key in {"bids", "asks"} and isinstance(value, list):
|
if key in {"bids", "asks"} and isinstance(value, list):
|
||||||
preview[key] = value[:2]
|
preview[key] = value[:2]
|
||||||
|
|
||||||
elif key == "payload" and isinstance(value, dict):
|
elif key == "payload" and isinstance(value, dict):
|
||||||
preview[key] = {
|
inner_preview: JsonDict = {}
|
||||||
inner_key: inner_value[:2]
|
|
||||||
if inner_key in {"bids", "asks"} and isinstance(inner_value, list)
|
for inner_key, inner_value in value.items():
|
||||||
else inner_value
|
if (
|
||||||
for inner_key, inner_value in value.items()
|
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:
|
else:
|
||||||
preview[key] = value
|
preview[key] = value
|
||||||
|
|
||||||
return preview
|
return preview
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _message(cls, context: MarketRuntimeContext, message: str) -> str:
|
def _message(
|
||||||
|
cls,
|
||||||
|
context: MarketRuntimeContext,
|
||||||
|
message: str,
|
||||||
|
) -> str:
|
||||||
return message
|
return message
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _payload(cls, context: MarketRuntimeContext, payload: dict | None = None) -> dict:
|
def _payload(
|
||||||
result = dict(payload or {})
|
cls,
|
||||||
|
context: MarketRuntimeContext,
|
||||||
|
payload: JsonDict | None = None,
|
||||||
|
) -> JsonDict:
|
||||||
|
result: JsonDict = dict(payload or {})
|
||||||
result.setdefault("runtime_key", context.runtime_key)
|
result.setdefault("runtime_key", context.runtime_key)
|
||||||
|
|
||||||
if context.screen:
|
if context.screen:
|
||||||
@@ -339,7 +535,7 @@ class MarketDataRunner:
|
|||||||
context: MarketRuntimeContext,
|
context: MarketRuntimeContext,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
message: str,
|
message: str,
|
||||||
payload: dict | None = None,
|
payload: JsonDict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
if context.screen:
|
if context.screen:
|
||||||
@@ -352,7 +548,12 @@ class MarketDataRunner:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
JournalService().log_info(event_type, cls._message(context, message), cls._payload(context, payload))
|
JournalService().log_info(
|
||||||
|
event_type,
|
||||||
|
cls._message(context, message),
|
||||||
|
cls._payload(context, payload),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -362,7 +563,7 @@ class MarketDataRunner:
|
|||||||
context: MarketRuntimeContext,
|
context: MarketRuntimeContext,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
message: str,
|
message: str,
|
||||||
payload: dict | None = None,
|
payload: JsonDict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
if context.screen:
|
if context.screen:
|
||||||
@@ -375,7 +576,12 @@ class MarketDataRunner:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
JournalService().log_warning(event_type, cls._message(context, message), cls._payload(context, payload))
|
JournalService().log_warning(
|
||||||
|
event_type,
|
||||||
|
cls._message(context, message),
|
||||||
|
cls._payload(context, payload),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -385,9 +591,16 @@ class MarketDataRunner:
|
|||||||
context: MarketRuntimeContext,
|
context: MarketRuntimeContext,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
message: str,
|
message: str,
|
||||||
payload: dict | None = None,
|
payload: JsonDict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
|
error_type = None
|
||||||
|
raw_error = None
|
||||||
|
|
||||||
|
if payload:
|
||||||
|
error_type = payload.get("error_type")
|
||||||
|
raw_error = payload.get("error")
|
||||||
|
|
||||||
if context.screen:
|
if context.screen:
|
||||||
JournalService().log_ui_error(
|
JournalService().log_ui_error(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
@@ -395,11 +608,16 @@ class MarketDataRunner:
|
|||||||
screen=context.screen,
|
screen=context.screen,
|
||||||
action=context.action,
|
action=context.action,
|
||||||
payload=cls._payload(context, payload),
|
payload=cls._payload(context, payload),
|
||||||
error_type=(payload or {}).get("error_type"),
|
error_type=str(error_type) if error_type is not None else None,
|
||||||
raw_error=(payload or {}).get("error"),
|
raw_error=str(raw_error) if raw_error is not None else None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
JournalService().log_error(event_type, cls._message(context, message), cls._payload(context, payload))
|
JournalService().log_error(
|
||||||
|
event_type,
|
||||||
|
cls._message(context, message),
|
||||||
|
cls._payload(context, payload),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -7,25 +7,39 @@ from datetime import datetime
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from src.core.config import load_settings
|
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.market_cache import MarketPriceCache
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp(raw_timestamp: object) -> str | None:
|
# безопасно форматирует timestamp биржи в локальное время
|
||||||
if raw_timestamp is None:
|
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
|
||||||
|
timestamp = safe_float(raw_timestamp)
|
||||||
|
|
||||||
|
if timestamp is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC"))
|
|
||||||
return dt_utc.astimezone(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
|
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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_market_event(payload: dict) -> dict | None:
|
# достаёт внутренний payload из websocket-сообщения
|
||||||
|
def _payload_from_message(payload: JsonDict) -> JsonDict | None:
|
||||||
event = payload.get("Payload") or payload.get("payload")
|
event = payload.get("Payload") or payload.get("payload")
|
||||||
|
|
||||||
if isinstance(event, dict) and "Payload" in event:
|
if isinstance(event, dict) and "Payload" in event:
|
||||||
@@ -34,16 +48,73 @@ def _extract_market_event(payload: dict) -> dict | None:
|
|||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
symbol = event.get("symbolName") or event.get("symbol")
|
return dict(event)
|
||||||
bid = event.get("bid")
|
|
||||||
ask = event.get("ofr") or event.get("ask")
|
|
||||||
timestamp = event.get("timestamp")
|
|
||||||
|
|
||||||
if symbol is None or bid is None or ask is None:
|
|
||||||
|
# извлекает 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
|
return None
|
||||||
|
|
||||||
bid_price = float(bid)
|
|
||||||
ask_price = float(ask)
|
|
||||||
price = (bid_price + ask_price) / 2
|
price = (bid_price + ask_price) / 2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,10 +122,11 @@ def _extract_market_event(payload: dict) -> dict | None:
|
|||||||
"price": price,
|
"price": price,
|
||||||
"bid_price": bid_price,
|
"bid_price": bid_price,
|
||||||
"ask_price": ask_price,
|
"ask_price": ask_price,
|
||||||
"updated_at": _format_timestamp(timestamp),
|
"updated_at": _format_timestamp(event.get("timestamp")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache
|
||||||
async def start_market_stream() -> None:
|
async def start_market_stream() -> None:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
journal = JournalService()
|
journal = JournalService()
|
||||||
@@ -86,16 +158,30 @@ async def start_market_stream() -> None:
|
|||||||
if event is None:
|
if event is None:
|
||||||
continue
|
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(
|
MarketPriceCache.set_price(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
price=event["price"],
|
price=price,
|
||||||
bid_price=event["bid_price"],
|
bid_price=bid_price,
|
||||||
ask_price=event["ask_price"],
|
ask_price=ask_price,
|
||||||
updated_at=event["updated_at"],
|
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:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
try:
|
||||||
journal.log_warning(
|
journal.log_warning(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# Состояние публичного API биржи.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ExchangeHealth:
|
class ExchangeHealth:
|
||||||
ok: bool
|
ok: bool
|
||||||
@@ -12,6 +13,19 @@ class ExchangeHealth:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# Состояние синхронизации времени сервера и биржи.
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TimeSyncStatus:
|
||||||
|
ok: bool
|
||||||
|
local_time: str
|
||||||
|
exchange_time: str | None
|
||||||
|
drift_seconds: float | None
|
||||||
|
hostname: str
|
||||||
|
local_ip: str | None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# Текущая рыночная цена инструмента.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TickerPrice:
|
class TickerPrice:
|
||||||
symbol: str
|
symbol: str
|
||||||
@@ -20,18 +34,26 @@ class TickerPrice:
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
# Snapshot цен для execution layer.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ExecutionPriceSnapshot:
|
class ExecutionPriceSnapshot:
|
||||||
symbol: str
|
symbol: str
|
||||||
|
|
||||||
last_price: float
|
last_price: float
|
||||||
bid_price: float
|
bid_price: float
|
||||||
ask_price: float
|
ask_price: float
|
||||||
|
|
||||||
updated_at: str
|
updated_at: str
|
||||||
source: str
|
source: str
|
||||||
|
|
||||||
is_fresh: bool
|
is_fresh: bool
|
||||||
|
|
||||||
age_seconds: float | None = None
|
age_seconds: float | None = None
|
||||||
|
freshness_status: str = "UNKNOWN"
|
||||||
|
spread_percent: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Баланс актива аккаунта.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BalanceSummary:
|
class BalanceSummary:
|
||||||
currency: str
|
currency: str
|
||||||
@@ -40,41 +62,54 @@ class BalanceSummary:
|
|||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
# Информация о торговом инструменте биржи.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ExchangeSymbol:
|
class ExchangeSymbol:
|
||||||
symbol: str
|
symbol: str
|
||||||
name: str
|
name: str
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
base_asset: str
|
base_asset: str
|
||||||
quote_asset: str
|
quote_asset: str
|
||||||
|
|
||||||
market_modes: list[str]
|
market_modes: list[str]
|
||||||
market_type: str
|
market_type: str
|
||||||
|
|
||||||
tick_size: float | None
|
tick_size: float | None
|
||||||
step_size: float | None
|
step_size: float | None
|
||||||
min_qty: float | None
|
min_qty: float | None
|
||||||
min_notional: float | None
|
min_notional: float | None
|
||||||
|
|
||||||
|
|
||||||
|
# Результат проверки символа.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SymbolValidationResult:
|
class SymbolValidationResult:
|
||||||
requested_symbol: str
|
requested_symbol: str
|
||||||
normalized_symbol: str
|
normalized_symbol: str
|
||||||
|
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
symbol_info: ExchangeSymbol | None
|
symbol_info: ExchangeSymbol | None
|
||||||
|
|
||||||
|
|
||||||
|
# Состояние приватного API аккаунта.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PrivateAuthHealth:
|
class PrivateAuthHealth:
|
||||||
ok: bool
|
ok: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# Runtime-статус рынка инструмента.
|
||||||
# MARKET ANALYSIS / KLINES
|
@dataclass(slots=True)
|
||||||
# =========================================================
|
class ExchangeMarketStatus:
|
||||||
|
symbol: str
|
||||||
|
is_open: bool
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# Одна свеча OHLCV.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Kline:
|
class Kline:
|
||||||
symbol: str
|
symbol: str
|
||||||
@@ -86,12 +121,12 @@ class Kline:
|
|||||||
high_price: float
|
high_price: float
|
||||||
low_price: float
|
low_price: float
|
||||||
close_price: float
|
close_price: float
|
||||||
|
|
||||||
volume: float
|
volume: float
|
||||||
|
|
||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
# Пакет свечей.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class KlineBatch:
|
class KlineBatch:
|
||||||
symbol: str
|
symbol: str
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
from websockets.typing import Subprotocol
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import JsonDict, JsonList, NumericLike
|
||||||
|
|
||||||
|
|
||||||
class ExchangeWebSocketClient:
|
class ExchangeWebSocketClient:
|
||||||
@@ -17,6 +20,7 @@ class ExchangeWebSocketClient:
|
|||||||
self.settings = load_settings()
|
self.settings = load_settings()
|
||||||
self.base_url = self._build_ws_base_url()
|
self.base_url = self._build_ws_base_url()
|
||||||
|
|
||||||
|
# собрать корректный websocket URL из настроек
|
||||||
def _build_ws_base_url(self) -> str:
|
def _build_ws_base_url(self) -> str:
|
||||||
raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url
|
raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url
|
||||||
|
|
||||||
@@ -32,12 +36,17 @@ class ExchangeWebSocketClient:
|
|||||||
|
|
||||||
return f"{raw_url}/connect"
|
return f"{raw_url}/connect"
|
||||||
|
|
||||||
async def stream_depth(
|
# безопасно нормализовать паузу между websocket-запросами
|
||||||
self,
|
def _interval_seconds(self, value: NumericLike | None) -> float:
|
||||||
symbol: str,
|
interval = safe_float(value)
|
||||||
*,
|
|
||||||
interval_seconds: float = 1.0,
|
if interval is None or interval <= 0:
|
||||||
) -> AsyncIterator[dict]:
|
return 1.0
|
||||||
|
|
||||||
|
return interval
|
||||||
|
|
||||||
|
# собрать headers для подключения к websocket
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
"Origin": self.settings.exchange_base_url.rstrip("/"),
|
"Origin": self.settings.exchange_base_url.rstrip("/"),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -46,22 +55,53 @@ class ExchangeWebSocketClient:
|
|||||||
if self.settings.exchange_api_key:
|
if self.settings.exchange_api_key:
|
||||||
headers["X-MBX-APIKEY"] = 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(
|
async with websockets.connect(
|
||||||
self.base_url,
|
self.base_url,
|
||||||
extra_headers=headers,
|
additional_headers=headers,
|
||||||
subprotocols=["json"],
|
subprotocols=[Subprotocol("json")],
|
||||||
ping_interval=20,
|
ping_interval=20,
|
||||||
open_timeout=self.settings.exchange_timeout_sec,
|
open_timeout=self.settings.exchange_timeout_sec,
|
||||||
) as websocket:
|
) as websocket:
|
||||||
while True:
|
while True:
|
||||||
request = {
|
request = self._depth_request(symbol)
|
||||||
"correlationId": str(uuid4()),
|
|
||||||
"destination": "/api/v2/depth",
|
|
||||||
"payload": {
|
|
||||||
"limit": 5,
|
|
||||||
"symbol": symbol,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
await websocket.send(json.dumps(request))
|
await websocket.send(json.dumps(request))
|
||||||
|
|
||||||
@@ -71,16 +111,16 @@ class ExchangeWebSocketClient:
|
|||||||
timeout=self.settings.exchange_timeout_sec,
|
timeout=self.settings.exchange_timeout_sec,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await asyncio.sleep(interval_seconds)
|
await asyncio.sleep(interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
if not isinstance(raw_message, (str, bytes)):
|
||||||
payload = json.loads(raw_message)
|
await asyncio.sleep(interval)
|
||||||
except json.JSONDecodeError:
|
|
||||||
await asyncio.sleep(interval_seconds)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
payload = self._loads_json(raw_message)
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
yield payload
|
yield payload
|
||||||
|
|
||||||
await asyncio.sleep(interval_seconds)
|
await asyncio.sleep(interval)
|
||||||
@@ -23,9 +23,6 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
|
|||||||
position_context = str(payload.get("position_context") or "NONE").upper()
|
position_context = str(payload.get("position_context") or "NONE").upper()
|
||||||
semantic_lines = _as_json_list(payload.get("semantic_lines"))
|
semantic_lines = _as_json_list(payload.get("semantic_lines"))
|
||||||
|
|
||||||
bid_price = payload.get("bid_price")
|
|
||||||
ask_price = payload.get("ask_price")
|
|
||||||
|
|
||||||
priority = str(
|
priority = str(
|
||||||
event.priority
|
event.priority
|
||||||
or _alert_priority(
|
or _alert_priority(
|
||||||
@@ -40,24 +37,32 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
|
|||||||
strength = _strength_label(priority)
|
strength = _strength_label(priority)
|
||||||
strength_bar = _strength_bar(priority)
|
strength_bar = _strength_bar(priority)
|
||||||
|
|
||||||
market_price_line = _market_price_line(
|
|
||||||
direction=direction_key,
|
|
||||||
bid_price=bid_price,
|
|
||||||
ask_price=ask_price,
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
|
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
|
||||||
"",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if market_price_line:
|
position_line = _position_context_line(
|
||||||
lines.extend([market_price_line, ""])
|
signal=signal,
|
||||||
|
position_context=position_context,
|
||||||
|
)
|
||||||
|
|
||||||
if position_context not in {"NONE", "—", ""} and position_context != direction_key:
|
if position_line:
|
||||||
lines.extend(["⚠️ ПРОТИВ ПОЗИЦИИ", ""])
|
lines.append(position_line)
|
||||||
|
|
||||||
lines.append(f"{strength_bar} {strength} · {confidence:.2f}")
|
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:
|
if semantic_lines:
|
||||||
lines.extend(
|
lines.extend(
|
||||||
@@ -74,6 +79,68 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _alert_priority(*, confidence: float, repeat_count: int) -> str:
|
||||||
if confidence >= 0.8 and repeat_count >= 3:
|
if confidence >= 0.8 and repeat_count >= 3:
|
||||||
return "HIGH"
|
return "HIGH"
|
||||||
@@ -131,27 +198,6 @@ def _format_symbol(symbol: str) -> str:
|
|||||||
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
|
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
|
||||||
|
|
||||||
|
|
||||||
def _market_price_line(
|
|
||||||
*,
|
|
||||||
direction: str,
|
|
||||||
bid_price: NumericLike | None,
|
|
||||||
ask_price: NumericLike | None,
|
|
||||||
) -> str:
|
|
||||||
bid = _format_price(bid_price)
|
|
||||||
ask = _format_price(ask_price)
|
|
||||||
|
|
||||||
if bid == "—" and ask == "—":
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if direction == "LONG":
|
|
||||||
return f"Цена входа: Ask ${ask} / Bid ${bid}"
|
|
||||||
|
|
||||||
if direction == "SHORT":
|
|
||||||
return f"Цена входа: Bid ${bid} / Ask ${ask}"
|
|
||||||
|
|
||||||
return f"Цена рынка: Bid ${bid} / Ask ${ask}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_price(value: NumericLike | None) -> str:
|
def _format_price(value: NumericLike | None) -> str:
|
||||||
number = safe_float(value)
|
number = safe_float(value)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/storage/models.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -18,16 +20,4 @@ class JournalEventRecord:
|
|||||||
level: str
|
level: str
|
||||||
event_type: str
|
event_type: str
|
||||||
message: str
|
message: str
|
||||||
payload_json: str | None
|
payload_json: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class OrderDraftRecord:
|
|
||||||
id: int | None
|
|
||||||
created_at: str
|
|
||||||
symbol: str
|
|
||||||
side: str
|
|
||||||
order_type: str
|
|
||||||
quantity: str
|
|
||||||
status: str
|
|
||||||
payload_json: str | None
|
|
||||||
@@ -17,7 +17,11 @@ class JournalRepository:
|
|||||||
message: str,
|
message: str,
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None
|
payload_json = (
|
||||||
|
json.dumps(payload, ensure_ascii=False)
|
||||||
|
if payload is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@@ -66,7 +70,11 @@ class JournalRepository:
|
|||||||
|
|
||||||
return [self._row_to_dict(row) for row in rows]
|
return [self._row_to_dict(row) for row in rows]
|
||||||
|
|
||||||
def list_recent_with_offset(self, limit: int, offset: int) -> list[dict[str, Any]]:
|
def list_recent_with_offset(
|
||||||
|
self,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@@ -82,13 +90,86 @@ class JournalRepository:
|
|||||||
|
|
||||||
return [self._row_to_dict(row) for row in rows]
|
return [self._row_to_dict(row) for row in rows]
|
||||||
|
|
||||||
def list_export_rows(self, limit: int = 5000) -> list[dict[str, Any]]:
|
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 get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, created_at, level, event_type, message, payload_json
|
SELECT id, created_at, level, event_type, message, payload_json
|
||||||
FROM journal_events
|
FROM journal_events
|
||||||
|
{where_sql}
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
@@ -114,17 +195,6 @@ class JournalRepository:
|
|||||||
|
|
||||||
return int(deleted_count or 0)
|
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]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_older_than_days(self, days: int) -> int:
|
def delete_older_than_days(self, days: int) -> int:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@@ -137,4 +207,14 @@ class JournalRepository:
|
|||||||
)
|
)
|
||||||
deleted_count = cursor.rowcount
|
deleted_count = cursor.rowcount
|
||||||
|
|
||||||
return deleted_count
|
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]),
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# app/src/storage/repositories/order_drafts.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from src.storage.session import get_connection
|
|
||||||
|
|
||||||
|
|
||||||
class OrderDraftRepository:
|
|
||||||
def add_draft(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
quantity: str,
|
|
||||||
status: str = "draft",
|
|
||||||
payload: dict[str, Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None
|
|
||||||
|
|
||||||
with get_connection() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
'''
|
|
||||||
INSERT INTO order_drafts (symbol, side, order_type, quantity, status, payload_json)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
|
||||||
''',
|
|
||||||
(symbol, side, order_type, quantity, status, payload_json),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_recent_drafts(self, limit: int = 10) -> list[dict[str, str]]:
|
|
||||||
with get_connection() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
'''
|
|
||||||
SELECT id, created_at, symbol, side, order_type, quantity::text, status
|
|
||||||
FROM order_drafts
|
|
||||||
ORDER BY created_at DESC, id DESC
|
|
||||||
LIMIT %s
|
|
||||||
''',
|
|
||||||
(limit,),
|
|
||||||
)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
items: list[dict[str, str]] = []
|
|
||||||
for row in rows:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": str(row[0]),
|
|
||||||
"created_at": str(row[1]),
|
|
||||||
"symbol": str(row[2]),
|
|
||||||
"side": str(row[3]),
|
|
||||||
"order_type": str(row[4]),
|
|
||||||
"quantity": str(row[5]),
|
|
||||||
"status": str(row[6]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
|
|
||||||
with get_connection() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
'''
|
|
||||||
SELECT id, created_at, symbol, side, order_type, quantity::text, status, payload_json
|
|
||||||
FROM order_drafts
|
|
||||||
WHERE id = %s
|
|
||||||
LIMIT 1
|
|
||||||
''',
|
|
||||||
(draft_id,),
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
payload_raw = row[7]
|
|
||||||
payload: dict[str, Any] = {}
|
|
||||||
|
|
||||||
if isinstance(payload_raw, dict):
|
|
||||||
payload = payload_raw
|
|
||||||
elif payload_raw:
|
|
||||||
try:
|
|
||||||
payload = json.loads(str(payload_raw))
|
|
||||||
except Exception:
|
|
||||||
payload = {}
|
|
||||||
|
|
||||||
price = payload.get("price")
|
|
||||||
price_text = str(price) if price not in (None, "") else ""
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(row[0]),
|
|
||||||
"created_at": str(row[1]),
|
|
||||||
"symbol": str(row[2]),
|
|
||||||
"side": str(row[3]),
|
|
||||||
"order_type": str(row[4]),
|
|
||||||
"quantity": str(row[5]),
|
|
||||||
"status": str(row[6]),
|
|
||||||
"price": price_text,
|
|
||||||
}
|
|
||||||
|
|
||||||
def count_drafts(self) -> int:
|
|
||||||
with get_connection() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM order_drafts")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
return int(row[0]) if row else 0
|
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
|
# app/src/storage/schema.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from psycopg import sql
|
||||||
|
|
||||||
from src.storage.session import get_connection
|
from src.storage.session import get_connection
|
||||||
DDL = [
|
|
||||||
'''
|
|
||||||
|
# SQL-команды для первичной инициализации базы данных.
|
||||||
|
DDL: list[sql.SQL] = [
|
||||||
|
sql.SQL("""
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
payload_json JSONB NOT NULL
|
payload_json JSONB NOT NULL
|
||||||
)
|
)
|
||||||
''',
|
"""),
|
||||||
'''
|
sql.SQL("""
|
||||||
CREATE TABLE IF NOT EXISTS journal_events (
|
CREATE TABLE IF NOT EXISTS journal_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
@@ -18,28 +26,19 @@ DDL = [
|
|||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
payload_json JSONB
|
payload_json JSONB
|
||||||
)
|
)
|
||||||
''',
|
"""),
|
||||||
'''
|
sql.SQL("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_journal_events_created_at
|
CREATE INDEX IF NOT EXISTS idx_journal_events_created_at
|
||||||
ON journal_events (created_at DESC)
|
ON journal_events (created_at DESC)
|
||||||
''',
|
"""),
|
||||||
'''
|
sql.SQL("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_journal_events_event_type
|
CREATE INDEX IF NOT EXISTS idx_journal_events_event_type
|
||||||
ON journal_events (event_type)
|
ON journal_events (event_type)
|
||||||
''',
|
"""),
|
||||||
'''
|
|
||||||
CREATE TABLE IF NOT EXISTS order_drafts (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
symbol TEXT NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
order_type TEXT NOT NULL,
|
|
||||||
quantity NUMERIC(36, 18) NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
payload_json JSONB
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# создаёт таблицы и индексы, если они ещё не существуют
|
||||||
def init_schema() -> None:
|
def init_schema() -> None:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/storage/session.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from src.telegram.handlers.auto.ui import (
|
|||||||
auto_keyboard,
|
auto_keyboard,
|
||||||
build_auto_text,
|
build_auto_text,
|
||||||
is_auto_configured,
|
is_auto_configured,
|
||||||
|
_auto_block_reason,
|
||||||
)
|
)
|
||||||
from src.telegram.handlers.system import open_auto_settings
|
from src.telegram.handlers.system import open_auto_settings
|
||||||
from src.telegram.live.active_screen import ActiveScreenManager
|
from src.telegram.live.active_screen import ActiveScreenManager
|
||||||
@@ -230,6 +231,16 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
|
|||||||
await callback.answer()
|
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")
|
@router.callback_query(F.data == "auto:start")
|
||||||
async def auto_start(callback: CallbackQuery) -> None:
|
async def auto_start(callback: CallbackQuery) -> None:
|
||||||
service = AutoTradeService()
|
service = AutoTradeService()
|
||||||
@@ -246,6 +257,22 @@ async def auto_start(callback: CallbackQuery) -> None:
|
|||||||
|
|
||||||
return
|
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()
|
_, message_text = service.start()
|
||||||
|
|
||||||
if await _prepare_auto_from_callback(callback):
|
if await _prepare_auto_from_callback(callback):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
|
||||||
from src.telegram.ui.common import mode_line
|
from src.telegram.ui.common import mode_line
|
||||||
from src.trading.auto.service import AutoTradeService
|
from src.trading.auto.service import AutoTradeService
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
@@ -18,6 +19,11 @@ from src.core.numbers import safe_float
|
|||||||
def build_auto_notification_text() -> str:
|
def build_auto_notification_text() -> str:
|
||||||
state = AutoTradeService().get_state()
|
state = AutoTradeService().get_state()
|
||||||
|
|
||||||
|
signal = str(getattr(state, "last_signal", "HOLD") or "HOLD").upper()
|
||||||
|
|
||||||
|
if signal in {"BUY", "SELL"}:
|
||||||
|
return _build_signal_notification_text(state, signal)
|
||||||
|
|
||||||
cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
|
cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
|
||||||
cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)
|
cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)
|
||||||
|
|
||||||
@@ -33,15 +39,102 @@ def build_auto_notification_text() -> str:
|
|||||||
return " · ".join(parts)
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signal_notification_text(state, signal: str) -> str:
|
||||||
|
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||||
|
|
||||||
|
bid_price = _price_from_snapshot(snapshot, "bid_price")
|
||||||
|
ask_price = _price_from_snapshot(snapshot, "ask_price")
|
||||||
|
|
||||||
|
side = "Long" if signal == "BUY" else "Short"
|
||||||
|
side_icon = _signal_icon(signal)
|
||||||
|
asset = _asset_symbol(getattr(state, "symbol", None))
|
||||||
|
|
||||||
|
confidence = safe_float(getattr(state, "last_signal_confidence", None)) or 0.0
|
||||||
|
reason = str(getattr(state, "last_signal_reason", "") or "").strip()
|
||||||
|
|
||||||
|
if signal == "BUY":
|
||||||
|
entry_price = ask_price
|
||||||
|
entry_source = "Ask"
|
||||||
|
second_price_label = "Bid"
|
||||||
|
second_price = bid_price
|
||||||
|
else:
|
||||||
|
entry_price = bid_price
|
||||||
|
entry_source = "Bid"
|
||||||
|
second_price_label = "Ask"
|
||||||
|
second_price = ask_price
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Сигнал {side_icon} {asset} · {side}",
|
||||||
|
"",
|
||||||
|
f"Цена входа {side} · {_format_plain_or_dash(entry_price)} ({entry_source})",
|
||||||
|
f"Цена {second_price_label} · {_format_plain_or_dash(second_price)}",
|
||||||
|
"",
|
||||||
|
_signal_strength_line(confidence),
|
||||||
|
]
|
||||||
|
|
||||||
|
compact_reason = _notification_signal_reason(reason)
|
||||||
|
if compact_reason:
|
||||||
|
lines.append(compact_reason)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _price_from_snapshot(
|
||||||
|
snapshot: dict[str, object] | None,
|
||||||
|
key: str,
|
||||||
|
) -> float | None:
|
||||||
|
if snapshot is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return safe_float(snapshot.get(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_strength_line(confidence: float) -> str:
|
||||||
|
filled = min(3, max(0, round(confidence * 3)))
|
||||||
|
bar = "●" * filled + "○" * (3 - filled)
|
||||||
|
|
||||||
|
if confidence >= 0.8:
|
||||||
|
level = "Сильный"
|
||||||
|
elif confidence >= 0.5:
|
||||||
|
level = "Средний"
|
||||||
|
else:
|
||||||
|
level = "Слабый"
|
||||||
|
|
||||||
|
return f"{bar} {level} · {confidence:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_signal_reason(reason: str) -> str:
|
||||||
|
reason_upper = reason.upper()
|
||||||
|
|
||||||
|
if "BREAKOUT_UP" in reason_upper:
|
||||||
|
return "Пробой вверх"
|
||||||
|
|
||||||
|
if "BREAKOUT_DOWN" in reason_upper:
|
||||||
|
return "Пробой вниз"
|
||||||
|
|
||||||
|
if "TREND_UP" in reason_upper:
|
||||||
|
return "Рынок растёт"
|
||||||
|
|
||||||
|
if "TREND_DOWN" in reason_upper:
|
||||||
|
return "Рынок снижается"
|
||||||
|
|
||||||
|
return _compact_entry_block_message(reason)
|
||||||
|
|
||||||
|
|
||||||
def auto_keyboard() -> InlineKeyboardMarkup:
|
def auto_keyboard() -> InlineKeyboardMarkup:
|
||||||
state = AutoTradeService().get_state()
|
state = AutoTradeService().get_state()
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
status = (state.status or "").upper()
|
status = (state.status or "").upper()
|
||||||
|
block_reason = _auto_block_reason()
|
||||||
|
|
||||||
if status == "OFF":
|
if status == "OFF":
|
||||||
builder.button(text="▶️ Запустить", callback_data="auto:start")
|
if block_reason:
|
||||||
|
builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked")
|
||||||
|
else:
|
||||||
|
builder.button(text="▶️ Запустить", callback_data="auto:start")
|
||||||
|
|
||||||
builder.button(text="👀 Наблюдать", callback_data="auto:observe")
|
builder.button(text="👀 Наблюдать", callback_data="auto:observe")
|
||||||
|
|
||||||
elif status == "RUNNING":
|
elif status == "RUNNING":
|
||||||
@@ -49,7 +142,11 @@ def auto_keyboard() -> InlineKeyboardMarkup:
|
|||||||
builder.button(text="🛑 Остановить", callback_data="auto:stop")
|
builder.button(text="🛑 Остановить", callback_data="auto:stop")
|
||||||
|
|
||||||
elif status == "OBSERVING":
|
elif status == "OBSERVING":
|
||||||
builder.button(text="▶️ Запустить", callback_data="auto:start")
|
if block_reason:
|
||||||
|
builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked")
|
||||||
|
else:
|
||||||
|
builder.button(text="▶️ Запустить", callback_data="auto:start")
|
||||||
|
|
||||||
builder.button(text="🛑 Остановить", callback_data="auto:stop")
|
builder.button(text="🛑 Остановить", callback_data="auto:stop")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -97,30 +194,31 @@ def is_auto_configured(state) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_auto_text() -> str:
|
def _auto_block_reason(state: object | None = None) -> str | None:
|
||||||
state = AutoTradeService().get_state()
|
state_to_use = state or AutoTradeService().get_state()
|
||||||
|
|
||||||
if not is_auto_configured(state):
|
lines = build_runtime_exchange_alert_lines(
|
||||||
return _build_not_configured_text(state)
|
symbol=getattr(state_to_use, "symbol", None),
|
||||||
|
include_exchange_unavailable=True,
|
||||||
if state.position_side != "NONE" and state.entry_price is not None:
|
|
||||||
return _build_active_position_text(state)
|
|
||||||
|
|
||||||
if state.status == "OFF":
|
|
||||||
return _build_stopped_without_position_text(state)
|
|
||||||
|
|
||||||
return _build_waiting_text(state)
|
|
||||||
|
|
||||||
|
|
||||||
def build_auto_semantic_text() -> str:
|
|
||||||
text = build_auto_text()
|
|
||||||
|
|
||||||
return re.sub(
|
|
||||||
r" · \d+с| · \d+м \d+с| · \d+ч \d+м",
|
|
||||||
"",
|
|
||||||
text,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_auto_block_reason(parts: list[str], state: object) -> None:
|
||||||
|
block_reason = _auto_block_reason(state)
|
||||||
|
|
||||||
|
if block_reason:
|
||||||
|
parts.extend([
|
||||||
|
"",
|
||||||
|
block_reason,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _build_not_configured_text(state) -> str:
|
def _build_not_configured_text(state) -> str:
|
||||||
symbol_ready = state.symbol is not None
|
symbol_ready = state.symbol is not None
|
||||||
@@ -134,11 +232,16 @@ def _build_not_configured_text(state) -> str:
|
|||||||
parts = [
|
parts = [
|
||||||
"🤖 Автоторговля ⚪ Не настроена",
|
"🤖 Автоторговля ⚪ Не настроена",
|
||||||
_account_mode_line(),
|
_account_mode_line(),
|
||||||
|
]
|
||||||
|
|
||||||
|
_append_auto_block_reason(parts, state)
|
||||||
|
|
||||||
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}",
|
f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}",
|
||||||
f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}",
|
f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}",
|
||||||
f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}",
|
f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}",
|
||||||
]
|
])
|
||||||
|
|
||||||
strategy = (state.strategy or "").upper()
|
strategy = (state.strategy or "").upper()
|
||||||
|
|
||||||
@@ -166,6 +269,11 @@ def _build_stopped_without_position_text(state) -> str:
|
|||||||
parts = [
|
parts = [
|
||||||
"⚪️ Автоторговля остановлена",
|
"⚪️ Автоторговля остановлена",
|
||||||
_account_mode_line(),
|
_account_mode_line(),
|
||||||
|
]
|
||||||
|
|
||||||
|
_append_auto_block_reason(parts, state)
|
||||||
|
|
||||||
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
f"Доступно 💰 {_format_money_compact(available)}",
|
f"Доступно 💰 {_format_money_compact(available)}",
|
||||||
"",
|
"",
|
||||||
@@ -180,11 +288,36 @@ def _build_stopped_without_position_text(state) -> str:
|
|||||||
f"Риск · {_format_percent(state.risk_percent)}",
|
f"Риск · {_format_percent(state.risk_percent)}",
|
||||||
"",
|
"",
|
||||||
_settings_risk_percent_line(state),
|
_settings_risk_percent_line(state),
|
||||||
]
|
])
|
||||||
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_auto_text() -> str:
|
||||||
|
state = AutoTradeService().get_state()
|
||||||
|
|
||||||
|
if not is_auto_configured(state):
|
||||||
|
return _build_not_configured_text(state)
|
||||||
|
|
||||||
|
if state.position_side != "NONE" and state.entry_price is not None:
|
||||||
|
return _build_active_position_text(state)
|
||||||
|
|
||||||
|
if state.status == "OFF":
|
||||||
|
return _build_stopped_without_position_text(state)
|
||||||
|
|
||||||
|
return _build_waiting_text(state)
|
||||||
|
|
||||||
|
|
||||||
|
def build_auto_semantic_text() -> str:
|
||||||
|
text = build_auto_text()
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
r" · \d+с| · \d+м \d+с| · \d+ч \d+м",
|
||||||
|
"",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _settings_risk_percent_line(state) -> str:
|
def _settings_risk_percent_line(state) -> str:
|
||||||
sl = _format_percent(state.stop_loss_percent)
|
sl = _format_percent(state.stop_loss_percent)
|
||||||
tp = _format_percent(state.take_profit_percent)
|
tp = _format_percent(state.take_profit_percent)
|
||||||
@@ -210,9 +343,14 @@ def _build_waiting_text(state) -> str:
|
|||||||
parts = [
|
parts = [
|
||||||
f"{_status_text(state)}",
|
f"{_status_text(state)}",
|
||||||
_account_mode_line(),
|
_account_mode_line(),
|
||||||
|
]
|
||||||
|
|
||||||
|
_append_auto_block_reason(parts, state)
|
||||||
|
|
||||||
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
f"Доступно 💰 {_format_money_compact(available)}",
|
f"Доступно 💰 {_format_money_compact(available)}",
|
||||||
]
|
])
|
||||||
|
|
||||||
if cycle_trades > 0:
|
if cycle_trades > 0:
|
||||||
parts.extend([
|
parts.extend([
|
||||||
@@ -367,10 +505,15 @@ def _build_active_position_text(state) -> str:
|
|||||||
parts = [
|
parts = [
|
||||||
_status_text(state),
|
_status_text(state),
|
||||||
_account_mode_line(),
|
_account_mode_line(),
|
||||||
|
]
|
||||||
|
|
||||||
|
_append_auto_block_reason(parts, state)
|
||||||
|
|
||||||
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
f"Доступно 💰 {_format_money_compact(available)}",
|
f"Доступно 💰 {_format_money_compact(available)}",
|
||||||
f"Маржа · {_format_usd_compact(reserved)}",
|
f"Маржа · {_format_usd_compact(reserved)}",
|
||||||
]
|
])
|
||||||
|
|
||||||
if cycle_trades > 0:
|
if cycle_trades > 0:
|
||||||
parts.extend([
|
parts.extend([
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BufferedInputFile,
|
BufferedInputFile,
|
||||||
@@ -17,11 +18,14 @@ from src.telegram.handlers.journal_ui import (
|
|||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
build_actions_keyboard,
|
build_actions_keyboard,
|
||||||
build_clear_confirm_keyboard,
|
build_clear_confirm_keyboard,
|
||||||
|
build_export_format_keyboard,
|
||||||
build_keyboard,
|
build_keyboard,
|
||||||
render,
|
render,
|
||||||
render_actions,
|
render_actions,
|
||||||
render_clear_confirm,
|
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.active_screen import ActiveScreenManager
|
||||||
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
@@ -175,10 +179,18 @@ async def _show_journal_page(
|
|||||||
kb = build_keyboard(page, total_pages)
|
kb = build_keyboard(page, total_pages)
|
||||||
|
|
||||||
if edit_mode:
|
if edit_mode:
|
||||||
await target_message.edit_text(
|
try:
|
||||||
text,
|
await target_message.edit_text(
|
||||||
reply_markup=kb,
|
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)
|
_register_journal_screen(target_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -200,10 +212,44 @@ async def journal_actions(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.edit_text(
|
try:
|
||||||
render_actions(),
|
await message.edit_text(
|
||||||
reply_markup=build_actions_keyboard(),
|
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)
|
_register_journal_screen(message)
|
||||||
|
|
||||||
@@ -224,8 +270,8 @@ async def open_journal(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "monitoring:journal")
|
@router.callback_query(F.data == "system:journal")
|
||||||
async def open_journal_from_monitoring(
|
async def open_journal_from_system(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -254,20 +300,36 @@ async def journal_noop(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "journal:export_csv")
|
@router.callback_query(F.data.startswith("journal:export_csv"))
|
||||||
async def export_journal_csv(callback: CallbackQuery) -> None:
|
async def export_journal_csv(callback: CallbackQuery) -> None:
|
||||||
service = JournalService()
|
service = JournalService()
|
||||||
message = _require_message(callback)
|
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:
|
try:
|
||||||
data = service.export_csv()
|
data = service.export_csv(export_filter=export_filter)
|
||||||
document = BufferedInputFile(
|
document = BufferedInputFile(
|
||||||
data,
|
data,
|
||||||
filename=service.build_export_filename("csv"),
|
filename=service.build_export_filename(
|
||||||
|
"csv",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if message is not None:
|
await message.answer_document(
|
||||||
await message.answer_document(document=document)
|
document=document,
|
||||||
|
request_timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
service.log_ui_info(
|
service.log_ui_info(
|
||||||
event_type="journal_exported",
|
event_type="journal_exported",
|
||||||
@@ -276,10 +338,31 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
|
|||||||
action="export_csv",
|
action="export_csv",
|
||||||
user_id=_user_id_from_callback(callback),
|
user_id=_user_id_from_callback(callback),
|
||||||
chat_id=_chat_id_from_callback(callback),
|
chat_id=_chat_id_from_callback(callback),
|
||||||
payload=_journal_payload(format="csv"),
|
payload=_journal_payload(
|
||||||
|
format="csv",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer("CSV экспортирован")
|
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:
|
except Exception as exc:
|
||||||
service.log_ui_error(
|
service.log_ui_error(
|
||||||
@@ -289,30 +372,46 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
|
|||||||
action="export_csv",
|
action="export_csv",
|
||||||
user_id=_user_id_from_callback(callback),
|
user_id=_user_id_from_callback(callback),
|
||||||
chat_id=_chat_id_from_callback(callback),
|
chat_id=_chat_id_from_callback(callback),
|
||||||
payload=_journal_payload(format="csv"),
|
payload=_journal_payload(
|
||||||
|
format="csv",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
raw_error=str(exc),
|
raw_error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer(
|
await message.answer("⛔️ Не удалось экспортировать CSV.")
|
||||||
"Не удалось экспортировать CSV",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "journal:export_xlsx")
|
@router.callback_query(F.data.startswith("journal:export_xlsx"))
|
||||||
async def export_journal_xlsx(callback: CallbackQuery) -> None:
|
async def export_journal_xlsx(callback: CallbackQuery) -> None:
|
||||||
service = JournalService()
|
service = JournalService()
|
||||||
message = _require_message(callback)
|
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:
|
try:
|
||||||
data = service.export_xlsx()
|
data = service.export_xlsx(export_filter=export_filter)
|
||||||
document = BufferedInputFile(
|
document = BufferedInputFile(
|
||||||
data,
|
data,
|
||||||
filename=service.build_export_filename("xlsx"),
|
filename=service.build_export_filename(
|
||||||
|
"xlsx",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if message is not None:
|
await message.answer_document(
|
||||||
await message.answer_document(document=document)
|
document=document,
|
||||||
|
request_timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
service.log_ui_info(
|
service.log_ui_info(
|
||||||
event_type="journal_exported",
|
event_type="journal_exported",
|
||||||
@@ -321,10 +420,31 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
|
|||||||
action="export_xlsx",
|
action="export_xlsx",
|
||||||
user_id=_user_id_from_callback(callback),
|
user_id=_user_id_from_callback(callback),
|
||||||
chat_id=_chat_id_from_callback(callback),
|
chat_id=_chat_id_from_callback(callback),
|
||||||
payload=_journal_payload(format="xlsx"),
|
payload=_journal_payload(
|
||||||
|
format="xlsx",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer("Excel экспортирован")
|
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:
|
except Exception as exc:
|
||||||
service.log_ui_error(
|
service.log_ui_error(
|
||||||
@@ -334,14 +454,14 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
|
|||||||
action="export_xlsx",
|
action="export_xlsx",
|
||||||
user_id=_user_id_from_callback(callback),
|
user_id=_user_id_from_callback(callback),
|
||||||
chat_id=_chat_id_from_callback(callback),
|
chat_id=_chat_id_from_callback(callback),
|
||||||
payload=_journal_payload(format="xlsx"),
|
payload=_journal_payload(
|
||||||
|
format="xlsx",
|
||||||
|
export_filter=export_filter,
|
||||||
|
),
|
||||||
raw_error=str(exc),
|
raw_error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer(
|
await message.answer("⛔️ Не удалось экспортировать Excel.")
|
||||||
"Не удалось экспортировать Excel",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "journal:clear_confirm")
|
@router.callback_query(F.data == "journal:clear_confirm")
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ from src.core.config import load_settings
|
|||||||
from src.core.event_titles import event_title
|
from src.core.event_titles import event_title
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, JsonList, NumericLike
|
from src.core.types import JsonDict, JsonList, NumericLike
|
||||||
|
from src.trading.journal.filters import journal_export_filter_label
|
||||||
|
|
||||||
|
|
||||||
PAGE_SIZE = 5
|
PAGE_SIZE = 3
|
||||||
|
|
||||||
LEVEL_ICONS = {
|
LEVEL_ICONS = {
|
||||||
"INFO": "ℹ️",
|
"INFO": "ℹ️",
|
||||||
@@ -52,7 +53,7 @@ def build_keyboard(
|
|||||||
|
|
||||||
kb.button(text="📤 Экспорт", callback_data="journal:actions")
|
kb.button(text="📤 Экспорт", callback_data="journal:actions")
|
||||||
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
|
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
|
||||||
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
kb.button(text="⬅️ Назад", callback_data="system:back")
|
||||||
|
|
||||||
nav_count = 1
|
nav_count = 1
|
||||||
|
|
||||||
@@ -67,10 +68,28 @@ def build_keyboard(
|
|||||||
|
|
||||||
|
|
||||||
def build_actions_keyboard() -> InlineKeyboardMarkup:
|
def build_actions_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
# Первый экран экспорта: выбираем, что именно экспортировать.
|
||||||
kb = InlineKeyboardBuilder()
|
kb = InlineKeyboardBuilder()
|
||||||
kb.button(text="📄 CSV", callback_data="journal:export_csv")
|
|
||||||
kb.button(text="📊 Excel", callback_data="journal:export_xlsx")
|
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.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)
|
kb.adjust(2, 1)
|
||||||
return kb.as_markup()
|
return kb.as_markup()
|
||||||
|
|
||||||
@@ -78,7 +97,18 @@ def build_actions_keyboard() -> InlineKeyboardMarkup:
|
|||||||
def render_actions() -> str:
|
def render_actions() -> str:
|
||||||
return (
|
return (
|
||||||
"<b>📤 Экспорт</b>\n\n"
|
"<b>📤 Экспорт</b>\n\n"
|
||||||
"<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"
|
||||||
"Выберите формат:"
|
"Выберите формат:"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -245,7 +275,7 @@ def render(
|
|||||||
lines = [
|
lines = [
|
||||||
"<b>📒 Журнал</b>",
|
"<b>📒 Журнал</b>",
|
||||||
"",
|
"",
|
||||||
"<b>МОНИТОРИНГ</b>",
|
"<b>СИСТЕМА</b>",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
# app/src/telegram/handlers/market.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
from aiogram.types import (
|
|
||||||
CallbackQuery,
|
|
||||||
InlineKeyboardMarkup,
|
|
||||||
Message,
|
|
||||||
InaccessibleMessage,
|
|
||||||
)
|
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.core.types import NumericLike
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
from src.telegram.live.active_screen import ActiveScreenManager
|
|
||||||
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
|
||||||
from src.telegram.ui.common import mode_line, now_line
|
|
||||||
from src.telegram.ui.currency_ui import format_usd_amount
|
|
||||||
from src.telegram.ui.exchange_error import (
|
|
||||||
classify_exchange_error,
|
|
||||||
show_callback_exchange_error,
|
|
||||||
show_message_exchange_error,
|
|
||||||
)
|
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="market")
|
|
||||||
_last_market_prices: dict[str, float] = {}
|
|
||||||
_last_market_directions: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _require_message(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
) -> Message | None:
|
|
||||||
message = callback.message
|
|
||||||
|
|
||||||
if (
|
|
||||||
message is None
|
|
||||||
or isinstance(message, InaccessibleMessage)
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
|
||||||
builder.adjust(1)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_market_text(
|
|
||||||
*,
|
|
||||||
ticker_price: NumericLike,
|
|
||||||
name: str,
|
|
||||||
market_type: str,
|
|
||||||
base_asset: str,
|
|
||||||
quote_asset: str,
|
|
||||||
) -> str:
|
|
||||||
price = safe_float(ticker_price)
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
price = 0.0
|
|
||||||
|
|
||||||
previous_price = _last_market_prices.get(name)
|
|
||||||
price_direction = _last_market_directions.get(name, "▲")
|
|
||||||
|
|
||||||
if previous_price is not None:
|
|
||||||
if price > previous_price:
|
|
||||||
price_direction = "🔺"
|
|
||||||
elif price < previous_price:
|
|
||||||
price_direction = "🔻"
|
|
||||||
|
|
||||||
_last_market_prices[name] = price
|
|
||||||
_last_market_directions[name] = price_direction
|
|
||||||
|
|
||||||
type_map = {
|
|
||||||
"LEVERAGE": "leverage",
|
|
||||||
"SPOT": "spot",
|
|
||||||
}
|
|
||||||
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
|
|
||||||
|
|
||||||
return (
|
|
||||||
"<b>📈 Рынок</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
"\n"
|
|
||||||
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
|
|
||||||
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
|
|
||||||
f"{now_line()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_market_live_text() -> str:
|
|
||||||
service = ExchangeService()
|
|
||||||
requested_symbol = service.settings.default_symbol
|
|
||||||
|
|
||||||
validation = service.validate_symbol(requested_symbol)
|
|
||||||
|
|
||||||
if not validation.is_valid:
|
|
||||||
return (
|
|
||||||
"<b>📈 Рынок</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
"⚠️ Ошибка инструмента\n\n"
|
|
||||||
"Инструмент недоступен."
|
|
||||||
)
|
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
|
||||||
base_asset = (
|
|
||||||
symbol_info.base_asset
|
|
||||||
if symbol_info and symbol_info.base_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
quote_asset = (
|
|
||||||
symbol_info.quote_asset
|
|
||||||
if symbol_info and symbol_info.quote_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
name = (
|
|
||||||
symbol_info.name
|
|
||||||
if symbol_info and symbol_info.name
|
|
||||||
else ticker.symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
return _build_market_text(
|
|
||||||
ticker_price=ticker.price,
|
|
||||||
name=name,
|
|
||||||
market_type=market_type,
|
|
||||||
base_asset=base_asset,
|
|
||||||
quote_asset=quote_asset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_market_live_screen(message: Message) -> None:
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
LiveScreenRunner.unregister_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
ScreenRegistry.unregister_message(
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
LiveScreenRunner.register_screen(
|
|
||||||
LiveScreen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
render_text=_build_market_live_text,
|
|
||||||
render_markup=_market_keyboard,
|
|
||||||
interval_seconds=5,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
LiveScreenRunner.start("market")
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_market_from_message(
|
|
||||||
message: Message,
|
|
||||||
) -> bool:
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_market_from_callback(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
) -> bool:
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer(
|
|
||||||
"Сообщение недоступно",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
await callback.answer(
|
|
||||||
"Bot недоступен",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="market",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
keep_message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _render_market_screen(
|
|
||||||
target_message: Message,
|
|
||||||
*,
|
|
||||||
user_id: int | None,
|
|
||||||
chat_id: int | None,
|
|
||||||
edit_mode: bool,
|
|
||||||
action: str,
|
|
||||||
) -> None:
|
|
||||||
service = ExchangeService()
|
|
||||||
journal = JournalService()
|
|
||||||
requested_symbol = service.settings.default_symbol
|
|
||||||
|
|
||||||
journal.log_ui_info(
|
|
||||||
event_type="market_open_requested",
|
|
||||||
message="Запрошено открытие экрана рынка.",
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={"symbol": requested_symbol},
|
|
||||||
)
|
|
||||||
|
|
||||||
validation = service.validate_symbol(requested_symbol)
|
|
||||||
|
|
||||||
if not validation.is_valid:
|
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="market_symbol_invalid",
|
|
||||||
message="Инструмент недоступен.",
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={
|
|
||||||
"symbol": requested_symbol,
|
|
||||||
"validation_message": validation.message,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"<b>📈 Рынок</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
"⚠️ Ошибка инструмента\n\n"
|
|
||||||
"Инструмент недоступен."
|
|
||||||
)
|
|
||||||
|
|
||||||
if edit_mode:
|
|
||||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
|
||||||
_register_market_live_screen(target_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=target_message)
|
|
||||||
else:
|
|
||||||
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
|
|
||||||
_register_market_live_screen(sent_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
|
||||||
base_asset = (
|
|
||||||
symbol_info.base_asset
|
|
||||||
if symbol_info and symbol_info.base_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
quote_asset = (
|
|
||||||
symbol_info.quote_asset
|
|
||||||
if symbol_info and symbol_info.quote_asset
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
name = (
|
|
||||||
symbol_info.name
|
|
||||||
if symbol_info and symbol_info.name
|
|
||||||
else ticker.symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
text = _build_market_text(
|
|
||||||
ticker_price=ticker.price,
|
|
||||||
name=name,
|
|
||||||
market_type=market_type,
|
|
||||||
base_asset=base_asset,
|
|
||||||
quote_asset=quote_asset,
|
|
||||||
)
|
|
||||||
|
|
||||||
journal.log_ui_info(
|
|
||||||
event_type="market_open_success",
|
|
||||||
message="Экран рынка загружен.",
|
|
||||||
screen="market",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={
|
|
||||||
"symbol": ticker.symbol,
|
|
||||||
"price": safe_float(ticker.price),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if edit_mode:
|
|
||||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
|
||||||
_register_market_live_screen(target_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=target_message)
|
|
||||||
else:
|
|
||||||
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
|
|
||||||
_register_market_live_screen(sent_message)
|
|
||||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "📈 Рынок")
|
|
||||||
async def open_market(message: Message, state: FSMContext) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_message(message):
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = message.from_user.id if message.from_user else None
|
|
||||||
chat_id = message.chat.id if message.chat else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=False,
|
|
||||||
action="open",
|
|
||||||
)
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_open_error",
|
|
||||||
message="Не удалось загрузить экран рынка.",
|
|
||||||
screen="market",
|
|
||||||
action="open",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_message_exchange_error(
|
|
||||||
message,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "monitoring:market")
|
|
||||||
async def open_market_from_monitoring(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer(
|
|
||||||
"Сообщение недоступно",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id if callback.from_user else None
|
|
||||||
chat_id = message.chat.id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=True,
|
|
||||||
action="open_from_monitoring",
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_open_error",
|
|
||||||
message="Не удалось загрузить экран рынка из мониторинга.",
|
|
||||||
screen="market",
|
|
||||||
action="open_from_monitoring",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "market:retry")
|
|
||||||
async def retry_market(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_market_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer(
|
|
||||||
"Сообщение недоступно",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id if callback.from_user else None
|
|
||||||
chat_id = message.chat.id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _render_market_screen(
|
|
||||||
message,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
edit_mode=True,
|
|
||||||
action="retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="market_retry_error",
|
|
||||||
message="Не удалось обновить экран рынка.",
|
|
||||||
screen="market",
|
|
||||||
action="retry",
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>📈 Рынок</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
|
||||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="market:retry",
|
|
||||||
)
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# app/src/telegram/handlers/monitoring.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import (
|
|
||||||
CallbackQuery,
|
|
||||||
InaccessibleMessage,
|
|
||||||
InlineKeyboardMarkup,
|
|
||||||
Message,
|
|
||||||
)
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
|
|
||||||
from src.telegram.live.active_screen import ActiveScreenManager
|
|
||||||
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="monitoring")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_message(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
) -> Message | None:
|
|
||||||
message = callback.message
|
|
||||||
|
|
||||||
if (
|
|
||||||
message is None
|
|
||||||
or isinstance(message, InaccessibleMessage)
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def _monitoring_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="💼 Портфель", callback_data="monitoring:portfolio")
|
|
||||||
builder.button(text="📈 Рынок", callback_data="monitoring:market")
|
|
||||||
builder.button(text="📒 Журнал", callback_data="monitoring:journal")
|
|
||||||
builder.adjust(2, 1)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _monitoring_text() -> str:
|
|
||||||
return (
|
|
||||||
"<b>📊 Мониторинг</b>\n\n"
|
|
||||||
"Выберите раздел:"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_monitoring_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="monitoring",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_monitoring_from_message(
|
|
||||||
message: Message,
|
|
||||||
) -> bool:
|
|
||||||
bot = message.bot
|
|
||||||
|
|
||||||
if bot is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="monitoring",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_monitoring_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="monitoring",
|
|
||||||
bot=bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
keep_message_id=message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "📊 Мониторинг")
|
|
||||||
async def open_monitoring(
|
|
||||||
message: Message,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_monitoring_from_message(message):
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_message = await message.answer(
|
|
||||||
_monitoring_text(),
|
|
||||||
reply_markup=_monitoring_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_monitoring_screen(sent_message)
|
|
||||||
|
|
||||||
ActiveScreenManager.register(
|
|
||||||
screen="monitoring",
|
|
||||||
message=sent_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "monitoring:home")
|
|
||||||
async def open_monitoring_callback(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_monitoring_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
message = _require_message(callback)
|
|
||||||
|
|
||||||
if message is None:
|
|
||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
await message.edit_text(
|
|
||||||
_monitoring_text(),
|
|
||||||
reply_markup=_monitoring_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_monitoring_screen(message)
|
|
||||||
|
|
||||||
ActiveScreenManager.register(
|
|
||||||
screen="monitoring",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
# app/src/telegram/handlers/portfolio.py
|
# app/src/telegram/handlers/portfolio.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
@@ -13,10 +12,14 @@ from aiogram.types import (
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import NumericLike
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
|
||||||
from src.integrations.exchange.models import BalanceSummary
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.integrations.exchange.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.active_screen import ActiveScreenManager
|
||||||
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
||||||
from src.telegram.ui.common import mode_line, now_line
|
from src.telegram.ui.common import mode_line, now_line
|
||||||
@@ -26,11 +29,6 @@ from src.telegram.ui.currency_ui import (
|
|||||||
format_usd_amount,
|
format_usd_amount,
|
||||||
is_zero_balance,
|
is_zero_balance,
|
||||||
)
|
)
|
||||||
from src.telegram.ui.exchange_error import (
|
|
||||||
classify_exchange_error,
|
|
||||||
show_callback_exchange_error,
|
|
||||||
show_message_exchange_error,
|
|
||||||
)
|
|
||||||
from src.trading.accounts.service import AccountsService
|
from src.trading.accounts.service import AccountsService
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
@@ -46,6 +44,7 @@ PINNED_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Получить доступное Telegram-сообщение из callback.
|
||||||
def _require_message(
|
def _require_message(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
) -> Message | None:
|
) -> Message | None:
|
||||||
@@ -60,10 +59,7 @@ def _require_message(
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
def _payload(**values: object) -> JsonDict:
|
# Отформатировать количество актива компактно для портфеля.
|
||||||
return dict(values)
|
|
||||||
|
|
||||||
|
|
||||||
def _compact_amount(currency: str, value: NumericLike) -> str:
|
def _compact_amount(currency: str, value: NumericLike) -> str:
|
||||||
number = safe_float(value) or 0.0
|
number = safe_float(value) or 0.0
|
||||||
currency = currency.upper()
|
currency = currency.upper()
|
||||||
@@ -90,21 +86,15 @@ def _compact_amount(currency: str, value: NumericLike) -> str:
|
|||||||
return f"{int(text):,}".replace(",", " ")
|
return f"{int(text):,}".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
# Клавиатура портфеля при частичных данных или ошибке.
|
||||||
|
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
# Отсортировать балансы: сначала основные активы, потом остальные по алфавиту.
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
|
||||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
|
||||||
builder.adjust(1, 1)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||||
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
||||||
currency = item.currency.upper()
|
currency = item.currency.upper()
|
||||||
@@ -114,11 +104,41 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
|||||||
return sorted(items, key=sort_key)
|
return sorted(items, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
# Собрать текст ошибки портфеля через единый 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()
|
service = AccountsService()
|
||||||
exchange_service = ExchangeService()
|
exchange_service = ExchangeService()
|
||||||
|
|
||||||
balances = service.get_live_balance_summary()
|
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:
|
if not balances:
|
||||||
text = (
|
text = (
|
||||||
@@ -127,7 +147,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
|||||||
"Нет данных по балансу.\n\n"
|
"Нет данных по балансу.\n\n"
|
||||||
f"{now_line()}"
|
f"{now_line()}"
|
||||||
)
|
)
|
||||||
return text, _portfolio_keyboard()
|
return text, None
|
||||||
|
|
||||||
visible_balances = [
|
visible_balances = [
|
||||||
item
|
item
|
||||||
@@ -143,7 +163,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
|||||||
"Нет активов с балансом.\n\n"
|
"Нет активов с балансом.\n\n"
|
||||||
f"{now_line()}"
|
f"{now_line()}"
|
||||||
)
|
)
|
||||||
return text, _portfolio_keyboard()
|
return text, None
|
||||||
|
|
||||||
price_cache: dict[str, float | None] = {}
|
price_cache: dict[str, float | None] = {}
|
||||||
total_estimated_usd = 0.0
|
total_estimated_usd = 0.0
|
||||||
@@ -160,11 +180,15 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
|||||||
currency = item.currency.upper()
|
currency = item.currency.upper()
|
||||||
total = safe_float(balance_total(item)) or 0.0
|
total = safe_float(balance_total(item)) or 0.0
|
||||||
locked = safe_float(item.locked) or 0.0
|
locked = safe_float(item.locked) or 0.0
|
||||||
estimated_usd = estimate_balance_usd(
|
|
||||||
item,
|
try:
|
||||||
exchange_service,
|
estimated_usd = estimate_balance_usd(
|
||||||
price_cache,
|
item,
|
||||||
)
|
exchange_service,
|
||||||
|
price_cache,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
estimated_usd = None
|
||||||
|
|
||||||
if estimated_usd is not None:
|
if estimated_usd is not None:
|
||||||
total_estimated_usd += estimated_usd
|
total_estimated_usd += estimated_usd
|
||||||
@@ -205,18 +229,20 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
|||||||
reply_markup = (
|
reply_markup = (
|
||||||
_portfolio_warning_keyboard()
|
_portfolio_warning_keyboard()
|
||||||
if has_partial_data
|
if has_partial_data
|
||||||
else _portfolio_keyboard()
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(lines).rstrip(), reply_markup
|
return "\n".join(lines).rstrip(), reply_markup
|
||||||
|
|
||||||
|
|
||||||
|
# Render-функция текста для live runner.
|
||||||
def _portfolio_live_text() -> str:
|
def _portfolio_live_text() -> str:
|
||||||
text, _ = _build_portfolio_live_text()
|
text, _ = _build_portfolio_live_text()
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _portfolio_live_markup() -> InlineKeyboardMarkup:
|
# Render-функция клавиатуры для live runner.
|
||||||
|
def _portfolio_live_markup() -> InlineKeyboardMarkup | None:
|
||||||
_, markup = _build_portfolio_live_text()
|
_, markup = _build_portfolio_live_text()
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
@@ -252,6 +278,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
|
|||||||
LiveScreenRunner.start("portfolio")
|
LiveScreenRunner.start("portfolio")
|
||||||
|
|
||||||
|
|
||||||
|
# Подготовить новый экран портфеля из обычного сообщения.
|
||||||
async def _prepare_portfolio_from_message(
|
async def _prepare_portfolio_from_message(
|
||||||
message: Message,
|
message: Message,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -269,6 +296,7 @@ async def _prepare_portfolio_from_message(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Подготовить экран портфеля из callback.
|
||||||
async def _prepare_portfolio_from_callback(
|
async def _prepare_portfolio_from_callback(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -294,6 +322,7 @@ async def _prepare_portfolio_from_callback(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Отрисовать экран портфеля и зарегистрировать live обновления.
|
||||||
async def _render_portfolio_screen(
|
async def _render_portfolio_screen(
|
||||||
target_message: Message,
|
target_message: Message,
|
||||||
*,
|
*,
|
||||||
@@ -313,7 +342,7 @@ async def _render_portfolio_screen(
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
text, reply_markup = _build_portfolio_live_text()
|
text, reply_markup = _build_portfolio_live_text(raise_errors=True)
|
||||||
|
|
||||||
journal.log_ui_info(
|
journal.log_ui_info(
|
||||||
event_type="portfolio_open_success",
|
event_type="portfolio_open_success",
|
||||||
@@ -356,7 +385,8 @@ async def open_portfolio(
|
|||||||
edit_mode=False,
|
edit_mode=False,
|
||||||
action="open",
|
action="open",
|
||||||
)
|
)
|
||||||
except ExchangeError as exc:
|
|
||||||
|
except Exception as exc:
|
||||||
JournalService().log_ui_error(
|
JournalService().log_ui_error(
|
||||||
event_type="portfolio_open_error",
|
event_type="portfolio_open_error",
|
||||||
message="Не удалось загрузить портфель.",
|
message="Не удалось загрузить портфель.",
|
||||||
@@ -368,64 +398,14 @@ async def open_portfolio(
|
|||||||
raw_error=str(exc),
|
raw_error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
await show_message_exchange_error(
|
sent_message = await message.answer(
|
||||||
message,
|
_build_portfolio_exchange_error_text(exc),
|
||||||
title="<b>💼 Портфель</b>",
|
reply_markup=_portfolio_warning_keyboard(),
|
||||||
exc=exc,
|
|
||||||
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
|
|
||||||
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="portfolio:retry",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ActiveScreenManager.register(
|
||||||
@router.callback_query(F.data == "monitoring:portfolio")
|
|
||||||
async def open_portfolio_from_monitoring(
|
|
||||||
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="open_from_monitoring",
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
except ExchangeError as exc:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type="portfolio_open_error",
|
|
||||||
message="Не удалось загрузить портфель из мониторинга.",
|
|
||||||
screen="portfolio",
|
screen="portfolio",
|
||||||
action="open_from_monitoring",
|
message=sent_message,
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
error_type=classify_exchange_error(exc),
|
|
||||||
raw_error=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>💼 Портфель</b>",
|
|
||||||
exc=exc,
|
|
||||||
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
|
|
||||||
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
|
|
||||||
retry_callback_data="portfolio:retry",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -458,7 +438,7 @@ async def retry_portfolio(
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
except ExchangeError as exc:
|
except Exception as exc:
|
||||||
JournalService().log_ui_error(
|
JournalService().log_ui_error(
|
||||||
event_type="portfolio_retry_error",
|
event_type="portfolio_retry_error",
|
||||||
message="Не удалось обновить портфель.",
|
message="Не удалось обновить портфель.",
|
||||||
@@ -470,11 +450,14 @@ async def retry_portfolio(
|
|||||||
raw_error=str(exc),
|
raw_error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
await show_callback_exchange_error(
|
await message.edit_text(
|
||||||
callback,
|
_build_portfolio_exchange_error_text(exc),
|
||||||
title="<b>💼 Портфель</b>",
|
reply_markup=_portfolio_warning_keyboard(),
|
||||||
exc=exc,
|
)
|
||||||
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
|
|
||||||
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
|
ActiveScreenManager.register(
|
||||||
retry_callback_data="portfolio:retry",
|
screen="portfolio",
|
||||||
)
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
@@ -8,13 +8,16 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
from src.core.system_status import build_system_text
|
from src.core.system_status import build_system_text
|
||||||
|
from src.telegram.handlers.auto.main import render_auto_screen
|
||||||
from src.telegram.keyboards.reply import build_main_menu_keyboard
|
from src.telegram.keyboards.reply import build_main_menu_keyboard
|
||||||
|
from src.telegram.live.active_screen import ActiveScreenManager
|
||||||
from src.telegram.menus import MAIN_MENU_TEXT
|
from src.telegram.menus import MAIN_MENU_TEXT
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="start")
|
router = Router(name="start")
|
||||||
|
|
||||||
|
|
||||||
|
# показать только reply-меню без открытия live-экрана
|
||||||
async def _show_main_menu(
|
async def _show_main_menu(
|
||||||
message: Message,
|
message: Message,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -24,6 +27,35 @@ async def _show_main_menu(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# открыть главный рабочий экран проекта — Автоторговлю
|
||||||
|
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"))
|
@router.message(Command("start"))
|
||||||
async def cmd_start(
|
async def cmd_start(
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -31,7 +63,7 @@ async def cmd_start(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await _show_main_menu(message)
|
await _open_auto_start_screen(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("menu"))
|
@router.message(Command("menu"))
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage
|
from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardMarkup, Message
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.core.types import JsonDict, NumericLike
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.constants import APP_NAME, APP_VERSION
|
from src.core.constants import APP_NAME, APP_VERSION
|
||||||
|
from src.core.numbers import safe_float
|
||||||
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
|
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.active_screen import ActiveScreenManager
|
||||||
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
||||||
from src.trading.auto.service import AutoTradeService
|
from src.trading.auto.service import AutoTradeService
|
||||||
@@ -21,15 +21,10 @@ from src.trading.journal.service import JournalService
|
|||||||
router = Router(name="system")
|
router = Router(name="system")
|
||||||
|
|
||||||
|
|
||||||
def _require_message(
|
def _require_message(callback: CallbackQuery) -> Message | None:
|
||||||
callback: CallbackQuery,
|
|
||||||
) -> Message | None:
|
|
||||||
message = callback.message
|
message = callback.message
|
||||||
|
|
||||||
if (
|
if message is None or isinstance(message, InaccessibleMessage):
|
||||||
message is None
|
|
||||||
or isinstance(message, InaccessibleMessage)
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return message
|
return message
|
||||||
@@ -38,8 +33,9 @@ def _require_message(
|
|||||||
def _system_keyboard() -> InlineKeyboardMarkup:
|
def _system_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
||||||
|
builder.button(text="📒 Журнал", callback_data="system:journal")
|
||||||
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
||||||
builder.adjust(2)
|
builder.adjust(2, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +43,9 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🔁 Обновить", callback_data="system:retry")
|
builder.button(text="🔁 Обновить", callback_data="system:retry")
|
||||||
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
||||||
|
builder.button(text="📒 Журнал", callback_data="system:journal")
|
||||||
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
||||||
builder.adjust(1, 2)
|
builder.adjust(1, 2, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +79,10 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool:
|
async def _prepare_system_from_message(
|
||||||
|
message: Message,
|
||||||
|
screen: str = "system",
|
||||||
|
) -> bool:
|
||||||
bot = message.bot
|
bot = message.bot
|
||||||
|
|
||||||
if bot is None:
|
if bot is None:
|
||||||
@@ -222,7 +222,7 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
action="retry",
|
action="retry",
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -239,11 +239,10 @@ async def open_system_management(callback: CallbackQuery) -> None:
|
|||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
|
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
|
||||||
builder.button(text="💹 Торговля", callback_data="settings:trade")
|
|
||||||
builder.button(text="🌍 Общие", callback_data="settings:general")
|
builder.button(text="🌍 Общие", callback_data="settings:general")
|
||||||
builder.button(text="📒 Журнал", callback_data="settings:journal")
|
builder.button(text="📒 Журнал", callback_data="settings:journal")
|
||||||
builder.button(text="⬅️ Назад", callback_data="system:back")
|
builder.button(text="⬅️ Назад", callback_data="system:back")
|
||||||
builder.adjust(2, 2, 1)
|
builder.adjust(1, 2, 1)
|
||||||
|
|
||||||
message = _require_message(callback)
|
message = _require_message(callback)
|
||||||
|
|
||||||
@@ -275,11 +274,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
leverage_ready = state.leverage is not None
|
leverage_ready = state.leverage is not None
|
||||||
|
|
||||||
is_trend_strategy = (state.strategy or "").upper() == "TREND"
|
is_trend_strategy = (state.strategy or "").upper() == "TREND"
|
||||||
|
sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
|
||||||
sl_ready = (
|
|
||||||
state.stop_loss_percent is not None
|
|
||||||
and state.stop_loss_percent > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
is_configured = (
|
is_configured = (
|
||||||
strategy_ready
|
strategy_ready
|
||||||
@@ -290,30 +285,14 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
strategy = strategy_map.get(state.strategy or "", "—")
|
strategy = strategy_map.get(state.strategy or "", "—")
|
||||||
symbol = "—"
|
symbol = _human_symbol(state.symbol)
|
||||||
|
|
||||||
if state.symbol:
|
|
||||||
base = state.symbol.split("_", 1)[0].upper()
|
|
||||||
|
|
||||||
if "/" in base:
|
|
||||||
symbol = base.split("/", 1)[0]
|
|
||||||
else:
|
|
||||||
for suffix in ("USDT", "USD", "EUR", "BTC"):
|
|
||||||
if base.endswith(suffix) and len(base) > len(suffix):
|
|
||||||
base = base[: -len(suffix)]
|
|
||||||
break
|
|
||||||
|
|
||||||
symbol = base
|
|
||||||
|
|
||||||
risk = _format_number(state.risk_percent, suffix="%", default="—")
|
risk = _format_number(state.risk_percent, suffix="%", default="—")
|
||||||
|
|
||||||
leverage_value = safe_float(state.leverage)
|
leverage_value = safe_float(state.leverage)
|
||||||
leverage = f"x{leverage_value:g}" if leverage_value is not None else "—"
|
leverage = f"x{leverage_value:g}" if leverage_value is not None else "—"
|
||||||
|
|
||||||
max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
|
max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
|
||||||
|
|
||||||
sl = _format_percent_setting(state.stop_loss_percent)
|
sl = _format_percent_setting(state.stop_loss_percent)
|
||||||
|
|
||||||
tp = _format_percent_setting(state.take_profit_percent)
|
tp = _format_percent_setting(state.take_profit_percent)
|
||||||
|
|
||||||
ml_value = safe_float(state.max_loss_usd)
|
ml_value = safe_float(state.max_loss_usd)
|
||||||
@@ -323,42 +302,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
symbol_icon = "✅" if symbol_ready else "⚠️"
|
symbol_icon = "✅" if symbol_ready else "⚠️"
|
||||||
risk_icon = "✅" if risk_ready else "⚠️"
|
risk_icon = "✅" if risk_ready else "⚠️"
|
||||||
leverage_icon = "✅" if leverage_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 "⚠️")
|
||||||
if is_trend_strategy and not sl_ready:
|
tp_icon = "✅" if state.take_profit_percent is not None else "⚠️"
|
||||||
sl_icon = "⛔️"
|
ml_icon = "✅" if state.max_loss_usd is not None else "⚠️"
|
||||||
else:
|
|
||||||
sl_icon = (
|
|
||||||
"✅"
|
|
||||||
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 "⚠️"
|
|
||||||
)
|
|
||||||
|
|
||||||
risk_controls_block = (
|
|
||||||
"<b>Защита позиции:</b>\n"
|
|
||||||
f"{sl_icon} Stop Loss · {sl}\n"
|
|
||||||
f"{tp_icon} Take Profit · {tp}\n"
|
|
||||||
f"{ml_icon} Max Loss · {ml}"
|
|
||||||
)
|
|
||||||
|
|
||||||
settings_status_icon = "✅" if is_configured else "⛔️"
|
settings_status_icon = "✅" if is_configured else "⛔️"
|
||||||
|
config_status = "" if is_configured else "\n\nНастрой все параметры"
|
||||||
config_status = (
|
|
||||||
""
|
|
||||||
if is_configured
|
|
||||||
else "\n\nНастрой все параметры"
|
|
||||||
)
|
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"<b>🤖 Автоторговля</b>\n\n"
|
"<b>🤖 Автоторговля</b>\n\n"
|
||||||
@@ -368,52 +317,22 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
|
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
|
||||||
f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
|
f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
|
||||||
f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n"
|
f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n"
|
||||||
f"{risk_controls_block}"
|
"<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}"
|
f"{config_status}"
|
||||||
)
|
)
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
|
||||||
builder.button(
|
builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
|
||||||
text="🧠 Стратегия",
|
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
|
||||||
callback_data="settings:auto_strategy",
|
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(
|
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
|
||||||
text="💱 Актив",
|
builder.button(text="⬅️ Назад", callback_data="system:management")
|
||||||
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)
|
builder.adjust(2, 2, 2, 2)
|
||||||
|
|
||||||
message = _require_message(callback)
|
message = _require_message(callback)
|
||||||
@@ -422,16 +341,8 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.edit_text(
|
await message.edit_text(text, reply_markup=builder.as_markup())
|
||||||
text,
|
_register_system_screen(message, screen="settings_auto")
|
||||||
reply_markup=builder.as_markup(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_system_screen(
|
|
||||||
message,
|
|
||||||
screen="settings_auto",
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -439,7 +350,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
|
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
|
||||||
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
|
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
|
||||||
return
|
return
|
||||||
|
|
||||||
message = _require_message(callback)
|
message = _require_message(callback)
|
||||||
|
|
||||||
if message is None:
|
if message is None:
|
||||||
@@ -813,36 +724,6 @@ async def set_auto_max_reserved(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("Лимит обновлён")
|
await callback.answer("Лимит обновлён")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "settings:trade")
|
|
||||||
async def open_trade_settings(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_system_from_callback(callback, screen="settings_trade"):
|
|
||||||
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="system:management")
|
|
||||||
builder.button(text="💹 Торговля", callback_data="trade:home")
|
|
||||||
builder.adjust(2)
|
|
||||||
|
|
||||||
await message.edit_text(text, reply_markup=builder.as_markup())
|
|
||||||
_register_system_screen(message, screen="settings_trade")
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "settings:general")
|
@router.callback_query(F.data == "settings:general")
|
||||||
async def open_general_settings(callback: CallbackQuery) -> None:
|
async def open_general_settings(callback: CallbackQuery) -> None:
|
||||||
if not await _prepare_system_from_callback(callback, screen="settings_general"):
|
if not await _prepare_system_from_callback(callback, screen="settings_general"):
|
||||||
@@ -1001,10 +882,14 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.in_({
|
@router.callback_query(
|
||||||
"settings:journal_limit_stub",
|
F.data.in_(
|
||||||
"settings:journal_retention_stub",
|
{
|
||||||
}))
|
"settings:journal_limit_stub",
|
||||||
|
"settings:journal_retention_stub",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
async def journal_settings_stub(callback: CallbackQuery) -> None:
|
async def journal_settings_stub(callback: CallbackQuery) -> None:
|
||||||
await callback.answer("Настройка скоро появится", show_alert=True)
|
await callback.answer("Настройка скоро появится", show_alert=True)
|
||||||
|
|
||||||
@@ -1030,6 +915,7 @@ async def back_to_system(callback: CallbackQuery) -> None:
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
action="back",
|
action="back",
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -1064,7 +950,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
|
|||||||
f"Режим: {'DEMO' if 'demo' in settings.exchange_base_url.lower() else 'LIVE'}\n"
|
f"Режим: {'DEMO' if 'demo' in settings.exchange_base_url.lower() else 'LIVE'}\n"
|
||||||
f"Часовой пояс: {settings.tz}\n\n"
|
f"Часовой пояс: {settings.tz}\n\n"
|
||||||
"Торговый Telegram-бот для контроля рынка, портфеля, журнала событий "
|
"Торговый Telegram-бот для контроля рынка, портфеля, журнала событий "
|
||||||
"и будущей автоторговли."
|
"и автоторговли."
|
||||||
)
|
)
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Package marker."""
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
# app/src/telegram/handlers/trade/main.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
|
|
||||||
from src.telegram.handlers.trade.new_order import (
|
|
||||||
show_recent_drafts,
|
|
||||||
start_new_order_draft,
|
|
||||||
)
|
|
||||||
from src.telegram.live.active_screen import ActiveScreenManager
|
|
||||||
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
|
|
||||||
from src.telegram.ui.common import mode_line
|
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="trade_main")
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_screen(title: str) -> str:
|
|
||||||
return (
|
|
||||||
f"<b>💹 Торговля — {title}</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
"Выбери раздел"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_home_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="📝 Ордер", callback_data="trade:new_order")
|
|
||||||
builder.button(text="📂 Ордера", callback_data="trade:orders")
|
|
||||||
builder.button(text="📜 История", callback_data="trade:history")
|
|
||||||
builder.button(text="🛠️ Настройки", callback_data="settings:trade")
|
|
||||||
builder.adjust(2, 2)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_home_button() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="💹 К торговле", callback_data="trade:home")
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _orders_menu_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="📂 Черновики", callback_data="trade:orders:drafts")
|
|
||||||
builder.button(text="💹 К торговле", callback_data="trade:home")
|
|
||||||
builder.adjust(2)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _history_menu_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="✅ Исполненные", callback_data="trade:history:filled")
|
|
||||||
builder.button(text="🚫 Отменённые", callback_data="trade:history:canceled")
|
|
||||||
builder.button(text="💹 К торговле", callback_data="trade:home")
|
|
||||||
builder.adjust(2, 1)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_menu_keyboard() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.button(text="⚙️ Параметры", callback_data="trade:settings:params")
|
|
||||||
builder.button(text="🔁 Режим", callback_data="trade:settings:mode")
|
|
||||||
builder.button(text="ℹ️ Справка", callback_data="trade:settings:help")
|
|
||||||
builder.button(text="💹 К торговле", callback_data="trade:home")
|
|
||||||
builder.adjust(2, 2)
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_home_text() -> str:
|
|
||||||
return _trade_screen("Основной экран")
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_orders_text() -> str:
|
|
||||||
return _trade_screen("Ордера")
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_history_text() -> str:
|
|
||||||
return _trade_screen("История")
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_settings_text() -> str:
|
|
||||||
return _trade_screen("Настройки")
|
|
||||||
|
|
||||||
|
|
||||||
def _register_trade_screen(message: Message) -> None:
|
|
||||||
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="trade",
|
|
||||||
bot=message.bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_id=message.message_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
ActiveScreenManager.register(
|
|
||||||
screen="trade",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_trade_from_message(message: Message) -> None:
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="trade",
|
|
||||||
bot=message.bot,
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_trade_from_callback(callback: CallbackQuery) -> bool:
|
|
||||||
if callback.message is None:
|
|
||||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
await ActiveScreenManager.prepare_new_screen(
|
|
||||||
screen="trade",
|
|
||||||
bot=callback.message.bot,
|
|
||||||
chat_id=callback.message.chat.id,
|
|
||||||
keep_message_id=callback.message.message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text.in_({"💹 Торговля"}))
|
|
||||||
async def open_trade(message: Message, state: FSMContext) -> None:
|
|
||||||
await state.clear()
|
|
||||||
await _prepare_trade_from_message(message)
|
|
||||||
|
|
||||||
sent_message = await message.answer(
|
|
||||||
_trade_home_text(),
|
|
||||||
reply_markup=_trade_home_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(sent_message)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:home")
|
|
||||||
async def open_trade_home_callback(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_trade_home_text(),
|
|
||||||
reply_markup=_trade_home_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:new_order")
|
|
||||||
async def open_new_order_from_trade(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await start_new_order_draft(callback.message, state, edit_mode=True)
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:orders")
|
|
||||||
async def open_orders_from_trade(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_trade_orders_text(),
|
|
||||||
reply_markup=_orders_menu_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:orders:drafts")
|
|
||||||
async def open_drafts_from_orders(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await show_recent_drafts(callback.message, edit_mode=True, page=1)
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:history")
|
|
||||||
async def open_trade_history(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_trade_history_text(),
|
|
||||||
reply_markup=_history_menu_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:history:filled")
|
|
||||||
async def open_filled_history(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля — История</b>\n\n"
|
|
||||||
"Шаг 1/1: Исполненные\n"
|
|
||||||
"Раздел в разработке.",
|
|
||||||
reply_markup=_trade_home_button(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:history:canceled")
|
|
||||||
async def open_canceled_history(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля — История</b>\n\n"
|
|
||||||
"Шаг 1/1: Отменённые\n"
|
|
||||||
"Раздел в разработке.",
|
|
||||||
reply_markup=_trade_home_button(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:settings")
|
|
||||||
async def open_trade_settings(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_trade_settings_text(),
|
|
||||||
reply_markup=_settings_menu_keyboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:settings:params")
|
|
||||||
async def open_trade_settings_params(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля — Настройки</b>\n\n"
|
|
||||||
"Шаг 1/1: Параметры ордера\n"
|
|
||||||
"Раздел в разработке.",
|
|
||||||
reply_markup=_trade_home_button(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:settings:mode")
|
|
||||||
async def open_trade_settings_mode(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля — Настройки</b>\n\n"
|
|
||||||
"Шаг 1/1: Режим работы\n"
|
|
||||||
"Текущий режим: <b>demo</b>",
|
|
||||||
reply_markup=_trade_home_button(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:settings:help")
|
|
||||||
async def open_trade_settings_help(callback: CallbackQuery) -> None:
|
|
||||||
if not await _prepare_trade_from_callback(callback):
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля — Справка</b>\n\n"
|
|
||||||
"Шаг 1/1: Информация\n"
|
|
||||||
"Раздел в разработке.",
|
|
||||||
reply_markup=_trade_home_button(),
|
|
||||||
)
|
|
||||||
|
|
||||||
_register_trade_screen(callback.message)
|
|
||||||
await callback.answer()
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# app/src/telegram/handlers/trade/new_order.py
|
|
||||||
# Точка сборки всех роутеров сценария нового ордера (flow, navigation, drafts).
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.telegram.handlers.trade.new_order_core import router
|
|
||||||
from src.telegram.handlers.trade import new_order_navigation as _new_order_navigation # noqa
|
|
||||||
from src.telegram.handlers.trade import new_order_flow as _new_order_flow # noqa
|
|
||||||
from src.telegram.handlers.trade.new_order_flow import start_new_order_draft
|
|
||||||
from src.telegram.handlers.trade.new_order_ui import show_recent_drafts
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"router",
|
|
||||||
"show_recent_drafts",
|
|
||||||
"start_new_order_draft",
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# app/src/telegram/handlers/trade/new_order_core.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import Router
|
|
||||||
|
|
||||||
router = Router(name="trade_new_order")
|
|
||||||
|
|
||||||
DRAFTS_PAGE_SIZE = 3
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,401 +0,0 @@
|
|||||||
# app/src/telegram/handlers/trade/new_order_navigation.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram import F
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import CallbackQuery
|
|
||||||
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError, format_exchange_error_for_user
|
|
||||||
from src.telegram.handlers.trade.new_order_core import router
|
|
||||||
from src.telegram.handlers.trade.new_order_ui import (
|
|
||||||
_draft_detail_keyboard,
|
|
||||||
_price_keyboard,
|
|
||||||
_quantity_keyboard,
|
|
||||||
_render_draft_detail,
|
|
||||||
_render_exchange_error,
|
|
||||||
_render_order_path,
|
|
||||||
_render_price_step_screen,
|
|
||||||
_render_quantity_step_screen,
|
|
||||||
_screen_title,
|
|
||||||
_side_keyboard,
|
|
||||||
_trade_back_home_keyboard,
|
|
||||||
_type_keyboard,
|
|
||||||
mode_line,
|
|
||||||
)
|
|
||||||
from src.trading.orders.service import OrderDraftsService
|
|
||||||
from src.trading.orders.states import NewOrderDraftStates
|
|
||||||
|
|
||||||
|
|
||||||
async def _return_to_draft_detail(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
*,
|
|
||||||
draft_id: str,
|
|
||||||
page: int,
|
|
||||||
) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
draft = service.get_draft_by_id(draft_id)
|
|
||||||
|
|
||||||
if not draft:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля</b>\n\n"
|
|
||||||
"Черновик не найден.",
|
|
||||||
reply_markup=_trade_back_home_keyboard(),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_draft_detail(draft),
|
|
||||||
reply_markup=_draft_detail_keyboard(draft_id, page),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
async def _show_navigation_exchange_error(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
exc: Exception,
|
|
||||||
draft_page: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
reply_markup = (
|
|
||||||
_draft_detail_keyboard("", draft_page) # won't use if branch below replaces
|
|
||||||
if False
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if draft_page:
|
|
||||||
keyboard = _draft_detail_keyboard("noop", draft_page)
|
|
||||||
# заменим клавиатуру сразу на корректную
|
|
||||||
# edit/detail тут не нужны, нужен простой возврат к черновикам
|
|
||||||
from src.telegram.handlers.trade.new_order_ui import _drafts_back_keyboard
|
|
||||||
|
|
||||||
reply_markup = _drafts_back_keyboard(int(draft_page))
|
|
||||||
else:
|
|
||||||
reply_markup = _trade_back_home_keyboard()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_exchange_error(
|
|
||||||
title=title,
|
|
||||||
message=format_exchange_error_for_user(exc),
|
|
||||||
),
|
|
||||||
reply_markup=reply_markup,
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_back:side")
|
|
||||||
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
|
||||||
text = (
|
|
||||||
"<b>💹 Торговля — Новый ордер</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
f"{context.symbol}\n\n"
|
|
||||||
"Шаг 1/4. Выбери сторону"
|
|
||||||
)
|
|
||||||
await callback.message.edit_text(text, reply_markup=_side_keyboard())
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>💹 Торговля — Новый ордер</b>",
|
|
||||||
exc=exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_back:type")
|
|
||||||
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
|
||||||
data = await state.get_data()
|
|
||||||
|
|
||||||
draft_id = data.get("draft_edit_id")
|
|
||||||
draft_page = data.get("draft_edit_page")
|
|
||||||
|
|
||||||
if draft_id and draft_page:
|
|
||||||
await _return_to_draft_detail(
|
|
||||||
callback,
|
|
||||||
draft_id=str(draft_id),
|
|
||||||
page=int(draft_page),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
service = OrderDraftsService()
|
|
||||||
side = data.get("side", "BUY")
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = service.get_entry_context(side=side, order_type="MARKET")
|
|
||||||
path = _render_order_path(side=side)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_type)
|
|
||||||
text = (
|
|
||||||
"<b>💹 Торговля — Новый ордер</b>\n"
|
|
||||||
f"{mode_line()}"
|
|
||||||
f"{context.symbol}\n\n"
|
|
||||||
f"{path}\n\n"
|
|
||||||
"Шаг 2/4. Выбери тип ордера"
|
|
||||||
)
|
|
||||||
await callback.message.edit_text(text, reply_markup=_type_keyboard())
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title="<b>💹 Торговля — Новый ордер</b>",
|
|
||||||
exc=exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_back:quantity")
|
|
||||||
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
data = await state.get_data()
|
|
||||||
|
|
||||||
side = data.get("side", "BUY")
|
|
||||||
order_type = data.get("order_type", "MARKET")
|
|
||||||
quantity = data.get("quantity")
|
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
|
||||||
draft_page = data.get("draft_edit_page")
|
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
|
||||||
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
quantity=quantity,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not quantity:
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_quantity_step_screen(
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
symbol=context.symbol,
|
|
||||||
available_balance=context.available_balance,
|
|
||||||
balance_currency=context.balance_currency,
|
|
||||||
reference_price=context.reference_price,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
order_path=path,
|
|
||||||
),
|
|
||||||
reply_markup=_quantity_keyboard(
|
|
||||||
context.quantity_presets,
|
|
||||||
drafts_page=drafts_page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
exc=exc,
|
|
||||||
draft_page=drafts_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_back:confirm")
|
|
||||||
async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
data = await state.get_data()
|
|
||||||
|
|
||||||
confirm_draft = data.get("confirm_draft")
|
|
||||||
if not confirm_draft:
|
|
||||||
await state.clear()
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"<b>💹 Торговля</b>\n\n"
|
|
||||||
"Не удалось восстановить шаг подтверждения.",
|
|
||||||
reply_markup=_trade_back_home_keyboard(),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
return
|
|
||||||
|
|
||||||
side = confirm_draft["side"]
|
|
||||||
order_type = confirm_draft["order_type"]
|
|
||||||
quantity = confirm_draft.get("quantity")
|
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
|
||||||
draft_page = data.get("draft_edit_page")
|
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
if order_type == "LIMIT":
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
quantity=quantity,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_price)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_price_step_screen(
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
symbol=context.symbol,
|
|
||||||
bid=context.bid_price,
|
|
||||||
ask=context.ask_price,
|
|
||||||
last=context.last_price,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
order_path=path,
|
|
||||||
),
|
|
||||||
reply_markup=_price_keyboard(
|
|
||||||
bid=context.bid_price,
|
|
||||||
ask=context.ask_price,
|
|
||||||
last=context.last_price,
|
|
||||||
drafts_page=drafts_page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
return
|
|
||||||
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
quantity=quantity,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_quantity_step_screen(
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
symbol=context.symbol,
|
|
||||||
available_balance=context.available_balance,
|
|
||||||
balance_currency=context.balance_currency,
|
|
||||||
reference_price=context.reference_price,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
order_path=path,
|
|
||||||
),
|
|
||||||
reply_markup=_quantity_keyboard(
|
|
||||||
context.quantity_presets,
|
|
||||||
drafts_page=drafts_page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
exc=exc,
|
|
||||||
draft_page=drafts_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_manual_back:quantity")
|
|
||||||
async def go_back_from_manual_quantity(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
data = await state.get_data()
|
|
||||||
|
|
||||||
side = data.get("side", "BUY")
|
|
||||||
order_type = data.get("order_type", "MARKET")
|
|
||||||
quantity = data.get("quantity")
|
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
|
||||||
draft_page = data.get("draft_edit_page")
|
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
quantity=quantity,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not quantity:
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_quantity_step_screen(
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
symbol=context.symbol,
|
|
||||||
available_balance=context.available_balance,
|
|
||||||
balance_currency=context.balance_currency,
|
|
||||||
reference_price=context.reference_price,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
order_path=path,
|
|
||||||
),
|
|
||||||
reply_markup=_quantity_keyboard(
|
|
||||||
context.quantity_presets,
|
|
||||||
drafts_page=drafts_page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
exc=exc,
|
|
||||||
draft_page=drafts_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "order_manual_back:price")
|
|
||||||
async def go_back_from_manual_price(
|
|
||||||
callback: CallbackQuery,
|
|
||||||
state: FSMContext,
|
|
||||||
) -> None:
|
|
||||||
service = OrderDraftsService()
|
|
||||||
data = await state.get_data()
|
|
||||||
|
|
||||||
side = data.get("side", "BUY")
|
|
||||||
order_type = data.get("order_type", "LIMIT")
|
|
||||||
quantity = data.get("quantity")
|
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
|
||||||
draft_page = data.get("draft_edit_page")
|
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
|
||||||
path = _render_order_path(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
quantity=quantity,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_price)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
_render_price_step_screen(
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
symbol=context.symbol,
|
|
||||||
bid=context.bid_price,
|
|
||||||
ask=context.ask_price,
|
|
||||||
last=context.last_price,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
order_path=path,
|
|
||||||
),
|
|
||||||
reply_markup=_price_keyboard(
|
|
||||||
bid=context.bid_price,
|
|
||||||
ask=context.ask_price,
|
|
||||||
last=context.last_price,
|
|
||||||
drafts_page=drafts_page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await callback.answer()
|
|
||||||
except ExchangeError as exc:
|
|
||||||
await _show_navigation_exchange_error(
|
|
||||||
callback,
|
|
||||||
title=_screen_title(is_edit_mode),
|
|
||||||
exc=exc,
|
|
||||||
draft_page=drafts_page,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,9 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
|||||||
keyboard=[
|
keyboard=[
|
||||||
[
|
[
|
||||||
KeyboardButton(text="🤖 Автоторговля"),
|
KeyboardButton(text="🤖 Автоторговля"),
|
||||||
KeyboardButton(text="💹 Торговля"),
|
KeyboardButton(text="💼 Портфель"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
KeyboardButton(text="📊 Мониторинг"),
|
|
||||||
KeyboardButton(text="🖥️ Система"),
|
KeyboardButton(text="🖥️ Система"),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,31 +2,48 @@
|
|||||||
|
|
||||||
MAIN_MENU_TEXT = (
|
MAIN_MENU_TEXT = (
|
||||||
"<b>Dzentra Bot</b>\n\n"
|
"<b>Dzentra Bot</b>\n\n"
|
||||||
"Новый каркас проекта успешно создан.\n\n"
|
"Trading Runtime Terminal\n\n"
|
||||||
"Выбери раздел через меню ниже."
|
"Доступные разделы:\n"
|
||||||
|
"• Автоторговля\n"
|
||||||
|
"• Портфель\n"
|
||||||
|
"• Система"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
HOME_TEXT = (
|
HOME_TEXT = (
|
||||||
"<b>🏠 Главная</b>\n\n"
|
"<b>🏠 Главная</b>\n\n"
|
||||||
"Это главный экран бота.\n\n"
|
"Главное меню Dzentra Bot.\n\n"
|
||||||
"Сейчас здесь отображается базовый статус:\n"
|
"Используй кнопки ниже для перехода в нужный раздел."
|
||||||
"- бот запущен\n"
|
|
||||||
"- меню подключено\n"
|
|
||||||
"- handlers работают\n"
|
|
||||||
"- проект на этапе Bootstrap v2\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_TEXT = (
|
SYSTEM_TEXT = (
|
||||||
"<b>🖥️ Система</b>\n\n"
|
"<b>🖥️ Система</b>\n\n"
|
||||||
"Системный экран.\n\n"
|
"Системный runtime экран.\n\n"
|
||||||
"<b>Справка</b>\n"
|
"<b>Разделы</b>\n"
|
||||||
|
"• Настройки\n"
|
||||||
|
"• Журнал\n"
|
||||||
|
"• Информация\n\n"
|
||||||
|
"<b>Команды</b>\n"
|
||||||
"/start — запуск\n"
|
"/start — запуск\n"
|
||||||
"/menu — показать меню\n"
|
"/menu — главное меню\n"
|
||||||
"/help — краткая справка\n"
|
"/help — системная информация\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
|
|
||||||
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
|
PORTFOLIO_TEXT = (
|
||||||
TRADE_TEXT = "<b>💹 Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
|
"<b>💼 Портфель</b>\n\n"
|
||||||
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
|
"Просмотр активов и баланса биржи."
|
||||||
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_TEXT = (
|
||||||
|
"<b>🤖 Автоторговля</b>\n\n"
|
||||||
|
"Runtime экран автоторговли."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
JOURNAL_TEXT = (
|
||||||
|
"<b>📒 Журнал</b>\n\n"
|
||||||
|
"Runtime события и execution logs."
|
||||||
|
)
|
||||||
@@ -7,23 +7,15 @@ 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.debug_auto.main import router as debug_auto_router
|
||||||
from src.telegram.handlers.home import router as home_router
|
from src.telegram.handlers.home import router as home_router
|
||||||
from src.telegram.handlers.journal import router as journal_router
|
from src.telegram.handlers.journal import router as journal_router
|
||||||
from src.telegram.handlers.market import router as market_router
|
|
||||||
from src.telegram.handlers.monitoring import router as monitoring_router
|
|
||||||
from src.telegram.handlers.portfolio import router as portfolio_router
|
from src.telegram.handlers.portfolio import router as portfolio_router
|
||||||
from src.telegram.handlers.start import router as start_router
|
from src.telegram.handlers.start import router as start_router
|
||||||
from src.telegram.handlers.system import router as system_router
|
from src.telegram.handlers.system import router as system_router
|
||||||
from src.telegram.handlers.trade.main import router as trade_main_router
|
|
||||||
from src.telegram.handlers.trade.new_order import router as trade_new_order_router
|
|
||||||
|
|
||||||
|
|
||||||
def setup_routers(dispatcher: Dispatcher) -> None:
|
def setup_routers(dispatcher: Dispatcher) -> None:
|
||||||
dispatcher.include_router(start_router)
|
dispatcher.include_router(start_router)
|
||||||
dispatcher.include_router(home_router)
|
dispatcher.include_router(home_router)
|
||||||
dispatcher.include_router(monitoring_router)
|
|
||||||
dispatcher.include_router(market_router)
|
|
||||||
dispatcher.include_router(portfolio_router)
|
dispatcher.include_router(portfolio_router)
|
||||||
dispatcher.include_router(trade_main_router)
|
|
||||||
dispatcher.include_router(trade_new_order_router)
|
|
||||||
dispatcher.include_router(auto_router)
|
dispatcher.include_router(auto_router)
|
||||||
dispatcher.include_router(journal_router)
|
dispatcher.include_router(journal_router)
|
||||||
dispatcher.include_router(debug_auto_router)
|
dispatcher.include_router(debug_auto_router)
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
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
|
from src.telegram.ui.common import mode_line, now_line
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +24,10 @@ class ExchangeErrorView:
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
||||||
DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи."
|
DEFAULT_AUTH_DETAILS = (
|
||||||
|
"Не удалось выполнить приватный запрос к аккаунту.\n"
|
||||||
|
"Проверь API-ключ, Secret Key, IP whitelist и права доступа."
|
||||||
|
)
|
||||||
DEFAULT_TIME_DETAILS = (
|
DEFAULT_TIME_DETAILS = (
|
||||||
"Не удалось выполнить запрос к бирже.\n"
|
"Не удалось выполнить запрос к бирже.\n"
|
||||||
"Проверь синхронизацию времени и обнови экран."
|
"Проверь синхронизацию времени и обнови экран."
|
||||||
@@ -25,43 +35,7 @@ DEFAULT_TIME_DETAILS = (
|
|||||||
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
||||||
|
|
||||||
|
|
||||||
def classify_exchange_error(exc: Exception) -> str:
|
# Собрать UI-представление ошибки через единый exchange status layer.
|
||||||
text = str(exc).lower()
|
|
||||||
|
|
||||||
network_markers = [
|
|
||||||
"nodename nor servname",
|
|
||||||
"name or service not known",
|
|
||||||
"network error",
|
|
||||||
"connection error",
|
|
||||||
"timed out",
|
|
||||||
"timeout",
|
|
||||||
]
|
|
||||||
if any(marker in text for marker in network_markers):
|
|
||||||
return "network"
|
|
||||||
|
|
||||||
time_markers = [
|
|
||||||
"-1021",
|
|
||||||
"doesn't match server time",
|
|
||||||
]
|
|
||||||
if any(marker in text for marker in time_markers):
|
|
||||||
return "time"
|
|
||||||
|
|
||||||
auth_markers = [
|
|
||||||
"invalid api key",
|
|
||||||
"api key",
|
|
||||||
"api-key",
|
|
||||||
"signature",
|
|
||||||
"expired",
|
|
||||||
"forbidden",
|
|
||||||
"unauthorized",
|
|
||||||
"private api error",
|
|
||||||
]
|
|
||||||
if any(marker in text for marker in auth_markers):
|
|
||||||
return "auth"
|
|
||||||
|
|
||||||
return "generic"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_exchange_error_view(
|
def _build_exchange_error_view(
|
||||||
*,
|
*,
|
||||||
exc: Exception,
|
exc: Exception,
|
||||||
@@ -70,32 +44,15 @@ def _build_exchange_error_view(
|
|||||||
time_details: str | None = None,
|
time_details: str | None = None,
|
||||||
generic_details: str | None = None,
|
generic_details: str | None = None,
|
||||||
) -> ExchangeErrorView:
|
) -> ExchangeErrorView:
|
||||||
error_type = classify_exchange_error(exc)
|
_, headline, details = build_exchange_error_ui_parts(exc)
|
||||||
|
|
||||||
if error_type == "network":
|
|
||||||
return ExchangeErrorView(
|
|
||||||
headline="🔴 Биржа недоступна",
|
|
||||||
details=network_details,
|
|
||||||
)
|
|
||||||
|
|
||||||
if error_type == "auth":
|
|
||||||
return ExchangeErrorView(
|
|
||||||
headline="🔴 Ошибка доступа к аккаунту",
|
|
||||||
details=auth_details,
|
|
||||||
)
|
|
||||||
|
|
||||||
if error_type == "time":
|
|
||||||
return ExchangeErrorView(
|
|
||||||
headline="🔴 Ошибка времени",
|
|
||||||
details=time_details or DEFAULT_TIME_DETAILS,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ExchangeErrorView(
|
return ExchangeErrorView(
|
||||||
headline="🔴 Ошибка биржи",
|
headline=headline,
|
||||||
details=generic_details or DEFAULT_GENERIC_DETAILS,
|
details=details or generic_details or DEFAULT_GENERIC_DETAILS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Отрисовать единый текст ошибки биржи для message/callback экранов.
|
||||||
def render_exchange_error(
|
def render_exchange_error(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -122,6 +79,7 @@ def render_exchange_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Собрать клавиатуру для экранов с ошибкой биржи.
|
||||||
def exchange_error_keyboard(
|
def exchange_error_keyboard(
|
||||||
*,
|
*,
|
||||||
retry_callback_data: str | None = None,
|
retry_callback_data: str | None = None,
|
||||||
@@ -129,7 +87,6 @@ def exchange_error_keyboard(
|
|||||||
drafts_page: int | None = None,
|
drafts_page: int | None = None,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
buttons: list[list[InlineKeyboardButton]] = []
|
buttons: list[list[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
first_row: list[InlineKeyboardButton] = []
|
first_row: list[InlineKeyboardButton] = []
|
||||||
|
|
||||||
if retry_callback_data:
|
if retry_callback_data:
|
||||||
@@ -160,12 +117,12 @@ def exchange_error_keyboard(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
elif back_callback_data is None:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="🏠 К торговле",
|
text="🤖 К автоторговле",
|
||||||
callback_data="trade:home",
|
callback_data="auto:home",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -173,6 +130,7 @@ def exchange_error_keyboard(
|
|||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
# Показать ошибку биржи через редактирование callback-сообщения.
|
||||||
async def show_callback_exchange_error(
|
async def show_callback_exchange_error(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
*,
|
*,
|
||||||
@@ -186,7 +144,9 @@ async def show_callback_exchange_error(
|
|||||||
back_callback_data: str | None = None,
|
back_callback_data: str | None = None,
|
||||||
drafts_page: int | None = None,
|
drafts_page: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if callback.message is None:
|
message = callback.message
|
||||||
|
|
||||||
|
if message is None or isinstance(message, InaccessibleMessage):
|
||||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -198,6 +158,7 @@ async def show_callback_exchange_error(
|
|||||||
time_details=time_details,
|
time_details=time_details,
|
||||||
generic_details=generic_details,
|
generic_details=generic_details,
|
||||||
)
|
)
|
||||||
|
|
||||||
markup = exchange_error_keyboard(
|
markup = exchange_error_keyboard(
|
||||||
retry_callback_data=retry_callback_data or callback.data,
|
retry_callback_data=retry_callback_data or callback.data,
|
||||||
back_callback_data=back_callback_data,
|
back_callback_data=back_callback_data,
|
||||||
@@ -205,15 +166,17 @@ async def show_callback_exchange_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(text, reply_markup=markup)
|
await message.edit_text(text, reply_markup=markup)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
except TelegramBadRequest as tg_exc:
|
except TelegramBadRequest as tg_exc:
|
||||||
if "message is not modified" in str(tg_exc).lower():
|
if "message is not modified" in str(tg_exc).lower():
|
||||||
await callback.answer("Ошибка всё ещё актуальна")
|
await callback.answer("Ошибка всё ещё актуальна")
|
||||||
return
|
return
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Показать ошибку биржи новым сообщением.
|
||||||
async def show_message_exchange_error(
|
async def show_message_exchange_error(
|
||||||
message: Message,
|
message: Message,
|
||||||
*,
|
*,
|
||||||
|
|||||||
0
app/src/telegram/ui/runtime_status.py
Normal file
0
app/src/telegram/ui/runtime_status.py
Normal file
@@ -1,8 +1,16 @@
|
|||||||
|
# app/src/trading/accounts/service.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.integrations.exchange.models import BalanceSummary
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.storage.repositories.balance_snapshots import BalanceSnapshotRepository
|
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
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +20,27 @@ class AccountsService:
|
|||||||
self.snapshot_repository = BalanceSnapshotRepository()
|
self.snapshot_repository = BalanceSnapshotRepository()
|
||||||
self.journal = JournalService()
|
self.journal = JournalService()
|
||||||
|
|
||||||
|
# получить live balance summary через typed exchange runtime layer
|
||||||
def get_live_balance_summary(self) -> list[BalanceSummary]:
|
def get_live_balance_summary(self) -> list[BalanceSummary]:
|
||||||
balances = self.exchange_service.get_balance_summary()
|
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)
|
self._save_snapshot(balances)
|
||||||
|
|
||||||
return balances
|
return balances
|
||||||
|
|
||||||
def _save_snapshot(self, balances: list[BalanceSummary]) -> None:
|
# сохранить snapshot баланса
|
||||||
|
def _save_snapshot(
|
||||||
|
self,
|
||||||
|
balances: list[BalanceSummary],
|
||||||
|
) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
@@ -35,22 +58,62 @@ class AccountsService:
|
|||||||
source="portfolio_screen",
|
source="portfolio_screen",
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
try:
|
||||||
self.journal.log_warning(
|
self.journal.log_warning(
|
||||||
"balance_snapshot_error",
|
"balance_snapshot_error",
|
||||||
f"Не удалось сохранить snapshot баланса: {exc}",
|
f"Не удалось сохранить snapshot баланса: {exc}",
|
||||||
{"assets_count": len(balances)},
|
{
|
||||||
|
"assets_count": len(balances),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.journal.log_info(
|
self.journal.log_info(
|
||||||
"balance_snapshot_saved",
|
"balance_snapshot_saved",
|
||||||
f"Snapshot баланса сохранён. Активов: {len(balances)}",
|
f"Snapshot баланса сохранён. Активов: {len(balances)}",
|
||||||
{"assets_count": len(balances)},
|
{
|
||||||
|
"assets_count": len(balances),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
# app/src/trading/auto/__init__.py
|
||||||
|
|
||||||
"""Package marker."""
|
"""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"
|
||||||
@@ -400,6 +400,57 @@ class AutoTradeRunner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
@classmethod
|
||||||
def _publish_strong_signal_event(
|
def _publish_strong_signal_event(
|
||||||
cls,
|
cls,
|
||||||
@@ -410,6 +461,7 @@ class AutoTradeRunner:
|
|||||||
signal = str(payload.get("signal", "")).upper()
|
signal = str(payload.get("signal", "")).upper()
|
||||||
symbol = str(payload.get("symbol") or state.symbol or "—")
|
symbol = str(payload.get("symbol") or state.symbol or "—")
|
||||||
strategy = str(payload.get("strategy") or state.strategy or "—")
|
strategy = str(payload.get("strategy") or state.strategy or "—")
|
||||||
|
|
||||||
repeat_count_value = (
|
repeat_count_value = (
|
||||||
payload.get("repeat_count")
|
payload.get("repeat_count")
|
||||||
if payload.get("repeat_count") is not None
|
if payload.get("repeat_count") is not None
|
||||||
@@ -417,18 +469,35 @@ class AutoTradeRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
repeat_count = int(safe_float(repeat_count_value) or 0)
|
repeat_count = int(safe_float(repeat_count_value) or 0)
|
||||||
confidence = safe_float(
|
|
||||||
payload.get("confidence")
|
confidence = safe_float(payload.get("confidence"))
|
||||||
)
|
|
||||||
|
|
||||||
if confidence is None:
|
if confidence is None:
|
||||||
confidence = safe_float(state.last_signal_confidence)
|
confidence = safe_float(state.last_signal_confidence)
|
||||||
|
|
||||||
if confidence is None:
|
if confidence is None:
|
||||||
confidence = 0.0
|
confidence = 0.0
|
||||||
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
|
|
||||||
|
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 "—")
|
reason = str(payload.get("reason") or state.last_signal_reason or "—")
|
||||||
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
|
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(
|
priority = cls._alert_priority(
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
@@ -449,12 +518,11 @@ class AutoTradeRunner:
|
|||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"position_context": position_context,
|
"position_context": position_context,
|
||||||
"decision_status": state.decision_status,
|
|
||||||
"semantic_lines": cls._notification_reason_lines(state),
|
|
||||||
"position_side": position_context,
|
"position_side": position_context,
|
||||||
"bid_price": payload.get("bid_price"),
|
"is_position_aligned_signal": is_aligned_signal,
|
||||||
"ask_price": payload.get("ask_price"),
|
"decision_status": state.decision_status,
|
||||||
"last_price": payload.get("last_price"),
|
"semantic_lines": semantic_lines,
|
||||||
|
**price_payload,
|
||||||
},
|
},
|
||||||
priority=priority.lower(),
|
priority=priority.lower(),
|
||||||
dedupe_key=(
|
dedupe_key=(
|
||||||
@@ -466,7 +534,8 @@ class AutoTradeRunner:
|
|||||||
f"{repeat_count}:"
|
f"{repeat_count}:"
|
||||||
f"{confidence:.2f}:"
|
f"{confidence:.2f}:"
|
||||||
f"{state.decision_status}:"
|
f"{state.decision_status}:"
|
||||||
f"{reason}"
|
f"{reason}:"
|
||||||
|
f"aligned={is_aligned_signal}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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))
|
||||||
@@ -404,4 +404,13 @@ class AutoTradeState:
|
|||||||
market_status_updated_at: float | None = None
|
market_status_updated_at: float | None = None
|
||||||
|
|
||||||
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
|
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
|
||||||
cycle_number: int = 0
|
cycle_number: int = 0
|
||||||
|
|
||||||
|
# уникальный номер сделки внутри runtime
|
||||||
|
trade_sequence: int = 0
|
||||||
|
|
||||||
|
# id текущей открытой сделки
|
||||||
|
current_trade_id: str | None = None
|
||||||
|
|
||||||
|
# номер цикла, в котором открыта текущая сделка
|
||||||
|
current_trade_cycle_number: int | None = None
|
||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.core.types import JsonDict, NumericLike
|
from src.core.types import JsonDict, NumericLike
|
||||||
|
from src.integrations.exchange.runtime_ui import format_runtime_exchange_alert
|
||||||
|
|
||||||
|
|
||||||
class SemanticDiagnosticFormatter:
|
class SemanticDiagnosticFormatter:
|
||||||
@@ -17,6 +18,11 @@ class SemanticDiagnosticFormatter:
|
|||||||
execution = snapshot.get("execution", {})
|
execution = snapshot.get("execution", {})
|
||||||
adaptive = snapshot.get("adaptive_size", {})
|
adaptive = snapshot.get("adaptive_size", {})
|
||||||
runtime = snapshot.get("runtime_health", {})
|
runtime = snapshot.get("runtime_health", {})
|
||||||
|
exchange_statuses = runtime.get("exchange_statuses") or []
|
||||||
|
exchange_status = runtime.get("exchange_status")
|
||||||
|
|
||||||
|
if not exchange_statuses and exchange_status:
|
||||||
|
exchange_statuses = [exchange_status]
|
||||||
summary = snapshot.get("summary", {})
|
summary = snapshot.get("summary", {})
|
||||||
position = snapshot.get("position", {})
|
position = snapshot.get("position", {})
|
||||||
|
|
||||||
@@ -33,6 +39,9 @@ class SemanticDiagnosticFormatter:
|
|||||||
self._status_block(status),
|
self._status_block(status),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
for item in exchange_statuses:
|
||||||
|
sections.append(self._runtime_exchange_block(item))
|
||||||
|
|
||||||
return "\n\n".join(
|
return "\n\n".join(
|
||||||
section.strip()
|
section.strip()
|
||||||
for section in sections
|
for section in sections
|
||||||
@@ -47,11 +56,16 @@ class SemanticDiagnosticFormatter:
|
|||||||
market=market,
|
market=market,
|
||||||
momentum=momentum,
|
momentum=momentum,
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
for item in exchange_statuses:
|
||||||
|
sections.append(self._runtime_exchange_block(item))
|
||||||
|
|
||||||
|
sections.extend([
|
||||||
self._execution_block(execution),
|
self._execution_block(execution),
|
||||||
self._signal_block(signal),
|
self._signal_block(signal),
|
||||||
self._market_block(market),
|
self._market_block(market),
|
||||||
self._momentum_block(momentum),
|
self._momentum_block(momentum),
|
||||||
]
|
])
|
||||||
|
|
||||||
if mode != "COMPACT":
|
if mode != "COMPACT":
|
||||||
if has_position:
|
if has_position:
|
||||||
@@ -752,6 +766,7 @@ class SemanticDiagnosticFormatter:
|
|||||||
quality = data.get("trend_quality")
|
quality = data.get("trend_quality")
|
||||||
volatility = data.get("volatility")
|
volatility = data.get("volatility")
|
||||||
market_closed = data.get("market_is_open") is False
|
market_closed = data.get("market_is_open") is False
|
||||||
|
market_data_state = self._market_live_state(data.get("age_seconds"))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
(
|
(
|
||||||
@@ -759,10 +774,7 @@ class SemanticDiagnosticFormatter:
|
|||||||
f"Рынок · "
|
f"Рынок · "
|
||||||
f"{self._market_title(data)}"
|
f"{self._market_title(data)}"
|
||||||
),
|
),
|
||||||
(
|
f"• Данные: {market_data_state}",
|
||||||
f"• Данные: "
|
|
||||||
f"{self._market_live_state(data.get('age_seconds'))}"
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if market_closed:
|
if market_closed:
|
||||||
@@ -1215,23 +1227,23 @@ class SemanticDiagnosticFormatter:
|
|||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
def _status_block(self, data: JsonDict) -> str:
|
def _status_block(self, data: JsonDict) -> str:
|
||||||
status = str(data.get("status") or "")
|
status = str(data.get("status") or "").upper()
|
||||||
|
|
||||||
if status == "RUNNING":
|
if status == "RUNNING":
|
||||||
icon = "🟢"
|
icon = "🟢"
|
||||||
title = "работает"
|
title = "работает"
|
||||||
elif status == "OBSERVING":
|
elif status == "OBSERVING":
|
||||||
icon = "🟡"
|
icon = "👀"
|
||||||
title = "наблюдение"
|
title = "под наблюдением"
|
||||||
elif status == "OFF":
|
elif status == "OFF":
|
||||||
icon = "⛔️"
|
icon = "⚪️"
|
||||||
title = "остановлена"
|
title = "остановлена"
|
||||||
else:
|
else:
|
||||||
icon = "⚪"
|
icon = "⛔️"
|
||||||
title = "не готова"
|
title = "не готова"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"{icon} Автоторговля · {title}\n"
|
f"{icon} Автоторговля {title}\n"
|
||||||
f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n"
|
f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n"
|
||||||
f"• Стратегия: {data.get('strategy') or '—'}\n"
|
f"• Стратегия: {data.get('strategy') or '—'}\n"
|
||||||
f"• Настроено: {self._bool(data.get('is_configured'))}"
|
f"• Настроено: {self._bool(data.get('is_configured'))}"
|
||||||
@@ -1319,7 +1331,7 @@ class SemanticDiagnosticFormatter:
|
|||||||
age_seconds = None
|
age_seconds = None
|
||||||
|
|
||||||
if age_seconds is None:
|
if age_seconds is None:
|
||||||
add("Нет live-данных")
|
add("Live-поток недоступен")
|
||||||
elif age_seconds > 60:
|
elif age_seconds > 60:
|
||||||
add("Данные рынка устарели")
|
add("Данные рынка устарели")
|
||||||
|
|
||||||
@@ -1798,6 +1810,9 @@ class SemanticDiagnosticFormatter:
|
|||||||
):
|
):
|
||||||
return "⛔️"
|
return "⛔️"
|
||||||
|
|
||||||
|
if data.get("age_seconds") is None:
|
||||||
|
return "🟡"
|
||||||
|
|
||||||
if state == "UNKNOWN":
|
if state == "UNKNOWN":
|
||||||
return "⚪️"
|
return "⚪️"
|
||||||
|
|
||||||
@@ -1904,7 +1919,7 @@ class SemanticDiagnosticFormatter:
|
|||||||
seconds_float = safe_float(value)
|
seconds_float = safe_float(value)
|
||||||
|
|
||||||
if seconds_float is None:
|
if seconds_float is None:
|
||||||
return "нет данных"
|
return "REST"
|
||||||
|
|
||||||
seconds = int(seconds_float)
|
seconds = int(seconds_float)
|
||||||
|
|
||||||
@@ -1991,4 +2006,10 @@ class SemanticDiagnosticFormatter:
|
|||||||
if not items:
|
if not items:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return "• Структура: " + " · ".join(items[:4])
|
return "• Структура: " + " · ".join(items[:4])
|
||||||
|
|
||||||
|
def _runtime_exchange_block(
|
||||||
|
self,
|
||||||
|
data: JsonDict,
|
||||||
|
) -> str:
|
||||||
|
return format_runtime_exchange_alert(data)
|
||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from src.trading.auto.state import AutoTradeState
|
from src.trading.auto.state import AutoTradeState
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
|
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
|
||||||
|
|
||||||
|
|
||||||
class SemanticDiagnosticSnapshotBuilder:
|
class SemanticDiagnosticSnapshotBuilder:
|
||||||
@@ -38,6 +39,8 @@ class SemanticDiagnosticSnapshotBuilder:
|
|||||||
current_price=position_current_price,
|
current_price=position_current_price,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
runtime_exchange_alerts = self._runtime_exchange_alerts(state)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": {
|
"status": {
|
||||||
"status": state.status,
|
"status": state.status,
|
||||||
@@ -161,6 +164,12 @@ class SemanticDiagnosticSnapshotBuilder:
|
|||||||
"adverse_momentum": position_health.get("adverse_momentum"),
|
"adverse_momentum": position_health.get("adverse_momentum"),
|
||||||
},
|
},
|
||||||
"runtime_health": {
|
"runtime_health": {
|
||||||
|
"exchange_statuses": runtime_exchange_alerts,
|
||||||
|
"exchange_status": (
|
||||||
|
runtime_exchange_alerts[0]
|
||||||
|
if runtime_exchange_alerts
|
||||||
|
else None
|
||||||
|
),
|
||||||
"health_score": health_score,
|
"health_score": health_score,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"is_runtime_degraded": self._is_runtime_degraded(state),
|
"is_runtime_degraded": self._is_runtime_degraded(state),
|
||||||
@@ -800,4 +809,10 @@ class SemanticDiagnosticSnapshotBuilder:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
move = entry_price * (take_profit_percent / 100)
|
move = entry_price * (take_profit_percent / 100)
|
||||||
return move * position_size
|
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"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
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}.",
|
||||||
|
)
|
||||||
478
app/src/trading/execution/position_actions.py
Normal file
478
app/src/trading/execution/position_actions.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# app/src/trading/execution/position_actions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.core.event_bus import EventBus
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import JsonDict, NumericLike
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
|
from src.trading.journal.service import JournalService
|
||||||
|
from src.trading.position.state import PositionState
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionPositionActionsProtocol(Protocol):
|
||||||
|
_position: PositionState
|
||||||
|
_last_flip_block_key: str | None
|
||||||
|
|
||||||
|
# создать trade id
|
||||||
|
def _create_trade_id(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
side: str,
|
||||||
|
) -> str: ...
|
||||||
|
|
||||||
|
# получить entry execution price
|
||||||
|
def _entry_price_for_side(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
) -> ExecutionPrice: ...
|
||||||
|
|
||||||
|
# получить exit execution price
|
||||||
|
def _exit_price_for_side(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
) -> ExecutionPrice: ...
|
||||||
|
|
||||||
|
# рассчитать adaptive size
|
||||||
|
def _calculate_position_size(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
entry_price: float | None = None,
|
||||||
|
) -> float: ...
|
||||||
|
|
||||||
|
# ограничить size margin limit
|
||||||
|
def _adjust_size_by_margin_limit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
entry_price: float,
|
||||||
|
size: float,
|
||||||
|
) -> float: ...
|
||||||
|
|
||||||
|
# обновить effective risk после margin limit
|
||||||
|
def _sync_effective_risk_after_margin_limit(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
base_size: float,
|
||||||
|
final_size: float,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
# округлить size
|
||||||
|
def _round_size(self, size: NumericLike | None) -> float: ...
|
||||||
|
|
||||||
|
# синхронизировать state с position
|
||||||
|
def _sync_state_from_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
# посчитать pnl
|
||||||
|
def _calculate_pnl(
|
||||||
|
self,
|
||||||
|
current_price: NumericLike | None,
|
||||||
|
) -> float: ...
|
||||||
|
|
||||||
|
# получить текущее время
|
||||||
|
def _now_time(self) -> str: ...
|
||||||
|
|
||||||
|
# reset runtime protection state
|
||||||
|
def _reset_runtime_protection_state(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
||||||
|
# создать новый trade_id для связки open -> close
|
||||||
|
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
|
||||||
|
state.trade_sequence = int(state.trade_sequence or 0) + 1
|
||||||
|
cycle_number = int(state.cycle_number or 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"trade-{cycle_number}-"
|
||||||
|
f"{state.trade_sequence}-"
|
||||||
|
f"{side.lower()}-"
|
||||||
|
f"{int(time.time())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# записать отказ открытия позиции в журнал
|
||||||
|
def _log_position_open_rejected(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
side: str,
|
||||||
|
action: str,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
payload: JsonDict = {
|
||||||
|
"execution_type": "ENTRY_REJECTED",
|
||||||
|
"action": action,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"side": side,
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"confidence": state.last_signal_confidence,
|
||||||
|
"execution_confidence_score": state.execution_confidence_score,
|
||||||
|
"execution_confidence_level": state.execution_confidence_level,
|
||||||
|
"execution_confidence_reason": state.execution_confidence_reason,
|
||||||
|
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||||
|
"adaptive_size_reason": state.adaptive_size_reason,
|
||||||
|
"adaptive_size_factors": state.adaptive_size_factors,
|
||||||
|
"effective_risk_percent": state.effective_risk_percent,
|
||||||
|
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||||
|
"adaptive_size_base": state.adaptive_size_base,
|
||||||
|
"adaptive_size_final": state.adaptive_size_final,
|
||||||
|
"repeat_count": state.last_signal_repeat_count,
|
||||||
|
"reason": state.last_signal_reason,
|
||||||
|
"reject_reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_warning(
|
||||||
|
event_type="position_open_rejected",
|
||||||
|
message=f"Открытие позиции {side} отклонено: {reason}",
|
||||||
|
screen="auto",
|
||||||
|
action="paper_execution",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# открыть позицию, если сейчас позиции нет
|
||||||
|
def _open_position_if_empty(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
side: str,
|
||||||
|
action: str,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side != "NONE":
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
if position.side == side:
|
||||||
|
reason = f"Позиция {side} уже открыта."
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
|
|
||||||
|
reason = (
|
||||||
|
f"Позиция уже открыта в другом направлении: "
|
||||||
|
f"{position.side}, новый запрос: {side}."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log_position_open_rejected(
|
||||||
|
state=state,
|
||||||
|
side=side,
|
||||||
|
action=action,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = self._entry_price_for_side(state.symbol, side)
|
||||||
|
entry_price = entry.price
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
reason = f"Не удалось получить цену для paper execution: {exc}"
|
||||||
|
|
||||||
|
self._log_position_open_rejected(
|
||||||
|
state=state,
|
||||||
|
side=side,
|
||||||
|
action=action,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
|
|
||||||
|
now = self._now_time()
|
||||||
|
opened_monotonic_at = time.monotonic()
|
||||||
|
|
||||||
|
size = self._calculate_position_size(
|
||||||
|
state,
|
||||||
|
entry_price=entry_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if size <= 0:
|
||||||
|
reason = "Позиция не открыта: невозможно рассчитать adaptive size."
|
||||||
|
|
||||||
|
self._log_position_open_rejected(
|
||||||
|
state=state,
|
||||||
|
side=side,
|
||||||
|
action=action,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
|
|
||||||
|
size = self._adjust_size_by_margin_limit(
|
||||||
|
state=state,
|
||||||
|
entry_price=entry_price,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_effective_risk_after_margin_limit(
|
||||||
|
state,
|
||||||
|
base_size=state.adaptive_size_base or 0.0,
|
||||||
|
final_size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
size = self._round_size(size)
|
||||||
|
|
||||||
|
if size <= 0:
|
||||||
|
reason = "Позиция не открыта: итоговый size равен 0."
|
||||||
|
|
||||||
|
self._log_position_open_rejected(
|
||||||
|
state=state,
|
||||||
|
side=side,
|
||||||
|
action=action,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
|
|
||||||
|
trade_id = self._create_trade_id(state, side)
|
||||||
|
state.current_trade_id = trade_id
|
||||||
|
state.current_trade_cycle_number = state.cycle_number
|
||||||
|
|
||||||
|
type(self)._position = PositionState(
|
||||||
|
trade_id=trade_id,
|
||||||
|
trade_cycle_number=state.current_trade_cycle_number,
|
||||||
|
trade_sequence=state.trade_sequence,
|
||||||
|
side=side,
|
||||||
|
symbol=state.symbol,
|
||||||
|
entry_price=entry_price,
|
||||||
|
size=size,
|
||||||
|
leverage=state.leverage,
|
||||||
|
unrealized_pnl_usd=0.0,
|
||||||
|
opened_at=now,
|
||||||
|
opened_monotonic_at=opened_monotonic_at,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
state.execution_block_reason = None
|
||||||
|
state.last_flip_block_reason = None
|
||||||
|
state.last_execution_action = action
|
||||||
|
state.last_execution_reason = f"Позиция {side} открыта."
|
||||||
|
|
||||||
|
payload: JsonDict = {
|
||||||
|
"trade_id": trade_id,
|
||||||
|
"trade_sequence": state.trade_sequence,
|
||||||
|
"trade_cycle_number": state.current_trade_cycle_number,
|
||||||
|
"execution_type": "ENTRY",
|
||||||
|
"action": action,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"side": side,
|
||||||
|
"entry_price": entry_price,
|
||||||
|
"size": size,
|
||||||
|
"leverage": state.leverage,
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"confidence": state.last_signal_confidence,
|
||||||
|
"execution_confidence_score": state.execution_confidence_score,
|
||||||
|
"execution_confidence_level": state.execution_confidence_level,
|
||||||
|
"execution_confidence_reason": state.execution_confidence_reason,
|
||||||
|
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||||
|
"adaptive_size_reason": state.adaptive_size_reason,
|
||||||
|
"adaptive_size_factors": state.adaptive_size_factors,
|
||||||
|
"effective_risk_percent": state.effective_risk_percent,
|
||||||
|
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||||
|
"adaptive_size_base": state.adaptive_size_base,
|
||||||
|
"adaptive_size_final": state.adaptive_size_final,
|
||||||
|
"repeat_count": state.last_signal_repeat_count,
|
||||||
|
"reason": state.last_signal_reason,
|
||||||
|
"opened_at": now,
|
||||||
|
"opened_monotonic_at": opened_monotonic_at,
|
||||||
|
"pricing": "ask_for_long_bid_for_short",
|
||||||
|
"pricing_role": entry.pricing_role,
|
||||||
|
"price_source": entry.source,
|
||||||
|
"price_age_seconds": entry.age_seconds,
|
||||||
|
"price_updated_at": entry.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_info(
|
||||||
|
event_type="position_opened",
|
||||||
|
message=f"Позиция {side} открыта: {state.symbol}.",
|
||||||
|
screen="auto",
|
||||||
|
action="paper_execution",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit("paper_position_opened", payload)
|
||||||
|
|
||||||
|
return ExecutionDecision(action, True, f"Позиция {side} открыта.")
|
||||||
|
|
||||||
|
# закрыть открытую позицию
|
||||||
|
def _close_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
forced_reason: str | None = None,
|
||||||
|
forced_exit_price: NumericLike | None = None,
|
||||||
|
forced_pnl: NumericLike | None = None,
|
||||||
|
forced_price_meta: ExecutionPrice | None = None,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
"Нет открытой позиции для закрытия.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if forced_exit_price is not None:
|
||||||
|
exit_price = safe_float(forced_exit_price) or 0.0
|
||||||
|
exit_execution = forced_price_meta
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
exit_execution = self._exit_price_for_side(
|
||||||
|
position.symbol or state.symbol,
|
||||||
|
position.side,
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_price = exit_execution.price
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
f"Ошибка получения цены для закрытия: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
pnl = (
|
||||||
|
safe_float(forced_pnl)
|
||||||
|
if forced_pnl is not None
|
||||||
|
else self._calculate_pnl(exit_price)
|
||||||
|
)
|
||||||
|
|
||||||
|
if pnl is None:
|
||||||
|
pnl = 0.0
|
||||||
|
|
||||||
|
state.realized_pnl_usd += pnl
|
||||||
|
state.cycle_realized_pnl_usd += pnl
|
||||||
|
state.cycle_closed_trades += 1
|
||||||
|
|
||||||
|
if pnl > 0:
|
||||||
|
state.cycle_winning_trades += 1
|
||||||
|
|
||||||
|
if pnl < 0:
|
||||||
|
state.last_loss_monotonic_at = time.monotonic()
|
||||||
|
|
||||||
|
now = self._now_time()
|
||||||
|
|
||||||
|
trade_id = (
|
||||||
|
position.trade_id
|
||||||
|
or state.current_trade_id
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: JsonDict = {
|
||||||
|
"trade_id": trade_id,
|
||||||
|
"trade_sequence": position.trade_sequence or state.trade_sequence,
|
||||||
|
"trade_cycle_number": (
|
||||||
|
position.trade_cycle_number
|
||||||
|
or state.current_trade_cycle_number
|
||||||
|
),
|
||||||
|
"execution_type": "EXIT",
|
||||||
|
"action": "CLOSE",
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"side": position.side,
|
||||||
|
"entry_price": position.entry_price,
|
||||||
|
"exit_price": exit_price,
|
||||||
|
"size": position.size,
|
||||||
|
"leverage": position.leverage,
|
||||||
|
"pnl": pnl,
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"confidence": state.last_signal_confidence,
|
||||||
|
"repeat_count": state.last_signal_repeat_count,
|
||||||
|
"reason": state.last_signal_reason,
|
||||||
|
"risk_reason": forced_reason,
|
||||||
|
"is_forced": forced_reason is not None,
|
||||||
|
"opened_at": position.opened_at,
|
||||||
|
"closed_at": now,
|
||||||
|
"pricing": "bid_for_long_exit_ask_for_short_exit",
|
||||||
|
"pricing_role": (
|
||||||
|
exit_execution.pricing_role
|
||||||
|
if exit_execution
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"price_source": (
|
||||||
|
exit_execution.source
|
||||||
|
if exit_execution
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"price_age_seconds": (
|
||||||
|
exit_execution.age_seconds
|
||||||
|
if exit_execution
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"price_updated_at": (
|
||||||
|
exit_execution.updated_at
|
||||||
|
if exit_execution
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
close_reason = forced_reason or "MANUAL"
|
||||||
|
|
||||||
|
JournalService().log_ui_info(
|
||||||
|
event_type="position_closed",
|
||||||
|
message=f"Позиция {position.side} закрыта: {close_reason}.",
|
||||||
|
screen="auto",
|
||||||
|
action="paper_execution",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit(
|
||||||
|
"paper_position_closed",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
type(self)._position = PositionState()
|
||||||
|
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
state.position_opened_monotonic_at = None
|
||||||
|
state.current_trade_id = None
|
||||||
|
state.current_trade_cycle_number = None
|
||||||
|
|
||||||
|
self._reset_runtime_protection_state(state)
|
||||||
|
|
||||||
|
state.execution_block_reason = None
|
||||||
|
state.last_flip_block_reason = None
|
||||||
|
|
||||||
|
state.last_execution_action = (
|
||||||
|
f"FORCE_CLOSE_{forced_reason}"
|
||||||
|
if forced_reason is not None
|
||||||
|
else "CLOSE"
|
||||||
|
)
|
||||||
|
|
||||||
|
state.last_execution_reason = (
|
||||||
|
f"Позиция закрыта по правилу защиты: {forced_reason}."
|
||||||
|
if forced_reason is not None
|
||||||
|
else "Позиция закрыта."
|
||||||
|
)
|
||||||
|
|
||||||
|
type(self)._last_flip_block_key = None
|
||||||
|
|
||||||
|
if forced_reason is not None:
|
||||||
|
return ExecutionDecision(
|
||||||
|
f"FORCE_CLOSE_{forced_reason}",
|
||||||
|
True,
|
||||||
|
f"Позиция закрыта по правилу защиты: {forced_reason}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionDecision(
|
||||||
|
"CLOSE",
|
||||||
|
True,
|
||||||
|
"Позиция закрыта.",
|
||||||
|
)
|
||||||
209
app/src/trading/execution/position_intelligence.py
Normal file
209
app/src/trading/execution/position_intelligence.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# app/src/trading/execution/position_intelligence.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import NumericLike
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.position.state import PositionState
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionPositionIntelligenceProtocol(Protocol):
|
||||||
|
_position: PositionState
|
||||||
|
|
||||||
|
# посчитать изменение цены позиции в процентах
|
||||||
|
def _calculate_price_move_percent(
|
||||||
|
self,
|
||||||
|
current_price: NumericLike | None,
|
||||||
|
) -> float:
|
||||||
|
...
|
||||||
|
|
||||||
|
# посчитать время удержания позиции в секундах
|
||||||
|
def _position_hold_seconds(
|
||||||
|
self,
|
||||||
|
position: PositionState,
|
||||||
|
) -> int | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPositionIntelligenceMixin(_ExecutionPositionIntelligenceProtocol):
|
||||||
|
# определить причину закрытия позиции по position intelligence
|
||||||
|
def _runtime_intelligence_close_reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> str | None:
|
||||||
|
giveback_reason = self._giveback_close_reason(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if giveback_reason is not None:
|
||||||
|
return giveback_reason
|
||||||
|
|
||||||
|
time_decay_reason = self._time_decay_close_reason(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if time_decay_reason is not None:
|
||||||
|
return time_decay_reason
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить закрытие по возврату прибыли от пика
|
||||||
|
def _giveback_close_reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> str | None:
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
peak_percent = safe_float(
|
||||||
|
getattr(state, "position_peak_pnl_percent", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if peak_percent is None or peak_percent <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pnl_percent is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
giveback = peak_percent - pnl_percent
|
||||||
|
|
||||||
|
if giveback <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
giveback_percent = round((giveback / peak_percent) * 100, 2)
|
||||||
|
|
||||||
|
fatigue_state = str(
|
||||||
|
getattr(state, "position_fatigue_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
reversal_risk = str(
|
||||||
|
getattr(state, "position_reversal_risk", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
adverse_momentum = bool(
|
||||||
|
getattr(state, "position_adverse_momentum", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_confidence = safe_float(
|
||||||
|
getattr(state, "position_exit_confidence", None)
|
||||||
|
) or 0.0
|
||||||
|
|
||||||
|
if (
|
||||||
|
peak_percent >= 0.75
|
||||||
|
and giveback_percent >= 55
|
||||||
|
and pnl_percent > 0
|
||||||
|
):
|
||||||
|
return "GIVEBACK_PROTECTION"
|
||||||
|
|
||||||
|
if (
|
||||||
|
peak_percent >= 0.50
|
||||||
|
and giveback_percent >= 40
|
||||||
|
and adverse_momentum
|
||||||
|
):
|
||||||
|
return "GIVEBACK_MOMENTUM_REVERSAL"
|
||||||
|
|
||||||
|
if (
|
||||||
|
peak_percent >= 0.50
|
||||||
|
and giveback_percent >= 35
|
||||||
|
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||||
|
):
|
||||||
|
return "GIVEBACK_FATIGUE_EXIT"
|
||||||
|
|
||||||
|
if (
|
||||||
|
peak_percent >= 0.50
|
||||||
|
and giveback_percent >= 35
|
||||||
|
and reversal_risk in {"ELEVATED", "HIGH"}
|
||||||
|
and exit_confidence >= 0.50
|
||||||
|
):
|
||||||
|
return "GIVEBACK_REVERSAL_RISK"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить закрытие по устареванию позиции во времени
|
||||||
|
def _time_decay_close_reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> str | None:
|
||||||
|
hold_seconds = safe_float(
|
||||||
|
getattr(state, "position_hold_seconds", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if hold_seconds is None:
|
||||||
|
hold_seconds = safe_float(
|
||||||
|
self._position_hold_seconds(type(self)._position)
|
||||||
|
)
|
||||||
|
|
||||||
|
if hold_seconds is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
fatigue_state = str(
|
||||||
|
getattr(state, "position_fatigue_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
conviction_state = str(
|
||||||
|
getattr(state, "position_conviction_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
decay_state = str(
|
||||||
|
getattr(state, "position_decay_state", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
adverse_momentum = bool(
|
||||||
|
getattr(state, "position_adverse_momentum", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
market_runtime_degraded = bool(
|
||||||
|
getattr(state, "market_runtime_degraded", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
if pnl_percent is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (
|
||||||
|
hold_seconds >= 2400
|
||||||
|
and -0.15 <= pnl_percent <= 0.25
|
||||||
|
and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"}
|
||||||
|
):
|
||||||
|
return "TIME_DECAY_EXIT"
|
||||||
|
|
||||||
|
if (
|
||||||
|
hold_seconds >= 1800
|
||||||
|
and -0.20 <= pnl_percent <= 0.35
|
||||||
|
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||||
|
):
|
||||||
|
return "TIME_DECAY_FATIGUE_EXIT"
|
||||||
|
|
||||||
|
if (
|
||||||
|
hold_seconds >= 1200
|
||||||
|
and pnl_percent <= 0.20
|
||||||
|
and adverse_momentum
|
||||||
|
):
|
||||||
|
return "TIME_DECAY_ADVERSE_MOMENTUM"
|
||||||
|
|
||||||
|
if (
|
||||||
|
hold_seconds >= 1200
|
||||||
|
and pnl_percent <= 0.30
|
||||||
|
and market_runtime_degraded
|
||||||
|
):
|
||||||
|
return "TIME_DECAY_DEGRADED_MARKET"
|
||||||
|
|
||||||
|
if (
|
||||||
|
hold_seconds >= 1800
|
||||||
|
and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"}
|
||||||
|
and pnl_percent <= 0.30
|
||||||
|
):
|
||||||
|
return "TIME_DECAY_CONTEXT_DECAY"
|
||||||
|
|
||||||
|
return None
|
||||||
398
app/src/trading/execution/position_protection.py
Normal file
398
app/src/trading/execution/position_protection.py
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
# app/src/trading/execution/position_protection.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import ClassVar, Protocol
|
||||||
|
|
||||||
|
from src.core.event_bus import EventBus
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import JsonDict, NumericLike
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
|
from src.trading.journal.service import JournalService
|
||||||
|
from src.trading.position.state import PositionState
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionPositionProtectionProtocol(Protocol):
|
||||||
|
_position: ClassVar[PositionState]
|
||||||
|
|
||||||
|
# получить цену закрытия позиции по стороне
|
||||||
|
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice:
|
||||||
|
...
|
||||||
|
|
||||||
|
# посчитать PnL позиции
|
||||||
|
def _calculate_pnl(self, current_price: NumericLike | None) -> float:
|
||||||
|
...
|
||||||
|
|
||||||
|
# посчитать движение цены от входа в процентах
|
||||||
|
def _calculate_price_move_percent(self, current_price: NumericLike | None) -> float:
|
||||||
|
...
|
||||||
|
|
||||||
|
# закрыть позицию
|
||||||
|
def _close_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
forced_reason: str | None = None,
|
||||||
|
forced_exit_price: NumericLike | None = None,
|
||||||
|
forced_pnl: NumericLike | None = None,
|
||||||
|
forced_price_meta: ExecutionPrice | None = None,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
# сбросить состояние runtime-защиты
|
||||||
|
def _reset_runtime_protection_state(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# получить intelligence-причину закрытия позиции
|
||||||
|
def _runtime_intelligence_close_reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> str | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||||
|
# обработать runtime-защиту открытой позиции
|
||||||
|
def _process_runtime_protection(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> ExecutionDecision | None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
self._reset_runtime_protection_state(state)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_execution = self._exit_price_for_side(
|
||||||
|
position.symbol or state.symbol,
|
||||||
|
position.side,
|
||||||
|
)
|
||||||
|
current_price = current_execution.price
|
||||||
|
except Exception:
|
||||||
|
self._sync_runtime_protection_state(
|
||||||
|
state=state,
|
||||||
|
status="DEGRADED",
|
||||||
|
reason="нет актуальной цены для protection engine",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._sync_runtime_protection_state(
|
||||||
|
state=state,
|
||||||
|
status="ACTIVE",
|
||||||
|
reason="protection engine активен",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_break_even_protection(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_profit_lock_protection(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_trailing_stop_protection(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
close_reason = self._runtime_protection_close_reason(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if close_reason is None:
|
||||||
|
close_reason = self._runtime_intelligence_close_reason(
|
||||||
|
state=state,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if close_reason is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pnl = self._calculate_pnl(current_price)
|
||||||
|
|
||||||
|
return self._close_position(
|
||||||
|
state,
|
||||||
|
forced_reason=close_reason,
|
||||||
|
forced_exit_price=current_price,
|
||||||
|
forced_pnl=pnl,
|
||||||
|
forced_price_meta=current_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
# синхронизировать состояние protection engine
|
||||||
|
def _sync_runtime_protection_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
status: str,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
state.position_protection_status = status
|
||||||
|
state.position_protection_reason = reason
|
||||||
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
# активировать break-even защиту
|
||||||
|
def _update_break_even_protection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if state.break_even_armed:
|
||||||
|
return
|
||||||
|
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
if pnl_percent < 0.35:
|
||||||
|
return
|
||||||
|
|
||||||
|
entry_price = safe_float(position.entry_price)
|
||||||
|
|
||||||
|
if entry_price is None or entry_price <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.break_even_armed = True
|
||||||
|
state.break_even_price = entry_price
|
||||||
|
state.runtime_protection_action = "BREAK_EVEN_ARMED"
|
||||||
|
state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован"
|
||||||
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
self._log_runtime_protection_event(
|
||||||
|
state=state,
|
||||||
|
action="BREAK_EVEN_ARMED",
|
||||||
|
reason=state.runtime_protection_reason,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# активировать profit lock защиту
|
||||||
|
def _update_profit_lock_protection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
if pnl_percent < 0.75:
|
||||||
|
return
|
||||||
|
|
||||||
|
entry_price = safe_float(position.entry_price)
|
||||||
|
|
||||||
|
if entry_price is None or entry_price <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
lock_price = entry_price * 1.003
|
||||||
|
elif position.side == "SHORT":
|
||||||
|
lock_price = entry_price * 0.997
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_price = safe_float(state.profit_lock_price)
|
||||||
|
|
||||||
|
if previous_price is not None:
|
||||||
|
if position.side == "LONG" and lock_price <= previous_price:
|
||||||
|
return
|
||||||
|
|
||||||
|
if position.side == "SHORT" and lock_price >= previous_price:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.profit_lock_active = True
|
||||||
|
state.profit_lock_price = round(lock_price, 8)
|
||||||
|
state.runtime_protection_action = "PROFIT_LOCK_ACTIVE"
|
||||||
|
state.runtime_protection_reason = "часть прибыли защищена profit lock"
|
||||||
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
self._log_runtime_protection_event(
|
||||||
|
state=state,
|
||||||
|
action="PROFIT_LOCK_ACTIVE",
|
||||||
|
reason=state.runtime_protection_reason,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# активировать trailing stop защиту
|
||||||
|
def _update_trailing_stop_protection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
if pnl_percent < 1.0:
|
||||||
|
return
|
||||||
|
|
||||||
|
trail_distance_percent = 0.35
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
trail_price = current_price * (1 - trail_distance_percent / 100)
|
||||||
|
previous_price = safe_float(state.trailing_stop_price)
|
||||||
|
|
||||||
|
if previous_price is not None and trail_price <= previous_price:
|
||||||
|
return
|
||||||
|
|
||||||
|
elif position.side == "SHORT":
|
||||||
|
trail_price = current_price * (1 + trail_distance_percent / 100)
|
||||||
|
previous_price = safe_float(state.trailing_stop_price)
|
||||||
|
|
||||||
|
if previous_price is not None and trail_price >= previous_price:
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.trailing_stop_active = True
|
||||||
|
state.trailing_stop_price = round(trail_price, 8)
|
||||||
|
state.runtime_protection_action = "TRAILING_STOP_ACTIVE"
|
||||||
|
state.runtime_protection_reason = "trailing stop подтянут вслед за прибылью"
|
||||||
|
state.runtime_protection_updated_at = time.monotonic()
|
||||||
|
|
||||||
|
self._log_runtime_protection_event(
|
||||||
|
state=state,
|
||||||
|
action="TRAILING_STOP_ACTIVE",
|
||||||
|
reason=state.runtime_protection_reason,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# определить причину закрытия по защите
|
||||||
|
def _runtime_protection_close_reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
current_price: float,
|
||||||
|
) -> str | None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
fatigue_state = str(getattr(state, "position_fatigue_state", "") or "").upper()
|
||||||
|
reversal_risk = str(getattr(state, "position_reversal_risk", "") or "").upper()
|
||||||
|
exit_urgency = str(getattr(state, "position_exit_urgency", "") or "").upper()
|
||||||
|
conviction = str(getattr(state, "position_conviction_state", "") or "").upper()
|
||||||
|
risk_level = str(getattr(state, "position_risk_level", "") or "").upper()
|
||||||
|
exit_signal = str(getattr(state, "position_exit_signal", "") or "").upper()
|
||||||
|
decay_state = str(getattr(state, "position_decay_state", "") or "").upper()
|
||||||
|
|
||||||
|
if exit_urgency == "IMMEDIATE":
|
||||||
|
return "LIFECYCLE_EXIT"
|
||||||
|
|
||||||
|
if conviction == "BROKEN":
|
||||||
|
return "CONVICTION_BROKEN"
|
||||||
|
|
||||||
|
if fatigue_state == "EXHAUSTED" and reversal_risk in {"ELEVATED", "HIGH"}:
|
||||||
|
return "FATIGUE_EXIT"
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.position_adverse_momentum
|
||||||
|
and reversal_risk == "HIGH"
|
||||||
|
and risk_level in {"ELEVATED", "HIGH"}
|
||||||
|
):
|
||||||
|
return "MOMENTUM_EXIT"
|
||||||
|
|
||||||
|
if (
|
||||||
|
getattr(state, "market_runtime_degraded", False)
|
||||||
|
and exit_signal in {"EXIT", "REDUCE_OR_PROTECT"}
|
||||||
|
and decay_state != "NONE"
|
||||||
|
):
|
||||||
|
return "DEGRADATION_EXIT"
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
if (
|
||||||
|
state.trailing_stop_active
|
||||||
|
and state.trailing_stop_price is not None
|
||||||
|
and current_price <= state.trailing_stop_price
|
||||||
|
):
|
||||||
|
return "TRAILING_STOP"
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.profit_lock_active
|
||||||
|
and state.profit_lock_price is not None
|
||||||
|
and current_price <= state.profit_lock_price
|
||||||
|
):
|
||||||
|
return "PROFIT_LOCK"
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.break_even_armed
|
||||||
|
and state.break_even_price is not None
|
||||||
|
and current_price <= state.break_even_price
|
||||||
|
):
|
||||||
|
return "BREAK_EVEN"
|
||||||
|
|
||||||
|
if position.side == "SHORT":
|
||||||
|
if (
|
||||||
|
state.trailing_stop_active
|
||||||
|
and state.trailing_stop_price is not None
|
||||||
|
and current_price >= state.trailing_stop_price
|
||||||
|
):
|
||||||
|
return "TRAILING_STOP"
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.profit_lock_active
|
||||||
|
and state.profit_lock_price is not None
|
||||||
|
and current_price >= state.profit_lock_price
|
||||||
|
):
|
||||||
|
return "PROFIT_LOCK"
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.break_even_armed
|
||||||
|
and state.break_even_price is not None
|
||||||
|
and current_price >= state.break_even_price
|
||||||
|
):
|
||||||
|
return "BREAK_EVEN"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# записать событие runtime-защиты в журнал
|
||||||
|
def _log_runtime_protection_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
action: str,
|
||||||
|
reason: str,
|
||||||
|
current_price: float,
|
||||||
|
) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
payload: JsonDict = {
|
||||||
|
"execution_type": "RUNTIME_PROTECTION",
|
||||||
|
"action": action,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"position_side": position.side,
|
||||||
|
"entry_price": position.entry_price,
|
||||||
|
"current_price": current_price,
|
||||||
|
"size": position.size,
|
||||||
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
|
"position_pnl_percent": self._calculate_price_move_percent(current_price),
|
||||||
|
"break_even_armed": state.break_even_armed,
|
||||||
|
"break_even_price": state.break_even_price,
|
||||||
|
"profit_lock_active": state.profit_lock_active,
|
||||||
|
"profit_lock_price": state.profit_lock_price,
|
||||||
|
"trailing_stop_active": state.trailing_stop_active,
|
||||||
|
"trailing_stop_price": state.trailing_stop_price,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_info(
|
||||||
|
event_type="runtime_protection_updated",
|
||||||
|
message=f"Runtime protection: {action}. {reason}.",
|
||||||
|
screen="auto",
|
||||||
|
action="runtime_protection",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit("runtime_protection_updated", payload)
|
||||||
317
app/src/trading/execution/position_runtime.py
Normal file
317
app/src/trading/execution/position_runtime.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# app/src/trading/execution/position_runtime.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
|
||||||
|
from src.core.types import NumericLike
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.position.state import PositionState
|
||||||
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionRuntimeProtocol(Protocol):
|
||||||
|
_position: PositionState
|
||||||
|
|
||||||
|
def _calculate_pnl(
|
||||||
|
self,
|
||||||
|
current_price: NumericLike | None,
|
||||||
|
) -> float: ...
|
||||||
|
|
||||||
|
def _calculate_price_move_percent(
|
||||||
|
self,
|
||||||
|
current_price: NumericLike | None,
|
||||||
|
) -> float: ...
|
||||||
|
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
|
||||||
|
def _now_time(self) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||||
|
# получить текущую paper-позицию
|
||||||
|
def get_position(self) -> PositionState:
|
||||||
|
return type(self)._position
|
||||||
|
|
||||||
|
# обновить unrealized PnL и runtime-память позиции
|
||||||
|
def _update_unrealized_pnl(self, state: AutoTradeState) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_execution = self._exit_price_for_side(
|
||||||
|
position.symbol or state.symbol,
|
||||||
|
position.side,
|
||||||
|
)
|
||||||
|
current_price = current_execution.price
|
||||||
|
except Exception:
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
pnl = self._calculate_pnl(current_price)
|
||||||
|
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
|
||||||
|
position.unrealized_pnl_usd = pnl
|
||||||
|
position.updated_at = self._now_time()
|
||||||
|
|
||||||
|
if position.peak_unrealized_pnl_usd is None or pnl > position.peak_unrealized_pnl_usd:
|
||||||
|
position.peak_unrealized_pnl_usd = pnl
|
||||||
|
|
||||||
|
if position.peak_pnl_percent is None or pnl_percent > position.peak_pnl_percent:
|
||||||
|
position.peak_pnl_percent = pnl_percent
|
||||||
|
|
||||||
|
if position.max_favorable_excursion_percent is None:
|
||||||
|
position.max_favorable_excursion_percent = max(0.0, pnl_percent)
|
||||||
|
else:
|
||||||
|
position.max_favorable_excursion_percent = max(
|
||||||
|
position.max_favorable_excursion_percent,
|
||||||
|
pnl_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
if position.max_adverse_excursion_percent is None:
|
||||||
|
position.max_adverse_excursion_percent = min(0.0, pnl_percent)
|
||||||
|
else:
|
||||||
|
position.max_adverse_excursion_percent = min(
|
||||||
|
position.max_adverse_excursion_percent,
|
||||||
|
pnl_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_position_runtime_memory(
|
||||||
|
position=position,
|
||||||
|
current_price=current_price,
|
||||||
|
pnl_percent=pnl_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
# синхронизировать AutoTradeState с текущей paper-позицией
|
||||||
|
def _sync_state_from_position(self, state: AutoTradeState) -> None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
state.position_side = position.side
|
||||||
|
state.entry_price = position.entry_price
|
||||||
|
state.position_size = position.size
|
||||||
|
state.unrealized_pnl_usd = position.unrealized_pnl_usd
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
state.position_opened_monotonic_at = 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
|
||||||
|
|
||||||
|
state.position_opened_monotonic_at = position.opened_monotonic_at
|
||||||
|
state.position_peak_pnl_usd = position.peak_unrealized_pnl_usd
|
||||||
|
state.position_peak_pnl_percent = position.peak_pnl_percent
|
||||||
|
state.position_mfe_percent = position.max_favorable_excursion_percent
|
||||||
|
state.position_mae_percent = position.max_adverse_excursion_percent
|
||||||
|
state.position_fatigue_score = position.fatigue_score
|
||||||
|
state.position_fatigue_state = position.fatigue_state
|
||||||
|
|
||||||
|
# обновить best/worst price и fatigue state позиции
|
||||||
|
def _sync_position_runtime_memory(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
position: PositionState,
|
||||||
|
current_price: float,
|
||||||
|
pnl_percent: float,
|
||||||
|
) -> None:
|
||||||
|
if position.best_price_seen is None:
|
||||||
|
position.best_price_seen = current_price
|
||||||
|
|
||||||
|
if position.worst_price_seen is None:
|
||||||
|
position.worst_price_seen = current_price
|
||||||
|
|
||||||
|
if position.side == "LONG":
|
||||||
|
position.best_price_seen = max(position.best_price_seen, current_price)
|
||||||
|
position.worst_price_seen = min(position.worst_price_seen, current_price)
|
||||||
|
|
||||||
|
elif position.side == "SHORT":
|
||||||
|
position.best_price_seen = min(position.best_price_seen, current_price)
|
||||||
|
position.worst_price_seen = max(position.worst_price_seen, current_price)
|
||||||
|
|
||||||
|
peak = safe_float(position.peak_pnl_percent) or 0.0
|
||||||
|
giveback_score = 0.0
|
||||||
|
|
||||||
|
if peak > 0:
|
||||||
|
giveback = max(0.0, peak - pnl_percent)
|
||||||
|
giveback_score = min(1.0, giveback / max(0.01, peak))
|
||||||
|
|
||||||
|
fatigue = 0.0
|
||||||
|
|
||||||
|
if giveback_score >= 0.70:
|
||||||
|
fatigue += 0.35
|
||||||
|
elif giveback_score >= 0.45:
|
||||||
|
fatigue += 0.25
|
||||||
|
elif giveback_score >= 0.25:
|
||||||
|
fatigue += 0.12
|
||||||
|
|
||||||
|
if pnl_percent < 0:
|
||||||
|
fatigue += 0.20
|
||||||
|
|
||||||
|
position.fatigue_score = round(max(0.0, min(1.0, fatigue)), 3)
|
||||||
|
|
||||||
|
if position.fatigue_score >= 0.75:
|
||||||
|
position.fatigue_state = "EXHAUSTED"
|
||||||
|
elif position.fatigue_score >= 0.50:
|
||||||
|
position.fatigue_state = "TIRED"
|
||||||
|
elif position.fatigue_score >= 0.25:
|
||||||
|
position.fatigue_state = "WATCH"
|
||||||
|
else:
|
||||||
|
position.fatigue_state = "FRESH"
|
||||||
|
|
||||||
|
# посчитать время удержания позиции в секундах
|
||||||
|
def _position_hold_seconds(self, position: PositionState) -> int | None:
|
||||||
|
opened_monotonic_at = safe_float(
|
||||||
|
getattr(position, "opened_monotonic_at", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if opened_monotonic_at is not None:
|
||||||
|
return max(0, int(time.monotonic() - opened_monotonic_at))
|
||||||
|
|
||||||
|
if not position.opened_at:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
opened_at = datetime.strptime(position.opened_at, "%H:%M:%S")
|
||||||
|
now = datetime.strptime(self._now_time(), "%H:%M:%S")
|
||||||
|
|
||||||
|
seconds = int((now - opened_at).total_seconds())
|
||||||
|
|
||||||
|
if seconds < 0:
|
||||||
|
seconds += 24 * 60 * 60
|
||||||
|
|
||||||
|
return seconds
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# обновить runtime-метрики позиции по текущей цене
|
||||||
|
def _refresh_position_runtime_metrics(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
position: PositionState,
|
||||||
|
current_price: float,
|
||||||
|
) -> None:
|
||||||
|
price_move_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
pnl = safe_float(position.unrealized_pnl_usd)
|
||||||
|
|
||||||
|
if pnl is not None:
|
||||||
|
peak_pnl = safe_float(position.peak_unrealized_pnl_usd)
|
||||||
|
|
||||||
|
if peak_pnl is None or pnl > peak_pnl:
|
||||||
|
position.peak_unrealized_pnl_usd = pnl
|
||||||
|
|
||||||
|
peak_percent = safe_float(position.peak_pnl_percent)
|
||||||
|
|
||||||
|
if peak_percent is None or price_move_percent > peak_percent:
|
||||||
|
position.peak_pnl_percent = price_move_percent
|
||||||
|
|
||||||
|
mfe = safe_float(position.max_favorable_excursion_percent)
|
||||||
|
mae = safe_float(position.max_adverse_excursion_percent)
|
||||||
|
|
||||||
|
if mfe is None or price_move_percent > mfe:
|
||||||
|
position.max_favorable_excursion_percent = price_move_percent
|
||||||
|
|
||||||
|
if mae is None or price_move_percent < mae:
|
||||||
|
position.max_adverse_excursion_percent = price_move_percent
|
||||||
|
|
||||||
|
best_price = safe_float(position.best_price_seen)
|
||||||
|
worst_price = safe_float(position.worst_price_seen)
|
||||||
|
|
||||||
|
if best_price is None:
|
||||||
|
position.best_price_seen = current_price
|
||||||
|
elif position.side == "LONG" and current_price > best_price:
|
||||||
|
position.best_price_seen = current_price
|
||||||
|
elif position.side == "SHORT" and current_price < best_price:
|
||||||
|
position.best_price_seen = current_price
|
||||||
|
|
||||||
|
if worst_price is None:
|
||||||
|
position.worst_price_seen = current_price
|
||||||
|
elif position.side == "LONG" and current_price < worst_price:
|
||||||
|
position.worst_price_seen = current_price
|
||||||
|
elif position.side == "SHORT" and current_price > worst_price:
|
||||||
|
position.worst_price_seen = current_price
|
||||||
|
|
||||||
|
fatigue_score = self._runtime_fatigue_score(position)
|
||||||
|
position.fatigue_score = fatigue_score
|
||||||
|
position.fatigue_state = self._runtime_fatigue_state(fatigue_score)
|
||||||
|
|
||||||
|
# рассчитать fatigue score позиции
|
||||||
|
def _runtime_fatigue_score(self, position: PositionState) -> float:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
mfe = safe_float(position.max_favorable_excursion_percent) or 0.0
|
||||||
|
current_peak = safe_float(position.peak_pnl_percent) or 0.0
|
||||||
|
mae = safe_float(position.max_adverse_excursion_percent) or 0.0
|
||||||
|
|
||||||
|
hold_seconds = 0
|
||||||
|
|
||||||
|
opened_at = safe_float(position.opened_monotonic_at)
|
||||||
|
if opened_at is not None:
|
||||||
|
hold_seconds = max(0, int(time.monotonic() - opened_at))
|
||||||
|
|
||||||
|
if hold_seconds >= 1800:
|
||||||
|
score += 0.25
|
||||||
|
elif hold_seconds >= 900:
|
||||||
|
score += 0.15
|
||||||
|
elif hold_seconds >= 300:
|
||||||
|
score += 0.08
|
||||||
|
|
||||||
|
if mfe > 0 and current_peak > 0:
|
||||||
|
giveback = max(0.0, mfe - current_peak)
|
||||||
|
|
||||||
|
if giveback >= 0.75:
|
||||||
|
score += 0.25
|
||||||
|
elif giveback >= 0.45:
|
||||||
|
score += 0.18
|
||||||
|
elif giveback >= 0.25:
|
||||||
|
score += 0.10
|
||||||
|
|
||||||
|
if mae <= -1.0:
|
||||||
|
score += 0.25
|
||||||
|
elif mae <= -0.5:
|
||||||
|
score += 0.15
|
||||||
|
|
||||||
|
return round(max(0.0, min(1.0, score)), 3)
|
||||||
|
|
||||||
|
# преобразовать fatigue score в semantic state
|
||||||
|
def _runtime_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"
|
||||||
|
|
||||||
|
# сбросить lifecycle-метрики позиции в AutoTradeState
|
||||||
|
def _reset_position_lifecycle_state(self, state: AutoTradeState) -> 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
|
||||||
134
app/src/trading/execution/pricing.py
Normal file
134
app/src/trading/execution/pricing.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# app/src/trading/execution/pricing.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import NumericLike
|
||||||
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExecutionPrice:
|
||||||
|
price: float
|
||||||
|
source: str
|
||||||
|
age_seconds: float | None
|
||||||
|
updated_at: str
|
||||||
|
pricing_role: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPricingMixin:
|
||||||
|
# получить цену входа по текущему сигналу
|
||||||
|
def _signal_entry_price(self, state: AutoTradeState) -> ExecutionPrice:
|
||||||
|
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) -> ExecutionPrice:
|
||||||
|
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||||
|
|
||||||
|
if snapshot.age_seconds is not None and snapshot.age_seconds > 5:
|
||||||
|
raise ValueError("Execution snapshot is stale.")
|
||||||
|
|
||||||
|
if side == "LONG":
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.ask_price, "ask_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="LONG_ENTRY_ASK",
|
||||||
|
)
|
||||||
|
|
||||||
|
if side == "SHORT":
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.bid_price, "bid_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="SHORT_ENTRY_BID",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="ENTRY_LAST",
|
||||||
|
)
|
||||||
|
|
||||||
|
# получить цену выхода по стороне позиции
|
||||||
|
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice:
|
||||||
|
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||||
|
|
||||||
|
if snapshot.age_seconds is not None and snapshot.age_seconds > 5:
|
||||||
|
raise ValueError("Execution snapshot is stale.")
|
||||||
|
|
||||||
|
if side == "LONG":
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.bid_price, "bid_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="LONG_EXIT_BID",
|
||||||
|
)
|
||||||
|
|
||||||
|
if side == "SHORT":
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.ask_price, "ask_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="SHORT_EXIT_ASK",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="EXIT_LAST",
|
||||||
|
)
|
||||||
|
|
||||||
|
# получить последнюю рыночную цену
|
||||||
|
def _market_last_price(self, symbol: str) -> ExecutionPrice:
|
||||||
|
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||||
|
|
||||||
|
return ExecutionPrice(
|
||||||
|
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||||
|
source=snapshot.source,
|
||||||
|
age_seconds=snapshot.age_seconds,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
pricing_role="MARKET_LAST",
|
||||||
|
)
|
||||||
|
|
||||||
|
# проверить и нормализовать цену из execution snapshot
|
||||||
|
def _snapshot_price(
|
||||||
|
self,
|
||||||
|
raw_price: NumericLike | None,
|
||||||
|
name: str,
|
||||||
|
) -> float:
|
||||||
|
if raw_price is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Execution snapshot price '{name}' is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
price = safe_float(raw_price)
|
||||||
|
|
||||||
|
if price is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Execution snapshot price '{name}' is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
if price <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Execution snapshot price '{name}' is invalid: {price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return price
|
||||||
74
app/src/trading/execution/resets.py
Normal file
74
app/src/trading/execution/resets.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# app/src/trading/execution/resets.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionResetsProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Protocol для reset mixin.
|
||||||
|
|
||||||
|
Сейчас пустой, но оставлен для единообразия архитектуры.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
||||||
|
"""
|
||||||
|
Общие reset-функции execution слоя.
|
||||||
|
|
||||||
|
Здесь находятся методы очистки runtime/protection/
|
||||||
|
lifecycle состояния позиции.
|
||||||
|
|
||||||
|
Это позволяет избежать циклических зависимостей между:
|
||||||
|
- position_actions.py
|
||||||
|
- position_protection.py
|
||||||
|
- runtime_actions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reset_runtime_protection_state(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Полный reset runtime protection состояния позиции.
|
||||||
|
Вызывается после закрытия позиции.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state.position_protection_status = None
|
||||||
|
state.position_protection_reason = None
|
||||||
|
|
||||||
|
state.break_even_armed = False
|
||||||
|
state.break_even_price = None
|
||||||
|
|
||||||
|
state.trailing_stop_active = False
|
||||||
|
state.trailing_stop_price = None
|
||||||
|
|
||||||
|
state.profit_lock_active = False
|
||||||
|
state.profit_lock_price = None
|
||||||
|
|
||||||
|
state.runtime_protection_action = None
|
||||||
|
state.runtime_protection_reason = None
|
||||||
|
state.runtime_protection_updated_at = None
|
||||||
|
|
||||||
|
def _reset_position_lifecycle_state(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Reset lifecycle состояния позиции.
|
||||||
|
Используется после полного закрытия позиции.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state.position_opened_monotonic_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.execution_block_reason = None
|
||||||
|
state.last_flip_block_reason = None
|
||||||
121
app/src/trading/execution/risk_close.py
Normal file
121
app/src/trading/execution/risk_close.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# app/src/trading/execution/risk_close.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.execution.models import ExecutionDecision
|
||||||
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
|
from src.trading.position.state import PositionState
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionRiskCloseProtocol(Protocol):
|
||||||
|
_position: PositionState
|
||||||
|
|
||||||
|
# получить цену выхода для стороны позиции
|
||||||
|
def _exit_price_for_side(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
) -> ExecutionPrice: ...
|
||||||
|
|
||||||
|
# посчитать движение цены позиции в процентах
|
||||||
|
def _calculate_price_move_percent(self, current_price) -> float: ...
|
||||||
|
|
||||||
|
# посчитать текущий PnL позиции
|
||||||
|
def _calculate_pnl(self, current_price) -> float: ...
|
||||||
|
|
||||||
|
# закрыть открытую позицию
|
||||||
|
def _close_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
forced_reason: str | None = None,
|
||||||
|
forced_exit_price=None,
|
||||||
|
forced_pnl=None,
|
||||||
|
forced_price_meta: ExecutionPrice | None = None,
|
||||||
|
) -> ExecutionDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
|
||||||
|
# проверить, нужно ли закрыть позицию по max loss / stop loss / take profit
|
||||||
|
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_execution = self._exit_price_for_side(
|
||||||
|
position.symbol or state.symbol,
|
||||||
|
position.side,
|
||||||
|
)
|
||||||
|
current_price = current_execution.price
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price_move_percent = self._calculate_price_move_percent(current_price)
|
||||||
|
unrealized_pnl = self._calculate_pnl(current_price)
|
||||||
|
|
||||||
|
if self._is_max_loss_hit(state, unrealized_pnl):
|
||||||
|
return self._close_position(
|
||||||
|
state,
|
||||||
|
forced_reason="MAX_LOSS",
|
||||||
|
forced_exit_price=current_price,
|
||||||
|
forced_pnl=unrealized_pnl,
|
||||||
|
forced_price_meta=current_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._is_stop_loss_hit(state, price_move_percent):
|
||||||
|
return self._close_position(
|
||||||
|
state,
|
||||||
|
forced_reason="STOP_LOSS",
|
||||||
|
forced_exit_price=current_price,
|
||||||
|
forced_pnl=unrealized_pnl,
|
||||||
|
forced_price_meta=current_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._is_take_profit_hit(state, price_move_percent):
|
||||||
|
return self._close_position(
|
||||||
|
state,
|
||||||
|
forced_reason="TAKE_PROFIT",
|
||||||
|
forced_exit_price=current_price,
|
||||||
|
forced_pnl=unrealized_pnl,
|
||||||
|
forced_price_meta=current_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# проверить, достигнут ли stop loss в процентах
|
||||||
|
def _is_stop_loss_hit(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
price_move_percent: float,
|
||||||
|
) -> bool:
|
||||||
|
if state.stop_loss_percent is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return price_move_percent <= -abs(state.stop_loss_percent)
|
||||||
|
|
||||||
|
# проверить, достигнут ли take profit в процентах
|
||||||
|
def _is_take_profit_hit(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
price_move_percent: float,
|
||||||
|
) -> bool:
|
||||||
|
if state.take_profit_percent is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return price_move_percent >= abs(state.take_profit_percent)
|
||||||
|
|
||||||
|
# проверить, достигнут ли максимальный убыток в USD
|
||||||
|
def _is_max_loss_hit(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
unrealized_pnl: float,
|
||||||
|
) -> bool:
|
||||||
|
if state.max_loss_usd is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return unrealized_pnl <= -abs(state.max_loss_usd)
|
||||||
309
app/src/trading/execution/runtime_actions.py
Normal file
309
app/src/trading/execution/runtime_actions.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# app/src/trading/execution/runtime_actions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.core.event_bus import EventBus
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import JsonDict
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionRuntimeActionsProtocol(Protocol):
|
||||||
|
_position: PositionState
|
||||||
|
|
||||||
|
def _sync_state_from_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def _close_position(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
forced_reason: str | None = None,
|
||||||
|
) -> ExecutionDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRuntimeActionsMixin(
|
||||||
|
_ExecutionRuntimeActionsProtocol
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Runtime autonomous actions subsystem.
|
||||||
|
|
||||||
|
Отвечает за:
|
||||||
|
- runtime EXIT
|
||||||
|
- runtime REDUCE
|
||||||
|
- runtime PROTECT
|
||||||
|
- cooldown runtime действий
|
||||||
|
- runtime logging
|
||||||
|
"""
|
||||||
|
|
||||||
|
_runtime_action_cooldown_seconds = 30
|
||||||
|
_last_runtime_action_key: str | None = None
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# PUBLIC
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def process_runtime_action(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
"""
|
||||||
|
Главный runtime action processor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
if state.status != "RUNNING":
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
"Runtime action доступен только в режиме RUNNING.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if position.side == "NONE":
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
"Нет открытой позиции для runtime action.",
|
||||||
|
)
|
||||||
|
|
||||||
|
action = str(
|
||||||
|
getattr(state, "autonomous_action", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
confidence = safe_float(
|
||||||
|
getattr(state, "autonomous_action_confidence", None)
|
||||||
|
) or 0.0
|
||||||
|
|
||||||
|
reason = str(
|
||||||
|
getattr(state, "autonomous_action_reason", "") or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# NO ACTION
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
if action in {"", "HOLD", "WATCH"}:
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
"Runtime action не требуется.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# COOLDOWN
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
if self._runtime_action_cooldown_active(state, action):
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
"Runtime action cooldown активен.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# PROTECT
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
if action == "PROTECT":
|
||||||
|
return self._log_runtime_action(
|
||||||
|
state=state,
|
||||||
|
action="PROTECT",
|
||||||
|
reason=reason or "позиция требует защиты",
|
||||||
|
confidence=confidence,
|
||||||
|
executed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# REDUCE
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
if action == "REDUCE":
|
||||||
|
return self._log_runtime_action(
|
||||||
|
state=state,
|
||||||
|
action="REDUCE",
|
||||||
|
reason=reason or "позиция требует уменьшения",
|
||||||
|
confidence=confidence,
|
||||||
|
executed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# EXIT
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
if action == "EXIT":
|
||||||
|
|
||||||
|
if confidence < 0.75:
|
||||||
|
return self._log_runtime_action(
|
||||||
|
state=state,
|
||||||
|
action="EXIT_BLOCKED",
|
||||||
|
reason=(
|
||||||
|
"autonomous exit заблокирован: "
|
||||||
|
f"confidence {confidence:.2f} < 0.75"
|
||||||
|
),
|
||||||
|
confidence=confidence,
|
||||||
|
executed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = self._close_position(
|
||||||
|
state,
|
||||||
|
forced_reason="AUTONOMOUS_EXIT",
|
||||||
|
)
|
||||||
|
|
||||||
|
state.autonomous_last_action = "EXIT"
|
||||||
|
state.autonomous_last_action_reason = (
|
||||||
|
reason or decision.reason
|
||||||
|
)
|
||||||
|
state.autonomous_last_action_at = (
|
||||||
|
time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
return decision
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# UNKNOWN ACTION
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
return ExecutionDecision(
|
||||||
|
"NONE",
|
||||||
|
False,
|
||||||
|
f"Неизвестный runtime action: {action}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# COOLDOWN
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _runtime_action_cooldown_active(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
action: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка cooldown runtime action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ts = safe_float(
|
||||||
|
getattr(state, "autonomous_last_action_at", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_action = str(
|
||||||
|
getattr(state, "autonomous_last_action", "") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
if ts is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if last_action != action:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
time.monotonic() - ts
|
||||||
|
) < self._runtime_action_cooldown_seconds
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# LOGGING
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _log_runtime_action(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
action: str,
|
||||||
|
reason: str,
|
||||||
|
confidence: float,
|
||||||
|
executed: bool,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
"""
|
||||||
|
Runtime action logging + deduplication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
position = type(self)._position
|
||||||
|
|
||||||
|
key = (
|
||||||
|
f"{state.symbol}:"
|
||||||
|
f"{position.side}:"
|
||||||
|
f"{action}:"
|
||||||
|
f"{reason}:"
|
||||||
|
f"{confidence:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if key != type(self)._last_runtime_action_key:
|
||||||
|
|
||||||
|
type(self)._last_runtime_action_key = key
|
||||||
|
|
||||||
|
payload: JsonDict = {
|
||||||
|
"execution_type": "RUNTIME_ACTION",
|
||||||
|
"action": action,
|
||||||
|
"executed": executed,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"position_side": position.side,
|
||||||
|
"entry_price": position.entry_price,
|
||||||
|
"size": position.size,
|
||||||
|
"unrealized_pnl_usd": (
|
||||||
|
state.unrealized_pnl_usd
|
||||||
|
),
|
||||||
|
"position_health_status": getattr(
|
||||||
|
state,
|
||||||
|
"position_health_status",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"position_risk_level": getattr(
|
||||||
|
state,
|
||||||
|
"position_risk_level",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"position_exit_signal": getattr(
|
||||||
|
state,
|
||||||
|
"position_exit_signal",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"position_exit_confidence": getattr(
|
||||||
|
state,
|
||||||
|
"position_exit_confidence",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"autonomous_action": getattr(
|
||||||
|
state,
|
||||||
|
"autonomous_action",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"confidence": confidence,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_warning(
|
||||||
|
event_type="runtime_position_action",
|
||||||
|
message=(
|
||||||
|
f"Runtime action: {action}. "
|
||||||
|
f"Причина: {reason}."
|
||||||
|
),
|
||||||
|
screen="auto",
|
||||||
|
action="runtime_position_action",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit(
|
||||||
|
"runtime_position_action",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.autonomous_last_action = action
|
||||||
|
state.autonomous_last_action_reason = reason
|
||||||
|
state.autonomous_last_action_at = time.monotonic()
|
||||||
|
|
||||||
|
return ExecutionDecision(
|
||||||
|
action,
|
||||||
|
executed,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
405
app/src/trading/execution/sizing.py
Normal file
405
app/src/trading/execution/sizing.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# app/src/trading/execution/sizing.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.core.numbers import safe_float
|
||||||
|
from src.core.types import NumericLike
|
||||||
|
from src.trading.auto.state import AutoTradeState
|
||||||
|
from src.trading.execution.pricing import ExecutionPrice
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionSizingProtocol(Protocol):
|
||||||
|
_size_precision: int
|
||||||
|
|
||||||
|
# получить цену входа по текущему сигналу
|
||||||
|
def _signal_entry_price(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> ExecutionPrice:
|
||||||
|
...
|
||||||
|
|
||||||
|
# округлить размер позиции
|
||||||
|
def _round_size(
|
||||||
|
self,
|
||||||
|
size: NumericLike | None,
|
||||||
|
) -> float:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||||
|
# рассчитать итоговый размер позиции с учётом риска и adaptive multiplier
|
||||||
|
def _calculate_position_size(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
entry_price: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
if state.risk_percent is None or state.risk_percent <= 0:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if state.stop_loss_percent is None or state.stop_loss_percent <= 0:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
price = entry_price
|
||||||
|
|
||||||
|
if price is None:
|
||||||
|
try:
|
||||||
|
price = self._signal_entry_price(state).price
|
||||||
|
except Exception:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if price <= 0:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
balance_usd = state.allocated_balance_usd
|
||||||
|
target_risk_usd = balance_usd * (state.risk_percent / 100)
|
||||||
|
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
|
||||||
|
|
||||||
|
if stop_loss_distance_usd <= 0:
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=0.0,
|
||||||
|
final_size=0.0,
|
||||||
|
multiplier=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
base_size = target_risk_usd / stop_loss_distance_usd
|
||||||
|
multiplier = self._adaptive_size_multiplier(state)
|
||||||
|
final_size = base_size * multiplier
|
||||||
|
|
||||||
|
self._sync_adaptive_size_state(
|
||||||
|
state,
|
||||||
|
base_size=base_size,
|
||||||
|
final_size=final_size,
|
||||||
|
multiplier=multiplier,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._round_size(final_size)
|
||||||
|
|
||||||
|
# рассчитать коэффициент изменения размера позиции по runtime/context факторам
|
||||||
|
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
|
||||||
|
multiplier = 1.0
|
||||||
|
|
||||||
|
execution_confidence_score = getattr(
|
||||||
|
state,
|
||||||
|
"execution_confidence_score",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
score_raw = safe_float(execution_confidence_score)
|
||||||
|
|
||||||
|
if score_raw is not None:
|
||||||
|
score = max(0.0, min(1.0, score_raw))
|
||||||
|
|
||||||
|
if score < 0.55:
|
||||||
|
multiplier *= 0.0
|
||||||
|
elif score < 0.65:
|
||||||
|
multiplier *= 0.65
|
||||||
|
elif score < 0.75:
|
||||||
|
multiplier *= 0.85
|
||||||
|
elif score >= 0.85:
|
||||||
|
multiplier *= 1.15
|
||||||
|
|
||||||
|
market_state = getattr(state, "market_state", None)
|
||||||
|
market_trend_strength = getattr(state, "market_trend_strength", None)
|
||||||
|
market_trend_quality = getattr(state, "market_trend_quality", None)
|
||||||
|
market_phase = getattr(state, "market_phase", None)
|
||||||
|
|
||||||
|
if market_state in {
|
||||||
|
"HIGH_VOLATILITY",
|
||||||
|
"LOW_VOLATILITY",
|
||||||
|
"RANGE",
|
||||||
|
"CHAOTIC",
|
||||||
|
"LIQUIDITY_VOID",
|
||||||
|
}:
|
||||||
|
multiplier *= 0.65
|
||||||
|
|
||||||
|
if market_trend_strength == "STRONG":
|
||||||
|
multiplier *= 1.1
|
||||||
|
elif market_trend_strength == "WEAK":
|
||||||
|
multiplier *= 0.75
|
||||||
|
|
||||||
|
if market_trend_quality == "CLEAN":
|
||||||
|
multiplier *= 1.05
|
||||||
|
elif market_trend_quality == "NOISY":
|
||||||
|
multiplier *= 0.75
|
||||||
|
|
||||||
|
if market_phase == "IMPULSE":
|
||||||
|
multiplier *= 1.1
|
||||||
|
elif market_phase == "PULLBACK":
|
||||||
|
multiplier *= 0.8
|
||||||
|
elif market_phase in {"RANGE", "SQUEEZE"}:
|
||||||
|
multiplier *= 0.7
|
||||||
|
|
||||||
|
momentum_state = getattr(state, "momentum_state", None)
|
||||||
|
momentum_direction = getattr(state, "momentum_direction", None)
|
||||||
|
momentum_strength = getattr(state, "momentum_strength", None)
|
||||||
|
|
||||||
|
signal = (state.last_signal or "").upper()
|
||||||
|
|
||||||
|
if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
|
||||||
|
multiplier *= 1.15
|
||||||
|
elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}:
|
||||||
|
multiplier *= 1.05
|
||||||
|
|
||||||
|
strength = safe_float(momentum_strength)
|
||||||
|
|
||||||
|
if strength is not None:
|
||||||
|
if strength >= 1.5:
|
||||||
|
multiplier *= 1.1
|
||||||
|
elif strength <= 0.7:
|
||||||
|
multiplier *= 0.8
|
||||||
|
|
||||||
|
if signal == "BUY" and momentum_direction == "DOWN":
|
||||||
|
multiplier *= 0.65
|
||||||
|
|
||||||
|
if signal == "SELL" and momentum_direction == "UP":
|
||||||
|
multiplier *= 0.65
|
||||||
|
|
||||||
|
execution_quality = getattr(state, "execution_quality", None)
|
||||||
|
execution_quality_reason = getattr(
|
||||||
|
state,
|
||||||
|
"execution_quality_reason",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if execution_quality == "BLOCKED":
|
||||||
|
multiplier *= 0.0
|
||||||
|
elif execution_quality == "WARNING":
|
||||||
|
if execution_quality_reason == "WIDE_SPREAD":
|
||||||
|
multiplier *= 0.75
|
||||||
|
elif execution_quality_reason == "AGING_SNAPSHOT":
|
||||||
|
multiplier *= 0.8
|
||||||
|
elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE":
|
||||||
|
multiplier *= 0.7
|
||||||
|
else:
|
||||||
|
multiplier *= 0.8
|
||||||
|
|
||||||
|
if getattr(state, "market_runtime_degraded", False):
|
||||||
|
multiplier *= 0.75
|
||||||
|
|
||||||
|
return round(max(0.0, min(1.25, multiplier)), 4)
|
||||||
|
|
||||||
|
# синхронизировать рассчитанный adaptive size в AutoTradeState
|
||||||
|
def _sync_adaptive_size_state(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
base_size: float,
|
||||||
|
final_size: float,
|
||||||
|
multiplier: float,
|
||||||
|
) -> None:
|
||||||
|
reason = self._adaptive_size_reason(multiplier)
|
||||||
|
|
||||||
|
state.adaptive_size_base = self._round_size(base_size)
|
||||||
|
state.adaptive_size_final = self._round_size(final_size)
|
||||||
|
state.adaptive_size_multiplier = multiplier
|
||||||
|
|
||||||
|
if multiplier != 1:
|
||||||
|
state.adaptive_size_changed_at = time.monotonic()
|
||||||
|
|
||||||
|
base_risk_percent = safe_float(state.risk_percent) or 0.0
|
||||||
|
|
||||||
|
state.effective_risk_percent = round(
|
||||||
|
base_risk_percent * multiplier,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.effective_target_risk_usd = round(
|
||||||
|
state.allocated_balance_usd
|
||||||
|
* (state.effective_risk_percent / 100),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.adaptive_size_reason = reason
|
||||||
|
state.adaptive_size_factors = {
|
||||||
|
"execution_confidence_score": getattr(
|
||||||
|
state,
|
||||||
|
"execution_confidence_score",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"execution_confidence_level": getattr(
|
||||||
|
state,
|
||||||
|
"execution_confidence_level",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"market_state": getattr(state, "market_state", None),
|
||||||
|
"market_trend_strength": getattr(
|
||||||
|
state,
|
||||||
|
"market_trend_strength",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"market_trend_quality": getattr(
|
||||||
|
state,
|
||||||
|
"market_trend_quality",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"market_phase": getattr(state, "market_phase", None),
|
||||||
|
"momentum_state": getattr(state, "momentum_state", None),
|
||||||
|
"momentum_direction": getattr(
|
||||||
|
state,
|
||||||
|
"momentum_direction",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"momentum_strength": getattr(
|
||||||
|
state,
|
||||||
|
"momentum_strength",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"execution_quality": getattr(state, "execution_quality", None),
|
||||||
|
"execution_quality_reason": getattr(
|
||||||
|
state,
|
||||||
|
"execution_quality_reason",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"spread_percent": getattr(state, "spread_percent", None),
|
||||||
|
"base_size": self._round_size(base_size),
|
||||||
|
"final_size": self._round_size(final_size),
|
||||||
|
"multiplier": multiplier,
|
||||||
|
}
|
||||||
|
|
||||||
|
if multiplier <= 0:
|
||||||
|
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO"
|
||||||
|
elif multiplier < 1:
|
||||||
|
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED"
|
||||||
|
elif multiplier > 1:
|
||||||
|
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED"
|
||||||
|
else:
|
||||||
|
state.execution_size_adjustment_reason = None
|
||||||
|
|
||||||
|
# пересчитать effective risk после ограничения размера по margin limit
|
||||||
|
def _sync_effective_risk_after_margin_limit(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
*,
|
||||||
|
base_size: float,
|
||||||
|
final_size: float,
|
||||||
|
) -> None:
|
||||||
|
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
|
||||||
|
|
||||||
|
if adaptive_final <= 0:
|
||||||
|
state.effective_risk_percent = 0.0
|
||||||
|
state.effective_target_risk_usd = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
margin_ratio = max(
|
||||||
|
0.0,
|
||||||
|
min(1.0, final_size / adaptive_final),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_effective_risk = safe_float(
|
||||||
|
state.effective_risk_percent
|
||||||
|
) or 0.0
|
||||||
|
|
||||||
|
state.effective_risk_percent = round(
|
||||||
|
current_effective_risk * margin_ratio,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.effective_target_risk_usd = round(
|
||||||
|
state.allocated_balance_usd
|
||||||
|
* (state.effective_risk_percent / 100),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# вернуть текстовую причину изменения adaptive size
|
||||||
|
def _adaptive_size_reason(self, multiplier: float) -> str:
|
||||||
|
if multiplier <= 0:
|
||||||
|
return "adaptive size заблокировал вход"
|
||||||
|
|
||||||
|
if multiplier < 0.75:
|
||||||
|
return "размер позиции сильно уменьшен по risk/runtime факторам"
|
||||||
|
|
||||||
|
if multiplier < 1:
|
||||||
|
return "размер позиции умеренно уменьшен по risk/runtime факторам"
|
||||||
|
|
||||||
|
if multiplier > 1:
|
||||||
|
return "размер позиции увеличен при сильном execution context"
|
||||||
|
|
||||||
|
return "размер позиции без adaptive корректировки"
|
||||||
|
|
||||||
|
# ограничить размер позиции по максимальному резервированию баланса
|
||||||
|
def _adjust_size_by_margin_limit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
entry_price: float,
|
||||||
|
size: float,
|
||||||
|
) -> float:
|
||||||
|
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 = "Invalid leverage or entry price."
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
balance_usd = state.allocated_balance_usd
|
||||||
|
max_reserved_usd = 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"
|
||||||
|
|
||||||
|
limited_size = self._round_size(max_size)
|
||||||
|
|
||||||
|
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
|
||||||
|
|
||||||
|
if adaptive_final > 0:
|
||||||
|
effective_multiplier = limited_size / adaptive_final
|
||||||
|
|
||||||
|
if effective_multiplier < 0.5:
|
||||||
|
state.adaptive_size_reason = (
|
||||||
|
"размер позиции сильно ограничен margin limit"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
state.adaptive_size_reason = (
|
||||||
|
"размер позиции ограничен margin limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
return limited_size
|
||||||
|
|
||||||
|
# округлить размер позиции вниз до допустимой точности
|
||||||
|
def _round_size(self, size: NumericLike | None) -> float:
|
||||||
|
value = safe_float(size)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
factor = 10 ** self._size_precision
|
||||||
|
return math.floor(value * factor) / factor
|
||||||
172
app/src/trading/execution/supervisor.py
Normal file
172
app/src/trading/execution/supervisor.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# app/src/trading/execution/supervisor.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
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecutionSupervisorProtocol(Protocol):
|
||||||
|
_emergency_halt_drawdown_usd: float
|
||||||
|
_emergency_halt_loss_streak: int
|
||||||
|
_execution_cooldown_after_loss_seconds: int
|
||||||
|
_max_execution_snapshot_age_seconds: int
|
||||||
|
_degraded_market_block_states: set[str]
|
||||||
|
_conflict_execution_block: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||||
|
# проверить все supervisor-блокировки перед исполнением
|
||||||
|
def _process_execution_supervisor(
|
||||||
|
self,
|
||||||
|
state: AutoTradeState,
|
||||||
|
) -> ExecutionDecision | None:
|
||||||
|
for reason, action in (
|
||||||
|
(self._execution_halt_reason(state), "EXECUTION_HALTED"),
|
||||||
|
(self._execution_cooldown_reason(state), "EXECUTION_COOLDOWN"),
|
||||||
|
(self._degraded_market_reason(state), "DEGRADED_MARKET"),
|
||||||
|
(self._stale_execution_reason(state), "STALE_EXECUTION"),
|
||||||
|
(self._conflict_signal_reason(state), "SIGNAL_CONFLICT"),
|
||||||
|
):
|
||||||
|
if reason is not None:
|
||||||
|
return self._block_execution(
|
||||||
|
state=state,
|
||||||
|
reason=reason,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить, нужно ли аварийно остановить execution
|
||||||
|
def _execution_halt_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
pnl = safe_float(state.cycle_realized_pnl_usd) or 0.0
|
||||||
|
|
||||||
|
if pnl <= -abs(self._emergency_halt_drawdown_usd):
|
||||||
|
return "execution emergency halt: cycle drawdown limit exceeded"
|
||||||
|
|
||||||
|
closed = safe_float(state.cycle_closed_trades) or 0
|
||||||
|
wins = safe_float(state.cycle_winning_trades) or 0
|
||||||
|
losses = max(0, int(closed - wins))
|
||||||
|
|
||||||
|
if losses >= self._emergency_halt_loss_streak:
|
||||||
|
return "execution emergency halt: loss streak exceeded"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить, активен ли cooldown после убыточной сделки
|
||||||
|
def _execution_cooldown_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
ts = safe_float(getattr(state, "last_loss_monotonic_at", None))
|
||||||
|
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = time.monotonic() - ts
|
||||||
|
|
||||||
|
if delta < self._execution_cooldown_after_loss_seconds:
|
||||||
|
remaining = int(self._execution_cooldown_after_loss_seconds - delta)
|
||||||
|
return f"execution cooldown after loss ({remaining}s remaining)"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить, запрещает ли состояние рынка исполнение
|
||||||
|
def _degraded_market_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
market_state = getattr(state, "market_state", None)
|
||||||
|
|
||||||
|
if market_state in self._degraded_market_block_states:
|
||||||
|
return f"market state blocked execution: {market_state}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить, устарели ли данные для исполнения
|
||||||
|
def _stale_execution_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
age = safe_float(getattr(state, "execution_price_age_seconds", None))
|
||||||
|
|
||||||
|
if age is None:
|
||||||
|
age = safe_float(getattr(state, "snapshot_age_seconds", None))
|
||||||
|
|
||||||
|
if age is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if age > self._max_execution_snapshot_age_seconds:
|
||||||
|
return f"execution snapshot stale: {age:.2f}s"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# определить конфликт сигнала с momentum или трендом
|
||||||
|
def _conflict_signal_reason(self, state: AutoTradeState) -> str | None:
|
||||||
|
if not self._conflict_execution_block:
|
||||||
|
return None
|
||||||
|
|
||||||
|
signal = (state.last_signal or "").upper()
|
||||||
|
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||||
|
trend_direction = str(getattr(state, "market_trend", "") or "").upper()
|
||||||
|
|
||||||
|
if signal == "BUY":
|
||||||
|
if momentum_direction == "DOWN":
|
||||||
|
return "BUY conflicts with momentum"
|
||||||
|
|
||||||
|
if trend_direction == "DOWN":
|
||||||
|
return "BUY conflicts with trend"
|
||||||
|
|
||||||
|
if signal == "SELL":
|
||||||
|
if momentum_direction == "UP":
|
||||||
|
return "SELL conflicts with momentum"
|
||||||
|
|
||||||
|
if trend_direction == "UP":
|
||||||
|
return "SELL conflicts with trend"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# заблокировать execution и записать событие в журнал
|
||||||
|
def _block_execution(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
reason: str,
|
||||||
|
action: str,
|
||||||
|
) -> ExecutionDecision:
|
||||||
|
state.execution_block_reason = reason
|
||||||
|
state.last_execution_action = action
|
||||||
|
state.last_execution_reason = reason
|
||||||
|
|
||||||
|
key_reason = reason
|
||||||
|
|
||||||
|
if action == "EXECUTION_COOLDOWN":
|
||||||
|
key_reason = "execution cooldown after loss"
|
||||||
|
|
||||||
|
key = f"{action}:{state.symbol}:{key_reason}"
|
||||||
|
last_key = getattr(type(self), "_last_supervisor_block_key", None)
|
||||||
|
|
||||||
|
if key != last_key:
|
||||||
|
setattr(type(self), "_last_supervisor_block_key", key)
|
||||||
|
|
||||||
|
payload: JsonDict = {
|
||||||
|
"execution_type": "SUPERVISOR_BLOCK",
|
||||||
|
"action": action,
|
||||||
|
"symbol": state.symbol,
|
||||||
|
"reason": reason,
|
||||||
|
"market_state": getattr(state, "market_state", None),
|
||||||
|
"signal": state.last_signal,
|
||||||
|
"confidence": state.last_signal_confidence,
|
||||||
|
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||||
|
"cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalService().log_ui_warning(
|
||||||
|
event_type="execution_supervisor_block",
|
||||||
|
message=f"Execution supervisor blocked action: {reason}",
|
||||||
|
screen="auto",
|
||||||
|
action="execution_supervisor",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventBus.emit("execution_supervisor_block", payload)
|
||||||
|
|
||||||
|
return ExecutionDecision("NONE", False, reason)
|
||||||
@@ -135,6 +135,7 @@ def _metadata_rows(
|
|||||||
export_limit: int,
|
export_limit: int,
|
||||||
account_mode: str,
|
account_mode: str,
|
||||||
journal_level: str,
|
journal_level: str,
|
||||||
|
export_filter_label: str = "Всё",
|
||||||
) -> list[list[str]]:
|
) -> list[list[str]]:
|
||||||
exported_count = len(rows)
|
exported_count = len(rows)
|
||||||
is_limited = total_count > exported_count
|
is_limited = total_count > exported_count
|
||||||
@@ -143,6 +144,7 @@ def _metadata_rows(
|
|||||||
["Экспорт журнала"],
|
["Экспорт журнала"],
|
||||||
["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")],
|
["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")],
|
||||||
["Аккаунт", account_mode.upper()],
|
["Аккаунт", account_mode.upper()],
|
||||||
|
["Фильтр", export_filter_label],
|
||||||
["Уровень журнала", journal_level],
|
["Уровень журнала", journal_level],
|
||||||
["Всего записей в журнале", str(total_count)],
|
["Всего записей в журнале", str(total_count)],
|
||||||
["Записей в файле", str(exported_count)],
|
["Записей в файле", str(exported_count)],
|
||||||
@@ -161,6 +163,7 @@ def build_csv(
|
|||||||
export_limit: int,
|
export_limit: int,
|
||||||
account_mode: str,
|
account_mode: str,
|
||||||
journal_level: str,
|
journal_level: str,
|
||||||
|
export_filter_label: str = "Всё",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
writer = csv.writer(
|
writer = csv.writer(
|
||||||
@@ -176,6 +179,7 @@ def build_csv(
|
|||||||
export_limit=export_limit,
|
export_limit=export_limit,
|
||||||
account_mode=account_mode,
|
account_mode=account_mode,
|
||||||
journal_level=journal_level,
|
journal_level=journal_level,
|
||||||
|
export_filter_label=export_filter_label,
|
||||||
):
|
):
|
||||||
writer.writerow(metadata_row)
|
writer.writerow(metadata_row)
|
||||||
|
|
||||||
@@ -194,6 +198,7 @@ def build_xlsx(
|
|||||||
export_limit: int,
|
export_limit: int,
|
||||||
account_mode: str,
|
account_mode: str,
|
||||||
journal_level: str,
|
journal_level: str,
|
||||||
|
export_filter_label: str = "Всё",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
sheet_rows: list[list[str]] = []
|
sheet_rows: list[list[str]] = []
|
||||||
|
|
||||||
@@ -204,6 +209,7 @@ def build_xlsx(
|
|||||||
export_limit=export_limit,
|
export_limit=export_limit,
|
||||||
account_mode=account_mode,
|
account_mode=account_mode,
|
||||||
journal_level=journal_level,
|
journal_level=journal_level,
|
||||||
|
export_filter_label=export_filter_label,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
64
app/src/trading/journal/filters.py
Normal file
64
app/src/trading/journal/filters.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# app/src/trading/journal/filters.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class JournalExportFilter:
|
||||||
|
# key используется в callback_data и имени файла
|
||||||
|
key: str
|
||||||
|
|
||||||
|
# label показываем в UI и metadata экспорта
|
||||||
|
label: str
|
||||||
|
|
||||||
|
# description можно использовать позже в UI/подсказках
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
JOURNAL_EXPORT_FILTERS: dict[str, JournalExportFilter] = {
|
||||||
|
"all": JournalExportFilter(
|
||||||
|
key="all",
|
||||||
|
label="Всё",
|
||||||
|
description="Все записи журнала.",
|
||||||
|
),
|
||||||
|
"auto": JournalExportFilter(
|
||||||
|
key="auto",
|
||||||
|
label="Автоторговля",
|
||||||
|
description="События автоторговли, сигналов, execution и runtime.",
|
||||||
|
),
|
||||||
|
"trades": JournalExportFilter(
|
||||||
|
key="trades",
|
||||||
|
label="Сделки",
|
||||||
|
description="Открытия, закрытия, flip и trade-события.",
|
||||||
|
),
|
||||||
|
"errors": JournalExportFilter(
|
||||||
|
key="errors",
|
||||||
|
label="Ошибки",
|
||||||
|
description="ERROR, CRITICAL и важные WARNING.",
|
||||||
|
),
|
||||||
|
"not_auto": JournalExportFilter(
|
||||||
|
key="not_auto",
|
||||||
|
label="Без авто",
|
||||||
|
description="Все записи, кроме автоторговли.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_journal_export_filter(value: str | None) -> str:
|
||||||
|
# Защита от неизвестных callback_data.
|
||||||
|
key = str(value or "all").strip().lower()
|
||||||
|
|
||||||
|
if key in JOURNAL_EXPORT_FILTERS:
|
||||||
|
return key
|
||||||
|
|
||||||
|
return "all"
|
||||||
|
|
||||||
|
|
||||||
|
def get_journal_export_filter(value: str | None) -> JournalExportFilter:
|
||||||
|
return JOURNAL_EXPORT_FILTERS[normalize_journal_export_filter(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def journal_export_filter_label(value: str | None) -> str:
|
||||||
|
return get_journal_export_filter(value).label
|
||||||
@@ -10,6 +10,11 @@ from src.core.config import load_settings
|
|||||||
from src.storage.repositories.journal import JournalRepository
|
from src.storage.repositories.journal import JournalRepository
|
||||||
from src.storage.session import check_database_health
|
from src.storage.session import check_database_health
|
||||||
from src.trading.journal.exporter import build_csv, build_xlsx
|
from src.trading.journal.exporter import build_csv, build_xlsx
|
||||||
|
from src.trading.journal.filters import (
|
||||||
|
journal_export_filter_label,
|
||||||
|
normalize_journal_export_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
EXPORT_LIMIT = 10000
|
EXPORT_LIMIT = 10000
|
||||||
|
|
||||||
@@ -201,8 +206,17 @@ class JournalService:
|
|||||||
def get_total_count(self) -> int:
|
def get_total_count(self) -> int:
|
||||||
return self.repository.count_events()
|
return self.repository.count_events()
|
||||||
|
|
||||||
def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]:
|
def get_export_rows(
|
||||||
return self.repository.list_export_rows(limit=limit)
|
self,
|
||||||
|
limit: int = EXPORT_LIMIT,
|
||||||
|
export_filter: str = "all",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
filter_key = normalize_journal_export_filter(export_filter)
|
||||||
|
|
||||||
|
return self.repository.list_export_rows(
|
||||||
|
limit=limit,
|
||||||
|
export_filter=filter_key,
|
||||||
|
)
|
||||||
|
|
||||||
def _journal_level(self) -> str:
|
def _journal_level(self) -> str:
|
||||||
return "INFO+"
|
return "INFO+"
|
||||||
@@ -216,20 +230,34 @@ class JournalService:
|
|||||||
|
|
||||||
return now.strftime("%Y-%m-%d_%H-%M-%S")
|
return now.strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
|
||||||
def build_export_filename(self, extension: str) -> str:
|
def build_export_filename(
|
||||||
|
self,
|
||||||
|
extension: str,
|
||||||
|
export_filter: str = "all",
|
||||||
|
) -> str:
|
||||||
safe_extension = extension.lower().strip().lstrip(".")
|
safe_extension = extension.lower().strip().lstrip(".")
|
||||||
safe_level = self._journal_level().lower().replace("+", "_plus")
|
safe_level = self._journal_level().lower().replace("+", "_plus")
|
||||||
|
safe_filter = normalize_journal_export_filter(export_filter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"journal_"
|
f"journal_"
|
||||||
f"{self._account_mode()}_"
|
f"{self._account_mode()}_"
|
||||||
|
f"{safe_filter}_"
|
||||||
f"{safe_level}_"
|
f"{safe_level}_"
|
||||||
f"{self._export_timestamp()}."
|
f"{self._export_timestamp()}."
|
||||||
f"{safe_extension}"
|
f"{safe_extension}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes:
|
def export_csv(
|
||||||
rows = self.get_export_rows(limit=limit)
|
self,
|
||||||
|
limit: int = EXPORT_LIMIT,
|
||||||
|
export_filter: str = "all",
|
||||||
|
) -> bytes:
|
||||||
|
filter_key = normalize_journal_export_filter(export_filter)
|
||||||
|
rows = self.get_export_rows(
|
||||||
|
limit=limit,
|
||||||
|
export_filter=filter_key,
|
||||||
|
)
|
||||||
|
|
||||||
return build_csv(
|
return build_csv(
|
||||||
rows,
|
rows,
|
||||||
@@ -237,10 +265,19 @@ class JournalService:
|
|||||||
export_limit=limit,
|
export_limit=limit,
|
||||||
account_mode=self._account_mode(),
|
account_mode=self._account_mode(),
|
||||||
journal_level=self._journal_level(),
|
journal_level=self._journal_level(),
|
||||||
|
export_filter_label=journal_export_filter_label(filter_key),
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes:
|
def export_xlsx(
|
||||||
rows = self.get_export_rows(limit=limit)
|
self,
|
||||||
|
limit: int = EXPORT_LIMIT,
|
||||||
|
export_filter: str = "all",
|
||||||
|
) -> bytes:
|
||||||
|
filter_key = normalize_journal_export_filter(export_filter)
|
||||||
|
rows = self.get_export_rows(
|
||||||
|
limit=limit,
|
||||||
|
export_filter=filter_key,
|
||||||
|
)
|
||||||
|
|
||||||
return build_xlsx(
|
return build_xlsx(
|
||||||
rows,
|
rows,
|
||||||
@@ -248,6 +285,7 @@ class JournalService:
|
|||||||
export_limit=limit,
|
export_limit=limit,
|
||||||
account_mode=self._account_mode(),
|
account_mode=self._account_mode(),
|
||||||
journal_level=self._journal_level(),
|
journal_level=self._journal_level(),
|
||||||
|
export_filter_label=journal_export_filter_label(filter_key),
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear_all(self) -> int:
|
def clear_all(self) -> int:
|
||||||
@@ -289,4 +327,100 @@ class JournalService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
|
def _build_trade_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: object,
|
||||||
|
action: str,
|
||||||
|
trade_id: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Единый payload сделки для будущего анализа стратегии.
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"trade_id": trade_id,
|
||||||
|
"action": action,
|
||||||
|
"symbol": getattr(state, "symbol", None),
|
||||||
|
"strategy": getattr(state, "strategy", None),
|
||||||
|
"cycle_number": getattr(state, "cycle_number", None),
|
||||||
|
"status": getattr(state, "status", None),
|
||||||
|
|
||||||
|
"position_side": getattr(state, "position_side", None),
|
||||||
|
"entry_price": getattr(state, "entry_price", None),
|
||||||
|
"position_size": getattr(state, "position_size", None),
|
||||||
|
"leverage": getattr(state, "leverage", None),
|
||||||
|
|
||||||
|
"unrealized_pnl_usd": getattr(state, "unrealized_pnl_usd", None),
|
||||||
|
"realized_pnl_usd": getattr(state, "realized_pnl_usd", None),
|
||||||
|
"cycle_realized_pnl_usd": getattr(state, "cycle_realized_pnl_usd", None),
|
||||||
|
"cycle_closed_trades": getattr(state, "cycle_closed_trades", None),
|
||||||
|
"cycle_winning_trades": getattr(state, "cycle_winning_trades", None),
|
||||||
|
|
||||||
|
"last_signal": getattr(state, "last_signal", None),
|
||||||
|
"last_signal_confidence": getattr(state, "last_signal_confidence", None),
|
||||||
|
"last_signal_reason": getattr(state, "last_signal_reason", None),
|
||||||
|
"decision_status": getattr(state, "decision_status", None),
|
||||||
|
"decision_reason": getattr(state, "decision_reason", None),
|
||||||
|
|
||||||
|
"market_state": getattr(state, "market_state", None),
|
||||||
|
"market_trend": getattr(state, "market_trend", None),
|
||||||
|
"market_trend_strength": getattr(state, "market_trend_strength", None),
|
||||||
|
"market_trend_quality": getattr(state, "market_trend_quality", None),
|
||||||
|
"market_phase": getattr(state, "market_phase", None),
|
||||||
|
"market_phase_direction": getattr(state, "market_phase_direction", None),
|
||||||
|
|
||||||
|
"momentum_state": getattr(state, "momentum_state", None),
|
||||||
|
"momentum_direction": getattr(state, "momentum_direction", None),
|
||||||
|
"momentum_strength": getattr(state, "momentum_strength", None),
|
||||||
|
"momentum_change_percent": getattr(state, "momentum_change_percent", None),
|
||||||
|
|
||||||
|
"execution_quality": getattr(state, "execution_quality", None),
|
||||||
|
"execution_quality_reason": getattr(state, "execution_quality_reason", None),
|
||||||
|
"execution_confidence_score": getattr(state, "execution_confidence_score", None),
|
||||||
|
"execution_confidence_level": getattr(state, "execution_confidence_level", None),
|
||||||
|
|
||||||
|
"spread_percent": getattr(state, "spread_percent", None),
|
||||||
|
"snapshot_age_seconds": getattr(state, "snapshot_age_seconds", None),
|
||||||
|
|
||||||
|
"adaptive_size_base": getattr(state, "adaptive_size_base", None),
|
||||||
|
"adaptive_size_final": getattr(state, "adaptive_size_final", None),
|
||||||
|
"adaptive_size_multiplier": getattr(state, "adaptive_size_multiplier", None),
|
||||||
|
"adaptive_size_reason": getattr(state, "adaptive_size_reason", None),
|
||||||
|
|
||||||
|
"position_mfe_percent": getattr(state, "position_mfe_percent", None),
|
||||||
|
"position_mae_percent": getattr(state, "position_mae_percent", None),
|
||||||
|
"position_peak_pnl_usd": getattr(state, "position_peak_pnl_usd", None),
|
||||||
|
"position_hold_seconds": getattr(state, "position_hold_seconds", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def log_trade_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
message: str,
|
||||||
|
state: object,
|
||||||
|
action: str,
|
||||||
|
trade_id: str | None = None,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Trade-события пишем в общий журнал, чтобы экспорт CSV/XLSX уже работал без новой таблицы.
|
||||||
|
self.log_info(
|
||||||
|
event_type=event_type,
|
||||||
|
message=self._build_message(message),
|
||||||
|
payload=self._build_payload(
|
||||||
|
screen="auto",
|
||||||
|
action=action,
|
||||||
|
payload=self._build_trade_payload(
|
||||||
|
state=state,
|
||||||
|
action=action,
|
||||||
|
trade_id=trade_id,
|
||||||
|
extra=payload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Package marker."""
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# app/src/trading/orders/models.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class OrderDraft:
|
|
||||||
symbol: str
|
|
||||||
side: str
|
|
||||||
order_type: str
|
|
||||||
quantity: str
|
|
||||||
price: str | None = None
|
|
||||||
status: str = "draft"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class OrderEntryContext:
|
|
||||||
symbol: str
|
|
||||||
side: str
|
|
||||||
order_type: str
|
|
||||||
base_currency: str
|
|
||||||
balance_currency: str
|
|
||||||
quote_currency: str
|
|
||||||
available_balance: float
|
|
||||||
reference_price: float
|
|
||||||
last_price: float
|
|
||||||
bid_price: float
|
|
||||||
ask_price: float
|
|
||||||
quantity_presets: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class OrderValidationResult:
|
|
||||||
is_valid: bool
|
|
||||||
errors: list[str] = field(default_factory=list)
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
# /app/src/trading/orders/service.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_UP
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
from src.integrations.exchange.models import ExchangeSymbol
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
from src.storage.repositories.order_drafts import OrderDraftRepository
|
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
from src.trading.orders.models import OrderDraft, OrderEntryContext, OrderValidationResult
|
|
||||||
|
|
||||||
|
|
||||||
class OrderDraftsService:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.settings = load_settings()
|
|
||||||
self.repository = OrderDraftRepository()
|
|
||||||
self.journal = JournalService()
|
|
||||||
self.exchange = ExchangeService()
|
|
||||||
|
|
||||||
def build_draft(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
quantity: str,
|
|
||||||
price: str | None = None,
|
|
||||||
) -> OrderDraft:
|
|
||||||
return OrderDraft(
|
|
||||||
symbol=self.settings.default_symbol,
|
|
||||||
side=side.upper(),
|
|
||||||
order_type=order_type.upper(),
|
|
||||||
quantity=quantity,
|
|
||||||
price=price,
|
|
||||||
status="draft",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_entry_rules(self) -> dict[str, str | None]:
|
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
|
|
||||||
if symbol_info is None:
|
|
||||||
return {
|
|
||||||
"min_qty": None,
|
|
||||||
"step_size": None,
|
|
||||||
"min_notional": None,
|
|
||||||
"tick_size": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
min_qty = getattr(symbol_info, "min_qty", None)
|
|
||||||
step_size = getattr(symbol_info, "step_size", None)
|
|
||||||
min_notional = getattr(symbol_info, "min_notional", None)
|
|
||||||
tick_size = getattr(symbol_info, "tick_size", None)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"min_qty": str(min_qty) if min_qty not in (None, "") else None,
|
|
||||||
"step_size": str(step_size) if step_size not in (None, "") else None,
|
|
||||||
"min_notional": str(min_notional) if min_notional not in (None, "") else None,
|
|
||||||
"tick_size": str(tick_size) if tick_size not in (None, "") else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_draft(self, draft: OrderDraft) -> None:
|
|
||||||
validation = self.validate_draft(draft)
|
|
||||||
if not validation.is_valid:
|
|
||||||
try:
|
|
||||||
self.journal.log_warning(
|
|
||||||
"order_draft_validation_failed",
|
|
||||||
"Черновик ордера не прошёл валидацию.",
|
|
||||||
{
|
|
||||||
"symbol": draft.symbol,
|
|
||||||
"side": draft.side,
|
|
||||||
"order_type": draft.order_type,
|
|
||||||
"quantity": draft.quantity,
|
|
||||||
"price": draft.price,
|
|
||||||
"errors": validation.errors,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise ValueError("; ".join(validation.errors))
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"source": "trade_screen",
|
|
||||||
"mode": "draft_only",
|
|
||||||
"price": draft.price,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.repository.add_draft(
|
|
||||||
symbol=draft.symbol,
|
|
||||||
side=draft.side,
|
|
||||||
order_type=draft.order_type,
|
|
||||||
quantity=draft.quantity,
|
|
||||||
status=draft.status,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.journal.log_info(
|
|
||||||
"order_draft_saved",
|
|
||||||
"Черновик ордера сохранён.",
|
|
||||||
{
|
|
||||||
"symbol": draft.symbol,
|
|
||||||
"side": draft.side,
|
|
||||||
"order_type": draft.order_type,
|
|
||||||
"quantity": draft.quantity,
|
|
||||||
"price": draft.price,
|
|
||||||
"status": draft.status,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def validate_draft(self, draft: OrderDraft) -> OrderValidationResult:
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
if draft.side not in {"BUY", "SELL"}:
|
|
||||||
errors.append("Сторона ордера должна быть BUY или SELL.")
|
|
||||||
|
|
||||||
if draft.order_type not in {"MARKET", "LIMIT"}:
|
|
||||||
errors.append("Тип ордера должен быть MARKET или LIMIT.")
|
|
||||||
|
|
||||||
symbol_validation = self.exchange.validate_symbol(draft.symbol)
|
|
||||||
if not symbol_validation.is_valid:
|
|
||||||
errors.append(symbol_validation.message)
|
|
||||||
|
|
||||||
quantity = self._to_decimal(draft.quantity)
|
|
||||||
if quantity is None or quantity <= 0:
|
|
||||||
errors.append("Количество должно быть числом больше нуля.")
|
|
||||||
|
|
||||||
symbol_info = symbol_validation.symbol_info
|
|
||||||
|
|
||||||
if quantity is not None and quantity > 0 and symbol_info is not None:
|
|
||||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
|
||||||
if min_qty is not None and min_qty > 0 and quantity < min_qty:
|
|
||||||
errors.append(
|
|
||||||
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
|
||||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
|
||||||
errors.append(
|
|
||||||
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
if draft.order_type == "LIMIT":
|
|
||||||
if not draft.price:
|
|
||||||
errors.append("Для LIMIT ордера требуется цена.")
|
|
||||||
else:
|
|
||||||
price = self._to_decimal(draft.price)
|
|
||||||
if price is None or price <= 0:
|
|
||||||
errors.append("Цена должна быть числом больше нуля.")
|
|
||||||
else:
|
|
||||||
tick_size = self._to_decimal(getattr(symbol_info, "tick_size", None))
|
|
||||||
if tick_size is not None and tick_size > 0:
|
|
||||||
if not self._fits_step(price, tick_size):
|
|
||||||
errors.append(
|
|
||||||
f"Цена должна соответствовать шагу tickSize = {getattr(symbol_info, 'tick_size', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
if quantity is not None and quantity > 0 and symbol_info is not None:
|
|
||||||
reference_price = self._resolve_reference_price_for_validation(draft, symbol_info)
|
|
||||||
if reference_price is not None:
|
|
||||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
|
||||||
if min_notional is not None and min_notional > 0:
|
|
||||||
notional = quantity * reference_price
|
|
||||||
if notional < min_notional:
|
|
||||||
errors.append(
|
|
||||||
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
return OrderValidationResult(
|
|
||||||
is_valid=len(errors) == 0,
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str | int]]:
|
|
||||||
return self.repository.list_recent_drafts(limit=limit)
|
|
||||||
|
|
||||||
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
|
|
||||||
return self.repository.get_draft_by_id(draft_id)
|
|
||||||
|
|
||||||
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
|
||||||
if not validation.is_valid or validation.symbol_info is None:
|
|
||||||
raise ValueError(validation.message)
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
balances = self.exchange.get_balance_summary()
|
|
||||||
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
|
||||||
|
|
||||||
base_asset = (symbol_info.base_asset or "").strip()
|
|
||||||
quote_asset = (symbol_info.quote_asset or "").strip()
|
|
||||||
|
|
||||||
if not base_asset or not quote_asset:
|
|
||||||
message = (
|
|
||||||
"Биржа не вернула base/quote валюту для инструмента. "
|
|
||||||
"Невозможно корректно рассчитать контекст ордера."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
self.journal.log_error(
|
|
||||||
"order_entry_context_assets_missing",
|
|
||||||
message,
|
|
||||||
{
|
|
||||||
"symbol": self.settings.default_symbol,
|
|
||||||
"base_asset": base_asset or None,
|
|
||||||
"quote_asset": quote_asset or None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise ValueError(message)
|
|
||||||
|
|
||||||
base_currency = base_asset.upper()
|
|
||||||
quote_currency = quote_asset.upper()
|
|
||||||
|
|
||||||
available_by_currency = {
|
|
||||||
item.currency.upper(): float(item.available)
|
|
||||||
for item in balances
|
|
||||||
}
|
|
||||||
|
|
||||||
side_upper = side.upper()
|
|
||||||
order_type_upper = order_type.upper()
|
|
||||||
|
|
||||||
if side_upper == "BUY":
|
|
||||||
balance_currency = quote_currency
|
|
||||||
available_balance = available_by_currency.get(balance_currency, 0.0)
|
|
||||||
reference_price = float(market["ask_price"])
|
|
||||||
max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0
|
|
||||||
else:
|
|
||||||
balance_currency = base_currency
|
|
||||||
available_balance = available_by_currency.get(balance_currency, 0.0)
|
|
||||||
reference_price = float(market["bid_price"])
|
|
||||||
max_qty = available_balance
|
|
||||||
|
|
||||||
quantity_presets = self._build_quantity_presets(
|
|
||||||
max_qty=max_qty,
|
|
||||||
reference_price=reference_price,
|
|
||||||
symbol_info=symbol_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
return OrderEntryContext(
|
|
||||||
symbol=self.settings.default_symbol,
|
|
||||||
side=side_upper,
|
|
||||||
order_type=order_type_upper,
|
|
||||||
base_currency=base_currency,
|
|
||||||
balance_currency=balance_currency,
|
|
||||||
quote_currency=quote_currency,
|
|
||||||
available_balance=available_balance,
|
|
||||||
reference_price=reference_price,
|
|
||||||
last_price=float(market["last_price"]),
|
|
||||||
bid_price=float(market["bid_price"]),
|
|
||||||
ask_price=float(market["ask_price"]),
|
|
||||||
quantity_presets=quantity_presets,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_entry_quantity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
quantity: str,
|
|
||||||
price: str | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
|
||||||
if not validation.is_valid or validation.symbol_info is None:
|
|
||||||
errors.append(validation.message)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
quantity_dec = self._to_decimal(quantity)
|
|
||||||
|
|
||||||
if quantity_dec is None or quantity_dec <= 0:
|
|
||||||
errors.append("Количество должно быть числом больше нуля.")
|
|
||||||
return errors
|
|
||||||
|
|
||||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
|
||||||
if min_qty is not None and min_qty > 0 and quantity_dec < min_qty:
|
|
||||||
errors.append(
|
|
||||||
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
|
||||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity_dec, step_size):
|
|
||||||
errors.append(
|
|
||||||
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
reference_price = self._resolve_reference_price_for_entry(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
price=price,
|
|
||||||
)
|
|
||||||
|
|
||||||
if reference_price is not None:
|
|
||||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
|
||||||
if min_notional is not None and min_notional > 0:
|
|
||||||
notional = quantity_dec * reference_price
|
|
||||||
if notional < min_notional:
|
|
||||||
errors.append(
|
|
||||||
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
def normalize_preset_quantity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
raw_quantity: str,
|
|
||||||
price: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
return self._normalize_entry_quantity_with_rules(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
raw_quantity=raw_quantity,
|
|
||||||
price=price,
|
|
||||||
raise_to_minimum=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def normalize_entry_quantity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
raw_quantity: str,
|
|
||||||
price: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
return self._normalize_entry_quantity_with_rules(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
raw_quantity=raw_quantity,
|
|
||||||
price=price,
|
|
||||||
raise_to_minimum=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _normalize_entry_quantity_with_rules(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
raw_quantity: str,
|
|
||||||
price: str | None = None,
|
|
||||||
raise_to_minimum: bool,
|
|
||||||
) -> str | None:
|
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
|
||||||
if not validation.is_valid or validation.symbol_info is None:
|
|
||||||
return self.normalize_quantity(raw_quantity)
|
|
||||||
|
|
||||||
original_quantity = self._to_decimal((raw_quantity or "").strip().replace(",", "."))
|
|
||||||
if original_quantity is None or original_quantity <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
|
||||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
|
||||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
|
||||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
|
||||||
|
|
||||||
minimum_allowed = min_qty if min_qty is not None and min_qty > 0 else None
|
|
||||||
|
|
||||||
reference_price = self._resolve_reference_price_for_entry(
|
|
||||||
side=side,
|
|
||||||
order_type=order_type,
|
|
||||||
price=price,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
reference_price is not None
|
|
||||||
and reference_price > 0
|
|
||||||
and min_notional is not None
|
|
||||||
and min_notional > 0
|
|
||||||
):
|
|
||||||
min_by_notional = min_notional / reference_price
|
|
||||||
if step_size is not None and step_size > 0:
|
|
||||||
min_by_notional = self._ceil_to_step(min_by_notional, step_size)
|
|
||||||
|
|
||||||
if minimum_allowed is None or min_by_notional > minimum_allowed:
|
|
||||||
minimum_allowed = min_by_notional
|
|
||||||
|
|
||||||
quantity = original_quantity
|
|
||||||
|
|
||||||
if step_size is not None and step_size > 0:
|
|
||||||
quantity = self._floor_to_step(quantity, step_size)
|
|
||||||
|
|
||||||
if quantity <= 0:
|
|
||||||
if raise_to_minimum and minimum_allowed is not None and minimum_allowed > 0:
|
|
||||||
quantity = minimum_allowed
|
|
||||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
|
||||||
quantity = self._ceil_to_step(quantity, step_size)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if raise_to_minimum and minimum_allowed is not None and quantity < minimum_allowed:
|
|
||||||
quantity = minimum_allowed
|
|
||||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
|
||||||
quantity = self._ceil_to_step(quantity, step_size)
|
|
||||||
|
|
||||||
if quantity <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self._format_decimal(quantity)
|
|
||||||
|
|
||||||
def _build_quantity_presets(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
max_qty: float,
|
|
||||||
reference_price: float,
|
|
||||||
symbol_info: ExchangeSymbol,
|
|
||||||
) -> list[str]:
|
|
||||||
percents = [0.01, 0.05, 0.10, 0.25, 0.50, 1.00]
|
|
||||||
|
|
||||||
max_qty_dec = self._to_decimal(max_qty)
|
|
||||||
reference_price_dec = self._to_decimal(reference_price)
|
|
||||||
|
|
||||||
if max_qty_dec is None or max_qty_dec <= 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
|
||||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
|
||||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
|
||||||
|
|
||||||
result: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
|
|
||||||
for percent in percents:
|
|
||||||
qty = max_qty_dec * Decimal(str(percent))
|
|
||||||
qty = self._normalize_quantity_to_exchange_rules(
|
|
||||||
quantity=qty,
|
|
||||||
step_size=step_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
if qty is None or qty <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if min_qty is not None and min_qty > 0 and qty < min_qty:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if reference_price_dec is not None and reference_price_dec > 0:
|
|
||||||
if min_notional is not None and min_notional > 0:
|
|
||||||
if qty * reference_price_dec < min_notional:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if qty > max_qty_dec:
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = self._format_decimal(qty)
|
|
||||||
if text == "0" or text in seen:
|
|
||||||
continue
|
|
||||||
|
|
||||||
seen.add(text)
|
|
||||||
result.append(text)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
fallback = self._normalize_quantity_to_exchange_rules(
|
|
||||||
quantity=max_qty_dec,
|
|
||||||
step_size=step_size,
|
|
||||||
)
|
|
||||||
if fallback is None or fallback <= 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if min_qty is not None and min_qty > 0 and fallback < min_qty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if reference_price_dec is not None and reference_price_dec > 0:
|
|
||||||
if min_notional is not None and min_notional > 0:
|
|
||||||
if fallback * reference_price_dec < min_notional:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [self._format_decimal(fallback)]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_side(raw: str) -> str | None:
|
|
||||||
value = (raw or "").strip().upper()
|
|
||||||
if value in {"BUY", "SELL"}:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_order_type(raw: str) -> str | None:
|
|
||||||
value = (raw or "").strip().upper()
|
|
||||||
if value in {"MARKET", "LIMIT"}:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_quantity(raw: str) -> str | None:
|
|
||||||
value = (raw or "").strip().replace(",", ".")
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
quantity = float(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if quantity <= 0:
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_price(raw: str) -> str | None:
|
|
||||||
value = (raw or "").strip().replace(",", ".")
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
price = float(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if price <= 0:
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_number(value: float) -> str:
|
|
||||||
text = f"{value:.8f}"
|
|
||||||
text = text.rstrip("0").rstrip(".")
|
|
||||||
return text or "0"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_decimal(value: Decimal) -> str:
|
|
||||||
text = f"{value:.8f}"
|
|
||||||
text = text.rstrip("0").rstrip(".")
|
|
||||||
return text or "0"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _to_decimal(value: str | float | Decimal | None) -> Decimal | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return Decimal(str(value).strip())
|
|
||||||
except (InvalidOperation, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _fits_step(value: Decimal, step: Decimal) -> bool:
|
|
||||||
if step <= 0:
|
|
||||||
return True
|
|
||||||
ratio = value / step
|
|
||||||
return ratio == ratio.to_integral_value()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
|
||||||
if step <= 0:
|
|
||||||
return value
|
|
||||||
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
|
|
||||||
return ratio * step
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
|
|
||||||
if step <= 0:
|
|
||||||
return value
|
|
||||||
ratio = (value / step).to_integral_value(rounding=ROUND_UP)
|
|
||||||
return ratio * step
|
|
||||||
|
|
||||||
def _normalize_quantity_to_exchange_rules(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
quantity: Decimal,
|
|
||||||
step_size: Decimal | None,
|
|
||||||
) -> Decimal | None:
|
|
||||||
if quantity <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if step_size is not None and step_size > 0:
|
|
||||||
quantity = self._floor_to_step(quantity, step_size)
|
|
||||||
|
|
||||||
if quantity <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return quantity
|
|
||||||
|
|
||||||
def _resolve_reference_price_for_validation(
|
|
||||||
self,
|
|
||||||
draft: OrderDraft,
|
|
||||||
symbol_info: ExchangeSymbol | None,
|
|
||||||
) -> Decimal | None:
|
|
||||||
price = self._to_decimal(draft.price)
|
|
||||||
if price is not None and price > 0:
|
|
||||||
return price
|
|
||||||
|
|
||||||
if symbol_info is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
market = self.exchange.get_market_snapshot(draft.symbol)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if draft.side.upper() == "BUY":
|
|
||||||
return self._to_decimal(market.get("ask_price"))
|
|
||||||
return self._to_decimal(market.get("bid_price"))
|
|
||||||
|
|
||||||
def _resolve_reference_price_for_entry(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
side: str,
|
|
||||||
order_type: str,
|
|
||||||
price: str | None = None,
|
|
||||||
) -> Decimal | None:
|
|
||||||
if order_type.upper() == "LIMIT":
|
|
||||||
explicit_price = self._to_decimal(price)
|
|
||||||
if explicit_price is not None and explicit_price > 0:
|
|
||||||
return explicit_price
|
|
||||||
|
|
||||||
try:
|
|
||||||
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if side.upper() == "BUY":
|
|
||||||
return self._to_decimal(market.get("ask_price"))
|
|
||||||
return self._to_decimal(market.get("bid_price"))
|
|
||||||
|
|
||||||
def calculate_notional(self, quantity: str, price: str | None) -> float | None:
|
|
||||||
q = self._to_decimal(quantity)
|
|
||||||
p = self._to_decimal(price) if price else None
|
|
||||||
|
|
||||||
if q is None or p is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return float(q * p)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# /app/src/trading/orders/states.py
|
|
||||||
|
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
|
||||||
|
|
||||||
|
|
||||||
class NewOrderDraftStates(StatesGroup):
|
|
||||||
waiting_side = State()
|
|
||||||
waiting_type = State()
|
|
||||||
waiting_quantity = State()
|
|
||||||
waiting_price = State()
|
|
||||||
waiting_confirm = State()
|
|
||||||
@@ -13,6 +13,15 @@ class PositionState:
|
|||||||
# торговый инструмент
|
# торговый инструмент
|
||||||
symbol: str = ""
|
symbol: str = ""
|
||||||
|
|
||||||
|
# id сделки, к которой относится текущая позиция
|
||||||
|
trade_id: str | None = None
|
||||||
|
|
||||||
|
# порядковый номер сделки внутри runtime
|
||||||
|
trade_sequence: int | None = None
|
||||||
|
|
||||||
|
# номер auto-cycle, в котором открыта сделка
|
||||||
|
trade_cycle_number: int | None = None
|
||||||
|
|
||||||
# цена входа
|
# цена входа
|
||||||
entry_price: float | None = None
|
entry_price: float | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1432,6 +1432,25 @@
|
|||||||
- реализована preparation for partial exit engine
|
- реализована preparation for partial exit engine
|
||||||
- реализована preparation for advanced runtime orchestration
|
- реализована preparation for advanced runtime orchestration
|
||||||
|
|
||||||
|
### 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
|
||||||
|
- разобран ExecutionEngine на отдельные mixin-модули
|
||||||
|
- добавлены position open/close/flip actions
|
||||||
|
- добавлены runtime protection, risk close, supervisor, sizing, pricing, resets
|
||||||
|
- добавлен trade_id / trade_sequence / trade_cycle_number для связки open-close-flip
|
||||||
|
- добавлено логирование trade_opened / trade_closed / trade_flipped
|
||||||
|
- добавлено логирование запуска, наблюдения и остановки автоторговли
|
||||||
|
- добавлены фильтры экспорта журнала: all, auto, trades, errors, not_auto
|
||||||
|
- исправлен экспорт CSV/XLSX с фильтрами журнала
|
||||||
|
- снижено дублирование market stream / REST fallback событий
|
||||||
|
- снижено дублирование exchange/runtime ошибок
|
||||||
|
- добавлены human-readable event titles для новых событий
|
||||||
|
- улучшены уведомления AUTO_SIGNAL_READY
|
||||||
|
- добавлена цена входа по направлению сигнала: Ask для Long, Bid для Short
|
||||||
|
- добавлен контекст сигнала относительно открытой позиции
|
||||||
|
- удалены legacy trade/order handlers и order drafts
|
||||||
|
- вынесены auto runtime слои: lifecycle, signal, market, quality, semantic, health, intelligence, autonomous management
|
||||||
|
- добавлены exchange status/runtime UI helpers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 07.4.5
|
### 07.4.5
|
||||||
|
|||||||
@@ -1542,6 +1542,25 @@
|
|||||||
- реализована preparation for partial exit engine
|
- реализована preparation for partial exit engine
|
||||||
- реализована preparation for advanced runtime orchestration
|
- реализована preparation for advanced runtime orchestration
|
||||||
|
|
||||||
|
### 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
|
||||||
|
- разобран ExecutionEngine на отдельные mixin-модули
|
||||||
|
- добавлены position open/close/flip actions
|
||||||
|
- добавлены runtime protection, risk close, supervisor, sizing, pricing, resets
|
||||||
|
- добавлен trade_id / trade_sequence / trade_cycle_number для связки open-close-flip
|
||||||
|
- добавлено логирование trade_opened / trade_closed / trade_flipped
|
||||||
|
- добавлено логирование запуска, наблюдения и остановки автоторговли
|
||||||
|
- добавлены фильтры экспорта журнала: all, auto, trades, errors, not_auto
|
||||||
|
- исправлен экспорт CSV/XLSX с фильтрами журнала
|
||||||
|
- снижено дублирование market stream / REST fallback событий
|
||||||
|
- снижено дублирование exchange/runtime ошибок
|
||||||
|
- добавлены human-readable event titles для новых событий
|
||||||
|
- улучшены уведомления AUTO_SIGNAL_READY
|
||||||
|
- добавлена цена входа по направлению сигнала: Ask для Long, Bid для Short
|
||||||
|
- добавлен контекст сигнала относительно открытой позиции
|
||||||
|
- удалены legacy trade/order handlers и order drafts
|
||||||
|
- вынесены auto runtime слои: lifecycle, signal, market, quality, semantic, health, intelligence, autonomous management
|
||||||
|
- добавлены exchange status/runtime UI helpers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 07.4.5
|
### 07.4.5
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
|
||||||
|
|
||||||
|
Статус
|
||||||
|
|
||||||
|
## ✅ Реализовано
|
||||||
|
|
||||||
|
Рекомендуемый commit message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Краткое описание этапа
|
||||||
|
|
||||||
|
Этап посвящён глубокой переработке execution architecture, runtime journal analytics и переходу execution engine к modular runtime orchestration architecture.
|
||||||
|
|
||||||
|
Главная цель этапа:
|
||||||
|
|
||||||
|
* декомпозировать execution engine;
|
||||||
|
* реализовать полноценный trade lifecycle tracking;
|
||||||
|
* внедрить runtime trade analytics;
|
||||||
|
* улучшить observability execution layer;
|
||||||
|
* реализовать execution supervisor protection;
|
||||||
|
* внедрить adaptive runtime execution modules;
|
||||||
|
* подготовить execution layer к institutional-grade market orchestration;
|
||||||
|
* снизить шум runtime уведомлений;
|
||||||
|
* унифицировать runtime journal/export infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Основные реализованные изменения
|
||||||
|
|
||||||
|
## 1. Execution Engine Refactor
|
||||||
|
|
||||||
|
ExecutionEngine был разбит на отдельные runtime execution modules.
|
||||||
|
|
||||||
|
Добавлены отдельные mixin-модули:
|
||||||
|
|
||||||
|
* execution calculations
|
||||||
|
* execution sizing
|
||||||
|
* execution pricing
|
||||||
|
* execution supervisor
|
||||||
|
* execution position actions
|
||||||
|
* execution flip logic
|
||||||
|
* execution runtime actions
|
||||||
|
* execution risk close
|
||||||
|
* execution position protection
|
||||||
|
* execution resets
|
||||||
|
* execution runtime synchronization
|
||||||
|
|
||||||
|
Теперь execution architecture:
|
||||||
|
|
||||||
|
* стала модульной;
|
||||||
|
* упростила дальнейшее развитие;
|
||||||
|
* уменьшила связность execution layer;
|
||||||
|
* позволила независимо развивать runtime risk modules;
|
||||||
|
* подготовила систему к HTF market orchestration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Position Open / Close / Flip Runtime Architecture
|
||||||
|
|
||||||
|
Полностью переработан lifecycle позиции.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* unified position open logic;
|
||||||
|
* unified close logic;
|
||||||
|
* unified flip execution pipeline;
|
||||||
|
* adaptive flip protection;
|
||||||
|
* flip cooldown;
|
||||||
|
* flip block runtime layer;
|
||||||
|
* execution side-aware pricing;
|
||||||
|
* runtime pnl synchronization.
|
||||||
|
|
||||||
|
Теперь execution layer умеет:
|
||||||
|
|
||||||
|
* безопасно разворачивать позиции;
|
||||||
|
* блокировать dangerous flips;
|
||||||
|
* предотвращать rapid flip spam;
|
||||||
|
* учитывать momentum conflict;
|
||||||
|
* учитывать holding duration;
|
||||||
|
* учитывать execution confidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Trade Lifecycle Analytics
|
||||||
|
|
||||||
|
Реализован полноценный trade lifecycle tracking.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* trade_id
|
||||||
|
* trade_sequence
|
||||||
|
* trade_cycle_number
|
||||||
|
* current_trade_id
|
||||||
|
* current_trade_cycle_number
|
||||||
|
|
||||||
|
Теперь каждая сделка имеет:
|
||||||
|
|
||||||
|
* lifecycle identity;
|
||||||
|
* связь open → flip → close;
|
||||||
|
* cycle-aware tracking;
|
||||||
|
* runtime analytics continuity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime Trade Journal Layer
|
||||||
|
|
||||||
|
Реализован отдельный runtime trade analytics layer.
|
||||||
|
|
||||||
|
Добавлены runtime journal events:
|
||||||
|
|
||||||
|
* trade_opened
|
||||||
|
* trade_closed
|
||||||
|
* trade_flipped
|
||||||
|
* trade_position_size_changed
|
||||||
|
|
||||||
|
Теперь journal способен:
|
||||||
|
|
||||||
|
* анализировать сделки по lifecycle;
|
||||||
|
* связывать execution actions;
|
||||||
|
* анализировать flips;
|
||||||
|
* анализировать resize events;
|
||||||
|
* отслеживать pnl структуры.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AutoTrade Lifecycle Runtime Layer
|
||||||
|
|
||||||
|
Реализован отдельный auto lifecycle orchestration layer.
|
||||||
|
|
||||||
|
Добавлены runtime lifecycle modules:
|
||||||
|
|
||||||
|
* auto_lifecycle
|
||||||
|
* signal_runtime
|
||||||
|
* market_runtime
|
||||||
|
* execution_quality
|
||||||
|
* execution_semantic
|
||||||
|
* position_health
|
||||||
|
* position_intelligence
|
||||||
|
* autonomous_management
|
||||||
|
|
||||||
|
Теперь AutoTrade architecture:
|
||||||
|
|
||||||
|
* разделена по responsibility layers;
|
||||||
|
* стала значительно проще для расширения;
|
||||||
|
* получила runtime semantic orchestration;
|
||||||
|
* получила execution-aware lifecycle control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Execution Supervisor Protection Layer
|
||||||
|
|
||||||
|
Существенно расширен supervisor runtime engine.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* execution emergency halt;
|
||||||
|
* cooldown after loss;
|
||||||
|
* stale execution blocking;
|
||||||
|
* degraded market blocking;
|
||||||
|
* momentum/trend conflict blocking;
|
||||||
|
* execution supervisor journal events.
|
||||||
|
|
||||||
|
Теперь execution supervisor умеет:
|
||||||
|
|
||||||
|
* блокировать execution после серии убытков;
|
||||||
|
* предотвращать execution в degraded market;
|
||||||
|
* блокировать stale snapshots;
|
||||||
|
* останавливать dangerous runtime execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Runtime Signal Notification Layer
|
||||||
|
|
||||||
|
Полностью переработан runtime signal notification system.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* side-aware entry price rendering;
|
||||||
|
* LONG Ask execution rendering;
|
||||||
|
* SHORT Bid execution rendering;
|
||||||
|
* position-aware signal context;
|
||||||
|
* aligned/opposite position detection;
|
||||||
|
* semantic runtime notification rendering.
|
||||||
|
|
||||||
|
Теперь уведомления умеют:
|
||||||
|
|
||||||
|
* показывать реальную execution entry price;
|
||||||
|
* отображать направление позиции;
|
||||||
|
* показывать conflict against open position;
|
||||||
|
* отображать semantic market reasoning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Runtime Exchange Status & UI Layer
|
||||||
|
|
||||||
|
Добавлен unified runtime exchange status architecture.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* runtime exchange UI helpers;
|
||||||
|
* exchange runtime status rendering;
|
||||||
|
* exchange degradation alerts;
|
||||||
|
* runtime availability synchronization.
|
||||||
|
|
||||||
|
Теперь runtime UI:
|
||||||
|
|
||||||
|
* показывает degradation state;
|
||||||
|
* отображает execution availability;
|
||||||
|
* умеет синхронизировать exchange runtime state;
|
||||||
|
* снижает UI noise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Journal Export & Filtering Layer
|
||||||
|
|
||||||
|
Существенно расширен journal export engine.
|
||||||
|
|
||||||
|
Добавлены фильтры:
|
||||||
|
|
||||||
|
* all
|
||||||
|
* auto
|
||||||
|
* trades
|
||||||
|
* errors
|
||||||
|
* not_auto
|
||||||
|
|
||||||
|
Теперь экспорт:
|
||||||
|
|
||||||
|
* умеет фильтровать execution events;
|
||||||
|
* умеет экспортировать trade analytics;
|
||||||
|
* поддерживает runtime segmentation;
|
||||||
|
* поддерживает execution observability analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Runtime Event Observability
|
||||||
|
|
||||||
|
Существенно расширена runtime observability architecture.
|
||||||
|
|
||||||
|
Добавлены runtime events:
|
||||||
|
|
||||||
|
* execution_supervisor_block
|
||||||
|
* paper_position_opened
|
||||||
|
* paper_position_closed
|
||||||
|
* paper_position_flipped
|
||||||
|
* paper_flip_blocked
|
||||||
|
* auto_signal_ready
|
||||||
|
* auto_position_aligned_signal_suppressed
|
||||||
|
|
||||||
|
Теперь runtime layer:
|
||||||
|
|
||||||
|
* полностью observability-aware;
|
||||||
|
* поддерживает semantic runtime analytics;
|
||||||
|
* поддерживает event-driven orchestration;
|
||||||
|
* поддерживает runtime notification synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Runtime Noise Reduction
|
||||||
|
|
||||||
|
Существенно снижено количество runtime spam событий.
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
* dedupe runtime keys;
|
||||||
|
* supervisor block deduplication;
|
||||||
|
* aligned signal suppression;
|
||||||
|
* runtime notification deduplication;
|
||||||
|
* execution event throttling.
|
||||||
|
|
||||||
|
Теперь runtime system:
|
||||||
|
|
||||||
|
* генерирует меньше шума;
|
||||||
|
* уменьшает notification flooding;
|
||||||
|
* уменьшает journal duplication;
|
||||||
|
* улучшает observability readability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Legacy Cleanup & Architecture Simplification
|
||||||
|
|
||||||
|
Удалены legacy runtime modules:
|
||||||
|
|
||||||
|
* legacy order drafts;
|
||||||
|
* old trade handlers;
|
||||||
|
* obsolete monitoring handlers;
|
||||||
|
* deprecated trade flow modules;
|
||||||
|
* unused order runtime structures.
|
||||||
|
|
||||||
|
Теперь architecture:
|
||||||
|
|
||||||
|
* стала чище;
|
||||||
|
* уменьшила technical debt;
|
||||||
|
* сократила legacy execution code;
|
||||||
|
* упростила поддержку runtime engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Итог этапа
|
||||||
|
|
||||||
|
После этапа:
|
||||||
|
|
||||||
|
* execution engine стал modular runtime system;
|
||||||
|
* реализован полноценный trade lifecycle tracking;
|
||||||
|
* execution layer стал position-aware;
|
||||||
|
* journal получил runtime trade analytics;
|
||||||
|
* supervisor стал execution-aware;
|
||||||
|
* runtime notifications стали semantic-aware;
|
||||||
|
* execution observability существенно улучшена;
|
||||||
|
* runtime spam значительно снижен;
|
||||||
|
* architecture подготовлена к HTF market analysis layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Рекомендуемый commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user