07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics

This commit is contained in:
2026-05-28 10:30:54 +03:00
parent f9a25e7671
commit d9e6392e28
75 changed files with 9934 additions and 10508 deletions

View File

@@ -16,18 +16,19 @@ EVENT_TITLES = {
# Настройки
"auto_settings_updated": "Автоторговля",
"auto_status_changed": "Автоторговля",
"risk_settings_updated": "Защита",
# Аналитика рынка
"market_state_changed": "Рынок",
"market_volatility_changed": "Рынок",
# Аналитика автоторговли
"market_state_changed": "Автоторговля",
"market_volatility_changed": "Автоторговля",
# Мониторинг рынка
"market_monitor_started": "Рынок",
"market_monitor_stopped": "Рынок",
"market_stream_connected": "Рынок",
"market_stream_disconnected": "Рынок",
"market_symbol_changed": "Рынок",
# Рыночные данные runtime
"market_monitor_started": "Автоторговля",
"market_monitor_stopped": "Автоторговля",
"market_stream_connected": "Автоторговля",
"market_stream_disconnected": "Автоторговля",
"market_symbol_changed": "Автоторговля",
# Мониторинг позиций
"entry_blocked": "Вход в позицию",
@@ -61,10 +62,6 @@ EVENT_TITLES = {
"system_retry": "Система",
"system_about_opened": "Система",
"market_open_requested": "Рынок",
"market_open_success": "Рынок",
"market_open_error": "Рынок",
"portfolio_open_requested": "Портфель",
"portfolio_open_success": "Портфель",
"portfolio_open_error": "Портфель",
@@ -72,10 +69,21 @@ EVENT_TITLES = {
"exchange_request_error": "Биржа",
"exchange_auth_error": "Аккаунт",
"exchange_auth_restored": "Аккаунт",
"exchange_time_sync_error": "Время биржи",
"exchange_time_sync_restored": "Время биржи",
"balance_summary_loaded": "Баланс",
"balance_summary_error": "Баланс",
"runtime_expired": "Runtime",
"market_status_unavailable": "Автоторговля",
"market_status_restored": "Автоторговля",
"market_closed": "Автоторговля",
"market_rest_fallback_available": "Автоторговля",
"market_rest_fallback_unavailable": "Автоторговля",
}

View File

@@ -9,7 +9,9 @@ from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import build_exchange_error_status
from src.storage.session import check_database_health
from src.trading.journal.service import JournalService
@@ -32,6 +34,96 @@ class SystemSnapshot:
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:
if not raw:
return "PostgreSQL"
@@ -43,99 +135,72 @@ def _extract_postgres_version(raw: str) -> str:
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]:
db_ok, db_message = check_database_health()
db_label = _extract_postgres_version(db_message)
if db_ok:
return ComponentStatus(name="База данных", state="🟢"), db_label
return (
ComponentStatus(
name="База данных",
state="🟢",
),
db_label,
)
return (
ComponentStatus(
name="База данных",
state="🔴",
details=db_message or "Ошибка подключения к БД.",
state="🔴 База данных недоступна",
),
db_label,
)
# проверить доступность журнала событий
def _build_journal_status() -> ComponentStatus:
ok, message = JournalService().get_journal_health()
ok, _ = JournalService().get_journal_health()
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:
settings = load_settings()
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
# вернуть человекочитаемую подпись runtime-режима
def get_runtime_mode_label() -> str:
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
# собрать полный snapshot системного экрана
def get_system_snapshot() -> SystemSnapshot:
settings = load_settings()
exchange_service = ExchangeService()
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()
exchange_components = _build_exchange_alert_components(
default_symbol=settings.default_symbol,
)
components = [
ComponentStatus(name="Приложение", state="🟢"),
database_status,
ComponentStatus(name="Telegram", state="🟢"),
exchange_status,
account_status,
*exchange_components,
journal_status,
]
@@ -150,19 +215,20 @@ def get_system_snapshot() -> SystemSnapshot:
)
# определить, есть ли системные предупреждения
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
return any(component.state != "🟢" for component in snapshot.components)
# отрендерить одну строку компонента системы
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 line
return f"{line}\n{component.details}"
return component.state
# получить текущее локальное время для подписи обновления
def _now_hhmmss() -> str:
settings = load_settings()
tz_name = settings.tz or "UTC"
@@ -175,50 +241,22 @@ def _now_hhmmss() -> str:
return local_dt.strftime("%H:%M:%S")
# собрать текст экрана "Система"
def build_system_text(*, include_updated_at: bool = False) -> str:
snapshot = get_system_snapshot()
components_block = "\n".join(
_render_component(component) for component in snapshot.components
_render_component(component)
for component in snapshot.components
)
text = (
"<b>🖥️ Система</b>\n"
f"🔸 <b>{snapshot.mode_label}</b>\n\n"
# f"⏱️ {snapshot.timezone_name}\n\n"
f"{components_block}"
)
if include_updated_at:
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
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 "Не удалось получить данные с биржи"
return text

View File

@@ -3,10 +3,13 @@
from __future__ import annotations
import asyncio
import time
import traceback
from dataclasses import dataclass
from typing import Callable
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
@@ -24,10 +27,42 @@ class MarketRuntimeContext:
runtime_label: str | None
last_market_status: str | None = None
# Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
last_status_error_key: str | None = None
last_stream_state: str | None = None
last_stream_error_key: str | None = None
last_rest_state: str | None = None
last_rest_error_key: str | None = None
class MarketDataRunner:
_runtimes: dict[str, MarketRuntimeContext] = {}
# Global dedupe runtime-событий между start/stop runtime.
_global_runtime_event_timestamps: dict[str, float] = {}
# Минимальный интервал повторного логирования одного runtime-состояния.
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
_runtime_log_cooldown_seconds = 300
@classmethod
def _can_log_runtime_event(
cls,
event_key: str,
) -> bool:
now = time.monotonic()
last_logged_at = cls._global_runtime_event_timestamps.get(event_key)
if last_logged_at is not None:
if (
now - last_logged_at
) < cls._runtime_log_cooldown_seconds:
return False
cls._global_runtime_event_timestamps[event_key] = now
return True
@classmethod
def start(
cls,
@@ -41,7 +76,11 @@ class MarketDataRunner:
) -> None:
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.interval_seconds = interval_seconds
existing.screen = screen
@@ -97,73 +136,143 @@ class MarketDataRunner:
async def _worker(cls, context: MarketRuntimeContext) -> None:
last_symbol: str | None = None
while True:
symbol = context.symbol_provider()
try:
while True:
symbol = context.symbol_provider()
if not symbol:
await asyncio.sleep(context.interval_seconds)
continue
if not symbol:
await asyncio.sleep(context.interval_seconds)
continue
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
if symbol != last_symbol:
previous_symbol = last_symbol
last_symbol = symbol
if symbol != last_symbol:
last_symbol = symbol
if not cls._is_cache_symbol_used_by_other_runtime(
runtime_key=context.runtime_key,
cache_symbol=cache_symbol,
):
MarketPriceCache.clear(cache_symbol)
if not cls._is_cache_symbol_used_by_other_runtime(
runtime_key=context.runtime_key,
cache_symbol=cache_symbol,
):
MarketPriceCache.clear(cache_symbol)
market_status = ExchangeService().get_symbol_market_status(symbol)
status_key = str(market_status.get("status") or "UNKNOWN")
try:
market_status = ExchangeService().get_symbol_market_status(symbol)
if not bool(market_status.get("is_open")):
if context.last_market_status != status_key:
context.last_market_status = status_key
except asyncio.CancelledError:
raise
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,
"market_closed",
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
"market_status_restored",
"Статус рынка снова доступен.",
{
"symbol": symbol,
"market_status": status_key,
"message": market_status.get("message"),
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
},
)
await asyncio.sleep(context.interval_seconds)
continue
status_key = str(market_status.get("status") or "UNKNOWN")
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:
await cls._run_websocket(context, symbol)
except asyncio.CancelledError:
raise
except Exception as exc:
cls._log_warning(
context,
"market_stream_disconnected",
"Поток рыночных данных отключён. Используется резервный 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),
},
)
cls._log_warning(
context,
"market_closed",
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
{
"symbol": symbol,
"market_status": status_key,
"message": market_status.get("message"),
},
)
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
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)
ws_symbol = cls._ws_symbol(symbol)
@@ -174,19 +283,33 @@ class MarketDataRunner:
interval_seconds=context.interval_seconds,
):
if payload_count == 0:
cls._log_info(
context,
"market_stream_connected",
"Поток рыночных данных подключён.",
{
"requested_symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"payload_keys": list(payload.keys()),
"payload_preview": cls._safe_payload_preview(payload),
},
should_log_connected = (
context.last_stream_state != "CONNECTED"
and cls._can_log_runtime_event(
f"market_stream_connected:{symbol}"
)
)
context.last_stream_state = "CONNECTED"
context.last_stream_error_key = None
context.last_rest_state = None
context.last_rest_error_key = None
if should_log_connected:
cls._log_info(
context,
"market_stream_connected",
"Live-поток рыночных данных подключён.",
{
"requested_symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"payload_keys": list(payload.keys()),
"payload_preview": cls._safe_payload_preview(payload),
},
)
payload_count += 1
current_symbol = context.symbol_provider()
@@ -209,28 +332,78 @@ class MarketDataRunner:
)
@classmethod
async def _rest_fallback_once(cls, context: MarketRuntimeContext, symbol: str) -> None:
async def _rest_fallback_once(
cls,
context: MarketRuntimeContext,
symbol: str,
) -> None:
try:
await asyncio.to_thread(
ExchangeService().refresh_market_snapshot_cache,
symbol,
runtime_key=context.runtime_key,
)
except Exception as exc:
cls._log_error(
context,
"market_stream_disconnected",
"Поток рыночных данных отключён. Резервный REST-режим недоступен.",
{
"symbol": symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
should_log_rest_available = (
context.last_rest_state != "AVAILABLE"
and cls._can_log_runtime_event(
f"market_rest_fallback_available:{symbol}"
)
)
context.last_rest_state = "AVAILABLE"
context.last_rest_error_key = None
if should_log_rest_available:
cls._log_info(
context,
"market_rest_fallback_available",
"REST-режим рыночных данных доступен. Live-поток пока недоступен.",
{
"symbol": symbol,
"cache_symbol": cls._cache_symbol(symbol),
"ws_symbol": cls._ws_symbol(symbol),
},
)
except Exception as exc:
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
should_log_rest_unavailable = (
(
context.last_rest_state != "UNAVAILABLE"
or context.last_rest_error_key != error_key
)
and cls._can_log_runtime_event(
f"market_rest_fallback_unavailable:{symbol}:{error_key}"
)
)
context.last_rest_state = "UNAVAILABLE"
context.last_rest_error_key = error_key
if should_log_rest_unavailable:
cls._log_error(
context,
"market_rest_fallback_unavailable",
"Live-поток отключён. REST-режим рыночных данных недоступен.",
{
"symbol": symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
@classmethod
def _is_cache_symbol_used_by_other_runtime(cls, *, runtime_key: str, cache_symbol: str) -> bool:
def _is_cache_symbol_used_by_other_runtime(
cls,
*,
runtime_key: str,
cache_symbol: str,
) -> bool:
for key, context in cls._runtimes.items():
if key == runtime_key:
continue
@@ -251,6 +424,7 @@ class MarketDataRunner:
validation = ExchangeService().validate_symbol(symbol)
if validation.is_valid:
return validation.normalized_symbol
except Exception:
pass
@@ -261,7 +435,11 @@ class MarketDataRunner:
return cls._cache_symbol(symbol)
@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
inner = payload.get("payload")
@@ -276,53 +454,71 @@ class MarketDataRunner:
first = values[0]
if isinstance(first, list) and first:
return cls._safe_float(first[0])
return cls._positive_float(first[0])
if isinstance(first, dict):
return cls._safe_float(
raw_price = (
first.get("price")
or first.get("p")
or first.get("bidPrice")
or first.get("askPrice")
)
return cls._positive_float(raw_price)
return None
@classmethod
def _safe_float(cls, value: object) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
def _positive_float(cls, value: NumericLike | None) -> float | None:
number = safe_float(value)
if number is None or number <= 0:
return None
return number if number > 0 else None
return number
@classmethod
def _safe_payload_preview(cls, payload: dict) -> dict:
preview: dict = {}
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
preview: JsonDict = {}
for key, value in payload.items():
if key in {"bids", "asks"} and isinstance(value, list):
preview[key] = value[:2]
elif key == "payload" and isinstance(value, dict):
preview[key] = {
inner_key: inner_value[:2]
if inner_key in {"bids", "asks"} and isinstance(inner_value, list)
else inner_value
for inner_key, inner_value in value.items()
}
inner_preview: JsonDict = {}
for inner_key, inner_value in value.items():
if (
inner_key in {"bids", "asks"}
and isinstance(inner_value, list)
):
inner_preview[inner_key] = inner_value[:2]
else:
inner_preview[inner_key] = inner_value
preview[key] = inner_preview
else:
preview[key] = value
return preview
@classmethod
def _message(cls, context: MarketRuntimeContext, message: str) -> str:
def _message(
cls,
context: MarketRuntimeContext,
message: str,
) -> str:
return message
@classmethod
def _payload(cls, context: MarketRuntimeContext, payload: dict | None = None) -> dict:
result = dict(payload or {})
def _payload(
cls,
context: MarketRuntimeContext,
payload: JsonDict | None = None,
) -> JsonDict:
result: JsonDict = dict(payload or {})
result.setdefault("runtime_key", context.runtime_key)
if context.screen:
@@ -339,7 +535,7 @@ class MarketDataRunner:
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: dict | None = None,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
@@ -352,7 +548,12 @@ class MarketDataRunner:
)
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:
pass
@@ -362,7 +563,7 @@ class MarketDataRunner:
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: dict | None = None,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
@@ -375,7 +576,12 @@ class MarketDataRunner:
)
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:
pass
@@ -385,9 +591,16 @@ class MarketDataRunner:
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: dict | None = None,
payload: JsonDict | None = None,
) -> None:
try:
error_type = None
raw_error = None
if payload:
error_type = payload.get("error_type")
raw_error = payload.get("error")
if context.screen:
JournalService().log_ui_error(
event_type=event_type,
@@ -395,11 +608,16 @@ class MarketDataRunner:
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
error_type=(payload or {}).get("error_type"),
raw_error=(payload or {}).get("error"),
error_type=str(error_type) if error_type is not None else None,
raw_error=str(raw_error) if raw_error is not None else None,
)
return
JournalService().log_error(event_type, cls._message(context, message), cls._payload(context, payload))
JournalService().log_error(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass

View File

@@ -7,25 +7,39 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.trading.journal.service import JournalService
def _format_timestamp(raw_timestamp: object) -> str | None:
if raw_timestamp is None:
# безопасно форматирует timestamp биржи в локальное время
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
timestamp = safe_float(raw_timestamp)
if timestamp is None:
return None
try:
settings = load_settings()
dt_utc = datetime.fromtimestamp(int(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:
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")
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):
return None
symbol = event.get("symbolName") or event.get("symbol")
bid = event.get("bid")
ask = event.get("ofr") or event.get("ask")
timestamp = event.get("timestamp")
return dict(event)
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
bid_price = float(bid)
ask_price = float(ask)
price = (bid_price + ask_price) / 2
return {
@@ -51,10 +122,11 @@ def _extract_market_event(payload: dict) -> dict | None:
"price": price,
"bid_price": bid_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:
settings = load_settings()
journal = JournalService()
@@ -86,16 +158,30 @@ async def start_market_stream() -> None:
if event is None:
continue
price = safe_float(event.get("price"))
bid_price = safe_float(event.get("bid_price"))
ask_price = safe_float(event.get("ask_price"))
if price is None or bid_price is None or ask_price is None:
continue
MarketPriceCache.set_price(
symbol=symbol,
price=event["price"],
bid_price=event["bid_price"],
ask_price=event["ask_price"],
updated_at=event["updated_at"],
price=price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=(
str(event.get("updated_at"))
if event.get("updated_at") is not None
else None
),
source="ws_market_stream",
runtime_key="default",
)
except asyncio.CancelledError:
raise
except Exception as exc:
try:
journal.log_warning(

View File

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

View 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

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

View File

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

View File

@@ -23,9 +23,6 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
position_context = str(payload.get("position_context") or "NONE").upper()
semantic_lines = _as_json_list(payload.get("semantic_lines"))
bid_price = payload.get("bid_price")
ask_price = payload.get("ask_price")
priority = str(
event.priority
or _alert_priority(
@@ -40,24 +37,32 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
strength = _strength_label(priority)
strength_bar = _strength_bar(priority)
market_price_line = _market_price_line(
direction=direction_key,
bid_price=bid_price,
ask_price=ask_price,
)
lines = [
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
"",
]
if market_price_line:
lines.extend([market_price_line, ""])
position_line = _position_context_line(
signal=signal,
position_context=position_context,
)
if position_context not in {"NONE", "", ""} and position_context != direction_key:
lines.extend(["⚠️ ПРОТИВ ПОЗИЦИИ", ""])
if position_line:
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:
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:
if confidence >= 0.8 and repeat_count >= 3:
return "HIGH"
@@ -131,27 +198,6 @@ def _format_symbol(symbol: str) -> str:
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:
number = safe_float(value)

View File

@@ -1,3 +1,5 @@
# app/src/storage/models.py
from __future__ import annotations
from dataclasses import dataclass
@@ -18,16 +20,4 @@ class JournalEventRecord:
level: str
event_type: str
message: str
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
payload_json: str | None

View File

@@ -17,7 +17,11 @@ class JournalRepository:
message: str,
payload: dict[str, Any] | 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 connection.cursor() as cursor:
@@ -66,7 +70,11 @@ class JournalRepository:
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 connection.cursor() as cursor:
cursor.execute(
@@ -82,13 +90,86 @@ class JournalRepository:
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 connection.cursor() as cursor:
cursor.execute(
"""
f"""
SELECT id, created_at, level, event_type, message, payload_json
FROM journal_events
{where_sql}
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
@@ -114,17 +195,6 @@ class JournalRepository:
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:
with get_connection() as connection:
with connection.cursor() as cursor:
@@ -137,4 +207,14 @@ class JournalRepository:
)
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]),
}

View File

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

View File

@@ -1,15 +1,23 @@
# app/src/storage/schema.py
from __future__ import annotations
from psycopg import sql
from src.storage.session import get_connection
DDL = [
'''
# SQL-команды для первичной инициализации базы данных.
DDL: list[sql.SQL] = [
sql.SQL("""
CREATE TABLE IF NOT EXISTS balance_snapshots (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL,
payload_json JSONB NOT NULL
)
''',
'''
"""),
sql.SQL("""
CREATE TABLE IF NOT EXISTS journal_events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -18,28 +26,19 @@ DDL = [
message TEXT NOT NULL,
payload_json JSONB
)
''',
'''
"""),
sql.SQL("""
CREATE INDEX IF NOT EXISTS idx_journal_events_created_at
ON journal_events (created_at DESC)
''',
'''
"""),
sql.SQL("""
CREATE INDEX IF NOT EXISTS idx_journal_events_event_type
ON journal_events (event_type)
''',
'''
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:
with get_connection() as connection:
with connection.cursor() as cursor:

View File

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

View File

@@ -12,6 +12,7 @@ from src.telegram.handlers.auto.ui import (
auto_keyboard,
build_auto_text,
is_auto_configured,
_auto_block_reason,
)
from src.telegram.handlers.system import open_auto_settings
from src.telegram.live.active_screen import ActiveScreenManager
@@ -230,6 +231,16 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
await callback.answer()
@router.callback_query(F.data == "auto:start_blocked")
async def auto_start_blocked(callback: CallbackQuery) -> None:
reason = _auto_block_reason() or "Запуск сейчас недоступен"
await callback.answer(
reason.replace("⛔️ ", ""),
show_alert=True,
)
@router.callback_query(F.data == "auto:start")
async def auto_start(callback: CallbackQuery) -> None:
service = AutoTradeService()
@@ -246,6 +257,22 @@ async def auto_start(callback: CallbackQuery) -> None:
return
block_reason = _auto_block_reason()
if block_reason:
await callback.answer(
block_reason.replace("⛔️ ", "").replace("", ""),
show_alert=True,
)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
return
_, message_text = service.start()
if await _prepare_auto_from_callback(callback):

View File

@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
from src.telegram.ui.common import mode_line
from src.trading.auto.service import AutoTradeService
from src.core.numbers import safe_float
@@ -18,6 +19,11 @@ from src.core.numbers import safe_float
def build_auto_notification_text() -> str:
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_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)
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:
state = AutoTradeService().get_state()
builder = InlineKeyboardBuilder()
status = (state.status or "").upper()
block_reason = _auto_block_reason()
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")
elif status == "RUNNING":
@@ -49,7 +142,11 @@ def auto_keyboard() -> InlineKeyboardMarkup:
builder.button(text="🛑 Остановить", callback_data="auto:stop")
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")
else:
@@ -97,30 +194,31 @@ def is_auto_configured(state) -> bool:
return True
def build_auto_text() -> str:
state = AutoTradeService().get_state()
def _auto_block_reason(state: object | None = None) -> str | None:
state_to_use = state or 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,
lines = build_runtime_exchange_alert_lines(
symbol=getattr(state_to_use, "symbol", None),
include_exchange_unavailable=True,
)
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:
symbol_ready = state.symbol is not None
@@ -134,11 +232,16 @@ def _build_not_configured_text(state) -> str:
parts = [
"🤖 Автоторговля ⚪ Не настроена",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}",
f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}",
f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}",
]
])
strategy = (state.strategy or "").upper()
@@ -166,6 +269,11 @@ def _build_stopped_without_position_text(state) -> str:
parts = [
"⚪️ Автоторговля остановлена",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
"",
@@ -180,11 +288,36 @@ def _build_stopped_without_position_text(state) -> str:
f"Риск · {_format_percent(state.risk_percent)}",
"",
_settings_risk_percent_line(state),
]
])
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:
sl = _format_percent(state.stop_loss_percent)
tp = _format_percent(state.take_profit_percent)
@@ -210,9 +343,14 @@ def _build_waiting_text(state) -> str:
parts = [
f"{_status_text(state)}",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
]
])
if cycle_trades > 0:
parts.extend([
@@ -367,10 +505,15 @@ def _build_active_position_text(state) -> str:
parts = [
_status_text(state),
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
f"Маржа · {_format_usd_compact(reserved)}",
]
])
if cycle_trades > 0:
parts.extend([

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
from aiogram.fsm.context import FSMContext
from aiogram.types import (
BufferedInputFile,
@@ -17,11 +18,14 @@ from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_actions_keyboard,
build_clear_confirm_keyboard,
build_export_format_keyboard,
build_keyboard,
render,
render_actions,
render_clear_confirm,
render_export_format,
)
from src.trading.journal.filters import normalize_journal_export_filter
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.journal.service import JournalService
@@ -175,10 +179,18 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages)
if edit_mode:
await target_message.edit_text(
text,
reply_markup=kb,
)
try:
await target_message.edit_text(
text,
reply_markup=kb,
)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
_register_journal_screen(target_message)
return
raise
_register_journal_screen(target_message)
return
@@ -200,10 +212,44 @@ async def journal_actions(callback: CallbackQuery) -> None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
try:
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data.startswith("journal:export_filter:"))
async def journal_export_filter(callback: CallbackQuery) -> None:
# Пользователь выбрал фильтр экспорта.
# Теперь показываем выбор формата: CSV или Excel.
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
raw_filter = (callback.data or "").rsplit(":", 1)[-1]
export_filter = normalize_journal_export_filter(raw_filter)
try:
await message.edit_text(
render_export_format(export_filter),
reply_markup=build_export_format_keyboard(export_filter),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
@@ -224,8 +270,8 @@ async def open_journal(message: Message, state: FSMContext) -> None:
)
@router.callback_query(F.data == "monitoring:journal")
async def open_journal_from_monitoring(
@router.callback_query(F.data == "system:journal")
async def open_journal_from_system(
callback: CallbackQuery,
state: FSMContext,
) -> None:
@@ -254,20 +300,36 @@ async def journal_noop(callback: CallbackQuery) -> None:
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:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю CSV…")
try:
data = service.export_csv()
data = service.export_csv(export_filter=export_filter)
document = BufferedInputFile(
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(document=document)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
@@ -276,10 +338,31 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_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:
service.log_ui_error(
@@ -289,30 +372,46 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_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),
)
await callback.answer(
"Не удалось экспортировать CSV",
show_alert=True,
)
await message.answer("⛔️ Не удалось экспортировать CSV.")
@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:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю Excel…")
try:
data = service.export_xlsx()
data = service.export_xlsx(export_filter=export_filter)
document = BufferedInputFile(
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(document=document)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
@@ -321,10 +420,31 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_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:
service.log_ui_error(
@@ -334,14 +454,14 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_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),
)
await callback.answer(
"Не удалось экспортировать Excel",
show_alert=True,
)
await message.answer("⛔️ Не удалось экспортировать Excel.")
@router.callback_query(F.data == "journal:clear_confirm")

View File

@@ -12,9 +12,10 @@ from src.core.config import load_settings
from src.core.event_titles import event_title
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
from src.trading.journal.filters import journal_export_filter_label
PAGE_SIZE = 5
PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
@@ -52,7 +53,7 @@ def build_keyboard(
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
kb.button(text="⬅️ Назад", callback_data="system:back")
nav_count = 1
@@ -67,10 +68,28 @@ def build_keyboard(
def build_actions_keyboard() -> InlineKeyboardMarkup:
# Первый экран экспорта: выбираем, что именно экспортировать.
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.adjust(2, 2, 1, 1)
return kb.as_markup()
def build_export_format_keyboard(export_filter: str) -> InlineKeyboardMarkup:
# Второй экран экспорта: после выбора фильтра выбираем формат.
kb = InlineKeyboardBuilder()
kb.button(text="📄 CSV", callback_data=f"journal:export_csv:{export_filter}")
kb.button(text="📊 Excel", callback_data=f"journal:export_xlsx:{export_filter}")
kb.button(text="⬅️ Назад", callback_data="journal:actions")
kb.adjust(2, 1)
return kb.as_markup()
@@ -78,7 +97,18 @@ def build_actions_keyboard() -> InlineKeyboardMarkup:
def render_actions() -> str:
return (
"<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 = [
"<b>📒 Журнал</b>",
"",
"<b>МОНИТОРИНГ</b>",
"<b>СИСТЕМА</b>",
"",
]

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
# app/src/telegram/handlers/portfolio.py
from __future__ import annotations
from aiogram import F, Router
@@ -13,10 +12,14 @@ from aiogram.types import (
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.core.types import NumericLike
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.runtime_ui import (
build_runtime_exchange_alerts,
format_runtime_exchange_alerts,
)
from src.integrations.exchange.status import classify_exchange_error
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
from src.telegram.ui.common import mode_line, now_line
@@ -26,11 +29,6 @@ from src.telegram.ui.currency_ui import (
format_usd_amount,
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.journal.service import JournalService
@@ -46,6 +44,7 @@ PINNED_ORDER = {
}
# Получить доступное Telegram-сообщение из callback.
def _require_message(
callback: CallbackQuery,
) -> Message | None:
@@ -60,10 +59,7 @@ def _require_message(
return message
def _payload(**values: object) -> JsonDict:
return dict(values)
# Отформатировать количество актива компактно для портфеля.
def _compact_amount(currency: str, value: NumericLike) -> str:
number = safe_float(value) or 0.0
currency = currency.upper()
@@ -90,21 +86,15 @@ def _compact_amount(currency: str, value: NumericLike) -> str:
return f"{int(text):,}".replace(",", " ")
def _portfolio_keyboard() -> InlineKeyboardMarkup:
# Клавиатура портфеля при частичных данных или ошибке.
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.adjust(1)
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_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper()
@@ -114,11 +104,41 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
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()
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:
text = (
@@ -127,7 +147,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
"Нет данных по балансу.\n\n"
f"{now_line()}"
)
return text, _portfolio_keyboard()
return text, None
visible_balances = [
item
@@ -143,7 +163,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
"Нет активов с балансом.\n\n"
f"{now_line()}"
)
return text, _portfolio_keyboard()
return text, None
price_cache: dict[str, float | None] = {}
total_estimated_usd = 0.0
@@ -160,11 +180,15 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
currency = item.currency.upper()
total = safe_float(balance_total(item)) or 0.0
locked = safe_float(item.locked) or 0.0
estimated_usd = estimate_balance_usd(
item,
exchange_service,
price_cache,
)
try:
estimated_usd = estimate_balance_usd(
item,
exchange_service,
price_cache,
)
except Exception:
estimated_usd = None
if estimated_usd is not None:
total_estimated_usd += estimated_usd
@@ -205,18 +229,20 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
reply_markup = (
_portfolio_warning_keyboard()
if has_partial_data
else _portfolio_keyboard()
else None
)
return "\n".join(lines).rstrip(), reply_markup
# Render-функция текста для live runner.
def _portfolio_live_text() -> str:
text, _ = _build_portfolio_live_text()
return text
def _portfolio_live_markup() -> InlineKeyboardMarkup:
# Render-функция клавиатуры для live runner.
def _portfolio_live_markup() -> InlineKeyboardMarkup | None:
_, markup = _build_portfolio_live_text()
return markup
@@ -252,6 +278,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
LiveScreenRunner.start("portfolio")
# Подготовить новый экран портфеля из обычного сообщения.
async def _prepare_portfolio_from_message(
message: Message,
) -> bool:
@@ -269,6 +296,7 @@ async def _prepare_portfolio_from_message(
return True
# Подготовить экран портфеля из callback.
async def _prepare_portfolio_from_callback(
callback: CallbackQuery,
) -> bool:
@@ -294,6 +322,7 @@ async def _prepare_portfolio_from_callback(
return True
# Отрисовать экран портфеля и зарегистрировать live обновления.
async def _render_portfolio_screen(
target_message: Message,
*,
@@ -313,7 +342,7 @@ async def _render_portfolio_screen(
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(
event_type="portfolio_open_success",
@@ -356,7 +385,8 @@ async def open_portfolio(
edit_mode=False,
action="open",
)
except ExchangeError as exc:
except Exception as exc:
JournalService().log_ui_error(
event_type="portfolio_open_error",
message="Не удалось загрузить портфель.",
@@ -368,64 +398,14 @@ async def open_portfolio(
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="portfolio:retry",
sent_message = await message.answer(
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
@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="Не удалось загрузить портфель из мониторинга.",
ActiveScreenManager.register(
screen="portfolio",
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="portfolio:retry",
message=sent_message,
)
@@ -458,7 +438,7 @@ async def retry_portfolio(
)
await callback.answer()
except ExchangeError as exc:
except Exception as exc:
JournalService().log_ui_error(
event_type="portfolio_retry_error",
message="Не удалось обновить портфель.",
@@ -470,11 +450,14 @@ async def retry_portfolio(
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",
)
await message.edit_text(
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
ActiveScreenManager.register(
screen="portfolio",
message=message,
)
await callback.answer()

View File

@@ -8,13 +8,16 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from src.core.system_status import build_system_text
from src.telegram.handlers.auto.main import render_auto_screen
from src.telegram.keyboards.reply import build_main_menu_keyboard
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start")
# показать только reply-меню без открытия live-экрана
async def _show_main_menu(
message: Message,
) -> 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"))
async def cmd_start(
message: Message,
@@ -31,7 +63,7 @@ async def cmd_start(
) -> None:
await state.clear()
await _show_main_menu(message)
await _open_auto_start_screen(message)
@router.message(Command("menu"))

View File

@@ -4,14 +4,14 @@ from __future__ import annotations
from aiogram import F, Router
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 src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION
from src.core.numbers import safe_float
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
from src.core.types import JsonDict, NumericLike
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.auto.service import AutoTradeService
@@ -21,15 +21,10 @@ from src.trading.journal.service import JournalService
router = Router(name="system")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
def _require_message(callback: CallbackQuery) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
if message is None or isinstance(message, InaccessibleMessage):
return None
return message
@@ -38,8 +33,9 @@ def _require_message(
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(2)
builder.adjust(2, 1)
return builder.as_markup()
@@ -47,8 +43,9 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="system:retry")
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(1, 2)
builder.adjust(1, 2, 1)
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
if bot is None:
@@ -222,7 +222,7 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
chat_id=chat_id,
action="retry",
)
await callback.answer()
@@ -239,11 +239,10 @@ async def open_system_management(callback: CallbackQuery) -> None:
builder = InlineKeyboardBuilder()
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:journal")
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1)
builder.adjust(1, 2, 1)
message = _require_message(callback)
@@ -275,11 +274,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
leverage_ready = state.leverage is not None
is_trend_strategy = (state.strategy or "").upper() == "TREND"
sl_ready = (
state.stop_loss_percent is not None
and state.stop_loss_percent > 0
)
sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
is_configured = (
strategy_ready
@@ -290,30 +285,14 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
)
strategy = strategy_map.get(state.strategy or "", "")
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
symbol = _human_symbol(state.symbol)
risk = _format_number(state.risk_percent, suffix="%", default="")
leverage_value = safe_float(state.leverage)
leverage = f"x{leverage_value:g}" if leverage_value is not None else ""
max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
sl = _format_percent_setting(state.stop_loss_percent)
tp = _format_percent_setting(state.take_profit_percent)
ml_value = safe_float(state.max_loss_usd)
@@ -323,42 +302,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
symbol_icon = "" if symbol_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️"
leverage_icon = "" if leverage_ready else "⚠️"
if is_trend_strategy and not sl_ready:
sl_icon = ""
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}"
)
sl_icon = "⛔️" if is_trend_strategy and not sl_ready else ("" if state.stop_loss_percent is not None else "⚠️")
tp_icon = "" if state.take_profit_percent is not None else "⚠️"
ml_icon = "" if state.max_loss_usd is not None else ""
settings_status_icon = "" if is_configured else "⛔️"
config_status = (
""
if is_configured
else "\n\nНастрой все параметры"
)
config_status = "" if is_configured else "\n\nНастрой все параметры"
text = (
"<b>🤖 Автоторговля</b>\n\n"
@@ -368,52 +317,22 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
f"{leverage_icon} Плечо: <b>{leverage}</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}"
)
builder = InlineKeyboardBuilder()
builder.button(
text="🧠 Стратегия",
callback_data="settings:auto_strategy",
)
builder.button(
text="💱 Актив",
callback_data="settings:auto_symbol",
)
builder.button(
text="⚙️ Плечо",
callback_data="settings:auto_leverage",
)
builder.button(
text="🏦 Лимит",
callback_data="settings:auto_max_reserved",
)
builder.button(
text="🛡️ Риск",
callback_data="settings:auto_risk",
)
builder.button(
text="🧯 Защита",
callback_data="auto:risk",
)
builder.button(
text="🤖 Автоторговля",
callback_data="auto:home",
)
builder.button(
text="⬅️ Назад",
callback_data="system:management",
)
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved")
builder.button(text="🛡️ Риск", callback_data="settings:auto_risk")
builder.button(text="🧯 Защита", callback_data="auto:risk")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(2, 2, 2, 2)
message = _require_message(callback)
@@ -422,16 +341,8 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
text,
reply_markup=builder.as_markup(),
)
_register_system_screen(
message,
screen="settings_auto",
)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@@ -439,7 +350,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
@@ -813,36 +724,6 @@ async def set_auto_max_reserved(callback: CallbackQuery) -> None:
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")
async def open_general_settings(callback: CallbackQuery) -> None:
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()
@router.callback_query(F.data.in_({
"settings:journal_limit_stub",
"settings:journal_retention_stub",
}))
@router.callback_query(
F.data.in_(
{
"settings:journal_limit_stub",
"settings:journal_retention_stub",
}
)
)
async def journal_settings_stub(callback: CallbackQuery) -> None:
await callback.answer("Настройка скоро появится", show_alert=True)
@@ -1030,6 +915,7 @@ async def back_to_system(callback: CallbackQuery) -> None:
chat_id=chat_id,
action="back",
)
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"Часовой пояс: {settings.tz}\n\n"
"Торговый Telegram-бот для контроля рынка, портфеля, журнала событий "
"и будущей автоторговли."
"и автоторговли."
)
builder = InlineKeyboardBuilder()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,9 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
keyboard=[
[
KeyboardButton(text="🤖 Автоторговля"),
KeyboardButton(text="💹 Торговля"),
KeyboardButton(text="💼 Портфель"),
],
[
KeyboardButton(text="📊 Мониторинг"),
KeyboardButton(text="🖥️ Система"),
],
],

View File

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

View File

@@ -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.home import router as home_router
from src.telegram.handlers.journal import router as journal_router
from src.telegram.handlers.market import router as market_router
from src.telegram.handlers.monitoring import router as monitoring_router
from src.telegram.handlers.portfolio import router as portfolio_router
from src.telegram.handlers.start import router as start_router
from src.telegram.handlers.system import router as system_router
from src.telegram.handlers.trade.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:
dispatcher.include_router(start_router)
dispatcher.include_router(home_router)
dispatcher.include_router(monitoring_router)
dispatcher.include_router(market_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(journal_router)
dispatcher.include_router(debug_auto_router)

View File

@@ -5,8 +5,15 @@ from __future__ import annotations
from dataclasses import dataclass
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
@@ -17,7 +24,10 @@ class ExchangeErrorView:
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи."
DEFAULT_AUTH_DETAILS = (
"Не удалось выполнить приватный запрос к аккаунту.\n"
"Проверь API-ключ, Secret Key, IP whitelist и права доступа."
)
DEFAULT_TIME_DETAILS = (
"Не удалось выполнить запрос к бирже.\n"
"Проверь синхронизацию времени и обнови экран."
@@ -25,43 +35,7 @@ DEFAULT_TIME_DETAILS = (
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
def classify_exchange_error(exc: Exception) -> str:
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"
# Собрать UI-представление ошибки через единый exchange status layer.
def _build_exchange_error_view(
*,
exc: Exception,
@@ -70,32 +44,15 @@ def _build_exchange_error_view(
time_details: str | None = None,
generic_details: str | None = None,
) -> ExchangeErrorView:
error_type = classify_exchange_error(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,
)
_, headline, details = build_exchange_error_ui_parts(exc)
return ExchangeErrorView(
headline="🔴 Ошибка биржи",
details=generic_details or DEFAULT_GENERIC_DETAILS,
headline=headline,
details=details or generic_details or DEFAULT_GENERIC_DETAILS,
)
# Отрисовать единый текст ошибки биржи для message/callback экранов.
def render_exchange_error(
*,
title: str,
@@ -122,6 +79,7 @@ def render_exchange_error(
)
# Собрать клавиатуру для экранов с ошибкой биржи.
def exchange_error_keyboard(
*,
retry_callback_data: str | None = None,
@@ -129,7 +87,6 @@ def exchange_error_keyboard(
drafts_page: int | None = None,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
first_row: list[InlineKeyboardButton] = []
if retry_callback_data:
@@ -160,12 +117,12 @@ def exchange_error_keyboard(
)
]
)
else:
elif back_callback_data is None:
buttons.append(
[
InlineKeyboardButton(
text="🏠 К торговле",
callback_data="trade:home",
text="🤖 К автоторговле",
callback_data="auto:home",
)
]
)
@@ -173,6 +130,7 @@ def exchange_error_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
# Показать ошибку биржи через редактирование callback-сообщения.
async def show_callback_exchange_error(
callback: CallbackQuery,
*,
@@ -186,7 +144,9 @@ async def show_callback_exchange_error(
back_callback_data: str | None = None,
drafts_page: int | 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)
return
@@ -198,6 +158,7 @@ async def show_callback_exchange_error(
time_details=time_details,
generic_details=generic_details,
)
markup = exchange_error_keyboard(
retry_callback_data=retry_callback_data or callback.data,
back_callback_data=back_callback_data,
@@ -205,15 +166,17 @@ async def show_callback_exchange_error(
)
try:
await callback.message.edit_text(text, reply_markup=markup)
await message.edit_text(text, reply_markup=markup)
await callback.answer()
except TelegramBadRequest as tg_exc:
if "message is not modified" in str(tg_exc).lower():
await callback.answer("Ошибка всё ещё актуальна")
return
raise
# Показать ошибку биржи новым сообщением.
async def show_message_exchange_error(
message: Message,
*,

View File

View File

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

View File

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

View 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

View 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

View 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

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

View 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, "Состояние рынка анализируется.")

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

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

View File

@@ -400,6 +400,57 @@ class AutoTradeRunner:
except Exception:
pass
@classmethod
def _signal_price_payload(
cls,
*,
state,
payload: JsonDict,
signal: str,
) -> JsonDict:
bid_price = safe_float(payload.get("bid_price"))
ask_price = safe_float(payload.get("ask_price"))
last_price = safe_float(payload.get("last_price"))
if bid_price is None:
bid_price = safe_float(getattr(state, "execution_bid_price", None))
if ask_price is None:
ask_price = safe_float(getattr(state, "execution_ask_price", None))
if last_price is None:
last_price = safe_float(getattr(state, "execution_last_price", None))
signal_price = None
signal_price_role = "last"
if signal == "BUY":
signal_price = ask_price or last_price or bid_price
signal_price_role = "ask"
elif signal == "SELL":
signal_price = bid_price or last_price or ask_price
signal_price_role = "bid"
else:
signal_price = last_price or ask_price or bid_price
return {
"bid_price": bid_price,
"ask_price": ask_price,
"last_price": last_price,
"signal_price": signal_price,
"signal_price_role": signal_price_role,
"signal_price_source": (
payload.get("price_source")
or getattr(state, "execution_price_source", None)
),
"signal_price_age_seconds": (
payload.get("price_age_seconds")
or getattr(state, "execution_price_age_seconds", None)
),
}
@classmethod
def _publish_strong_signal_event(
cls,
@@ -410,6 +461,7 @@ class AutoTradeRunner:
signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
@@ -417,18 +469,35 @@ class AutoTradeRunner:
)
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:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
leverage = (
payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
)
reason = str(payload.get("reason") or state.last_signal_reason or "")
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper()
is_aligned_signal = cls._is_position_aligned_signal(
state=state,
signal=signal,
)
price_payload = cls._signal_price_payload(
state=state,
payload=payload,
signal=signal,
)
semantic_lines = cls._notification_reason_lines(state)
priority = cls._alert_priority(
confidence=confidence,
@@ -449,12 +518,11 @@ class AutoTradeRunner:
"leverage": leverage,
"reason": reason,
"position_context": position_context,
"decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context,
"bid_price": payload.get("bid_price"),
"ask_price": payload.get("ask_price"),
"last_price": payload.get("last_price"),
"is_position_aligned_signal": is_aligned_signal,
"decision_status": state.decision_status,
"semantic_lines": semantic_lines,
**price_payload,
},
priority=priority.lower(),
dedupe_key=(
@@ -466,7 +534,8 @@ class AutoTradeRunner:
f"{repeat_count}:"
f"{confidence:.2f}:"
f"{state.decision_status}:"
f"{reason}"
f"{reason}:"
f"aligned={is_aligned_signal}"
),
)
)

File diff suppressed because it is too large Load Diff

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

View File

@@ -404,4 +404,13 @@ class AutoTradeState:
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

View File

@@ -6,6 +6,7 @@ from typing import Any
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.runtime_ui import format_runtime_exchange_alert
class SemanticDiagnosticFormatter:
@@ -17,6 +18,11 @@ class SemanticDiagnosticFormatter:
execution = snapshot.get("execution", {})
adaptive = snapshot.get("adaptive_size", {})
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", {})
position = snapshot.get("position", {})
@@ -33,6 +39,9 @@ class SemanticDiagnosticFormatter:
self._status_block(status),
]
for item in exchange_statuses:
sections.append(self._runtime_exchange_block(item))
return "\n\n".join(
section.strip()
for section in sections
@@ -47,11 +56,16 @@ class SemanticDiagnosticFormatter:
market=market,
momentum=momentum,
),
]
for item in exchange_statuses:
sections.append(self._runtime_exchange_block(item))
sections.extend([
self._execution_block(execution),
self._signal_block(signal),
self._market_block(market),
self._momentum_block(momentum),
]
])
if mode != "COMPACT":
if has_position:
@@ -752,6 +766,7 @@ class SemanticDiagnosticFormatter:
quality = data.get("trend_quality")
volatility = data.get("volatility")
market_closed = data.get("market_is_open") is False
market_data_state = self._market_live_state(data.get("age_seconds"))
lines = [
(
@@ -759,10 +774,7 @@ class SemanticDiagnosticFormatter:
f"Рынок · "
f"{self._market_title(data)}"
),
(
f"• Данные: "
f"{self._market_live_state(data.get('age_seconds'))}"
),
f"• Данные: {market_data_state}",
]
if market_closed:
@@ -1215,23 +1227,23 @@ class SemanticDiagnosticFormatter:
).strip()
def _status_block(self, data: JsonDict) -> str:
status = str(data.get("status") or "")
status = str(data.get("status") or "").upper()
if status == "RUNNING":
icon = "🟢"
title = "работает"
elif status == "OBSERVING":
icon = "🟡"
title = "наблюдение"
icon = "👀"
title = "под наблюдением"
elif status == "OFF":
icon = ""
icon = ""
title = "остановлена"
else:
icon = ""
icon = "⛔️"
title = "не готова"
return (
f"{icon} Автоторговля · {title}\n"
f"{icon} Автоторговля {title}\n"
f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n"
f"• Стратегия: {data.get('strategy') or ''}\n"
f"• Настроено: {self._bool(data.get('is_configured'))}"
@@ -1319,7 +1331,7 @@ class SemanticDiagnosticFormatter:
age_seconds = None
if age_seconds is None:
add("Нет live-данных")
add("Live-поток недоступен")
elif age_seconds > 60:
add("Данные рынка устарели")
@@ -1798,6 +1810,9 @@ class SemanticDiagnosticFormatter:
):
return "⛔️"
if data.get("age_seconds") is None:
return "🟡"
if state == "UNKNOWN":
return "⚪️"
@@ -1904,7 +1919,7 @@ class SemanticDiagnosticFormatter:
seconds_float = safe_float(value)
if seconds_float is None:
return "нет данных"
return "REST"
seconds = int(seconds_float)
@@ -1991,4 +2006,10 @@ class SemanticDiagnosticFormatter:
if not items:
return ""
return "• Структура: " + " · ".join(items[:4])
return "• Структура: " + " · ".join(items[:4])
def _runtime_exchange_block(
self,
data: JsonDict,
) -> str:
return format_runtime_exchange_alert(data)

View File

@@ -7,6 +7,7 @@ from typing import Any
from src.trading.auto.state import AutoTradeState
from src.core.numbers import safe_float
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
class SemanticDiagnosticSnapshotBuilder:
@@ -38,6 +39,8 @@ class SemanticDiagnosticSnapshotBuilder:
current_price=position_current_price,
)
runtime_exchange_alerts = self._runtime_exchange_alerts(state)
return {
"status": {
"status": state.status,
@@ -161,6 +164,12 @@ class SemanticDiagnosticSnapshotBuilder:
"adverse_momentum": position_health.get("adverse_momentum"),
},
"runtime_health": {
"exchange_statuses": runtime_exchange_alerts,
"exchange_status": (
runtime_exchange_alerts[0]
if runtime_exchange_alerts
else None
),
"health_score": health_score,
"severity": severity,
"is_runtime_degraded": self._is_runtime_degraded(state),
@@ -800,4 +809,10 @@ class SemanticDiagnosticSnapshotBuilder:
return None
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)

View 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

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

View 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,
"Позиция закрыта.",
)

View 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

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

View 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

View 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

View 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

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

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

View 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

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

View File

@@ -135,6 +135,7 @@ def _metadata_rows(
export_limit: int,
account_mode: str,
journal_level: str,
export_filter_label: str = "Всё",
) -> list[list[str]]:
exported_count = len(rows)
is_limited = total_count > exported_count
@@ -143,6 +144,7 @@ def _metadata_rows(
["Экспорт журнала"],
["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")],
["Аккаунт", account_mode.upper()],
["Фильтр", export_filter_label],
["Уровень журнала", journal_level],
["Всего записей в журнале", str(total_count)],
["Записей в файле", str(exported_count)],
@@ -161,6 +163,7 @@ def build_csv(
export_limit: int,
account_mode: str,
journal_level: str,
export_filter_label: str = "Всё",
) -> bytes:
output = StringIO()
writer = csv.writer(
@@ -176,6 +179,7 @@ def build_csv(
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
export_filter_label=export_filter_label,
):
writer.writerow(metadata_row)
@@ -194,6 +198,7 @@ def build_xlsx(
export_limit: int,
account_mode: str,
journal_level: str,
export_filter_label: str = "Всё",
) -> bytes:
sheet_rows: list[list[str]] = []
@@ -204,6 +209,7 @@ def build_xlsx(
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
export_filter_label=export_filter_label,
)
)

View 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

View File

@@ -10,6 +10,11 @@ from src.core.config import load_settings
from src.storage.repositories.journal import JournalRepository
from src.storage.session import check_database_health
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
@@ -201,8 +206,17 @@ class JournalService:
def get_total_count(self) -> int:
return self.repository.count_events()
def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]:
return self.repository.list_export_rows(limit=limit)
def get_export_rows(
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:
return "INFO+"
@@ -216,20 +230,34 @@ class JournalService:
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_level = self._journal_level().lower().replace("+", "_plus")
safe_filter = normalize_journal_export_filter(export_filter)
return (
f"journal_"
f"{self._account_mode()}_"
f"{safe_filter}_"
f"{safe_level}_"
f"{self._export_timestamp()}."
f"{safe_extension}"
)
def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes:
rows = self.get_export_rows(limit=limit)
def export_csv(
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(
rows,
@@ -237,10 +265,19 @@ class JournalService:
export_limit=limit,
account_mode=self._account_mode(),
journal_level=self._journal_level(),
export_filter_label=journal_export_filter_label(filter_key),
)
def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes:
rows = self.get_export_rows(limit=limit)
def export_xlsx(
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(
rows,
@@ -248,6 +285,7 @@ class JournalService:
export_limit=limit,
account_mode=self._account_mode(),
journal_level=self._journal_level(),
export_filter_label=journal_export_filter_label(filter_key),
)
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,
),
),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,15 @@ class PositionState:
# торговый инструмент
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

View File

@@ -1432,6 +1432,25 @@
- реализована preparation for partial exit engine
- реализована 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

View File

@@ -1542,6 +1542,25 @@
- реализована preparation for partial exit engine
- реализована 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

View File

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