07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer

This commit is contained in:
2026-05-20 21:15:00 +03:00
parent 2c75f95b46
commit 06ea376cb5
36 changed files with 6260 additions and 2092 deletions

23
app/src/core/numbers.py Normal file
View File

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

View File

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

12
app/src/core/types.py Normal file
View File

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

View File

@@ -22,6 +22,7 @@ class MarketRuntimeContext:
screen: str | None screen: str | None
action: str action: str
runtime_label: str | None runtime_label: str | None
last_market_status: str | None = None
class MarketDataRunner: class MarketDataRunner:
@@ -116,18 +117,29 @@ class MarketDataRunner:
): ):
MarketPriceCache.clear(cache_symbol) MarketPriceCache.clear(cache_symbol)
#if previous_symbol is not None: market_status = ExchangeService().get_symbol_market_status(symbol)
# cls._log_info( status_key = str(market_status.get("status") or "UNKNOWN")
# context,
# "market_symbol_changed", if not bool(market_status.get("is_open")):
# f"Инструмент автоторговли изменён: {cache_symbol}.", if context.last_market_status != status_key:
# { context.last_market_status = status_key
# "previous_symbol": previous_symbol,
# "symbol": symbol, cls._log_warning(
# "cache_symbol": cache_symbol, context,
# "ws_symbol": ws_symbol, "market_closed",
# }, "Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
# ) {
"symbol": symbol,
"market_status": status_key,
"message": market_status.get("message"),
},
)
await asyncio.sleep(context.interval_seconds)
continue
context.last_market_status = status_key
try: try:
await cls._run_websocket(context, symbol) await cls._run_websocket(context, symbol)
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -36,6 +36,73 @@ class ExchangeService:
_execution_cache_max_age_seconds = 2.0 _execution_cache_max_age_seconds = 2.0
_default_runtime_key = "auto" _default_runtime_key = "auto"
def get_symbol_market_status(self, symbol: str | None = None) -> dict[str, object]:
symbol_to_use = symbol or self.settings.default_symbol
if not self.settings.exchange_enabled:
return {
"symbol": symbol_to_use,
"is_open": True,
"status": "OPEN",
"message": "Mock market is open.",
}
validation = self.validate_symbol(symbol_to_use)
if not validation.is_valid:
return {
"symbol": symbol_to_use,
"is_open": False,
"status": "INVALID_SYMBOL",
"message": validation.message,
}
symbol_info = validation.symbol_info
raw_status = str(getattr(symbol_info, "status", "") or "").upper()
open_statuses = {
"TRADING",
"OPEN",
"ACTIVE",
"ENABLED",
"ONLINE",
}
closed_statuses = {
"BREAK",
"CLOSED",
"HALT",
"HALTED",
"PAUSED",
"SUSPENDED",
"DISABLED",
"SETTLING",
"POST_ONLY",
}
if raw_status in open_statuses:
return {
"symbol": validation.normalized_symbol,
"is_open": True,
"status": raw_status,
"message": "Рынок открыт.",
}
if raw_status in closed_statuses:
return {
"symbol": validation.normalized_symbol,
"is_open": False,
"status": raw_status,
"message": "Рынок закрыт или на паузе.",
}
return {
"symbol": validation.normalized_symbol,
"is_open": False,
"status": raw_status or "UNKNOWN",
"message": "Статус рынка не определён.",
}
def __init__(self) -> None: def __init__(self) -> None:
self.settings = load_settings() self.settings = load_settings()
self.journal = JournalService() self.journal = JournalService()

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from src.notifications.models import NotificationMessage from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent from src.runtime_events.models import RuntimeEvent
from src.core.numbers import safe_float
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None: def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
@@ -27,8 +28,9 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload payload = event.payload
symbol = _format_symbol(payload.get("symbol")) symbol = _format_symbol(payload.get("symbol"))
strategy = str(payload.get("strategy") or "") strategy = str(payload.get("strategy") or "").title()
side = str(payload.get("side") or "").upper() side_raw = str(payload.get("side") or "").upper()
side = side_raw.title()
leverage = _format_leverage(payload.get("leverage")) leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price")) entry_price = _format_price(payload.get("entry_price"))
size = _format_size(payload.get("size")) size = _format_size(payload.get("size"))
@@ -39,13 +41,13 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
) )
semantic_lines = payload.get("semantic_lines") or [] semantic_lines = payload.get("semantic_lines") or []
side_icon = "🟢" if side == "LONG" else "🔴" side_icon = "🟢" if side_raw == "LONG" else "🔴"
lines = [ lines = [
"<b>🧾 Позиция открыта</b>", "<b>🧾 Позиция открыта</b>",
"", "",
f"{side_icon} {symbol} · {strategy} · {side} {leverage}", f"{side_icon} {symbol} · {strategy} · {side} {leverage}",
f"Вход: {entry_price}", f"Вход: ${entry_price}",
f"Размер: {size}", f"Размер: {size}",
f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}", f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}",
"", "",
@@ -71,7 +73,7 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload payload = event.payload
symbol = _format_symbol(payload.get("symbol")) symbol = _format_symbol(payload.get("symbol"))
side = str(payload.get("side") or "").upper() side = str(payload.get("side") or "").title()
leverage = _format_leverage(payload.get("leverage")) leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price")) entry_price = _format_price(payload.get("entry_price"))
@@ -91,8 +93,8 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
f"{pnl_icon} {pnl_label} · {pnl_text}", f"{pnl_icon} {pnl_label} · {pnl_text}",
"", "",
f"{symbol} · {side} {leverage}", f"{symbol} · {side} {leverage}",
f"Вход: $ {entry_price}", f"Вход: ${entry_price}",
f"Выход: $ {exit_price}", f"Выход: ${exit_price}",
f"Размер: {size}", f"Размер: {size}",
] ]
@@ -138,8 +140,13 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
symbol = _format_symbol(payload.get("symbol")) symbol = _format_symbol(payload.get("symbol"))
strategy = str(payload.get("strategy") or "").title() strategy = str(payload.get("strategy") or "").title()
old_side = str(payload.get("old_side") or "").upper() old_side_raw = str(payload.get("old_side") or "").upper()
new_side = str(payload.get("new_side") or payload.get("side") or "").upper() new_side_raw = str(
payload.get("new_side") or payload.get("side") or ""
).upper()
old_side = old_side_raw.title()
new_side = new_side_raw.title()
old_leverage = _format_leverage( old_leverage = _format_leverage(
payload.get("old_leverage") payload.get("old_leverage")
@@ -161,8 +168,8 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
pnl_icon = "🟢" if pnl_value >= 0 else "🔴" pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток" pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
old_icon = "🟢" if old_side == "LONG" else "🔴" old_icon = "🟢" if old_side_raw == "LONG" else "🔴"
new_icon = "🟢" if new_side == "LONG" else "🔴" new_icon = "🟢" if new_side_raw == "LONG" else "🔴"
confidence = float(payload.get("confidence") or 0.0) confidence = float(payload.get("confidence") or 0.0)
repeat_count = int(payload.get("repeat_count") or 0) repeat_count = int(payload.get("repeat_count") or 0)
@@ -178,12 +185,12 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
f"{symbol} · {strategy} {old_icon} {old_side}{new_icon} {new_side}", f"{symbol} · {strategy} {old_icon} {old_side}{new_icon} {new_side}",
"", "",
f"Закрыта {old_side} {old_leverage}", f"Закрыта {old_side} {old_leverage}",
f"Вход: $ {entry_price}", f"Вход: ${entry_price}",
f"Выход: $ {exit_price}", f"Выход: ${exit_price}",
f"Размер: {old_size}", f"Размер: {old_size}",
"", "",
f"Открыта {new_side} {new_leverage}", f"Открыта {new_side} {new_leverage}",
f"Вход: $ {new_entry_price}", f"Вход: ${new_entry_price}",
f"Размер: {new_size}", f"Размер: {new_size}",
( (
"Объём: " "Объём: "
@@ -215,9 +222,9 @@ def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage:
signal = str(payload.get("signal") or "").upper() signal = str(payload.get("signal") or "").upper()
confidence = float(payload.get("confidence") or 0.0) confidence = float(payload.get("confidence") or 0.0)
reason = str(payload.get("reason") or "Flip заблокирован") reason = str(payload.get("reason") or "Flip заблокирован")
position_side = str(payload.get("position_side") or "").upper() position_side = str(payload.get("position_side") or "").title()
target_side = "LONG" if signal == "BUY" else "SHORT" if signal == "SELL" else "" target_side = "Long" if signal == "BUY" else "Short" if signal == "SELL" else ""
icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else "" icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else ""
text = ( text = (
@@ -247,43 +254,30 @@ def _format_symbol(value: object) -> str:
def _format_leverage(value: object) -> str: def _format_leverage(value: object) -> str:
try: number = safe_float(value)
return f"x{float(value):g}"
except (TypeError, ValueError): if number is None:
return "" return ""
return f"x{number:g}"
def _format_price(value: object) -> str: def _format_price(value: object) -> str:
try: number = safe_float(value)
number = float(value)
except (TypeError, ValueError): if number is None:
return "" return ""
return f"{number:,.2f}".replace(",", " ") return f"{number:,.2f}".replace(",", " ")
def _format_size(value: object) -> str: def _format_size(value: object) -> str:
try: number = safe_float(value)
return f"{float(value):.8f}".rstrip("0").rstrip(".")
except (TypeError, ValueError): if number is None:
return "" return ""
return f"{number:.8f}".rstrip("0").rstrip(".")
def _format_pnl(value: object) -> str:
try:
number = float(value)
except (TypeError, ValueError):
return ""
amount = f"$ {abs(number):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
if number > 0:
return f"🟢 +{amount}"
if number < 0:
return f"🔴 {amount}"
return "$ 0"
def _alert_priority(*, confidence: float, repeat_count: int) -> str: def _alert_priority(*, confidence: float, repeat_count: int) -> str:
@@ -319,9 +313,12 @@ def _format_notional(
entry_price: object, entry_price: object,
size: object, size: object,
) -> str: ) -> str:
try: entry = safe_float(entry_price)
value = float(entry_price) * float(size) amount = safe_float(size)
except (TypeError, ValueError):
if entry is None or amount is None:
return "" return ""
value = entry * amount
return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".") return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
from src.notifications.models import NotificationMessage from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent from src.runtime_events.models import RuntimeEvent
@@ -11,34 +13,49 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY: if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY:
return None return None
payload = event.payload payload: JsonDict = event.payload
signal = str(payload.get("signal") or "").upper() signal = str(payload.get("signal") or "").upper()
symbol = _format_symbol(str(payload.get("symbol") or "")) symbol = _format_symbol(str(payload.get("symbol") or ""))
confidence = float(payload.get("confidence") or 0.0) confidence = safe_float(payload.get("confidence")) or 0.0
position_context = str(payload.get("position_context") or "NONE").upper() repeat_count = int(safe_float(payload.get("repeat_count")) or 0)
semantic_lines = payload.get("semantic_lines") or []
priority = str(event.priority or _alert_priority( position_context = str(payload.get("position_context") or "NONE").upper()
confidence=confidence, semantic_lines = _as_json_list(payload.get("semantic_lines"))
repeat_count=int(payload.get("repeat_count") or 0),
)).upper() bid_price = payload.get("bid_price")
ask_price = payload.get("ask_price")
priority = str(
event.priority
or _alert_priority(
confidence=confidence,
repeat_count=repeat_count,
)
).upper()
direction = _signal_direction(signal) direction = _signal_direction(signal)
direction_key = direction.upper()
icon = _direction_icon(direction) icon = _direction_icon(direction)
strength = _strength_label(priority) strength = _strength_label(priority)
strength_bar = _strength_bar(priority) strength_bar = _strength_bar(priority)
market_price_line = _market_price_line(
direction=direction_key,
bid_price=bid_price,
ask_price=ask_price,
)
lines = [ lines = [
f"<b>Сигнал {icon} {symbol} · {direction}</b>", f"<b>Сигнал {icon} {symbol} · {direction}</b>",
"", "",
] ]
if position_context not in {"NONE", "", ""} and position_context != direction: if market_price_line:
lines.extend([ lines.extend([market_price_line, ""])
"⚠️ ПРОТИВ ПОЗИЦИИ",
"", if position_context not in {"NONE", "", ""} and position_context != direction_key:
]) lines.extend(["⚠️ ПРОТИВ ПОЗИЦИИ", ""])
lines.append(f"{strength_bar} {strength} · {confidence:.2f}") lines.append(f"{strength_bar} {strength} · {confidence:.2f}")
@@ -87,19 +104,21 @@ def _strength_bar(priority: str) -> str:
def _signal_direction(signal: str) -> str: def _signal_direction(signal: str) -> str:
if signal == "BUY": if signal == "BUY":
return "LONG" return "Long"
if signal == "SELL": if signal == "SELL":
return "SHORT" return "Short"
return "" return ""
def _direction_icon(direction: str) -> str: def _direction_icon(direction: str) -> str:
if direction == "LONG": normalized = direction.upper()
if normalized == "LONG":
return "🟢" return "🟢"
if direction == "SHORT": if normalized == "SHORT":
return "🔴" return "🔴"
return "" return ""
@@ -112,14 +131,39 @@ def _format_symbol(symbol: str) -> str:
return symbol.split("_", 1)[0].split("/", 1)[0].upper() return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_leverage(leverage: object) -> str: def _market_price_line(
if isinstance(leverage, (int, float)): *,
return f"x{leverage:g}" direction: str,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> str:
bid = _format_price(bid_price)
ask = _format_price(ask_price)
return "" 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 _dedupe_key(payload: dict) -> str: def _format_price(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return ""
return f"{number:,.2f}".replace(",", " ")
def _dedupe_key(payload: JsonDict) -> str:
confidence = safe_float(payload.get("confidence")) or 0.0
return ( return (
f"auto_signal_ready:" f"auto_signal_ready:"
f"{payload.get('position_context')}:" f"{payload.get('position_context')}:"
@@ -127,7 +171,14 @@ def _dedupe_key(payload: dict) -> str:
f"{payload.get('strategy')}:" f"{payload.get('strategy')}:"
f"{payload.get('signal')}:" f"{payload.get('signal')}:"
f"{payload.get('repeat_count')}:" f"{payload.get('repeat_count')}:"
f"{float(payload.get('confidence') or 0.0):.2f}:" f"{confidence:.2f}:"
f"{payload.get('decision_status')}:" f"{payload.get('decision_status')}:"
f"{payload.get('reason')}" f"{payload.get('reason')}"
) )
def _as_json_list(value: object) -> JsonList:
if isinstance(value, list):
return value
return []

View File

@@ -1,364 +0,0 @@
# app/src/telegram/handlers/auto.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.ui.common import mode_line
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.telegram.handlers.system import open_auto_settings
from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.currency_ui import format_usd_amount
router = Router(name="auto")
# красивое отображение стратегии
def _strategy_label(strategy: str | None) -> str:
mapping = {
"TREND": "📈 Trend Following",
"GRID": "🧩 Grid Trading",
"SCALP": "⚡ Scalping",
}
return mapping.get(strategy or "", "")
# красивое отображение статуса
def _status_label(status: str) -> str:
mapping = {
"OFF": "⚪ Выключена",
"OBSERVING": "👀 Наблюдение",
"RUNNING": "🟢 Активна",
}
return mapping.get(status, status)
# красивое отображение сигнала
def _signal_label(signal: str | None) -> str:
mapping = {
"BUY": "🟢 BUY",
"SELL": "🔴 SELL",
"HOLD": "🟡 HOLD",
}
return mapping.get(signal or "", "")
# красивое отображение решения
def _decision_label(status: str) -> str:
mapping = {
"WAITING": "🟡 Ожидание",
"CONFIRMING": "🟠 Подтверждение",
"READY": "🟢 Готово к входу",
"BLOCKED": "🔴 Заблокировано",
}
return mapping.get(status, status)
# компактное значение или заглушка
def _value_or_dash(value: object) -> str:
if value is None:
return ""
return str(value)
# формат цены
def _price_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f}"
# текущая цена инструмента
def _market_price_or_dash(symbol: str | None) -> str:
if not symbol:
return ""
try:
ticker = ExchangeService().get_price(symbol)
return f"$ {format_usd_amount(ticker.price)}"
except Exception:
return ""
# формат USD
def _usd_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f} USD"
# формат размера позиции
def _size_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.8f}".rstrip("0").rstrip(".")
# формат плеча
def _leverage_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.1f}x"
# формат торгового инструмента для UI
def _format_symbol(symbol: str | None) -> str:
if not symbol:
return ""
base_symbol = symbol.split("_", 1)[0]
parts = base_symbol.split("/", 1)
if len(parts) == 2:
return f"{parts[0]} / {parts[1]}"
return base_symbol
# стратегия для компактного UI
def _compact_strategy(strategy: str | None) -> str:
if not strategy:
return ""
return strategy.upper()
# плечо для компактного UI
def _compact_leverage(value: float | None) -> str:
if value is None:
return ""
return f"x{value:g}"
# проверка, настроена ли автоторговля минимально
def _is_auto_configured(state) -> bool:
return bool(
state.symbol
and state.strategy
and state.risk_percent is not None
)
# строка инструмента / стратегии / плеча
def _context_line(state) -> str:
symbol = _format_symbol(state.symbol)
strategy = _compact_strategy(state.strategy)
leverage = _compact_leverage(state.leverage)
if leverage == "":
return f"{symbol} · {strategy}"
return f"{symbol} · {strategy} · {leverage}"
# клавиатура автоторговли
def _auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="▶️ Start", callback_data="auto:start")
builder.button(text="👀 Watch", callback_data="auto:observe")
builder.button(text="🛑 Stop", callback_data="auto:stop")
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
builder.adjust(3, 1)
return builder.as_markup()
# собрать текст экрана
def _build_auto_text() -> str:
service = AutoTradeService()
state = service.get_state()
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else ""
configured = _is_auto_configured(state)
price = _market_price_or_dash(state.symbol)
status_line = {
"OFF": "⚪ Off",
"OBSERVING": "👀 Watch",
"RUNNING": "🟢 On",
}.get(state.status, state.status)
header = (
f"<b>🤖 Автоторговля · {status_line}</b>\n"
f"🔸 {account_mode} аккаунт\n\n"
)
if state.status == "OFF":
if not configured:
return (
f"{header}"
"⚠️ Не настроена\n"
"Настрой параметры"
)
return (
f"{header}"
f"{_context_line(state)}\n"
f"Price: {price}\n"
f"Risk: {risk}"
)
position_line = (
f"Pos: {_value_or_dash(state.position_side)} | "
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
)
if state.position_side != "NONE" and state.entry_price is not None:
position_line = (
f"Pos: {_value_or_dash(state.position_side)} | "
f"Entry: $ {_price_or_dash(state.entry_price)} | "
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
)
return (
f"{header}"
f"{_context_line(state)}\n"
f"Price: {price}\n\n"
f"{_signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
f"· {state.decision_status}\n\n"
f"{position_line}\n"
f"Risk: {risk}"
)
# отрисовать live-экран автоторговли
async def _render_auto_screen(
target_message: Message,
*,
edit_mode: bool,
) -> None:
text = _build_auto_text()
if edit_mode:
try:
await target_message.edit_text(text, reply_markup=_auto_keyboard())
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
AutoTradeRunner.register_screen(
bot=target_message.bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=_build_auto_text,
render_markup=_auto_keyboard,
)
return
sent_message = await target_message.answer(text, reply_markup=_auto_keyboard())
AutoTradeRunner.register_screen(
bot=sent_message.bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=_build_auto_text,
render_markup=_auto_keyboard,
)
# открыть экран из главного меню
@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear()
AutoTradeRunner.set_current_screen("auto")
current_state = AutoTradeService().get_state()
if current_state.status in {"RUNNING", "OBSERVING"}:
await AutoTradeRunner.delete_registered_screen(
bot=message.bot,
chat_id=message.chat.id,
)
await _render_auto_screen(message, edit_mode=False)
# открыть экран через callback
@router.callback_query(F.data == "auto:home")
async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
AutoTradeRunner.set_current_screen("auto")
await _render_auto_screen(callback.message, edit_mode=True)
await callback.answer()
# включить активную торговлю
@router.callback_query(F.data == "auto:start")
async def auto_start(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not _is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if callback.message is not None:
await open_auto_settings(callback)
return
_, message = service.start()
AutoTradeRunner.set_current_screen("auto")
AutoTradeRunner.start()
if callback.message is not None:
await _render_auto_screen(callback.message, edit_mode=True)
await callback.answer(message)
# включить наблюдение
@router.callback_query(F.data == "auto:observe")
async def auto_observe(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not _is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if callback.message is not None:
await open_auto_settings(callback)
return
_, message = service.observe()
AutoTradeRunner.set_current_screen("auto")
AutoTradeRunner.start()
if callback.message is not None:
await _render_auto_screen(callback.message, edit_mode=True)
await callback.answer(message)
# выключить автоторговлю
@router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService()
_, message = service.stop()
AutoTradeRunner.stop()
if callback.message is not None:
await _render_auto_screen(callback.message, edit_mode=True)
await callback.answer(message)

View File

@@ -1,11 +1,11 @@
# app/src/telegram/handlers/auto/main.py # app/src/telegram/handlers/auto/main.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, InaccessibleMessage, Message
from src.telegram.handlers.auto.ui import ( from src.telegram.handlers.auto.ui import (
auto_diagnostics_keyboard, auto_diagnostics_keyboard,
@@ -24,6 +24,20 @@ from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto") router = Router(name="auto")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
async def render_auto_screen( async def render_auto_screen(
target_message: Message, target_message: Message,
*, *,
@@ -38,8 +52,13 @@ async def render_auto_screen(
if "message is not modified" not in str(exc).lower(): if "message is not modified" not in str(exc).lower():
raise raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen( AutoTradeRunner.register_screen(
bot=target_message.bot, bot=bot,
chat_id=target_message.chat.id, chat_id=target_message.chat.id,
message_id=target_message.message_id, message_id=target_message.message_id,
render_text=build_auto_text, render_text=build_auto_text,
@@ -53,9 +72,13 @@ async def render_auto_screen(
return return
sent_message = await target_message.answer(text, reply_markup=auto_keyboard()) sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen( AutoTradeRunner.register_screen(
bot=sent_message.bot, bot=bot,
chat_id=sent_message.chat.id, chat_id=sent_message.chat.id,
message_id=sent_message.message_id, message_id=sent_message.message_id,
render_text=build_auto_text, render_text=build_auto_text,
@@ -68,29 +91,43 @@ async def render_auto_screen(
) )
async def _prepare_auto_from_message(message: Message) -> None: async def _prepare_auto_from_message(message: Message) -> bool:
await ActiveScreenManager.prepare_new_screen( bot = message.bot
screen="auto",
bot=message.bot,
chat_id=message.chat.id,
)
if bot is None:
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return False return False
await ActiveScreenManager.prepare_new_screen( await ActiveScreenManager.prepare_new_screen(
screen="auto", screen="auto",
bot=callback.message.bot, bot=bot,
chat_id=callback.message.chat.id, chat_id=message.chat.id,
keep_message_id=callback.message.message_id,
) )
return True return True
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
def build_auto_diagnostics_text() -> str: def build_auto_diagnostics_text() -> str:
service = AutoTradeService() service = AutoTradeService()
@@ -118,10 +155,28 @@ async def render_auto_diagnostics_screen(
error_text = str(exc).lower() error_text = str(exc).lower()
if "message to edit not found" in error_text: if "message to edit not found" in error_text:
await target_message.answer( sent_message = await target_message.answer(
text, text,
reply_markup=auto_diagnostics_keyboard(), reply_markup=auto_diagnostics_keyboard(),
) )
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_diagnostics_text,
render_markup=auto_diagnostics_keyboard,
)
ActiveScreenManager.register(
screen="auto_diagnostics",
message=sent_message,
)
return return
if "message is not modified" in error_text: if "message is not modified" in error_text:
@@ -129,8 +184,13 @@ async def render_auto_diagnostics_screen(
raise raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen( AutoTradeRunner.register_screen(
bot=target_message.bot, bot=bot,
chat_id=target_message.chat.id, chat_id=target_message.chat.id,
message_id=target_message.message_id, message_id=target_message.message_id,
render_text=build_auto_diagnostics_text, render_text=build_auto_diagnostics_text,
@@ -147,7 +207,8 @@ async def render_auto_diagnostics_screen(
async def open_auto(message: Message, state: FSMContext) -> None: async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await _prepare_auto_from_message(message) if not await _prepare_auto_from_message(message):
return
await render_auto_screen(message, edit_mode=False) await render_auto_screen(message, edit_mode=False)
@@ -159,7 +220,13 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
if not await _prepare_auto_from_callback(callback): if not await _prepare_auto_from_callback(callback):
return return
await render_auto_screen(callback.message, edit_mode=True) message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await render_auto_screen(message, edit_mode=True)
await callback.answer() await callback.answer()
@@ -174,20 +241,22 @@ async def auto_start(callback: CallbackQuery) -> None:
show_alert=True, show_alert=True,
) )
if callback.message is not None: if _require_message(callback) is not None:
await open_auto_settings(callback) await open_auto_settings(callback)
return return
_, message = service.start() _, message_text = service.start()
if callback.message is not None: if await _prepare_auto_from_callback(callback):
await _prepare_auto_from_callback(callback) message = _require_message(callback)
await render_auto_screen(callback.message, edit_mode=True)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start() AutoTradeRunner.start()
await callback.answer(message) await callback.answer(message_text)
@router.callback_query(F.data == "auto:observe") @router.callback_query(F.data == "auto:observe")
@@ -201,48 +270,60 @@ async def auto_observe(callback: CallbackQuery) -> None:
show_alert=True, show_alert=True,
) )
if callback.message is not None: if _require_message(callback) is not None:
await open_auto_settings(callback) await open_auto_settings(callback)
return return
_, message = service.observe() _, message_text = service.observe()
if callback.message is not None: if await _prepare_auto_from_callback(callback):
await _prepare_auto_from_callback(callback) message = _require_message(callback)
await render_auto_screen(callback.message, edit_mode=True)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start() AutoTradeRunner.start()
await callback.answer(message) await callback.answer(message_text)
@router.callback_query(F.data == "auto:stop") @router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None: async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService() service = AutoTradeService()
_, message = service.stop() _, message_text = service.stop()
AutoTradeRunner.stop() AutoTradeRunner.stop()
if callback.message is not None: if await _prepare_auto_from_callback(callback):
await _prepare_auto_from_callback(callback) message = _require_message(callback)
await render_auto_screen(callback.message, edit_mode=True)
await callback.answer(message) if message is not None:
await render_auto_screen(message, edit_mode=True)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:diagnostics") @router.callback_query(F.data == "auto:diagnostics")
async def open_auto_diagnostics(callback: CallbackQuery) -> None: async def open_auto_diagnostics(callback: CallbackQuery) -> None:
if callback.message is None: message = _require_message(callback)
await callback.answer("Сообщение не найдено", show_alert=True)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( await ActiveScreenManager.prepare_new_screen(
screen="auto_diagnostics", screen="auto_diagnostics",
bot=callback.message.bot, bot=bot,
chat_id=callback.message.chat.id, chat_id=message.chat.id,
keep_message_id=callback.message.message_id, keep_message_id=message.message_id,
) )
await render_auto_diagnostics_screen(callback.message) await render_auto_diagnostics_screen(message)
await callback.answer() await callback.answer()

View File

@@ -7,9 +7,16 @@ import asyncio
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
CallbackQuery,
InlineKeyboardMarkup,
Message,
InaccessibleMessage,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -18,17 +25,31 @@ from src.trading.journal.service import JournalService
router = Router(name="auto_risk") router = Router(name="auto_risk")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
class AutoRiskStates(StatesGroup): class AutoRiskStates(StatesGroup):
waiting_stop_loss = State() waiting_stop_loss = State()
waiting_take_profit = State() waiting_take_profit = State()
waiting_max_loss = State() waiting_max_loss = State()
def _format_number(value: float | int | None) -> str: def _format_number(value: NumericLike | None) -> str:
if value is None: number = safe_float(value)
return ""
number = float(value) if number is None:
return ""
if abs(number - round(number)) < 1e-9: if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}" return f"{int(round(number))}"
@@ -36,20 +57,26 @@ def _format_number(value: float | int | None) -> str:
return f"{number:.2f}".rstrip("0").rstrip(".") return f"{number:.2f}".rstrip("0").rstrip(".")
def _format_percent(value: float | None) -> str: def _format_percent(value: NumericLike | None) -> str:
if value is None: number = safe_float(value)
if number is None:
return "off" return "off"
return f"{_format_number(value)}%"
return f"{_format_number(number)}%"
def _format_usd(value: float | None) -> str: def _format_usd(value: NumericLike | None) -> str:
if value is None: number = safe_float(value)
if number is None:
return "off" return "off"
return f"{_format_number(value)} USD"
return f"{_format_number(number)} USD"
def _rule_icon(value: float | None) -> str: def _rule_icon(value: NumericLike | None) -> str:
return "" if value is not None else "⚠️" return "" if safe_float(value) is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup: def _risk_keyboard() -> InlineKeyboardMarkup:
@@ -96,19 +123,27 @@ def _risk_text(status_message: str | None = None) -> str:
return text return text
async def _render_risk_screen(callback: CallbackQuery) -> None: async def _render_risk_screen(
callback: CallbackQuery,
) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
if callback.message is None: message = _require_message(callback)
await callback.answer("Сообщение не найдено", show_alert=True)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return return
_unregister_auto_screen_message(callback) _unregister_auto_screen_message(callback)
await callback.message.edit_text( await message.edit_text(
_risk_text(), _risk_text(),
reply_markup=_risk_keyboard(), reply_markup=_risk_keyboard(),
) )
await callback.answer() await callback.answer()
@@ -121,18 +156,34 @@ async def _render_risk_screen_by_message(
) -> None: ) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
data = await state.get_data() bot = message.bot
chat_id = data.get("risk_chat_id")
message_id = data.get("risk_message_id")
if chat_id is None or message_id is None: if bot is None:
return
data: JsonDict = await state.get_data()
raw_chat_id = data.get("risk_chat_id")
raw_message_id = data.get("risk_message_id")
if not isinstance(raw_chat_id, int):
await message.answer( await message.answer(
_risk_text(status_message=status_message), _risk_text(status_message=status_message),
reply_markup=_risk_keyboard(), reply_markup=_risk_keyboard(),
) )
return return
await message.bot.edit_message_text( if not isinstance(raw_message_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
chat_id = raw_chat_id
message_id = raw_message_id
await bot.edit_message_text(
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
text=_risk_text(status_message=status_message), text=_risk_text(status_message=status_message),
@@ -146,7 +197,7 @@ async def _render_risk_screen_by_message(
return return
try: try:
await message.bot.edit_message_text( await bot.edit_message_text(
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
text=_risk_text(), text=_risk_text(),
@@ -156,33 +207,56 @@ async def _render_risk_screen_by_message(
pass pass
async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> None: async def _remember_risk_screen(
if callback.message is None: callback: CallbackQuery,
state: FSMContext,
) -> None:
message = _require_message(callback)
if message is None:
return return
await state.update_data( await state.update_data(
risk_chat_id=callback.message.chat.id, risk_chat_id=message.chat.id,
risk_message_id=callback.message.message_id, risk_message_id=message.message_id,
) )
def _unregister_auto_screen_message(callback: CallbackQuery) -> None: def _unregister_auto_screen_message(
if callback.message is None: callback: CallbackQuery,
) -> None:
message = _require_message(callback)
if message is None:
return return
AutoTradeRunner.unregister_screen( AutoTradeRunner.unregister_screen(
chat_id=callback.message.chat.id, chat_id=message.chat.id,
message_id=callback.message.message_id, message_id=message.message_id,
) )
def _parse_positive_or_none(raw_text: str | None) -> float | None: def _risk_payload(**values: object) -> JsonDict:
return dict(values)
def _parse_positive_or_none(
raw_text: str | None,
) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".") value_text = (raw_text or "").strip().replace(",", ".")
if value_text.lower() in {"0", "0.0", "off", "-"}: if value_text.lower() in {
"0",
"0.0",
"off",
"-",
}:
return None return None
value = float(value_text) value = safe_float(value_text)
if value is None:
raise ValueError
if value <= 0: if value <= 0:
return None return None
@@ -190,16 +264,26 @@ def _parse_positive_or_none(raw_text: str | None) -> float | None:
return value return value
def _validate_percent(value: float | None) -> bool: def _validate_percent(
if value is None: value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True return True
return 0 < value <= 100
return 0 < number <= 100
def _validate_max_loss(value: float | None) -> bool: def _validate_max_loss(
if value is None: value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True return True
return 0 < value <= 10000
return 0 < number <= 10000
def _log_risk_updated(action: str) -> None: def _log_risk_updated(action: str) -> None:
@@ -216,11 +300,11 @@ def _log_risk_updated(action: str) -> None:
), ),
screen="auto", screen="auto",
action=action, action=action,
payload={ payload=_risk_payload(
"stop_loss_percent": state.stop_loss_percent, stop_loss_percent=state.stop_loss_percent,
"take_profit_percent": state.take_profit_percent, take_profit_percent=state.take_profit_percent,
"max_loss_usd": state.max_loss_usd, max_loss_usd=state.max_loss_usd,
}, ),
) )
except Exception: except Exception:
pass pass
@@ -242,17 +326,26 @@ async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContex
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None: async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback) _unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_stop_loss) await state.set_state(AutoRiskStates.waiting_stop_loss)
await _remember_risk_screen(callback, state) await _remember_risk_screen(callback, state)
if callback.message is not None: await message.edit_text(
await callback.message.edit_text( "<b>Stop Loss</b>\n\n"
"<b>Stop Loss</b>\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "Введите Stop Loss в процентах.\n"
"Введите Stop Loss в процентах.\n" "Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n"
"Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n" "отключить параметр - <code>0</code>"
"отключить параметр - <code>0</code>" )
)
await callback.answer() await callback.answer()
@@ -261,17 +354,26 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None: async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback) _unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_take_profit) await state.set_state(AutoRiskStates.waiting_take_profit)
await _remember_risk_screen(callback, state) await _remember_risk_screen(callback, state)
if callback.message is not None: await message.edit_text(
await callback.message.edit_text( "<b>Take Profit</b>\n\n"
"<b>Take Profit</b>\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "Введите Take Profit в процентах.\n"
"Введите Take Profit в процентах.\n" "Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n"
"Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n" "отключить параметр - <code>0</code>"
"отключить параметр - <code>0</code>" )
)
await callback.answer() await callback.answer()
@@ -280,17 +382,26 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None: async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback) _unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_max_loss) await state.set_state(AutoRiskStates.waiting_max_loss)
await _remember_risk_screen(callback, state) await _remember_risk_screen(callback, state)
if callback.message is not None: await message.edit_text(
await callback.message.edit_text( "<b>Maximum Loss</b>\n\n"
"<b>Maximum Loss</b>\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "Введите максимальный paper-убыток в USD.\n"
"Введите максимальный paper-убыток в USD.\n" "Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n"
"Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n" "отключить параметр - <code>0</code>"
"отключить параметр - <code>0</code>" )
)
await callback.answer() await callback.answer()
@@ -301,6 +412,12 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk") AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback) _unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = AutoTradeService() service = AutoTradeService()
service.set_stop_loss_percent(None) service.set_stop_loss_percent(None)
service.set_take_profit_percent(None) service.set_take_profit_percent(None)
@@ -308,29 +425,26 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
_log_risk_updated("risk_reset") _log_risk_updated("risk_reset")
if callback.message is not None: await message.edit_text(
await callback.message.edit_text( _risk_text(status_message="✅ Risk Controls сброшены"),
_risk_text(status_message="✅ Risk Controls сброшены"), reply_markup=_risk_keyboard(),
reply_markup=_risk_keyboard(), )
)
await callback.answer()
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await callback.message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
return
await callback.answer() await callback.answer()
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
@router.message(AutoRiskStates.waiting_stop_loss) @router.message(AutoRiskStates.waiting_stop_loss)
async def set_stop_loss(message: Message, state: FSMContext) -> None: async def set_stop_loss(message: Message, state: FSMContext) -> None:

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,14 @@
from __future__ import annotations from __future__ import annotations
import math import math
import time
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message
from src.core.config import load_settings from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonList, NumericLike
from src.trading.debug.execution import DebugExecutionEngine from src.trading.debug.execution import DebugExecutionEngine
from src.trading.debug.service import DebugTradeService from src.trading.debug.service import DebugTradeService
from src.trading.debug.state import DebugTradeState from src.trading.debug.state import DebugTradeState
@@ -18,7 +21,7 @@ router = Router(name="debug")
def _debug_enabled() -> bool: def _debug_enabled() -> bool:
return load_settings().debug_enabled return bool(load_settings().debug_enabled)
def _debug_help_text() -> str: def _debug_help_text() -> str:
@@ -80,6 +83,7 @@ async def debug_auto(message: Message) -> None:
if command == "reset": if command == "reset":
state = service.reset() state = service.reset()
await message.answer( await message.answer(
"✅ [DEBUG] Runtime reset\n\n" "✅ [DEBUG] Runtime reset\n\n"
f"{_debug_state_text(state)}" f"{_debug_state_text(state)}"
@@ -88,6 +92,7 @@ async def debug_auto(message: Message) -> None:
if command == "off": if command == "off":
state = service.stop() state = service.stop()
await message.answer( await message.answer(
"✅ [DEBUG] Runtime stopped\n\n" "✅ [DEBUG] Runtime stopped\n\n"
f"{_debug_state_text(state)}" f"{_debug_state_text(state)}"
@@ -97,17 +102,20 @@ async def debug_auto(message: Message) -> None:
if command == "state": if command == "state":
state = service.get_state() state = service.get_state()
service.update_market() service.update_market()
await message.answer(_debug_state_text(state)) await message.answer(_debug_state_text(state))
return return
if command == "hold": if command == "hold":
seconds = _parse_int(parts, index=2, default=335) seconds = _parse_int(parts, index=2, default=335)
state = service.set_signal_duration( state = service.set_signal_duration(
signal="HOLD", signal="HOLD",
seconds=seconds, seconds=seconds,
confidence=0.0, confidence=0.0,
force_ready=False, force_ready=False,
) )
await message.answer( await message.answer(
f"✅ [DEBUG] HOLD {seconds}s\n\n" f"✅ [DEBUG] HOLD {seconds}s\n\n"
f"{_debug_state_text(state)}" f"{_debug_state_text(state)}"
@@ -182,15 +190,31 @@ async def debug_auto(message: Message) -> None:
if command == "long": if command == "long":
state, result = service.open_long() state, result = service.open_long()
await message.answer(_execution_result_text("OPEN LONG", state, result))
await message.answer(
_execution_result_text(
"OPEN LONG",
state,
result,
)
)
return return
if command == "short": if command == "short":
state, result = service.open_short() state, result = service.open_short()
await message.answer(_execution_result_text("OPEN SHORT", state, result))
await message.answer(
_execution_result_text(
"OPEN SHORT",
state,
result,
)
)
return return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_exec")) @router.message(F.text.startswith("/debug_exec"))
@@ -211,43 +235,82 @@ async def debug_exec(message: Message) -> None:
if command == "state": if command == "state":
state = service.get_state() state = service.get_state()
service.update_market() service.update_market()
await message.answer(_debug_state_text(state)) await message.answer(_debug_state_text(state))
return return
if command == "buy": if command == "buy":
state, result = service.open_long() state, result = service.open_long()
await message.answer(_execution_result_text("EXEC BUY / LONG", state, result))
await message.answer(
_execution_result_text(
"EXEC BUY / LONG",
state,
result,
)
)
return return
if command == "sell": if command == "sell":
state, result = service.open_short() state, result = service.open_short()
await message.answer(_execution_result_text("EXEC SELL / SHORT", state, result))
await message.answer(
_execution_result_text(
"EXEC SELL / SHORT",
state,
result,
)
)
return return
if command == "flip": if command == "flip":
state, result = service.flip() state, result = service.flip()
await message.answer(_execution_result_text("EXEC AUTO FLIP", state, result))
await message.answer(
_execution_result_text(
"EXEC AUTO FLIP",
state,
result,
)
)
return return
if command == "close": if command == "close":
state, result = service.close(reason="DEBUG_CLOSE") state, result = service.close(reason="DEBUG_CLOSE")
await message.answer(_execution_result_text("EXEC CLOSE", state, result))
await message.answer(
_execution_result_text(
"EXEC CLOSE",
state,
result,
)
)
return return
if command == "process": if command == "process":
state, result = service.process() state, result = service.process()
await message.answer(_execution_result_text("EXEC PROCESS", state, result))
await message.answer(
_execution_result_text(
"EXEC PROCESS",
state,
result,
)
)
return return
if command == "update": if command == "update":
state = service.update_market() state = service.update_market()
await message.answer( await message.answer(
"✅ [DEBUG] Market update\n\n" "✅ [DEBUG] Market update\n\n"
f"{_debug_state_text(state)}" f"{_debug_state_text(state)}"
) )
return return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_live")) @router.message(F.text.startswith("/debug_live"))
@@ -264,8 +327,9 @@ async def debug_live(message: Message) -> None:
"/debug_exec sell\n" "/debug_exec sell\n"
"/debug_exec flip\n" "/debug_exec flip\n"
"/debug_exec close\n\n" "/debug_exec close\n\n"
"Live-мониторинг для изолированного debug будет добавлен в следующем пакете " "Live-мониторинг для изолированного debug будет добавлен "
"через отдельный DebugTradeRunner и отдельный Debug Auto экран." "в следующем пакете через отдельный DebugTradeRunner "
"и отдельный Debug Auto экран."
) )
@@ -275,19 +339,30 @@ async def debug_signal(message: Message) -> None:
await message.answer("Debug mode выключен.") await message.answer("Debug mode выключен.")
return return
signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text) signal, confidence, repeat_count, error = _parse_debug_signal_args(
message.text,
)
if error is not None: if error is not None:
await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}") await message.answer(
f"⛔️ {error}\n\n{_debug_help_text()}"
)
return return
service = DebugTradeService() service = DebugTradeService()
state = service.set_signal( state = service.set_signal(
signal=signal, signal=signal,
confidence=confidence, confidence=confidence,
repeat_count=repeat_count, repeat_count=repeat_count,
reason=f"[DEBUG] LEGACY FORCE {signal} {confidence:.2f} ×{repeat_count}", reason=(
force_ready=signal in {"BUY", "SELL"} and repeat_count >= 2, f"[DEBUG] LEGACY FORCE "
f"{signal} {confidence:.2f} ×{repeat_count}"
),
force_ready=(
signal in {"BUY", "SELL"}
and repeat_count >= 2
),
) )
await message.answer( await message.answer(
@@ -303,6 +378,7 @@ async def debug_ready(message: Message) -> None:
return return
service = DebugTradeService() service = DebugTradeService()
state = service.set_signal_duration( state = service.set_signal_duration(
signal="BUY", signal="BUY",
seconds=15, seconds=15,
@@ -323,13 +399,16 @@ async def debug_state(message: Message) -> None:
return return
service = DebugTradeService() service = DebugTradeService()
state = service.get_state() state = service.get_state()
service.update_market() service.update_market()
await message.answer(_debug_state_text(state)) await message.answer(_debug_state_text(state))
def _debug_state_text(state: DebugTradeState) -> str: def _debug_state_text(
state: DebugTradeState,
) -> str:
position = state.position position = state.position
duration = _signal_duration_text(state) duration = _signal_duration_text(state)
@@ -348,7 +427,7 @@ def _debug_state_text(state: DebugTradeState) -> str:
f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n" f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n"
f"Duration: {duration}\n" f"Duration: {duration}\n"
f"Repeats: {state.last_signal_repeat_count}\n" f"Repeats: {state.last_signal_repeat_count}\n"
f"Confidence: {state.last_signal_confidence:.2f}\n" f"Confidence: {safe_float(state.last_signal_confidence) or 0.0:.2f}\n"
f"Decision: {state.decision_status}\n" f"Decision: {state.decision_status}\n"
f"Ready: {state.is_signal_ready}\n" f"Ready: {state.is_signal_ready}\n"
f"Reason: {state.last_signal_reason or ''}\n\n" f"Reason: {state.last_signal_reason or ''}\n\n"
@@ -385,53 +464,95 @@ def _execution_result_text(
) )
def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]: def _parse_debug_signal_args(
raw_text: str | None,
) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split() parts = (raw_text or "").split()
signal = parts[1].upper() if len(parts) > 1 else "BUY" signal = parts[1].upper() if len(parts) > 1 else "BUY"
if signal not in {"BUY", "SELL", "HOLD"}:
return "BUY", 0.9, 2, "SIGNAL должен быть BUY, SELL или HOLD."
try: if signal not in {"BUY", "SELL", "HOLD"}:
confidence = float(parts[2]) if len(parts) > 2 else 0.9 return (
except ValueError: "BUY",
return "BUY", 0.9, 2, "CONFIDENCE должен быть числом от 0.00 до 1.00." 0.9,
2,
"SIGNAL должен быть BUY, SELL или HOLD.",
)
confidence = _parse_float(parts, index=2, default=0.9)
if confidence < 0 or confidence > 1: if confidence < 0 or confidence > 1:
return "BUY", 0.9, 2, "CONFIDENCE должен быть от 0.00 до 1.00." return (
"BUY",
0.9,
2,
"CONFIDENCE должен быть от 0.00 до 1.00.",
)
try: repeat_count = _parse_int(parts, index=3, default=2)
repeat_count = int(parts[3]) if len(parts) > 3 else 2
except ValueError:
return "BUY", 0.9, 2, "REPEATS должен быть целым числом."
if repeat_count < 1: if repeat_count < 1:
return "BUY", 0.9, 2, "REPEATS должен быть больше или равен 1." return (
"BUY",
0.9,
2,
"REPEATS должен быть больше или равен 1.",
)
return signal, confidence, repeat_count, None return signal, confidence, repeat_count, None
def _parse_int(parts: list[str], *, index: int, default: int) -> int: def _parse_int(
parts: JsonList,
*,
index: int,
default: int,
) -> int:
try: try:
return int(parts[index]) value = parts[index]
except (IndexError, TypeError, ValueError): except (IndexError, TypeError):
return default return default
number = safe_float(value)
def _parse_float(parts: list[str], *, index: int, default: float) -> float: if number is None:
try:
return float(parts[index])
except (IndexError, TypeError, ValueError):
return default return default
return int(number)
def _signal_duration_text(state: DebugTradeState) -> str:
started_at = state.signal_started_at def _parse_float(
parts: JsonList,
*,
index: int,
default: float,
) -> float:
try:
value = parts[index]
except (IndexError, TypeError):
return default
number = safe_float(value)
if number is None:
return default
return number
def _signal_duration_text(
state: DebugTradeState,
) -> str:
started_at = safe_float(state.signal_started_at)
if started_at is not None: if started_at is not None:
total_seconds = max(0, int(__import__("time").monotonic() - float(started_at))) total_seconds = max(
0,
int(time.monotonic() - started_at),
)
else: else:
total_seconds = max(0, (state.last_signal_repeat_count or 0) * 5) repeats = state.last_signal_repeat_count or 0
total_seconds = max(0, int(repeats) * 5)
hours = total_seconds // 3600 hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60 minutes = (total_seconds % 3600) // 60
@@ -446,73 +567,98 @@ def _signal_duration_text(state: DebugTradeState) -> str:
return f"{seconds}с" return f"{seconds}с"
def _signal_icon(signal: str | None) -> str: def _signal_icon(
signal: str | None,
) -> str:
mapping = { mapping = {
"BUY": "🟢", "BUY": "🟢",
"SELL": "🔴", "SELL": "🔴",
"HOLD": "🟡", "HOLD": "🟡",
} }
return mapping.get(signal or "", "") return mapping.get(signal or "", "")
def _format_leverage(value: float | int | None) -> str: def _format_leverage(
if value is None: value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "x—" return "x—"
return f"x{float(value):g}" return f"x{number:g}"
def _format_crypto_size(value: float | int | None) -> str: def _format_crypto_size(
if value is None: value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "" return ""
number = float(value)
return f"{number:.5f}".rstrip("0").rstrip(".") return f"{number:.5f}".rstrip("0").rstrip(".")
def _format_percent(value: float | int | None) -> str: def _format_percent(
if value is None: value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "off" return "off"
number = float(value) if math.isclose(number, round(number), abs_tol=1e-9):
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}%" return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%" return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_money_compact(value: float | int | None) -> str: def _format_money_compact(
if value is None: value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "" return ""
number = float(value) if math.isclose(number, round(number), abs_tol=1e-9):
if abs(number - round(number)) < 1e-9:
return f"{number:,.0f}".replace(",", " ") return f"{number:,.0f}".replace(",", " ")
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".") return (
f"{number:,.2f}"
.replace(",", " ")
.rstrip("0")
.rstrip(".")
)
def _format_usd_or_dash(value: float | int | None) -> str: def _format_usd_or_dash(
if value is None: value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return "" return ""
return f"$ {_format_money_compact(value)}" return f"$ {_format_money_compact(value)}"
def _format_usd_or_off(value: float | int | None) -> str: def _format_usd_or_off(
if value is None: value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return "off" return "off"
return f"$ {_format_money_compact(value)}" return f"$ {_format_money_compact(value)}"
def _format_signed_usd(value: float | int | None) -> str: def _format_signed_usd(
if value is None: value: NumericLike | None,
return "" ) -> str:
amount = safe_float(value)
amount = float(value) if amount is None:
return ""
if amount > 0: if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}" return f"🟢 +$ {_format_money_compact(amount)}"

View File

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

View File

@@ -1,11 +1,18 @@
# app/src/telegram/handlers/journal.py # app/src/telegram/handlers/journal.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram.types import (
BufferedInputFile,
CallbackQuery,
InaccessibleMessage,
Message,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.telegram.handlers.journal_ui import ( from src.telegram.handlers.journal_ui import (
PAGE_SIZE, PAGE_SIZE,
build_actions_keyboard, build_actions_keyboard,
@@ -23,12 +30,26 @@ from src.trading.journal.service import JournalService
router = Router(name="journal") router = Router(name="journal")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _user_id_from_message(message: Message) -> int | None: def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None return message.from_user.id if message.from_user else None
def _chat_id_from_message(message: Message) -> int | None: def _chat_id_from_message(message: Message) -> int:
return message.chat.id if message.chat else None return message.chat.id
def _user_id_from_callback(callback: CallbackQuery) -> int | None: def _user_id_from_callback(callback: CallbackQuery) -> int | None:
@@ -36,17 +57,38 @@ def _user_id_from_callback(callback: CallbackQuery) -> int | None:
def _chat_id_from_callback(callback: CallbackQuery) -> int | None: def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
if callback.message and callback.message.chat: message = _require_message(callback)
return callback.message.chat.id
return None if message is None:
return None
return message.chat.id
def _parse_page(value: NumericLike | None) -> int | None:
number = safe_float(value)
if number is None:
return None
return int(number)
def _journal_payload(**values: object) -> JsonDict:
return dict(values)
def _register_journal_screen(message: Message) -> None: def _register_journal_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message( LiveScreenRunner.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
ScreenRegistry.unregister_message( ScreenRegistry.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
@@ -55,7 +97,7 @@ def _register_journal_screen(message: Message) -> None:
ScreenRegistry.register_screen( ScreenRegistry.register_screen(
StaticScreen( StaticScreen(
screen="journal", screen="journal",
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
@@ -67,6 +109,54 @@ def _register_journal_screen(message: Message) -> None:
) )
async def _prepare_journal_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_journal_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return False
bot = message.bot
if bot is None:
await callback.answer(
"Bot недоступен",
show_alert=True,
)
return False
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _show_journal_page( async def _show_journal_page(
target_message: Message, target_message: Message,
*, *,
@@ -85,33 +175,37 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages) kb = build_keyboard(page, total_pages)
if edit_mode: if edit_mode:
await target_message.edit_text(text, reply_markup=kb) await target_message.edit_text(
text,
reply_markup=kb,
)
_register_journal_screen(target_message) _register_journal_screen(target_message)
return return
sent_message = await target_message.answer(text, reply_markup=kb) sent_message = await target_message.answer(
text,
reply_markup=kb,
)
_register_journal_screen(sent_message) _register_journal_screen(sent_message)
@router.callback_query(F.data == "journal:actions") @router.callback_query(F.data == "journal:actions")
async def journal_actions(callback: CallbackQuery) -> None: async def journal_actions(callback: CallbackQuery) -> None:
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
await callback.message.edit_text( if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
render_actions(), render_actions(),
reply_markup=build_actions_keyboard(), reply_markup=build_actions_keyboard(),
) )
_register_journal_screen(callback.message) _register_journal_screen(message)
await callback.answer() await callback.answer()
@@ -120,11 +214,8 @@ async def journal_actions(callback: CallbackQuery) -> None:
async def open_journal(message: Message, state: FSMContext) -> None: async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await ActiveScreenManager.prepare_new_screen( if not await _prepare_journal_from_message(message):
screen="journal", return
bot=message.bot,
chat_id=message.chat.id,
)
await _show_journal_page( await _show_journal_page(
message, message,
@@ -134,22 +225,23 @@ async def open_journal(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:journal") @router.callback_query(F.data == "monitoring:journal")
async def open_journal_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: async def open_journal_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="journal",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
await _show_journal_page( await _show_journal_page(
callback.message, message,
page=1, page=1,
edit_mode=True, edit_mode=True,
) )
@@ -165,6 +257,7 @@ async def journal_noop(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "journal:export_csv") @router.callback_query(F.data == "journal:export_csv")
async def export_journal_csv(callback: CallbackQuery) -> None: async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService() service = JournalService()
message = _require_message(callback)
try: try:
data = service.export_csv() data = service.export_csv()
@@ -173,8 +266,8 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
filename=service.build_export_filename("csv"), filename=service.build_export_filename("csv"),
) )
if callback.message is not None: if message is not None:
await callback.message.answer_document(document=document) await message.answer_document(document=document)
service.log_ui_info( service.log_ui_info(
event_type="journal_exported", event_type="journal_exported",
@@ -183,10 +276,11 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv", action="export_csv",
user_id=_user_id_from_callback(callback), user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback), chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"}, payload=_journal_payload(format="csv"),
) )
await callback.answer("CSV экспортирован") await callback.answer("CSV экспортирован")
except Exception as exc: except Exception as exc:
service.log_ui_error( service.log_ui_error(
event_type="journal_export_error", event_type="journal_export_error",
@@ -195,15 +289,20 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv", action="export_csv",
user_id=_user_id_from_callback(callback), user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback), chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"}, payload=_journal_payload(format="csv"),
raw_error=str(exc), raw_error=str(exc),
) )
await callback.answer("Не удалось экспортировать CSV", show_alert=True)
await callback.answer(
"Не удалось экспортировать CSV",
show_alert=True,
)
@router.callback_query(F.data == "journal:export_xlsx") @router.callback_query(F.data == "journal:export_xlsx")
async def export_journal_xlsx(callback: CallbackQuery) -> None: async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService() service = JournalService()
message = _require_message(callback)
try: try:
data = service.export_xlsx() data = service.export_xlsx()
@@ -212,8 +311,8 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
filename=service.build_export_filename("xlsx"), filename=service.build_export_filename("xlsx"),
) )
if callback.message is not None: if message is not None:
await callback.message.answer_document(document=document) await message.answer_document(document=document)
service.log_ui_info( service.log_ui_info(
event_type="journal_exported", event_type="journal_exported",
@@ -222,10 +321,11 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx", action="export_xlsx",
user_id=_user_id_from_callback(callback), user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback), chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"}, payload=_journal_payload(format="xlsx"),
) )
await callback.answer("Excel экспортирован") await callback.answer("Excel экспортирован")
except Exception as exc: except Exception as exc:
service.log_ui_error( service.log_ui_error(
event_type="journal_export_error", event_type="journal_export_error",
@@ -234,56 +334,56 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx", action="export_xlsx",
user_id=_user_id_from_callback(callback), user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback), chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"}, payload=_journal_payload(format="xlsx"),
raw_error=str(exc), raw_error=str(exc),
) )
await callback.answer("Не удалось экспортировать Excel", show_alert=True)
await callback.answer(
"Не удалось экспортировать Excel",
show_alert=True,
)
@router.callback_query(F.data == "journal:clear_confirm") @router.callback_query(F.data == "journal:clear_confirm")
async def clear_journal_confirm(callback: CallbackQuery) -> None: async def clear_journal_confirm(callback: CallbackQuery) -> None:
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="journal",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
service = JournalService() service = JournalService()
total_count = service.get_total_count() total_count = service.get_total_count()
await callback.message.edit_text( await message.edit_text(
render_clear_confirm(total_count=total_count), render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(), reply_markup=build_clear_confirm_keyboard(),
) )
_register_journal_screen(callback.message) _register_journal_screen(message)
await callback.answer() await callback.answer()
@router.callback_query(F.data == "journal:clear") @router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None: async def clear_journal(callback: CallbackQuery) -> None:
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="journal",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
service = JournalService() service = JournalService()
deleted_count = service.clear_all() deleted_count = service.clear_all()
total_count = service.get_total_count() total_count = service.get_total_count()
await callback.message.edit_text( await message.edit_text(
render_clear_confirm( render_clear_confirm(
total_count=total_count, total_count=total_count,
deleted_count=deleted_count, deleted_count=deleted_count,
@@ -291,29 +391,27 @@ async def clear_journal(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(), reply_markup=build_clear_confirm_keyboard(),
) )
_register_journal_screen(callback.message) _register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}") await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90") @router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None: async def clear_journal_older_90(callback: CallbackQuery) -> None:
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="journal",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
service = JournalService() service = JournalService()
deleted_count = service.clear_older_than_days(90) deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count() total_count = service.get_total_count()
await callback.message.edit_text( await message.edit_text(
render_clear_confirm( render_clear_confirm(
total_count=total_count, total_count=total_count,
deleted_count=deleted_count if deleted_count > 0 else None, deleted_count=deleted_count if deleted_count > 0 else None,
@@ -322,34 +420,37 @@ async def clear_journal_older_90(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(), reply_markup=build_clear_confirm_keyboard(),
) )
_register_journal_screen(callback.message) _register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}") await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:")) @router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None: async def paginate(callback: CallbackQuery) -> None:
if callback.message is None: if not await _prepare_journal_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
page_raw = callback.data.split(":", 1)[1] message = _require_message(callback)
try: if message is None:
page = int(page_raw) await callback.answer("Сообщение недоступно", show_alert=True)
except ValueError: return
data = callback.data or ""
parts = data.split(":", 1)
if len(parts) < 2:
await callback.answer("Неизвестное действие", show_alert=True) await callback.answer("Неизвестное действие", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( page = _parse_page(parts[1])
screen="journal",
bot=callback.message.bot, if page is None:
chat_id=callback.message.chat.id, await callback.answer("Неизвестное действие", show_alert=True)
keep_message_id=callback.message.message_id, return
)
await _show_journal_page( await _show_journal_page(
callback.message, message,
page=page, page=page,
edit_mode=True, edit_mode=True,
) )

View File

@@ -10,6 +10,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.config import load_settings from src.core.config import load_settings
from src.core.event_titles import event_title from src.core.event_titles import event_title
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
PAGE_SIZE = 5 PAGE_SIZE = 5
@@ -30,26 +32,34 @@ TECH_TO_HUMAN_MESSAGES = {
} }
def build_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup: def build_keyboard(
page: int,
total_pages: int,
) -> InlineKeyboardMarkup:
current_page = max(1, int(page))
pages_count = max(1, int(total_pages))
kb = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
if page > 1: if current_page > 1:
kb.button(text="⏮️", callback_data="journal:1") kb.button(text="⏮️", callback_data="journal:1")
kb.button(text="⬅️", callback_data=f"journal:{page - 1}") kb.button(text="⬅️", callback_data=f"journal:{current_page - 1}")
kb.button(text=f"{page}/{total_pages}", callback_data="journal:noop") kb.button(text=f"{current_page}/{pages_count}", callback_data="journal:noop")
if page < total_pages: if current_page < pages_count:
kb.button(text="➡️", callback_data=f"journal:{page + 1}") kb.button(text="➡️", callback_data=f"journal:{current_page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions") kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal") kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home") kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
nav_count = 1 nav_count = 1
if page > 1:
if current_page > 1:
nav_count += 2 nav_count += 2
if page < total_pages:
if current_page < pages_count:
nav_count += 1 nav_count += 1
kb.adjust(nav_count, 2, 1) kb.adjust(nav_count, 2, 1)
@@ -83,33 +93,51 @@ def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
return kb.as_markup() return kb.as_markup()
def _format_int(
value: NumericLike | None,
*,
default: int = 0,
) -> int:
number = safe_float(value)
if number is None:
return default
return int(number)
def render_clear_confirm( def render_clear_confirm(
*, *,
total_count: int, total_count: NumericLike,
deleted_count: int | None = None, deleted_count: NumericLike | None = None,
no_old_records_days: int | None = None, no_old_records_days: NumericLike | None = None,
) -> str: ) -> str:
total = _format_int(total_count)
lines = [ lines = [
"<b>⚠️ Очистить журнал</b>", "<b>⚠️ Очистить журнал</b>",
"", "",
"<b>СИСТЕМА</b> · Настройки · Журнал", "<b>СИСТЕМА</b> · Настройки · Журнал",
"", "",
f"📄 Записей: {total_count}", f"📄 Записей: {total}",
] ]
if deleted_count is not None: if deleted_count is not None:
lines.append(f"🧹 Удалено записей: {deleted_count}") lines.append(f"🧹 Удалено записей: {_format_int(deleted_count)}")
if no_old_records_days is not None: if no_old_records_days is not None:
lines.append(f"📭 Нет записей старше {no_old_records_days} дней") lines.append(
f"📭 Нет записей старше {_format_int(no_old_records_days)} дней"
)
return "\n".join(lines) return "\n".join(lines)
def _parse_local_datetime(value: str) -> datetime | None: def _parse_local_datetime(value: object) -> datetime | None:
try: try:
settings = load_settings() settings = load_settings()
dt = datetime.fromisoformat(value) raw_value = str(value or "")
dt = datetime.fromisoformat(raw_value)
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC")) dt = dt.replace(tzinfo=ZoneInfo("UTC"))
@@ -135,39 +163,52 @@ def _date_group_label(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d") return dt.strftime("%Y-%m-%d")
def _time_label(dt: datetime | None, raw_value: str) -> str: def _time_label(
dt: datetime | None,
raw_value: object,
) -> str:
if dt is None: if dt is None:
return raw_value return str(raw_value or "")
return dt.strftime("%H:%M:%S") return dt.strftime("%H:%M:%S")
def _event_title(event_type: str) -> str: def _event_title(event_type: object) -> str:
return event_title(event_type) return event_title(str(event_type or ""))
def _humanize_message(message: str) -> str: def _humanize_message(message: object) -> str:
lower = message.lower() text = str(message or "")
for k, v in TECH_TO_HUMAN_MESSAGES.items(): lower = text.lower()
if k in lower:
return v for technical_text, human_text in TECH_TO_HUMAN_MESSAGES.items():
return message if technical_text in lower:
return human_text
return text
def _payload(event: dict) -> dict: def _payload(event: JsonDict) -> JsonDict:
payload = event.get("payload") payload = event.get("payload")
return payload if isinstance(payload, dict) else {}
if isinstance(payload, dict):
return payload
return {}
def _render_auto_signal(event: dict, created_time: str) -> list[str]: def _render_auto_signal(
level = str(event.get("level", "INFO")).upper() event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "") icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", ""))) title = _event_title(event.get("event_type"))
message = _humanize_message(str(event.get("message", ""))) message = _humanize_message(event.get("message"))
lines = [ lines = [
f"{icon} <b>{level}</b> · {title}", f"{icon} <b>{level}</b> · {title}",
f"{created_time}", created_time,
] ]
if message: if message:
@@ -176,15 +217,18 @@ def _render_auto_signal(event: dict, created_time: str) -> list[str]:
return lines return lines
def _render_default_event(event: dict, created_time: str) -> list[str]: def _render_default_event(
level = str(event.get("level", "INFO")).upper() event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "") icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", ""))) title = _event_title(event.get("event_type"))
message = _humanize_message(str(event.get("message", ""))) message = _humanize_message(event.get("message"))
lines = [ lines = [
f"{icon} <b>{level}</b> · {title}", f"{icon} <b>{level}</b> · {title}",
f"{created_time}", created_time,
] ]
if message: if message:
@@ -193,7 +237,11 @@ def _render_default_event(event: dict, created_time: str) -> list[str]:
return lines return lines
def render(events, page, total_pages): def render(
events: JsonList,
page: NumericLike,
total_pages: NumericLike,
) -> str:
lines = [ lines = [
"<b>📒 Журнал</b>", "<b>📒 Журнал</b>",
"", "",
@@ -205,10 +253,15 @@ def render(events, page, total_pages):
lines.append("Событий пока нет.") lines.append("Событий пока нет.")
return "\n".join(lines) return "\n".join(lines)
current_group = None current_group: str | None = None
for event in events: for raw_event in events:
raw_created_at = str(event.get("created_at", "")) if not isinstance(raw_event, dict):
continue
event: JsonDict = raw_event
raw_created_at = event.get("created_at") or ""
dt = _parse_local_datetime(raw_created_at) dt = _parse_local_datetime(raw_created_at)
group_label = _date_group_label(dt) group_label = _date_group_label(dt)
created_time = _time_label(dt, raw_created_at) created_time = _time_label(dt, raw_created_at)
@@ -218,7 +271,7 @@ def render(events, page, total_pages):
lines.append(f"<b>{group_label}</b>") lines.append(f"<b>{group_label}</b>")
lines.append("") lines.append("")
event_type = str(event.get("event_type", "")) event_type = str(event.get("event_type") or "")
if event_type in {"signal_summary", "signal_ready"}: if event_type in {"signal_summary", "signal_ready"}:
lines.extend(_render_auto_signal(event, created_time)) lines.extend(_render_auto_signal(event, created_time))

View File

@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder 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.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.active_screen import ActiveScreenManager
@@ -26,6 +33,20 @@ _last_market_prices: dict[str, float] = {}
_last_market_directions: dict[str, str] = {} _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: def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home") builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
@@ -35,22 +56,27 @@ def _market_keyboard() -> InlineKeyboardMarkup:
def _build_market_text( def _build_market_text(
*, *,
ticker_price: float, ticker_price: NumericLike,
name: str, name: str,
market_type: str, market_type: str,
base_asset: str, base_asset: str,
quote_asset: str, quote_asset: str,
) -> str: ) -> str:
price = safe_float(ticker_price)
if price is None:
price = 0.0
previous_price = _last_market_prices.get(name) previous_price = _last_market_prices.get(name)
price_direction = _last_market_directions.get(name, "") price_direction = _last_market_directions.get(name, "")
if previous_price is not None: if previous_price is not None:
if ticker_price > previous_price: if price > previous_price:
price_direction = "🔺" price_direction = "🔺"
elif ticker_price < previous_price: elif price < previous_price:
price_direction = "🔻" price_direction = "🔻"
_last_market_prices[name] = ticker_price _last_market_prices[name] = price
_last_market_directions[name] = price_direction _last_market_directions[name] = price_direction
type_map = { type_map = {
@@ -64,7 +90,7 @@ def _build_market_text(
f"{mode_line()}" f"{mode_line()}"
"\n" "\n"
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n" f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
f"<b>$ {format_usd_amount(ticker_price)}</b> {price_direction}\n\n" f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
f"{now_line()}" f"{now_line()}"
) )
@@ -87,9 +113,21 @@ def _build_market_live_text() -> str:
symbol_info = validation.symbol_info symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a" 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" base_asset = (
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a" symbol_info.base_asset
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol 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( return _build_market_text(
ticker_price=ticker.price, ticker_price=ticker.price,
@@ -101,10 +139,16 @@ def _build_market_live_text() -> str:
def _register_market_live_screen(message: Message) -> None: def _register_market_live_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message( LiveScreenRunner.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
ScreenRegistry.unregister_message( ScreenRegistry.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
@@ -113,7 +157,7 @@ def _register_market_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen( LiveScreenRunner.register_screen(
LiveScreen( LiveScreen(
screen="market", screen="market",
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
render_text=_build_market_live_text, render_text=_build_market_live_text,
@@ -121,9 +165,58 @@ def _register_market_live_screen(message: Message) -> None:
interval_seconds=5, interval_seconds=5,
) )
) )
LiveScreenRunner.start("market") 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( async def _render_market_screen(
target_message: Message, target_message: Message,
*, *,
@@ -184,9 +277,21 @@ async def _render_market_screen(
symbol_info = validation.symbol_info symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a" 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" base_asset = (
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a" symbol_info.base_asset
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol 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( text = _build_market_text(
ticker_price=ticker.price, ticker_price=ticker.price,
@@ -205,7 +310,7 @@ async def _render_market_screen(
chat_id=chat_id, chat_id=chat_id,
payload={ payload={
"symbol": ticker.symbol, "symbol": ticker.symbol,
"price": ticker.price, "price": safe_float(ticker.price),
}, },
) )
@@ -223,11 +328,8 @@ async def _render_market_screen(
async def open_market(message: Message, state: FSMContext) -> None: async def open_market(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await ActiveScreenManager.prepare_new_screen( if not await _prepare_market_from_message(message):
screen="market", return
bot=message.bot,
chat_id=message.chat.id,
)
user_id = message.from_user.id if message.from_user else None user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None chat_id = message.chat.id if message.chat else None
@@ -263,32 +365,38 @@ async def open_market(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:market") @router.callback_query(F.data == "monitoring:market")
async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: async def open_market_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_market_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="market",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer(
keep_message_id=callback.message.message_id, "Сообщение недоступно",
) show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
try: try:
await _render_market_screen( await _render_market_screen(
callback.message, message,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
edit_mode=True, edit_mode=True,
action="open_from_monitoring", action="open_from_monitoring",
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: except ExchangeError as exc:
JournalService().log_ui_error( JournalService().log_ui_error(
event_type="market_open_error", event_type="market_open_error",
@@ -312,32 +420,38 @@ async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext
@router.callback_query(F.data == "market:retry") @router.callback_query(F.data == "market:retry")
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: async def retry_market(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_market_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="market",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer(
keep_message_id=callback.message.message_id, "Сообщение недоступно",
) show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
try: try:
await _render_market_screen( await _render_market_screen(
callback.message, message,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
edit_mode=True, edit_mode=True,
action="retry", action="retry",
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: except ExchangeError as exc:
JournalService().log_ui_error( JournalService().log_ui_error(
event_type="market_retry_error", event_type="market_retry_error",

View File

@@ -4,7 +4,12 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.active_screen import ActiveScreenManager
@@ -14,6 +19,20 @@ from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScr
router = Router(name="monitoring") 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: def _monitoring_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="💼 Портфель", callback_data="monitoring:portfolio") builder.button(text="💼 Портфель", callback_data="monitoring:portfolio")
@@ -31,10 +50,16 @@ def _monitoring_text() -> str:
def _register_monitoring_screen(message: Message) -> None: def _register_monitoring_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message( LiveScreenRunner.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
ScreenRegistry.unregister_message( ScreenRegistry.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
@@ -43,23 +68,65 @@ def _register_monitoring_screen(message: Message) -> None:
ScreenRegistry.register_screen( ScreenRegistry.register_screen(
StaticScreen( StaticScreen(
screen="monitoring", screen="monitoring",
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
) )
@router.message(F.text == "📊 Мониторинг") async def _prepare_monitoring_from_message(
async def open_monitoring(message: Message, state: FSMContext) -> None: message: Message,
await state.clear() ) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen( await ActiveScreenManager.prepare_new_screen(
screen="monitoring", screen="monitoring",
bot=message.bot, bot=bot,
chat_id=message.chat.id, 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( sent_message = await message.answer(
_monitoring_text(), _monitoring_text(),
reply_markup=_monitoring_keyboard(), reply_markup=_monitoring_keyboard(),
@@ -74,30 +141,31 @@ async def open_monitoring(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:home") @router.callback_query(F.data == "monitoring:home")
async def open_monitoring_callback(callback: CallbackQuery, state: FSMContext) -> None: async def open_monitoring_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_monitoring_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="monitoring",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
await callback.message.edit_text( if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
_monitoring_text(), _monitoring_text(),
reply_markup=_monitoring_keyboard(), reply_markup=_monitoring_keyboard(),
) )
_register_monitoring_screen(callback.message) _register_monitoring_screen(message)
ActiveScreenManager.register( ActiveScreenManager.register(
screen="monitoring", screen="monitoring",
message=callback.message, message=message,
) )
await callback.answer() await callback.answer()

View File

@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
@@ -39,13 +46,32 @@ PINNED_ORDER = {
} }
def _compact_amount(currency: str, value: float) -> str: def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
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() currency = currency.upper()
if currency in {"USD", "USDT", "EUR"}: if currency in {"USD", "USDT", "EUR"}:
return format_usd_amount(value) return format_usd_amount(number)
text = f"{value:.8f}" text = f"{number:.8f}"
if "." in text: if "." in text:
integer, frac = text.split(".") integer, frac = text.split(".")
@@ -103,7 +129,11 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
) )
return text, _portfolio_keyboard() return text, _portfolio_keyboard()
visible_balances = [item for item in balances if not is_zero_balance(item)] visible_balances = [
item
for item in balances
if not is_zero_balance(item)
]
visible_balances = sort_balances(visible_balances) visible_balances = sort_balances(visible_balances)
if not visible_balances: if not visible_balances:
@@ -128,8 +158,13 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
for item in visible_balances: for item in visible_balances:
currency = item.currency.upper() currency = item.currency.upper()
total = balance_total(item) total = safe_float(balance_total(item)) or 0.0
estimated_usd = estimate_balance_usd(item, exchange_service, price_cache) locked = safe_float(item.locked) or 0.0
estimated_usd = estimate_balance_usd(
item,
exchange_service,
price_cache,
)
if estimated_usd is not None: if estimated_usd is not None:
total_estimated_usd += estimated_usd total_estimated_usd += estimated_usd
@@ -139,8 +174,8 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
line = f"{currency}: {_compact_amount(currency, total)}" line = f"{currency}: {_compact_amount(currency, total)}"
if item.locked > 0: if locked > 0:
line += f" · locked {_compact_amount(currency, item.locked)}" line += f" · locked {_compact_amount(currency, locked)}"
if estimated_usd is not None and currency not in {"USD", "USDT"}: if estimated_usd is not None and currency not in {"USD", "USDT"}:
line += f" ≈ $ {format_usd_amount(estimated_usd)}" line += f" ≈ $ {format_usd_amount(estimated_usd)}"
@@ -157,7 +192,10 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
if has_any_estimate: if has_any_estimate:
lines.insert(3, "") lines.insert(3, "")
lines.insert(3, f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>") lines.insert(
3,
f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>",
)
if missing_estimate_assets: if missing_estimate_assets:
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
@@ -184,10 +222,16 @@ def _portfolio_live_markup() -> InlineKeyboardMarkup:
def _register_portfolio_live_screen(message: Message) -> None: def _register_portfolio_live_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message( LiveScreenRunner.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
ScreenRegistry.unregister_message( ScreenRegistry.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
@@ -196,7 +240,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen( LiveScreenRunner.register_screen(
LiveScreen( LiveScreen(
screen="portfolio", screen="portfolio",
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
render_text=_portfolio_live_text, render_text=_portfolio_live_text,
@@ -204,9 +248,52 @@ def _register_portfolio_live_screen(message: Message) -> None:
interval_seconds=10, interval_seconds=10,
) )
) )
LiveScreenRunner.start("portfolio") LiveScreenRunner.start("portfolio")
async def _prepare_portfolio_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_portfolio_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _render_portfolio_screen( async def _render_portfolio_screen(
target_message: Message, target_message: Message,
*, *,
@@ -241,24 +328,25 @@ async def _render_portfolio_screen(
await target_message.edit_text(text, reply_markup=reply_markup) await target_message.edit_text(text, reply_markup=reply_markup)
_register_portfolio_live_screen(target_message) _register_portfolio_live_screen(target_message)
ActiveScreenManager.register(screen="portfolio", message=target_message) ActiveScreenManager.register(screen="portfolio", message=target_message)
else: return
sent_message = await target_message.answer(text, reply_markup=reply_markup)
_register_portfolio_live_screen(sent_message) sent_message = await target_message.answer(text, reply_markup=reply_markup)
ActiveScreenManager.register(screen="portfolio", message=sent_message) _register_portfolio_live_screen(sent_message)
ActiveScreenManager.register(screen="portfolio", message=sent_message)
@router.message(F.text == "💼 Портфель") @router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None: async def open_portfolio(
message: Message,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
await ActiveScreenManager.prepare_new_screen( if not await _prepare_portfolio_from_message(message):
screen="portfolio", return
bot=message.bot,
chat_id=message.chat.id,
)
user_id = message.from_user.id if message.from_user else None user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None chat_id = message.chat.id
try: try:
await _render_portfolio_screen( await _render_portfolio_screen(
@@ -291,32 +379,34 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:portfolio") @router.callback_query(F.data == "monitoring:portfolio")
async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: async def open_portfolio_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_portfolio_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="portfolio",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
user_id = callback.from_user.id if callback.from_user else None user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
try: try:
await _render_portfolio_screen( await _render_portfolio_screen(
callback.message, message,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
edit_mode=True, edit_mode=True,
action="open_from_monitoring", action="open_from_monitoring",
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: except ExchangeError as exc:
JournalService().log_ui_error( JournalService().log_ui_error(
event_type="portfolio_open_error", event_type="portfolio_open_error",
@@ -340,32 +430,34 @@ async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMCont
@router.callback_query(F.data == "portfolio:retry") @router.callback_query(F.data == "portfolio:retry")
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: async def retry_portfolio(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
if callback.message is None: if not await _prepare_portfolio_from_callback(callback):
await callback.answer("Сообщение не найдено", show_alert=True)
return return
await ActiveScreenManager.prepare_new_screen( message = _require_message(callback)
screen="portfolio",
bot=callback.message.bot, if message is None:
chat_id=callback.message.chat.id, await callback.answer("Сообщение недоступно", show_alert=True)
keep_message_id=callback.message.message_id, return
)
user_id = callback.from_user.id if callback.from_user else None user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
try: try:
await _render_portfolio_screen( await _render_portfolio_screen(
callback.message, message,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
edit_mode=True, edit_mode=True,
action="retry", action="retry",
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: except ExchangeError as exc:
JournalService().log_ui_error( JournalService().log_ui_error(
event_type="portfolio_retry_error", event_type="portfolio_retry_error",

View File

@@ -15,30 +15,42 @@ from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start") router = Router(name="start")
@router.message(Command("start")) async def _show_main_menu(
async def cmd_start(message: Message, state: FSMContext) -> None: message: Message,
# Глобальный экран: всегда выходим из текущего FSM-сценария. ) -> None:
await state.clear()
await message.answer( await message.answer(
MAIN_MENU_TEXT, MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
) )
@router.message(Command("start"))
async def cmd_start(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await _show_main_menu(message)
@router.message(Command("menu")) @router.message(Command("menu"))
async def cmd_menu(message: Message, state: FSMContext) -> None: async def cmd_menu(
# Глобальный экран: всегда выходим из текущего FSM-сценария. message: Message,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
await message.answer(
MAIN_MENU_TEXT, await _show_main_menu(message)
reply_markup=build_main_menu_keyboard(),
)
@router.message(Command("help")) @router.message(Command("help"))
async def cmd_help(message: Message, state: FSMContext) -> None: async def cmd_help(
# Глобальный экран: всегда выходим из текущего FSM-сценария. message: Message,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
await message.answer( await message.answer(
build_system_text(), build_system_text(),
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
@@ -46,10 +58,10 @@ async def cmd_help(message: Message, state: FSMContext) -> None:
@router.message(F.text == "Меню") @router.message(F.text == "Меню")
async def menu_shortcut(message: Message, state: FSMContext) -> None: async def menu_shortcut(
# Глобальный экран: всегда выходим из текущего FSM-сценария. message: Message,
state: FSMContext,
) -> None:
await state.clear() await state.clear()
await message.answer(
MAIN_MENU_TEXT, await _show_main_menu(message)
reply_markup=build_main_menu_keyboard(),
)

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.core.config import load_settings from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION from src.core.constants import APP_NAME, APP_VERSION
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
@@ -19,6 +21,20 @@ from src.trading.journal.service import JournalService
router = Router(name="system") router = Router(name="system")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _system_keyboard() -> InlineKeyboardMarkup: def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management") builder.button(text="🛠️ Настройки", callback_data="system:management")
@@ -37,6 +53,11 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
def _register_system_screen(message: Message, screen: str = "system") -> None: def _register_system_screen(message: Message, screen: str = "system") -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message( LiveScreenRunner.unregister_message(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
@@ -49,7 +70,7 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
ScreenRegistry.register_screen( ScreenRegistry.register_screen(
StaticScreen( StaticScreen(
screen=screen, screen=screen,
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_id, message_id=message.message_id,
) )
@@ -61,27 +82,42 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
) )
async def _prepare_system_from_message(message: Message, screen: str = "system") -> None: async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen( await ActiveScreenManager.prepare_new_screen(
screen=screen, screen=screen,
bot=message.bot, bot=bot,
chat_id=message.chat.id, chat_id=message.chat.id,
) )
return True
async def _prepare_system_from_callback( async def _prepare_system_from_callback(
callback: CallbackQuery, callback: CallbackQuery,
screen: str = "system", screen: str = "system",
) -> bool: ) -> bool:
if callback.message is None: message = _require_message(callback)
await callback.answer("Сообщение не найдено", show_alert=True)
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 return False
await ActiveScreenManager.prepare_new_screen( await ActiveScreenManager.prepare_new_screen(
screen=screen, screen=screen,
bot=callback.message.bot, bot=bot,
chat_id=callback.message.chat.id, chat_id=message.chat.id,
keep_message_id=callback.message.message_id, keep_message_id=message.message_id,
) )
return True return True
@@ -148,7 +184,8 @@ async def _render_system_screen(
async def open_system(message: Message, state: FSMContext) -> None: async def open_system(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await _prepare_system_from_message(message, screen="system") if not await _prepare_system_from_message(message, screen="system"):
return
user_id = message.from_user.id if message.from_user else None user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None chat_id = message.chat.id if message.chat else None
@@ -169,16 +206,23 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
if not await _prepare_system_from_callback(callback, screen="system"): if not await _prepare_system_from_callback(callback, screen="system"):
return 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 user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
await _render_system_screen( await _render_system_screen(
callback.message, message,
edit_mode=True, edit_mode=True,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
action="retry", action="retry",
) )
await callback.answer() await callback.answer()
@@ -201,8 +245,14 @@ async def open_system_management(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:back") builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1) builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) message = _require_message(callback)
_register_system_screen(callback.message, screen="system")
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="system")
await callback.answer() await callback.answer()
@@ -225,7 +275,11 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
leverage_ready = state.leverage is not None leverage_ready = state.leverage is not None
is_trend_strategy = (state.strategy or "").upper() == "TREND" is_trend_strategy = (state.strategy or "").upper() == "TREND"
sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
sl_ready = (
state.stop_loss_percent is not None
and state.stop_loss_percent > 0
)
is_configured = ( is_configured = (
strategy_ready strategy_ready
@@ -248,67 +302,136 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
if base.endswith(suffix) and len(base) > len(suffix): if base.endswith(suffix) and len(base) > len(suffix):
base = base[: -len(suffix)] base = base[: -len(suffix)]
break break
symbol = base symbol = base
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "" risk = _format_number(state.risk_percent, suffix="%", default="")
leverage = f"x{state.leverage:g}" if state.leverage is not None else ""
max_reserved = ( leverage_value = safe_float(state.leverage)
f"{state.max_reserved_balance_percent:g}%" leverage = f"x{leverage_value:g}" if leverage_value is not None else ""
if state.max_reserved_balance_percent is not None
else "off" max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
)
sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" sl = _format_percent_setting(state.stop_loss_percent)
tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off"
ml = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" tp = _format_percent_setting(state.take_profit_percent)
ml_value = safe_float(state.max_loss_usd)
ml = f"{ml_value:g} USD" if ml_value is not None else "off"
strategy_icon = "" if strategy_ready else "⚠️" strategy_icon = "" if strategy_ready else "⚠️"
symbol_icon = "" if symbol_ready else "⚠️" symbol_icon = "" if symbol_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️" risk_icon = "" if risk_ready else "⚠️"
leverage_icon = "" if leverage_ready else "⚠️" leverage_icon = "" if leverage_ready else "⚠️"
sl_icon = "" if sl_ready else "⚠️"
if is_trend_strategy: if is_trend_strategy and not sl_ready:
risk_controls_block = ( sl_icon = "⛔️"
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · <b>{'required' if not sl_ready else sl}</b>\n"
f"✅ Take Profit · {tp}\n"
f"✅ Max Loss · {ml}"
)
else: else:
risk_controls_block = ( sl_icon = (
"<b>Защита позиции:</b>\n" ""
f"✅ Stop Loss · {sl}\n" if state.stop_loss_percent is not None
f"✅ Take Profit · {tp}\n" else "⚠️"
f"✅ Max Loss · {ml}"
) )
config_status = "Все параметры настроены" if is_configured else "⚠️ Настрой все параметры" tp_icon = (
""
if state.take_profit_percent is not None
else "⚠️"
)
ml_icon = (
""
if state.max_loss_usd is not None
else "⚠️"
)
risk_controls_block = (
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · {sl}\n"
f"{tp_icon} Take Profit · {tp}\n"
f"{ml_icon} Max Loss · {ml}"
)
settings_status_icon = "" if is_configured else "⛔️"
config_status = (
""
if is_configured
else "\n\nНастрой все параметры"
)
text = ( text = (
"<b>🤖 Автоторговля</b>\n\n" "<b>🤖 Автоторговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n" f"<b>СИСТЕМА</b> · Настройки {settings_status_icon}\n\n"
f"{strategy_icon} Стратегия: <b>{strategy}</b>\n" f"{strategy_icon} Стратегия: <b>{strategy}</b>\n"
f"{symbol_icon} Актив: <b>{symbol}</b>\n" f"{symbol_icon} Актив: <b>{symbol}</b>\n"
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n" f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n" f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n" f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n"
f"{risk_controls_block}\n\n" f"{risk_controls_block}"
f"{config_status}" f"{config_status}"
) )
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="💱 Актив", callback_data="settings:auto_symbol") builder.button(
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage") text="🧠 Стратегия",
builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved") callback_data="settings:auto_strategy",
builder.button(text="🛡️ Риск", callback_data="settings:auto_risk") )
builder.button(text="🧯 Защита", callback_data="auto:risk")
builder.button(text="🤖 Автоторговля", callback_data="auto:home") builder.button(
builder.button(text="⬅️ Назад", callback_data="system:management") 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) builder.adjust(2, 2, 2, 2)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) message = _require_message(callback)
_register_system_screen(callback.message, screen="settings_auto")
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
text,
reply_markup=builder.as_markup(),
)
_register_system_screen(
message,
screen="settings_auto",
)
await callback.answer() await callback.answer()
@@ -316,6 +439,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
async def open_auto_strategy_settings(callback: CallbackQuery) -> None: async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"): if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>🧠 Стратегия</b>\n\n" "<b>🧠 Стратегия</b>\n\n"
@@ -330,8 +459,8 @@ async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1) builder.adjust(3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto") _register_system_screen(message, screen="settings_auto")
await callback.answer() await callback.answer()
@@ -340,7 +469,7 @@ def _log_auto_setting_updated(
event_type: str = "auto_settings_updated", event_type: str = "auto_settings_updated",
message: str, message: str,
action: str, action: str,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
try: try:
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -370,9 +499,38 @@ def _human_symbol(symbol: str | None) -> str:
return base return base
def _format_number(
value: NumericLike | None,
*,
suffix: str = "",
default: str = "",
) -> str:
number = safe_float(value)
if number is None:
return default
return f"{number:g}{suffix}"
def _format_percent_setting(value: NumericLike | None) -> str:
return _format_number(value, suffix="%", default="off")
def _parse_callback_float(value: object) -> float | None:
return safe_float(value)
@router.callback_query(F.data.startswith("settings:auto_strategy:")) @router.callback_query(F.data.startswith("settings:auto_strategy:"))
async def set_auto_strategy(callback: CallbackQuery) -> None: async def set_auto_strategy(callback: CallbackQuery) -> None:
strategy = callback.data.split(":", 2)[2].upper() data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение стратегии", show_alert=True)
return
strategy = parts[2].upper()
service = AutoTradeService() service = AutoTradeService()
state = service.get_state() state = service.get_state()
@@ -399,6 +557,12 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"): if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>💱 Актив</b>\n\n" "<b>💱 Актив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -413,14 +577,21 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1) builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto") _register_system_screen(message, screen="settings_auto")
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_symbol:")) @router.callback_query(F.data.startswith("settings:auto_symbol:"))
async def set_auto_symbol(callback: CallbackQuery) -> None: async def set_auto_symbol(callback: CallbackQuery) -> None:
symbol = callback.data.split(":", 2)[2] data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение актива", show_alert=True)
return
symbol = parts[2]
service = AutoTradeService() service = AutoTradeService()
state = service.get_state() state = service.get_state()
@@ -447,6 +618,12 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"): if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>🛡️ Риск на сделку</b>\n\n" "<b>🛡️ Риск на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -460,14 +637,25 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1) builder.adjust(3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto") _register_system_screen(message, screen="settings_auto")
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_risk:")) @router.callback_query(F.data.startswith("settings:auto_risk:"))
async def set_auto_risk(callback: CallbackQuery) -> None: async def set_auto_risk(callback: CallbackQuery) -> None:
risk = float(callback.data.split(":", 2)[2]) data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение риска", show_alert=True)
return
risk = _parse_callback_float(parts[2])
if risk is None:
await callback.answer("Некорректное значение риска", show_alert=True)
return
service = AutoTradeService() service = AutoTradeService()
state = service.get_state() state = service.get_state()
@@ -494,6 +682,12 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"): if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>⚙️ Плечо</b>\n\n" "<b>⚙️ Плечо</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -510,14 +704,25 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 3, 1) builder.adjust(3, 3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto") _register_system_screen(message, screen="settings_auto")
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_leverage:")) @router.callback_query(F.data.startswith("settings:auto_leverage:"))
async def set_auto_leverage(callback: CallbackQuery) -> None: async def set_auto_leverage(callback: CallbackQuery) -> None:
leverage = float(callback.data.split(":", 2)[2]) data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
leverage = _parse_callback_float(parts[2])
if leverage is None:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
service = AutoTradeService() service = AutoTradeService()
state = service.get_state() state = service.get_state()
@@ -544,6 +749,12 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"): if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>🏦 Лимит на сделку</b>\n\n" "<b>🏦 Лимит на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -559,15 +770,26 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1, 1) builder.adjust(2, 2, 1, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto") _register_system_screen(message, screen="settings_auto")
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_max_reserved:")) @router.callback_query(F.data.startswith("settings:auto_max_reserved:"))
async def set_auto_max_reserved(callback: CallbackQuery) -> None: async def set_auto_max_reserved(callback: CallbackQuery) -> None:
raw_value = callback.data.split(":", 2)[2] data = callback.data or ""
value = None if raw_value == "off" else float(raw_value) parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректные данные кнопки", show_alert=True)
return
raw_value = parts[2]
value = None if raw_value == "off" else _parse_callback_float(raw_value)
if raw_value != "off" and value is None:
await callback.answer("Некорректное значение лимита", show_alert=True)
return
service = AutoTradeService() service = AutoTradeService()
state = service.get_state() state = service.get_state()
@@ -596,6 +818,12 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_trade"): if not await _prepare_system_from_callback(callback, screen="settings_trade"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>💹 Торговля</b>\n\n" "<b>💹 Торговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n" "<b>СИСТЕМА</b> · Настройки\n\n"
@@ -610,8 +838,8 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
builder.button(text="💹 Торговля", callback_data="trade:home") builder.button(text="💹 Торговля", callback_data="trade:home")
builder.adjust(2) builder.adjust(2)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_trade") _register_system_screen(message, screen="settings_trade")
await callback.answer() await callback.answer()
@@ -620,6 +848,12 @@ async def open_general_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_general"): if not await _prepare_system_from_callback(callback, screen="settings_general"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>🌍 Общие</b>\n\n" "<b>🌍 Общие</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n" "<b>СИСТЕМА</b> · Настройки\n\n"
@@ -633,8 +867,8 @@ async def open_general_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:management") builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(1) builder.adjust(1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_general") _register_system_screen(message, screen="settings_general")
await callback.answer() await callback.answer()
@@ -643,6 +877,12 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"): if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService() service = JournalService()
total = service.get_total_count() total = service.get_total_count()
@@ -664,8 +904,8 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
builder.button(text="📒 Журнал", callback_data="journal:1") builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2, 2, 2) builder.adjust(2, 2, 2)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_journal") _register_system_screen(message, screen="settings_journal")
await callback.answer() await callback.answer()
@@ -674,6 +914,12 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"): if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>🗄 Архив</b>\n\n" "<b>🗄 Архив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n" "<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -688,8 +934,8 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
builder.button(text="📒 Журнал", callback_data="journal:1") builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2) builder.adjust(2)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_journal") _register_system_screen(message, screen="settings_journal")
await callback.answer() await callback.answer()
@@ -698,6 +944,12 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"): if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>📦 Лимит</b>\n\n" "<b>📦 Лимит</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n" "<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -713,8 +965,8 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal") builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1) builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_journal") _register_system_screen(message, screen="settings_journal")
await callback.answer() await callback.answer()
@@ -723,6 +975,12 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"): if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = ( text = (
"<b>⏳ Хранение</b>\n\n" "<b>⏳ Хранение</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n" "<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -738,8 +996,8 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal") builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1) builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_journal") _register_system_screen(message, screen="settings_journal")
await callback.answer() await callback.answer()
@@ -756,11 +1014,17 @@ async def back_to_system(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system"): if not await _prepare_system_from_callback(callback, screen="system"):
return 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 user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None chat_id = message.chat.id
await _render_system_screen( await _render_system_screen(
callback.message, message,
edit_mode=True, edit_mode=True,
user_id=user_id, user_id=user_id,
chat_id=chat_id, chat_id=chat_id,
@@ -774,6 +1038,12 @@ async def open_system_about(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system_about"): if not await _prepare_system_from_callback(callback, screen="system_about"):
return return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
settings = load_settings() settings = load_settings()
journal = JournalService() journal = JournalService()
@@ -783,7 +1053,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
screen="system", screen="system",
action="about", action="about",
user_id=callback.from_user.id if callback.from_user else None, user_id=callback.from_user.id if callback.from_user else None,
chat_id=callback.message.chat.id if callback.message.chat else None, chat_id=message.chat.id,
) )
text = ( text = (
@@ -801,6 +1071,6 @@ async def open_system_about(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:back") builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(1) builder.adjust(1)
await callback.message.edit_text(text, reply_markup=builder.as_markup()) await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="system_about") _register_system_screen(message, screen="system_about")
await callback.answer() await callback.answer()

View File

@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from collections.abc import Callable
from aiogram import Bot from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
@@ -17,7 +18,7 @@ class LiveScreen:
chat_id: int chat_id: int
message_id: int message_id: int
render_text: Callable[[], str] render_text: Callable[[], str]
render_markup: Callable[[], object] render_markup: Callable[[], InlineKeyboardMarkup | None]
interval_seconds: int = 5 interval_seconds: int = 5

View File

@@ -4,12 +4,16 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from typing import Callable
from collections.abc import Callable
from typing import ClassVar
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.core.event_bus import EventBus from src.core.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_data_runner import MarketDataRunner from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry from src.notifications.targets import NotificationTargetRegistry
from src.runtime_events.event_types import RuntimeEventType from src.runtime_events.event_types import RuntimeEventType
@@ -17,30 +21,27 @@ from src.runtime_events.models import RuntimeEvent
from src.runtime_events.publisher import RuntimeEventPublisher from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.telegram.handlers.auto.ui import build_auto_semantic_text from src.telegram.handlers.auto.ui import build_auto_notification_text
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner: class AutoTradeRunner:
_task: asyncio.Task | None = None _task: ClassVar[asyncio.Task | None] = None
_bot: Bot | None = None _bot: ClassVar[Bot | None] = None
_chat_id: int | None = None _chat_id: ClassVar[int | None] = None
_message_id: int | None = None _message_id: ClassVar[int | None] = None
_render_text: Callable[[], str] | None = None _render_text: ClassVar[staticmethod | None] = None
_render_markup: Callable[[], object] | None = None _render_markup: ClassVar[staticmethod | None] = None
_current_screen: str | None = None _current_screen: ClassVar[str | None] = None
_analysis_interval_seconds = 5 _analysis_interval_seconds = 5
_ui_interval_seconds = 30 _ui_interval_seconds = 30
_last_text: ClassVar[str | None] = None
_last_text: str | None = None _last_semantic_text: ClassVar[str | None] = None
_last_semantic_text: str | None = None _last_ui_refresh_at: ClassVar[float] = 0.0
_last_ui_refresh_at: float = 0.0 _last_event_version: ClassVar[int] = 0
_last_event_version: int = 0 _retry_after_until: ClassVar[float] = 0.0
_retry_after_until: float = 0.0 _last_screen_state_key: ClassVar[str | None] = None
_last_screen_state_key: str | None = None
_position_aligned_signal_log_interval_seconds = 900 _position_aligned_signal_log_interval_seconds = 900
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {} _last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
@@ -57,8 +58,8 @@ class AutoTradeRunner:
cls._bot = bot cls._bot = bot
cls._chat_id = chat_id cls._chat_id = chat_id
cls._message_id = message_id cls._message_id = message_id
cls._render_text = render_text cls._render_text = staticmethod(render_text)
cls._render_markup = render_markup cls._render_markup = staticmethod(render_markup)
cls._last_text = None cls._last_text = None
cls._last_semantic_text = None cls._last_semantic_text = None
cls._last_screen_state_key = None cls._last_screen_state_key = None
@@ -260,8 +261,13 @@ class AutoTradeRunner:
await cls._handle_important_event(state) await cls._handle_important_event(state)
@classmethod @classmethod
async def _handle_important_event(cls, state) -> None: async def _handle_important_event(
cls,
state,
) -> None:
event_type, payload = EventBus.last_event() event_type, payload = EventBus.last_event()
if not isinstance(payload, dict):
payload = {}
if event_type == "auto_decision_changed": if event_type == "auto_decision_changed":
if payload.get("decision_status") != "READY": if payload.get("decision_status") != "READY":
@@ -298,7 +304,10 @@ class AutoTradeRunner:
return return
@classmethod @classmethod
def _notification_reason_lines(cls, state) -> list[str]: def _notification_reason_lines(
cls,
state,
) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build( snapshot = SemanticDiagnosticSnapshotBuilder().build(
state, state,
is_configured=True, is_configured=True,
@@ -326,14 +335,29 @@ class AutoTradeRunner:
cls, cls,
*, *,
state, state,
payload: dict, payload: JsonDict,
signal: str, signal: str,
) -> None: ) -> None:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper() position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
symbol = str(payload.get("symbol") or state.symbol or "") symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "") strategy = str(payload.get("strategy") or state.strategy or "")
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0) confidence = safe_float(
repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0) payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
log_key = ( log_key = (
f"{position_side}:" f"{position_side}:"
@@ -377,12 +401,31 @@ class AutoTradeRunner:
pass pass
@classmethod @classmethod
def _publish_strong_signal_event(cls, *, state, payload: dict) -> None: def _publish_strong_signal_event(
cls,
*,
state,
payload: JsonDict,
) -> None:
signal = str(payload.get("signal", "")).upper() signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "") symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "") strategy = str(payload.get("strategy") or state.strategy or "")
repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0) repeat_count_value = (
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0) payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
confidence = safe_float(
payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
reason = str(payload.get("reason") or state.last_signal_reason or "") reason = str(payload.get("reason") or state.last_signal_reason or "")
position_context = str(getattr(state, "position_side", "NONE") or "NONE") position_context = str(getattr(state, "position_side", "NONE") or "NONE")
@@ -409,6 +452,9 @@ class AutoTradeRunner:
"decision_status": state.decision_status, "decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state), "semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context, "position_side": position_context,
"bid_price": payload.get("bid_price"),
"ask_price": payload.get("ask_price"),
"last_price": payload.get("last_price"),
}, },
priority=priority.lower(), priority=priority.lower(),
dedupe_key=( dedupe_key=(
@@ -431,7 +477,7 @@ class AutoTradeRunner:
*, *,
state, state,
event_type: str, event_type: str,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
runtime_event_type = cls._runtime_execution_event_type(event_type) runtime_event_type = cls._runtime_execution_event_type(event_type)
if runtime_event_type is None: if runtime_event_type is None:
@@ -450,13 +496,17 @@ class AutoTradeRunner:
source="auto_trade_runner", source="auto_trade_runner",
title=cls._execution_event_title(runtime_event_type), title=cls._execution_event_title(runtime_event_type),
payload={ payload={
**payload,
"source_event_type": event_type, "source_event_type": event_type,
"symbol": symbol, "symbol": symbol,
"side": side, "side": side,
"old_side": old_side, "old_side": old_side,
"new_side": new_side, "new_side": new_side,
"leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage, "leverage": (
**payload, payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
),
"strategy": state.strategy, "strategy": state.strategy,
"semantic_lines": semantic_lines, "semantic_lines": semantic_lines,
}, },
@@ -493,7 +543,7 @@ class AutoTradeRunner:
cls, cls,
*, *,
runtime_event_type: RuntimeEventType, runtime_event_type: RuntimeEventType,
payload: dict, payload: JsonDict,
) -> str: ) -> str:
return ( return (
f"{runtime_event_type.value}:" f"{runtime_event_type.value}:"
@@ -516,27 +566,40 @@ class AutoTradeRunner:
def _alert_priority( def _alert_priority(
cls, cls,
*, *,
confidence: float, confidence: NumericLike,
repeat_count: int, repeat_count: int,
) -> str: ) -> str:
if confidence >= 0.8 and repeat_count >= 3: confidence_value = safe_float(confidence) or 0.0
if confidence_value >= 0.8 and repeat_count >= 3:
return "HIGH" return "HIGH"
if confidence >= 0.6 or repeat_count >= 2: if confidence_value >= 0.6 or repeat_count >= 2:
return "MEDIUM" return "MEDIUM"
return "LOW" return "LOW"
@classmethod @classmethod
def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None: def _log_refresh_skip(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
return return
@classmethod @classmethod
def _log_refresh_success(cls, payload: dict | None = None) -> None: def _log_refresh_success(
cls,
payload: JsonDict | None = None,
) -> None:
return return
@classmethod @classmethod
def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None: def _log_refresh_error(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
try: try:
JournalService().log_error( JournalService().log_error(
"auto_screen_refresh_error", "auto_screen_refresh_error",
@@ -547,7 +610,10 @@ class AutoTradeRunner:
pass pass
@classmethod @classmethod
def _screen_state_key(cls, state) -> str: def _screen_state_key(
cls,
state,
) -> str:
return "|".join( return "|".join(
str(value) str(value)
for value in [ for value in [
@@ -581,6 +647,9 @@ class AutoTradeRunner:
getattr(state, "position_size", None), getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None), #getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None), getattr(state, "realized_pnl_usd", None),
getattr(state, "cycle_closed_trades", None),
getattr(state, "cycle_realized_pnl_usd", None),
getattr(state, "cycle_winning_trades", None),
getattr(state, "last_execution_action", None), getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None), getattr(state, "last_execution_reason", None),
] ]
@@ -628,19 +697,30 @@ class AutoTradeRunner:
) )
return return
text = cls._render_text() render_text = cls._render_text
semantic_text = build_auto_semantic_text() render_markup = cls._render_markup
bot = cls._bot
if (
render_text is None
or render_markup is None
or bot is None
):
return
text = render_text()
semantic_text = build_auto_notification_text()
if semantic_text == cls._last_semantic_text: if semantic_text == cls._last_semantic_text:
cls._log_refresh_skip("text_not_changed") cls._log_refresh_skip("text_not_changed")
return return
try: try:
await cls._bot.edit_message_text( await bot.edit_message_text(
chat_id=cls._chat_id, chat_id=cls._chat_id,
message_id=cls._message_id, message_id=cls._message_id,
text=text, text=text,
reply_markup=cls._render_markup(), reply_markup=render_markup(),
) )
cls._last_text = text cls._last_text = text
cls._last_semantic_text = semantic_text cls._last_semantic_text = semantic_text

View File

@@ -8,6 +8,8 @@ from datetime import datetime
from src.core.config import load_settings from src.core.config import load_settings
from src.core.event_bus import EventBus from src.core.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.state import AutoTradeState from src.trading.auto.state import AutoTradeState
from src.trading.execution.engine import ExecutionEngine from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -40,7 +42,7 @@ class AutoTradeService:
_last_signal_value: str | None = None _last_signal_value: str | None = None
_last_signal_reason: str = "" _last_signal_reason: str = ""
_last_signal_confidence: float = 0.0 _last_signal_confidence: float = 0.0
_last_signal_payload: dict | None = None _last_signal_payload: JsonDict | None = None
_last_signal_started_at: float | None = None _last_signal_started_at: float | None = None
_last_logged_market_state: str | None = None _last_logged_market_state: str | None = None
_last_logged_market_trend: str | None = None _last_logged_market_trend: str | None = None
@@ -50,46 +52,145 @@ class AutoTradeService:
_max_snapshot_age_seconds = 5.0 _max_snapshot_age_seconds = 5.0
_warning_snapshot_age_seconds = 2.0 _warning_snapshot_age_seconds = 2.0
_spread_warning_enter_percent = 0.08 _spread_thresholds_by_asset: dict[str, dict[str, float]] = {
_spread_warning_exit_percent = 0.06 "BTC": {
_spread_block_enter_percent = 0.15 "warning_enter": 0.08,
_spread_block_exit_percent = 0.12 "warning_exit": 0.06,
"block_enter": 0.15,
"block_exit": 0.12,
},
"ETH": {
"warning_enter": 0.10,
"warning_exit": 0.08,
"block_enter": 0.18,
"block_exit": 0.15,
},
"LTC": {
"warning_enter": 0.18,
"warning_exit": 0.14,
"block_enter": 0.35,
"block_exit": 0.28,
},
"XRP": {
"warning_enter": 0.20,
"warning_exit": 0.16,
"block_enter": 0.40,
"block_exit": 0.32,
},
}
_default_spread_thresholds: dict[str, float] = {
"warning_enter": 0.12,
"warning_exit": 0.09,
"block_enter": 0.25,
"block_exit": 0.20,
}
_last_logged_execution_quality_key: str | None = None _last_logged_execution_quality_key: str | None = None
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
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,
)
def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
status = ExchangeService().get_symbol_market_status(state.symbol)
is_open = bool(status.get("is_open"))
market_status = str(status.get("status") or "UNKNOWN")
message = str(status.get("message") or "")
state.market_is_open = is_open
state.market_status = market_status
state.market_status_message = message
state.market_status_updated_at = time.monotonic()
if is_open:
if state.execution_quality_reason == "MARKET_CLOSED":
state.execution_quality = None
state.execution_quality_reason = None
state.execution_quality_message = None
state.execution_block_reason = None
state.market_runtime_degraded = False
return True
state.execution_quality = "BLOCKED"
state.execution_quality_reason = "MARKET_CLOSED"
state.execution_quality_message = "рынок закрыт"
state.execution_block_reason = "рынок закрыт"
state.market_runtime_degraded = True
state.entry_block_reason = "MARKET_CLOSED"
state.entry_block_message = "рынок закрыт"
state.decision_status = "WAITING"
state.decision_reason = message or "Рынок закрыт."
state.is_signal_confirmed = False
state.is_signal_ready = False
return False
def _spread_execution_quality( def _spread_execution_quality(
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
spread_percent: float | None, spread_percent: NumericLike | None,
) -> tuple[str | None, str | None, str | None, bool]: ) -> tuple[str | None, str | None, str | None, bool]:
if spread_percent is None: spread = safe_float(spread_percent)
if spread is None:
return None, None, None, False 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_quality = state.execution_quality
previous_reason = state.execution_quality_reason previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD": if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
if spread_percent > self._spread_block_exit_percent: if spread > block_exit:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent > self._spread_warning_exit_percent: if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False return "GOOD", "MARKET_OK", "рынок готов", False
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD": if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
if spread_percent >= self._spread_block_enter_percent: if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent > self._spread_warning_exit_percent: if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False return "GOOD", "MARKET_OK", "рынок готов", False
if spread_percent >= self._spread_block_enter_percent: if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent >= self._spread_warning_enter_percent: if spread >= warning_enter:
return "WARNING", "WIDE_SPREAD", "spread повышен", False return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False return "GOOD", "MARKET_OK", "рынок готов", False
@@ -99,11 +200,12 @@ class AutoTradeService:
self, self,
*, *,
signal: str, signal: str,
confidence: float = 0.9, confidence: NumericLike = 0.9,
repeat_count: int = 2, repeat_count: int = 2,
reason: str = "DEBUG SIGNAL", reason: str = "DEBUG SIGNAL",
) -> AutoTradeState: ) -> AutoTradeState:
state = self.get_state() state = self.get_state()
confidence_value = safe_float(confidence) or 0.0
normalized_signal = signal.strip().upper() normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}: if normalized_signal not in {"BUY", "SELL", "HOLD"}:
@@ -117,7 +219,7 @@ class AutoTradeService:
state.last_signal = normalized_signal state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count state.last_signal_repeat_count = repeat_count
state.last_signal_confidence = confidence state.last_signal_confidence = confidence_value
state.last_signal_reason = reason state.last_signal_reason = reason
state.signal_confirmation_seconds = self._confirm_min_duration_seconds state.signal_confirmation_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
@@ -162,13 +264,15 @@ class AutoTradeService:
return state return state
# установить капитал, выделенный под автоторговлю # установить капитал, выделенный под автоторговлю
def set_allocated_balance_usd(self, value: float) -> AutoTradeState: def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
state = self.get_state() state = self.get_state()
if value <= 0: numeric_value = safe_float(value)
value = 1000.0
state.allocated_balance_usd = 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_block_reason = None
state.execution_size_adjustment_reason = None state.execution_size_adjustment_reason = None
return state return state
@@ -231,6 +335,10 @@ class AutoTradeService:
state.status = "RUNNING" state.status = "RUNNING"
self._reset_signal_tracking() self._reset_signal_tracking()
state.cycle_realized_pnl_usd = 0.0 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_old_side = None
state.last_flip_new_side = None state.last_flip_new_side = None
state.last_flip_pnl_usd = None state.last_flip_pnl_usd = None
@@ -268,6 +376,9 @@ class AutoTradeService:
if previous_status == "OFF": if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0 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_old_side = None
state.last_flip_new_side = None state.last_flip_new_side = None
state.last_flip_pnl_usd = None state.last_flip_pnl_usd = None
@@ -288,6 +399,10 @@ class AutoTradeService:
state.status = "OFF" state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0 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_old_side = None
state.last_flip_new_side = None state.last_flip_new_side = None
state.last_flip_pnl_usd = None state.last_flip_pnl_usd = None
@@ -333,39 +448,39 @@ class AutoTradeService:
return state return state
# установить риск # установить риск
def set_risk_percent(self, risk_percent: float) -> AutoTradeState: def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.risk_percent = risk_percent state.risk_percent = safe_float(risk_percent)
return state return state
# установить плечо # установить плечо
def set_leverage(self, leverage: float) -> AutoTradeState: def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.leverage = leverage state.leverage = safe_float(leverage)
return state return state
# установить stop loss в % # установить stop loss в %
def set_stop_loss_percent(self, value: float | None) -> AutoTradeState: def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.stop_loss_percent = value state.stop_loss_percent = safe_float(value)
return state return state
# установить take profit в % # установить take profit в %
def set_take_profit_percent(self, value: float | None) -> AutoTradeState: def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.take_profit_percent = value state.take_profit_percent = safe_float(value)
return state return state
# установить max loss в USD # установить max loss в USD
def set_max_loss_usd(self, value: float | None) -> AutoTradeState: def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.max_loss_usd = value state.max_loss_usd = safe_float(value)
return state return state
# установить максимальное использование баланса под маржу # установить максимальное использование баланса под маржу
def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState: def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.max_reserved_balance_percent = value state.max_reserved_balance_percent = safe_float(value)
state.execution_block_reason = None state.execution_block_reason = None
return state return state
@@ -380,6 +495,7 @@ class AutoTradeService:
self._same_signal_count = 0 self._same_signal_count = 0
state = self.get_state() state = self.get_state()
state.adaptive_size_base = None state.adaptive_size_base = None
state.adaptive_size_final = None state.adaptive_size_final = None
state.adaptive_size_multiplier = None state.adaptive_size_multiplier = None
@@ -387,6 +503,7 @@ class AutoTradeService:
state.adaptive_size_factors = None state.adaptive_size_factors = None
state.effective_risk_percent = None state.effective_risk_percent = None
state.effective_target_risk_usd = None state.effective_target_risk_usd = None
state.last_signal_repeat_count = 0 state.last_signal_repeat_count = 0
state.last_signal_confidence = 0.0 state.last_signal_confidence = 0.0
state.last_signal_reason = None state.last_signal_reason = None
@@ -399,6 +516,9 @@ class AutoTradeService:
state.signal_confirmation_missing_repeats = self._confirm_repeats state.signal_confirmation_missing_repeats = self._confirm_repeats
state.signal_confirmation_progress = 0.0 state.signal_confirmation_progress = 0.0
state.signal_confirmation_reason = None state.signal_confirmation_reason = None
state.signal_started_at = None
state.signal_updated_at = None
state.execution_block_reason = None state.execution_block_reason = None
state.execution_semantic_status = None state.execution_semantic_status = None
state.execution_semantic_message = None state.execution_semantic_message = None
@@ -411,8 +531,7 @@ class AutoTradeService:
state.execution_confidence_required_score = self._execution_confidence_required_score state.execution_confidence_required_score = self._execution_confidence_required_score
state.execution_confidence_reason = None state.execution_confidence_reason = None
state.execution_confidence_factors = None state.execution_confidence_factors = None
state.signal_started_at = None
state.signal_updated_at = None
state.market_state = None state.market_state = None
state.market_trend = None state.market_trend = None
state.market_volatility = None state.market_volatility = None
@@ -424,8 +543,29 @@ class AutoTradeService:
state.market_trend_quality = None state.market_trend_quality = None
state.market_phase = None state.market_phase = None
state.market_phase_direction = 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_reason = None
state.entry_block_message = None state.entry_block_message = None
state.momentum_state = None state.momentum_state = None
state.momentum_direction = None state.momentum_direction = None
state.momentum_change_percent = None state.momentum_change_percent = None
@@ -433,6 +573,7 @@ class AutoTradeService:
state.breakout_level = None state.breakout_level = None
state.breakout_distance_percent = None state.breakout_distance_percent = None
state.breakout_reason = None state.breakout_reason = None
state.runtime_expired_reason = None state.runtime_expired_reason = None
state.runtime_expired_message = None state.runtime_expired_message = None
state.snapshot_age_seconds = None state.snapshot_age_seconds = None
@@ -508,7 +649,12 @@ class AutoTradeService:
if state.signal_started_at is None: if state.signal_started_at is None:
signal_age_seconds = 0 signal_age_seconds = 0
else: else:
signal_age_seconds = max(0, int(now - float(state.signal_started_at))) 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_repeats = max(0, self._confirm_repeats - self._same_signal_count)
missing_seconds = max( missing_seconds = max(
@@ -589,7 +735,7 @@ class AutoTradeService:
signal: str, signal: str,
reason: str, reason: str,
confidence: float, confidence: float,
payload: dict | None, payload: JsonDict | None,
) -> None: ) -> None:
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}" signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value previous_signal = self._last_signal_value
@@ -757,7 +903,7 @@ class AutoTradeService:
signal: str, signal: str,
reason: str, reason: str,
confidence: float, confidence: float,
payload: dict | None, payload: JsonDict | None,
) -> None: ) -> None:
return return
@@ -772,7 +918,7 @@ class AutoTradeService:
next_signal: str, next_signal: str,
reason: str, reason: str,
confidence: float, confidence: float,
payload: dict | None, payload: JsonDict | None,
duration_seconds: int, duration_seconds: int,
) -> None: ) -> None:
if previous_signal != "HOLD": if previous_signal != "HOLD":
@@ -822,6 +968,11 @@ class AutoTradeService:
if normalized_signal not in {"BUY", "SELL"}: if normalized_signal not in {"BUY", "SELL"}:
return return
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
try: try:
JournalService().log_ui_info( JournalService().log_ui_info(
event_type="signal_ready", event_type="signal_ready",
@@ -846,6 +997,9 @@ class AutoTradeService:
"confirmation_seconds": state.signal_confirmation_seconds, "confirmation_seconds": state.signal_confirmation_seconds,
"confirmation_required_seconds": state.signal_confirmation_required_seconds, "confirmation_required_seconds": state.signal_confirmation_required_seconds,
"confirmation_progress": state.signal_confirmation_progress, "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: except Exception:
@@ -855,7 +1009,7 @@ class AutoTradeService:
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
payload: dict | None, payload: JsonDict | None,
) -> None: ) -> None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return return
@@ -864,25 +1018,42 @@ class AutoTradeService:
previous_market_trend = state.market_trend previous_market_trend = state.market_trend
previous_market_volatility = state.market_volatility previous_market_volatility = state.market_volatility
state.market_state = payload.get("market_state") state.market_state = str(payload.get("market_state") or "")
state.market_trend = payload.get("market_trend") state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
state.market_volatility = payload.get("market_volatility") state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
state.market_trend_strength = payload.get("market_trend_strength") state.market_trend_strength = str(payload.get("market_trend_strength") or "")
state.market_trend_quality = payload.get("market_trend_quality") state.market_trend_quality = str(payload.get("market_trend_quality") or "")
state.market_phase = payload.get("market_phase") state.market_phase = str(payload.get("market_phase") or "")
state.market_phase_direction = payload.get("market_phase_direction") state.market_phase_direction = str(payload.get("market_phase_direction") or "")
state.market_analysis_interval = payload.get("market_analysis_interval") state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
state.market_analysis_reason = payload.get("market_analysis_reason") state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
state.momentum_state = payload.get("momentum_state") state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
state.momentum_direction = payload.get("momentum_direction") state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
state.momentum_change_percent = payload.get("momentum_change_percent") state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
state.momentum_strength = payload.get("momentum_strength") state.ema_distance_state = str(payload.get("ema_distance_state") or "")
state.breakout_level = payload.get("breakout_level") state.entry_timing_state = str(payload.get("entry_timing_state") or "")
state.breakout_distance_percent = payload.get("breakout_distance_percent") state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
state.breakout_reason = payload.get("breakout_reason") 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.market_analysis_updated_at = time.monotonic() state.market_analysis_updated_at = time.monotonic()
state.entry_block_reason = payload.get("entry_block_reason") state.entry_block_reason = str(payload.get("entry_block_reason") or "")
state.entry_block_message = payload.get("entry_block_message") state.entry_block_message = str(payload.get("entry_block_message") or "")
self._log_market_state_if_changed( self._log_market_state_if_changed(
state=state, state=state,
@@ -901,7 +1072,7 @@ class AutoTradeService:
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
reason = state.entry_block_reason reason = state.entry_block_reason
message = state.entry_block_message message = state.entry_block_message
@@ -938,7 +1109,7 @@ class AutoTradeService:
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
payload: dict, payload: JsonDict,
previous_market_state: str | None, previous_market_state: str | None,
previous_market_trend: str | None, previous_market_trend: str | None,
previous_market_volatility: str | None, previous_market_volatility: str | None,
@@ -1003,7 +1174,7 @@ class AutoTradeService:
event_type: str, event_type: str,
market_state: str, market_state: str,
message: str, message: str,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
level = self._market_journal_level(market_state) level = self._market_journal_level(market_state)
@@ -1034,7 +1205,7 @@ class AutoTradeService:
return messages.get(str(market_volatility or ""), "Волатильность не определена.") return messages.get(str(market_volatility or ""), "Волатильность не определена.")
def _market_journal_level(self, market_state: str) -> str: def _market_journal_level(self, market_state: str | None) -> str:
if market_state == "HIGH_VOLATILITY": if market_state == "HIGH_VOLATILITY":
return "WARNING" return "WARNING"
@@ -1056,8 +1227,11 @@ class AutoTradeService:
signal_updated_at = getattr(state, "signal_updated_at", None) signal_updated_at = getattr(state, "signal_updated_at", None)
if signal_updated_at is not None: if signal_updated_at is not None:
signal_age = now - float(signal_updated_at) 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: if signal_age > self._signal_ttl_seconds:
previous_signal = state.last_signal previous_signal = state.last_signal
@@ -1081,7 +1255,12 @@ class AutoTradeService:
market_updated_at = getattr(state, "market_analysis_updated_at", None) market_updated_at = getattr(state, "market_analysis_updated_at", None)
if market_updated_at is not None: if market_updated_at is not None:
market_age = now - float(market_updated_at) 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: if market_age > self._market_analysis_ttl_seconds:
state.market_state = None state.market_state = None
@@ -1096,7 +1275,23 @@ class AutoTradeService:
state.market_trend_quality = None state.market_trend_quality = None
state.market_phase = None state.market_phase = None
state.market_phase_direction = 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_state = None
state.momentum_direction = None state.momentum_direction = None
state.momentum_change_percent = None state.momentum_change_percent = None
@@ -1123,7 +1318,7 @@ class AutoTradeService:
state: AutoTradeState, state: AutoTradeState,
reason: str, reason: str,
message: str, message: str,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}" key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
@@ -1159,7 +1354,7 @@ class AutoTradeService:
fallback_price = None fallback_price = None
try: try:
fallback_price = float( fallback_price = safe_float(
ExchangeService().get_price( ExchangeService().get_price(
state.symbol, state.symbol,
runtime_key="auto", runtime_key="auto",
@@ -1192,10 +1387,10 @@ class AutoTradeService:
) )
return return
bid_price = self._safe_float(snapshot.get("bid_price")) bid_price = safe_float(snapshot.get("bid_price"))
ask_price = self._safe_float(snapshot.get("ask_price")) ask_price = safe_float(snapshot.get("ask_price"))
last_price = self._safe_float(snapshot.get("last_price")) last_price = safe_float(snapshot.get("last_price"))
age_seconds = self._safe_float(snapshot.get("age_seconds")) age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False)) is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "") source = str(snapshot.get("source") or "")
@@ -1240,6 +1435,8 @@ class AutoTradeService:
elif state.execution_block_reason == state.execution_quality_message: elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None state.execution_block_reason = None
spread_thresholds = self._spread_thresholds(state.symbol)
self._log_execution_quality_if_changed( self._log_execution_quality_if_changed(
state=state, state=state,
payload={ payload={
@@ -1258,49 +1455,46 @@ class AutoTradeService:
"market_runtime_degraded": state.market_runtime_degraded, "market_runtime_degraded": state.market_runtime_degraded,
"max_snapshot_age_seconds": self._max_snapshot_age_seconds, "max_snapshot_age_seconds": self._max_snapshot_age_seconds,
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds, "warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
"spread_warning_enter_percent": self._spread_warning_enter_percent, "spread_asset": self._asset_symbol(state.symbol),
"spread_warning_exit_percent": self._spread_warning_exit_percent, "spread_warning_enter_percent": spread_thresholds["warning_enter"],
"spread_block_enter_percent": self._spread_block_enter_percent, "spread_warning_exit_percent": spread_thresholds["warning_exit"],
"spread_block_exit_percent": self._spread_block_exit_percent, "spread_block_enter_percent": spread_thresholds["block_enter"],
"spread_block_exit_percent": spread_thresholds["block_exit"],
}, },
) )
def _spread_percent( def _spread_percent(
self, self,
*, *,
bid_price: float | None, bid_price: NumericLike | None,
ask_price: float | None, ask_price: NumericLike | None,
) -> float | None: ) -> float | None:
if bid_price is None or ask_price is None: bid = safe_float(bid_price)
ask = safe_float(ask_price)
if bid is None or ask is None:
return None return None
if bid_price <= 0 or ask_price <= 0: if bid <= 0 or ask <= 0:
return None return None
mid_price = (bid_price + ask_price) / 2 mid_price = (bid + ask) / 2
if mid_price <= 0: if mid_price <= 0:
return None return None
spread = ask_price - bid_price spread = ask - bid
if spread < 0: if spread < 0:
return None return None
return round((spread / mid_price) * 100, 5) return round((spread / mid_price) * 100, 5)
def _safe_float(self, value: object) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _log_execution_quality_if_changed( def _log_execution_quality_if_changed(
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
payload: dict, payload: JsonDict,
) -> None: ) -> None:
quality = state.execution_quality quality = state.execution_quality
reason = state.execution_quality_reason reason = state.execution_quality_reason
@@ -1408,8 +1602,18 @@ class AutoTradeService:
strength = state.market_trend_strength strength = state.market_trend_strength
quality = state.market_trend_quality quality = state.market_trend_quality
phase = state.market_phase 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}: if market_state in {
"HIGH_VOLATILITY",
"LOW_VOLATILITY",
"RANGE",
"UNKNOWN",
None,
"",
}:
return 0.25 return 0.25
score = 0.65 score = 0.65
@@ -1422,7 +1626,9 @@ class AutoTradeService:
score -= 0.25 score -= 0.25
if quality == "CLEAN": if quality == "CLEAN":
score += 0.1 score += 0.12
elif quality == "NORMAL":
score += 0.04
elif quality == "NOISY": elif quality == "NOISY":
score -= 0.25 score -= 0.25
@@ -1433,6 +1639,30 @@ class AutoTradeService:
elif phase in {"RANGE", "SQUEEZE"}: elif phase in {"RANGE", "SQUEEZE"}:
score -= 0.3 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) return self._clamp_score(score)
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float: def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
@@ -1482,11 +1712,16 @@ class AutoTradeService:
return "достаточная совокупная уверенность входа" return "достаточная совокупная уверенность входа"
def _clamp_score(self, value: float | int | None) -> float: def _clamp_score(self, value: NumericLike | None) -> float:
if value is None: if value is None:
return 0.0 return 0.0
return max(0.0, min(1.0, float(value))) numeric = safe_float(value)
if numeric is None:
return 0.0
return max(0.0, min(1.0, numeric))
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None: def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
if state.execution_quality == "BLOCKED": if state.execution_quality == "BLOCKED":
@@ -1541,6 +1776,9 @@ class AutoTradeService:
def _execution_block_semantic_message(self, state: AutoTradeState) -> str: def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
reason = state.execution_quality_reason reason = state.execution_quality_reason
if reason == "MARKET_CLOSED":
return "⏸️ Исполнение · рынок закрыт"
if reason == "STALE_SNAPSHOT": if reason == "STALE_SNAPSHOT":
return "⛔ Исполнение · рынок неактуален" return "⛔ Исполнение · рынок неактуален"
@@ -1561,6 +1799,11 @@ class AutoTradeService:
if state.status == "OFF": if state.status == "OFF":
return state 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) self._expire_runtime_if_needed(state)
strategy = self._get_strategy() strategy = self._get_strategy()

View File

@@ -97,6 +97,18 @@ class AutoTradeState:
# cumulative realized pnl за текущий цикл автоторговли # cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0 cycle_realized_pnl_usd: float = 0.0
# количество закрытых сделок в текущем цикле
cycle_closed_trades: int = 0
# количество прибыльных закрытых сделок
cycle_winning_trades: int = 0
# время запуска текущего цикла
cycle_started_at: float | None = None
# время последней adaptive size корректировки
adaptive_size_changed_at: float | None = None
# данные последнего flip # данные последнего flip
last_flip_old_side: str | None = None last_flip_old_side: str | None = None
last_flip_new_side: str | None = None last_flip_new_side: str | None = None
@@ -130,7 +142,7 @@ class AutoTradeState:
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN # сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
market_trend_strength: str | None = None market_trend_strength: str | None = None
# качество тренда: CLEAN / NOISY / UNKNOWN # качество тренда: CLEAN / NORMAL / NOISY / UNKNOWN
market_trend_quality: str | None = None market_trend_quality: str | None = None
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN # фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
@@ -139,6 +151,29 @@ class AutoTradeState:
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN # направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
market_phase_direction: str | None = None market_phase_direction: str | None = None
# advanced trend quality metrics
market_trend_gap_percent: float | None = None
market_trend_consistency: float | None = None
market_trend_efficiency: float | None = None
ema_distance_atr_ratio: float | None = None
ema_fast_slope_percent: float | None = None
ema_slow_slope_percent: float | None = None
candle_noise_score: float | None = None
price_position_score: float | None = None
# advanced trend quality semantic states
trend_quality_score: float | None = None
ema_distance_state: str | None = None
entry_timing_state: str | None = None
entry_timing_reason: str | None = None
# higher timeframe volatility context
htf_interval: str | None = None
htf_atr_percent: float | None = None
htf_atr_percent_baseline: float | None = None
htf_volatility_ratio: float | None = None
htf_volatility: str | None = None
# состояние momentum/breakout semantic engine # состояние momentum/breakout semantic engine
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN # NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
momentum_state: str | None = None momentum_state: str | None = None
@@ -262,4 +297,13 @@ class AutoTradeState:
adaptive_size_reason: str | None = None adaptive_size_reason: str | None = None
# факторы adaptive sizing для логов / отладки # факторы adaptive sizing для логов / отладки
adaptive_size_factors: dict | None = None adaptive_size_factors: dict | None = None
# статус торговой сессии инструмента
market_is_open: bool | None = None
market_status: str | None = None
market_status_message: str | None = None
market_status_updated_at: float | None = None
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
cycle_number: int = 0

View File

@@ -4,33 +4,49 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from typing import Callable from collections.abc import Callable
from typing import ClassVar, Protocol
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from src.core.telegram_errors import (
is_message_not_modified,
is_message_to_edit_not_found,
)
from src.integrations.exchange.market_data_runner import MarketDataRunner from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.trading.debug.service import DebugTradeService
from src.notifications.targets import NotificationTargetRegistry from src.notifications.targets import NotificationTargetRegistry
from src.trading.debug.service import DebugTradeService
class RenderText(Protocol):
def __call__(self) -> str: ...
class RenderMarkup(Protocol):
def __call__(self) -> InlineKeyboardMarkup | None: ...
class DebugTradeRunner: class DebugTradeRunner:
_task: asyncio.Task | None = None _task: ClassVar[asyncio.Task[None] | None] = None
_bot: Bot | None = None _bot: ClassVar[Bot | None] = None
_chat_id: int | None = None _chat_id: ClassVar[int | None] = None
_message_id: int | None = None _message_id: ClassVar[int | None] = None
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_current_screen: str | None = None _text_renderer: ClassVar[RenderText | None] = None
_markup_renderer: ClassVar[RenderMarkup | None] = None
_interval_seconds = 5 _current_screen: ClassVar[str | None] = None
_market_interval_seconds = 1
_last_text: str | None = None _interval_seconds: ClassVar[int] = 5
_last_refresh_at: float = 0.0 _market_interval_seconds: ClassVar[int] = 1
_retry_after_until: float = 0.0
_last_text: ClassVar[str | None] = None
_last_refresh_at: ClassVar[float] = 0.0
_retry_after_until: ClassVar[float] = 0.0
@classmethod @classmethod
def register_screen( def register_screen(
@@ -39,14 +55,14 @@ class DebugTradeRunner:
bot: Bot, bot: Bot,
chat_id: int, chat_id: int,
message_id: int, message_id: int,
render_text: Callable[[], str], render_text: RenderText,
render_markup: Callable[[], object], render_markup: RenderMarkup,
) -> None: ) -> None:
cls._bot = bot cls._bot = bot
cls._chat_id = chat_id cls._chat_id = chat_id
cls._message_id = message_id cls._message_id = message_id
cls._render_text = render_text cls._text_renderer = render_text
cls._render_markup = render_markup cls._markup_renderer = render_markup
cls._last_text = None cls._last_text = None
NotificationTargetRegistry.set_default_chat( NotificationTargetRegistry.set_default_chat(
@@ -54,6 +70,30 @@ class DebugTradeRunner:
chat_id=chat_id, chat_id=chat_id,
) )
@classmethod
def _reset_screen(cls) -> None:
cls._message_id = None
cls._text_renderer = None
cls._markup_renderer = None
cls._last_text = None
@classmethod
def _reset_runtime(cls) -> None:
cls._bot = None
cls._chat_id = None
cls._current_screen = None
cls._reset_screen()
@classmethod
def _is_screen_ready(cls) -> bool:
return (
cls._bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._text_renderer is not None
and cls._markup_renderer is not None
)
@classmethod @classmethod
async def delete_registered_screen( async def delete_registered_screen(
cls, cls,
@@ -75,10 +115,7 @@ class DebugTradeRunner:
except Exception: except Exception:
pass pass
cls._message_id = None cls._reset_screen()
cls._render_text = None
cls._render_markup = None
cls._last_text = None
@classmethod @classmethod
async def detach_screen( async def detach_screen(
@@ -105,13 +142,7 @@ class DebugTradeRunner:
except Exception: except Exception:
pass pass
cls._bot = None cls._reset_runtime()
cls._chat_id = None
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._current_screen = None
cls._last_text = None
@classmethod @classmethod
def set_current_screen(cls, screen: str) -> None: def set_current_screen(cls, screen: str) -> None:
@@ -121,6 +152,7 @@ class DebugTradeRunner:
def start(cls) -> None: def start(cls) -> None:
service = DebugTradeService() service = DebugTradeService()
state = service.get_state() state = service.get_state()
state.status = "RUNNING" state.status = "RUNNING"
MarketDataRunner.start( MarketDataRunner.start(
@@ -167,7 +199,11 @@ class DebugTradeRunner:
await asyncio.sleep(cls._interval_seconds) await asyncio.sleep(cls._interval_seconds)
@classmethod @classmethod
async def refresh_screen(cls, *, force: bool = False) -> None: async def refresh_screen(
cls,
*,
force: bool = False,
) -> None:
if cls._current_screen != "debug_auto": if cls._current_screen != "debug_auto":
return return
@@ -176,32 +212,43 @@ class DebugTradeRunner:
if now < cls._retry_after_until: if now < cls._retry_after_until:
return return
if not force and now - cls._last_refresh_at < cls._interval_seconds: if (
return not force
and now - cls._last_refresh_at < cls._interval_seconds
if not all(
[
cls._bot,
cls._chat_id,
cls._message_id,
cls._render_text,
cls._render_markup,
]
): ):
return return
text = cls._render_text() if not cls._is_screen_ready():
return
bot = cls._bot
chat_id = cls._chat_id
message_id = cls._message_id
text_renderer = cls._text_renderer
markup_renderer = cls._markup_renderer
if (
bot is None
or chat_id is None
or message_id is None
or text_renderer is None
or markup_renderer is None
):
return
text = text_renderer()
if text == cls._last_text: if text == cls._last_text:
return return
try: try:
await cls._bot.edit_message_text( await bot.edit_message_text(
chat_id=cls._chat_id, chat_id=chat_id,
message_id=cls._message_id, message_id=message_id,
text=text, text=text,
reply_markup=cls._render_markup(), reply_markup=markup_renderer(),
) )
cls._last_text = text cls._last_text = text
cls._last_refresh_at = now cls._last_refresh_at = now
@@ -209,18 +256,13 @@ class DebugTradeRunner:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5 cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc: except TelegramBadRequest as exc:
error_text = str(exc).lower() if is_message_not_modified(exc):
if "message is not modified" in error_text:
cls._last_text = text cls._last_text = text
cls._last_refresh_at = now cls._last_refresh_at = now
return return
if "message to edit not found" in error_text: if is_message_to_edit_not_found(exc):
cls._message_id = None cls._reset_screen()
cls._render_text = None
cls._render_markup = None
cls._last_text = None
return return
except Exception: except Exception:

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import time
from typing import Any from typing import Any
from src.trading.auto.state import AutoTradeState from src.trading.auto.state import AutoTradeState
from src.core.numbers import safe_float
class SemanticDiagnosticSnapshotBuilder: class SemanticDiagnosticSnapshotBuilder:
@@ -30,6 +31,8 @@ class SemanticDiagnosticSnapshotBuilder:
blockers=blockers, blockers=blockers,
) )
position_current_price = self._position_current_price(state)
return { return {
"status": { "status": {
"status": state.status, "status": state.status,
@@ -59,6 +62,27 @@ class SemanticDiagnosticSnapshotBuilder:
"entry_block_reason": state.entry_block_reason, "entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message, "entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds, "age_seconds": market_age_seconds,
"market_is_open": state.market_is_open,
"market_status": state.market_status,
"market_status_message": state.market_status_message,
"market_status_updated_at": state.market_status_updated_at,
"trend_gap_percent": state.market_trend_gap_percent,
"trend_consistency": state.market_trend_consistency,
"trend_efficiency": state.market_trend_efficiency,
"trend_quality_score": state.trend_quality_score,
"ema_distance_atr_ratio": state.ema_distance_atr_ratio,
"ema_distance_state": state.ema_distance_state,
"entry_timing_state": state.entry_timing_state,
"entry_timing_reason": state.entry_timing_reason,
"ema_fast_slope_percent": state.ema_fast_slope_percent,
"ema_slow_slope_percent": state.ema_slow_slope_percent,
"candle_noise_score": state.candle_noise_score,
"price_position_score": state.price_position_score,
"htf_interval": state.htf_interval,
"htf_atr_percent": state.htf_atr_percent,
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
"htf_volatility_ratio": state.htf_volatility_ratio,
"htf_volatility": state.htf_volatility,
}, },
"momentum": { "momentum": {
"state": getattr(state, "momentum_state", None), "state": getattr(state, "momentum_state", None),
@@ -113,6 +137,11 @@ class SemanticDiagnosticSnapshotBuilder:
"last_flip_pnl_usd": state.last_flip_pnl_usd, "last_flip_pnl_usd": state.last_flip_pnl_usd,
"last_flip_reason": state.last_flip_reason, "last_flip_reason": state.last_flip_reason,
"last_flip_monotonic_at": state.last_flip_monotonic_at, "last_flip_monotonic_at": state.last_flip_monotonic_at,
"current_price": position_current_price,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"stop_loss_usd": state.effective_target_risk_usd,
"take_profit_usd": self._take_profit_usd(state),
"max_loss_usd": state.max_loss_usd,
}, },
"runtime_health": { "runtime_health": {
"health_score": health_score, "health_score": health_score,
@@ -151,19 +180,52 @@ class SemanticDiagnosticSnapshotBuilder:
"is_ready": state.is_signal_ready, "is_ready": state.is_signal_ready,
"is_blocked": bool(blockers), "is_blocked": bool(blockers),
"blockers": blockers, "blockers": blockers,
"symbol": state.symbol,
}, },
} }
def _position_current_price(
self,
state: AutoTradeState,
) -> float | None:
if state.position_side == "NONE":
return None
try:
from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
side = str(state.position_side or "").upper()
price = snapshot.get("last_price")
if side == "LONG":
price = snapshot.get("bid_price") or price
elif side == "SHORT":
price = snapshot.get("ask_price") or price
return safe_float(price)
except Exception:
return None
def _age_seconds( def _age_seconds(
self, self,
*, *,
now: float, now: float,
started_at: float | None, started_at: float | None,
) -> int | None: ) -> int | None:
if started_at is None: started = safe_float(started_at)
if started is None:
return None return None
return max(0, int(now - float(started_at))) return max(0, int(now - started))
def _is_runtime_degraded(self, state: AutoTradeState) -> bool: def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
return bool( return bool(
@@ -203,6 +265,28 @@ class SemanticDiagnosticSnapshotBuilder:
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}: if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10 score -= 10
if state.ema_distance_state == "COMPRESSED":
score -= 10
if state.ema_distance_state == "EXTENDED":
score -= 8
if state.ema_distance_state == "OVEREXTENDED":
score -= 25
if state.entry_timing_state == "LATE":
score -= 18
if state.entry_timing_state == "CHASING":
score -= 30
trend_quality_score = safe_float(state.trend_quality_score)
if trend_quality_score is not None:
if trend_quality_score < 0.45:
score -= 12
elif trend_quality_score >= 0.7:
score += 5
if state.market_runtime_degraded: if state.market_runtime_degraded:
score -= 15 score -= 15
@@ -225,6 +309,9 @@ class SemanticDiagnosticSnapshotBuilder:
has_ready_signal = bool(state.is_signal_ready) has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE" has_position = state.position_side != "NONE"
if state.market_is_open is False:
return "RED"
has_waiting_data_blocker = any( has_waiting_data_blocker = any(
str(item).strip().lower() str(item).strip().lower()
in { in {
@@ -258,6 +345,12 @@ class SemanticDiagnosticSnapshotBuilder:
return "WAITING" return "WAITING"
if state.entry_block_reason == "MARKET_FILTER_BLOCKED": if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
return "RED"
if state.market_trend_quality == "NOISY":
return "RED"
return "YELLOW" return "YELLOW"
if health_score < 45: if health_score < 45:
@@ -301,6 +394,9 @@ class SemanticDiagnosticSnapshotBuilder:
state: AutoTradeState, state: AutoTradeState,
blockers: list[str], blockers: list[str],
) -> str: ) -> str:
if state.market_is_open is False:
return state.market_status_message or "Биржа временно недоступна для торговли."
if state.entry_block_reason == "MARKET_FILTER_BLOCKED": if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE": if state.market_state == "RANGE" or state.market_phase == "RANGE":
return "Ожидание: рынок без направления." return "Ожидание: рынок без направления."
@@ -341,6 +437,13 @@ class SemanticDiagnosticSnapshotBuilder:
def _blockers(self, state: AutoTradeState) -> list[str]: def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = [] blockers: list[str] = []
if state.market_is_open is False:
blockers.append(
state.market_status_message
or "рынок закрыт"
)
return blockers
if state.entry_block_reason == "MARKET_FILTER_BLOCKED": if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE": if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления") blockers.append("рынок без направления")
@@ -349,7 +452,17 @@ class SemanticDiagnosticSnapshotBuilder:
else: else:
blockers.append("рынок не подходит") blockers.append("рынок не подходит")
return blockers if state.ema_distance_state == "COMPRESSED":
blockers.append("EMA слишком сжаты")
if state.ema_distance_state == "OVEREXTENDED":
blockers.append("тренд перерастянут")
if state.entry_timing_state == "LATE":
blockers.append("поздний вход")
if state.entry_timing_state == "CHASING":
blockers.append("вход запрещён: chasing move")
if state.entry_block_message: if state.entry_block_message:
blockers.append(str(state.entry_block_message)) blockers.append(str(state.entry_block_message))
@@ -363,4 +476,26 @@ class SemanticDiagnosticSnapshotBuilder:
if state.runtime_expired_message: if state.runtime_expired_message:
blockers.append(str(state.runtime_expired_message)) blockers.append(str(state.runtime_expired_message))
return blockers result: list[str] = []
for item in blockers:
if item and item not in result:
result.append(item)
return result
def _take_profit_usd(self, state: AutoTradeState) -> float | None:
take_profit_percent = safe_float(state.take_profit_percent)
position_size = safe_float(state.position_size)
entry_price = safe_float(state.entry_price)
if (
take_profit_percent is None
or position_size is None
or position_size <= 0
or entry_price is None
or entry_price <= 0
):
return None
move = entry_price * (take_profit_percent / 100)
return move * position_size

View File

@@ -13,6 +13,8 @@ from src.trading.auto.state import AutoTradeState
from src.trading.execution.models import ExecutionDecision from src.trading.execution.models import ExecutionDecision
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState from src.trading.position.state import PositionState
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
@dataclass(slots=True) @dataclass(slots=True)
@@ -108,7 +110,7 @@ class ExecutionEngine:
final_size=size, final_size=size,
) )
size = self._round_order_size(size) size = self._round_size(size)
if size <= 0: if size <= 0:
return ExecutionDecision( return ExecutionDecision(
@@ -134,7 +136,7 @@ class ExecutionEngine:
state.last_execution_action = action state.last_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта." state.last_execution_reason = f"Позиция {side} открыта."
payload = { payload: JsonDict = {
"execution_type": "ENTRY", "execution_type": "ENTRY",
"action": action, "action": action,
"symbol": state.symbol, "symbol": state.symbol,
@@ -218,7 +220,7 @@ class ExecutionEngine:
final_size=new_size, final_size=new_size,
) )
new_size = self._round_order_size(new_size) new_size = self._round_size(new_size)
if new_size <= 0: if new_size <= 0:
return ExecutionDecision( return ExecutionDecision(
@@ -230,11 +232,10 @@ class ExecutionEngine:
state.realized_pnl_usd += pnl state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl
state.last_flip_old_side = old_side state.cycle_closed_trades += 1
state.last_flip_new_side = new_side
state.last_flip_pnl_usd = pnl if pnl > 0:
state.last_flip_reason = state.last_signal_reason state.cycle_winning_trades += 1
state.last_flip_monotonic_at = time.monotonic()
old_side = position.side old_side = position.side
old_entry_price = position.entry_price old_entry_price = position.entry_price
@@ -242,6 +243,12 @@ class ExecutionEngine:
old_leverage = position.leverage old_leverage = position.leverage
old_opened_at = position.opened_at 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()
type(self)._position = PositionState( type(self)._position = PositionState(
side=new_side, side=new_side,
symbol=state.symbol, symbol=state.symbol,
@@ -261,7 +268,7 @@ class ExecutionEngine:
state.last_flip_at = now state.last_flip_at = now
type(self)._last_flip_block_key = None type(self)._last_flip_block_key = None
payload = { payload: JsonDict = {
"execution_type": "FLIP", "execution_type": "FLIP",
"action": f"FLIP_{old_side}_TO_{new_side}", "action": f"FLIP_{old_side}_TO_{new_side}",
"symbol": state.symbol, "symbol": state.symbol,
@@ -326,8 +333,8 @@ class ExecutionEngine:
state: AutoTradeState, state: AutoTradeState,
*, *,
forced_reason: str | None = None, forced_reason: str | None = None,
forced_exit_price: float | None = None, forced_exit_price: NumericLike | None = None,
forced_pnl: float | None = None, forced_pnl: NumericLike | None = None,
forced_price_meta: _ExecutionPrice | None = None, forced_price_meta: _ExecutionPrice | None = None,
) -> ExecutionDecision: ) -> ExecutionDecision:
position = type(self)._position position = type(self)._position
@@ -337,7 +344,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.") return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.")
if forced_exit_price is not None: if forced_exit_price is not None:
exit_price = forced_exit_price exit_price = safe_float(forced_exit_price) or 0.0
exit_execution = forced_price_meta exit_execution = forced_price_meta
else: else:
try: try:
@@ -346,14 +353,26 @@ class ExecutionEngine:
except Exception as exc: except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}")
pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price) 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.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
now = self._now_time() now = self._now_time()
payload = { payload: JsonDict = {
"execution_type": "EXIT", "execution_type": "EXIT",
"action": "CLOSE", "action": "CLOSE",
"symbol": state.symbol, "symbol": state.symbol,
@@ -413,7 +432,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.", f"Позиция закрыта по правилу защиты: {forced_reason}.",
) )
return ExecutionDecision("CLOSE", True, "Позиция закрыта.") return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
@@ -475,18 +494,24 @@ class ExecutionEngine:
return False return False
return unrealized_pnl <= -abs(state.max_loss_usd) return unrealized_pnl <= -abs(state.max_loss_usd)
def _calculate_price_move_percent(self, current_price: float) -> float: def _calculate_price_move_percent(
self,
current_price: NumericLike | None,
) -> float:
position = type(self)._position position = type(self)._position
entry = position.entry_price or 0.0 price = safe_float(current_price) or 0.0
entry = safe_float(position.entry_price) or 0.0
if entry <= 0: if entry <= 0:
return 0.0 return 0.0
if position.side == "LONG": if position.side == "LONG":
return round(((current_price - entry) / entry) * 100, 4) return round(((price - entry) / entry) * 100, 4)
if position.side == "SHORT": if position.side == "SHORT":
return round(((entry - current_price) / entry) * 100, 4) return round(((entry - price) / entry) * 100, 4)
return 0.0 return 0.0
@@ -507,9 +532,9 @@ class ExecutionEngine:
def _flip_block_reason(self, state: AutoTradeState) -> str | None: def _flip_block_reason(self, state: AutoTradeState) -> str | None:
position = type(self)._position position = type(self)._position
confidence = float(state.last_signal_confidence or 0.0) confidence = safe_float(state.last_signal_confidence) or 0.0
repeat_count = int(state.last_signal_repeat_count or 0) repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
unrealized_pnl = float(state.unrealized_pnl_usd or 0.0) unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
hold_seconds = self._position_hold_seconds(position) hold_seconds = self._position_hold_seconds(position)
momentum_direction = getattr(state, "momentum_direction", None) momentum_direction = getattr(state, "momentum_direction", None)
momentum_state = getattr(state, "momentum_state", None) momentum_state = getattr(state, "momentum_state", None)
@@ -560,7 +585,7 @@ class ExecutionEngine:
reason: str, reason: str,
) -> ExecutionDecision: ) -> ExecutionDecision:
position = type(self)._position position = type(self)._position
confidence = float(state.last_signal_confidence or 0.0) confidence = safe_float(state.last_signal_confidence) or 0.0
state.execution_block_reason = reason state.execution_block_reason = reason
state.last_flip_block_reason = reason state.last_flip_block_reason = reason
@@ -578,7 +603,7 @@ class ExecutionEngine:
if block_key != type(self)._last_flip_block_key: if block_key != type(self)._last_flip_block_key:
type(self)._last_flip_block_key = block_key type(self)._last_flip_block_key = block_key
payload = { payload: JsonDict = {
"execution_type": "FLIP_BLOCKED", "execution_type": "FLIP_BLOCKED",
"symbol": state.symbol, "symbol": state.symbol,
"position_side": position.side, "position_side": position.side,
@@ -700,8 +725,10 @@ class ExecutionEngine:
multiplier = 1.0 multiplier = 1.0
execution_confidence_score = getattr(state, "execution_confidence_score", None) execution_confidence_score = getattr(state, "execution_confidence_score", None)
if execution_confidence_score is not None: score_raw = safe_float(execution_confidence_score)
score = max(0.0, min(1.0, float(execution_confidence_score)))
if score_raw is not None:
score = max(0.0, min(1.0, score_raw))
if score < 0.55: if score < 0.55:
multiplier *= 0.0 multiplier *= 0.0
@@ -750,17 +777,14 @@ class ExecutionEngine:
multiplier *= 1.05 multiplier *= 1.05
if momentum_strength is not None: if momentum_strength is not None:
try: strength = safe_float(momentum_strength)
strength = float(momentum_strength)
if strength is not None:
if strength >= 1.5: if strength >= 1.5:
multiplier *= 1.1 multiplier *= 1.1
elif strength <= 0.7: elif strength <= 0.7:
multiplier *= 0.8 multiplier *= 0.8
except Exception:
pass
if signal == "BUY": if signal == "BUY":
if momentum_direction == "DOWN": if momentum_direction == "DOWN":
multiplier *= 0.75 multiplier *= 0.75
@@ -800,7 +824,10 @@ class ExecutionEngine:
state.adaptive_size_final = self._round_size(final_size) state.adaptive_size_final = self._round_size(final_size)
state.adaptive_size_multiplier = multiplier state.adaptive_size_multiplier = multiplier
base_risk_percent = float(state.risk_percent or 0.0) 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( state.effective_risk_percent = round(
base_risk_percent * multiplier, base_risk_percent * multiplier,
@@ -847,7 +874,7 @@ class ExecutionEngine:
base_size: float, base_size: float,
final_size: float, final_size: float,
) -> None: ) -> None:
adaptive_final = float(state.adaptive_size_final or 0.0) adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final <= 0: if adaptive_final <= 0:
state.effective_risk_percent = 0.0 state.effective_risk_percent = 0.0
@@ -859,7 +886,7 @@ class ExecutionEngine:
min(1.0, final_size / adaptive_final), min(1.0, final_size / adaptive_final),
) )
current_effective_risk = float(state.effective_risk_percent or 0.0) current_effective_risk = safe_float(state.effective_risk_percent) or 0.0
state.effective_risk_percent = round( state.effective_risk_percent = round(
current_effective_risk * margin_ratio, current_effective_risk * margin_ratio,
@@ -917,7 +944,7 @@ class ExecutionEngine:
limited_size = self._round_size(max_size) limited_size = self._round_size(max_size)
adaptive_final = float(state.adaptive_size_final or 0.0) adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final > 0: if adaptive_final > 0:
effective_multiplier = limited_size / adaptive_final effective_multiplier = limited_size / adaptive_final
@@ -1011,32 +1038,55 @@ class ExecutionEngine:
pricing_role="MARKET_LAST", pricing_role="MARKET_LAST",
) )
def _snapshot_price(self, raw_price: object, name: str) -> float: def _snapshot_price(
self,
raw_price: NumericLike | None,
name: str,
) -> float:
if raw_price is None: if raw_price is None:
raise ValueError(f"Execution snapshot price '{name}' is missing.") raise ValueError(
f"Execution snapshot price '{name}' is missing."
)
price = float(raw_price) price = safe_float(raw_price)
if price is None:
raise ValueError(
f"Execution snapshot price '{name}' is invalid."
)
if price <= 0: if price <= 0:
raise ValueError(f"Execution snapshot price '{name}' is invalid: {price}") raise ValueError(
f"Execution snapshot price '{name}' is invalid: {price}"
)
return price return price
def _round_size(self, size: float) -> float: def _round_size(self, size: NumericLike | None) -> float:
factor = 10 ** self._size_precision value = safe_float(size)
return math.floor(float(size) * factor) / factor
def _calculate_pnl(self, current_price: float) -> float: if value is None:
return 0.0
factor = 10 ** self._size_precision
return math.floor(value * factor) / factor
def _calculate_pnl(
self,
current_price: NumericLike | None,
) -> float:
position = type(self)._position position = type(self)._position
entry = position.entry_price or 0.0 price = safe_float(current_price) or 0.0
size = position.size or 0.0
entry = safe_float(position.entry_price) or 0.0
size = safe_float(position.size) or 0.0
if position.side == "LONG": if position.side == "LONG":
return round((current_price - entry) * size, 4) return round((price - entry) * size, 4)
if position.side == "SHORT": if position.side == "SHORT":
return round((entry - current_price) * size, 4) return round((entry - price) * size, 4)
return 0.0 return 0.0
@@ -1048,9 +1098,5 @@ class ExecutionEngine:
state.position_size = position.size state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd state.unrealized_pnl_usd = position.unrealized_pnl_usd
def _round_order_size(self, value: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(value) * factor) / factor
def _now_time(self) -> str: def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S") return datetime.now().strftime("%H:%M:%S")

View File

@@ -1,17 +1,16 @@
# app/src/trading/journal/exporter.py # app/src/trading/journal/exporter.py
from __future__ import annotations from __future__ import annotations
import csv import csv
import json import json
import re import re
import zipfile
from datetime import datetime from datetime import datetime
from io import BytesIO, StringIO from io import BytesIO, StringIO
from xml.sax.saxutils import escape
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from openpyxl import Workbook
from openpyxl.styles import Font
from src.core.config import load_settings from src.core.config import load_settings
from src.core.event_titles import event_title from src.core.event_titles import event_title
@@ -61,12 +60,12 @@ def _event_title(event_type: object) -> str:
return event_title(event_type) return event_title(event_type)
def _payload(row: dict) -> dict: def _payload(row: dict[str, object]) -> dict[str, object]:
payload = row.get("payload") payload = row.get("payload")
return payload if isinstance(payload, dict) else {} return payload if isinstance(payload, dict) else {}
def _payload_json(payload: dict) -> str: def _payload_json(payload: dict[str, object]) -> str:
if not payload: if not payload:
return "" return ""
@@ -74,7 +73,7 @@ def _payload_json(payload: dict) -> str:
return _strip_emoji(text) return _strip_emoji(text)
def _export_row(row: dict) -> list[str]: def _export_row(row: dict[str, object]) -> list[str]:
payload = _payload(row) payload = _payload(row)
return [ return [
@@ -108,15 +107,19 @@ def _headers() -> list[str]:
] ]
def _levels_summary(rows: list[dict]) -> str: def _levels_summary(rows: list[dict[str, object]]) -> str:
levels = sorted( levels = sorted(
{str(row.get("level") or "").upper() for row in rows if row.get("level")} {str(row.get("level") or "").upper() for row in rows if row.get("level")}
) )
return ", ".join(levels) if levels else "" return ", ".join(levels) if levels else ""
def _period_summary(rows: list[dict]) -> str: def _period_summary(rows: list[dict[str, object]]) -> str:
dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")] dates = [
_format_datetime(row.get("created_at"))
for row in rows
if row.get("created_at")
]
dates = [value for value in dates if value] dates = [value for value in dates if value]
if not dates: if not dates:
@@ -127,7 +130,7 @@ def _period_summary(rows: list[dict]) -> str:
def _metadata_rows( def _metadata_rows(
*, *,
rows: list[dict], rows: list[dict[str, object]],
total_count: int, total_count: int,
export_limit: int, export_limit: int,
account_mode: str, account_mode: str,
@@ -152,7 +155,7 @@ def _metadata_rows(
def build_csv( def build_csv(
rows: list[dict], rows: list[dict[str, object]],
*, *,
total_count: int, total_count: int,
export_limit: int, export_limit: int,
@@ -185,42 +188,199 @@ def build_csv(
def build_xlsx( def build_xlsx(
rows: list[dict], rows: list[dict[str, object]],
*, *,
total_count: int, total_count: int,
export_limit: int, export_limit: int,
account_mode: str, account_mode: str,
journal_level: str, journal_level: str,
) -> bytes: ) -> bytes:
wb = Workbook() sheet_rows: list[list[str]] = []
ws = wb.active
ws.title = "Journal"
for metadata_row in _metadata_rows( sheet_rows.extend(
rows=rows, _metadata_rows(
total_count=total_count, rows=rows,
export_limit=export_limit, total_count=total_count,
account_mode=account_mode, export_limit=export_limit,
journal_level=journal_level, account_mode=account_mode,
): journal_level=journal_level,
ws.append(metadata_row) )
)
header_row_index = ws.max_row + 1 sheet_rows.append(_headers())
ws.append(_headers())
for cell in ws[1]:
cell.font = Font(bold=True)
for cell in ws[header_row_index]:
cell.font = Font(bold=True)
for row in rows: for row in rows:
ws.append(_export_row(row)) sheet_rows.append(_export_row(row))
for column_cells in ws.columns: return _build_xlsx_bytes(
max_length = max(len(str(cell.value or "")) for cell in column_cells) sheet_name="Journal",
ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60) rows=sheet_rows,
)
def _build_xlsx_bytes(
*,
sheet_name: str,
rows: list[list[str]],
) -> bytes:
stream = BytesIO() stream = BytesIO()
wb.save(stream)
return stream.getvalue() with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", _content_types_xml())
archive.writestr("_rels/.rels", _root_rels_xml())
archive.writestr("xl/workbook.xml", _workbook_xml(sheet_name))
archive.writestr("xl/_rels/workbook.xml.rels", _workbook_rels_xml())
archive.writestr("xl/styles.xml", _styles_xml())
archive.writestr("xl/worksheets/sheet1.xml", _worksheet_xml(rows))
return stream.getvalue()
def _worksheet_xml(rows: list[list[str]]) -> str:
header_row_index = _header_row_index(rows)
column_widths = _column_widths(rows)
xml_rows: list[str] = []
for row_index, row in enumerate(rows, start=1):
cells: list[str] = []
for column_index, value in enumerate(row, start=1):
cell_ref = f"{_column_letter(column_index)}{row_index}"
style = ' s="1"' if row_index in {1, header_row_index} else ""
cells.append(
f'<c r="{cell_ref}" t="inlineStr"{style}>'
f"<is><t>{_xml_text(value)}</t></is>"
f"</c>"
)
xml_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
cols_xml = "".join(
(
f'<col min="{index}" max="{index}" '
f'width="{width}" customWidth="1"/>'
)
for index, width in enumerate(column_widths, start=1)
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f"<cols>{cols_xml}</cols>"
"<sheetData>"
f"{''.join(xml_rows)}"
"</sheetData>"
"</worksheet>"
)
def _header_row_index(rows: list[list[str]]) -> int:
headers = _headers()
for index, row in enumerate(rows, start=1):
if row == headers:
return index
return 1
def _column_widths(rows: list[list[str]]) -> list[int]:
max_columns = max((len(row) for row in rows), default=1)
widths: list[int] = []
for column_index in range(max_columns):
max_length = 0
for row in rows:
if column_index < len(row):
max_length = max(max_length, len(str(row[column_index] or "")))
widths.append(min(max_length + 2, 60))
return widths
def _column_letter(index: int) -> str:
result = ""
while index > 0:
index, remainder = divmod(index - 1, 26)
result = chr(65 + remainder) + result
return result
def _xml_text(value: object) -> str:
text = str(value or "")
return escape(text, {'"': "&quot;", "'": "&apos;"})
def _content_types_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
"</Types>"
)
def _root_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.xml"/>'
"</Relationships>"
)
def _workbook_xml(sheet_name: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
"<sheets>"
f'<sheet name="{_xml_text(sheet_name)}" sheetId="1" r:id="rId1"/>'
"</sheets>"
"</workbook>"
)
def _workbook_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
'Target="worksheets/sheet1.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
'Target="styles.xml"/>'
"</Relationships>"
)
def _styles_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
"<fonts count=\"2\">"
"<font><sz val=\"11\"/><name val=\"Calibri\"/></font>"
"<font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font>"
"</fonts>"
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>"
"<borders count=\"1\"><border/></borders>"
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>"
"<cellXfs count=\"2\">"
"<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>"
"<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>"
"</cellXfs>"
"</styleSheet>"
)

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from src.core.types import JsonDict
class MarketState(StrEnum): class MarketState(StrEnum):
TREND_UP = "TREND_UP" TREND_UP = "TREND_UP"
@@ -46,12 +48,6 @@ class TrendStrength(StrEnum):
UNKNOWN = "UNKNOWN" UNKNOWN = "UNKNOWN"
class TrendQuality(StrEnum):
CLEAN = "CLEAN"
NOISY = "NOISY"
UNKNOWN = "UNKNOWN"
class MarketPhase(StrEnum): class MarketPhase(StrEnum):
IMPULSE = "IMPULSE" IMPULSE = "IMPULSE"
PULLBACK = "PULLBACK" PULLBACK = "PULLBACK"
@@ -60,6 +56,29 @@ class MarketPhase(StrEnum):
UNKNOWN = "UNKNOWN" UNKNOWN = "UNKNOWN"
class TrendQuality(StrEnum):
CLEAN = "CLEAN"
NORMAL = "NORMAL"
NOISY = "NOISY"
UNKNOWN = "UNKNOWN"
class EmaDistanceState(StrEnum):
COMPRESSED = "COMPRESSED"
HEALTHY = "HEALTHY"
EXTENDED = "EXTENDED"
OVEREXTENDED = "OVEREXTENDED"
UNKNOWN = "UNKNOWN"
class EntryTimingState(StrEnum):
EARLY = "EARLY"
NORMAL = "NORMAL"
LATE = "LATE"
CHASING = "CHASING"
UNKNOWN = "UNKNOWN"
@dataclass(slots=True) @dataclass(slots=True)
class MarketAnalysisResult: class MarketAnalysisResult:
symbol: str symbol: str
@@ -80,23 +99,42 @@ class MarketAnalysisResult:
reason: str reason: str
is_trade_allowed: bool is_trade_allowed: bool
payload: dict payload: JsonDict
trend_strength: TrendStrength trend_strength: TrendStrength
trend_quality: TrendQuality trend_quality: TrendQuality
market_phase: MarketPhase market_phase: MarketPhase
trend_gap_percent: float | None trend_gap_percent: float | None
trend_consistency: float | None trend_consistency: float | None
trend_efficiency: float | None
ema_distance_atr_ratio: float | None
phase_direction: TrendDirection phase_direction: TrendDirection
phase_change_percent: float | None phase_change_percent: float | None
phase_reason: str | None phase_reason: str | None
ema_fast_slope_percent: float | None = None
ema_slow_slope_percent: float | None = None
phase_direction_consistency: float | None = None phase_direction_consistency: float | None = None
momentum_state: MomentumState | None = None momentum_state: MomentumState | None = None
momentum_direction: TrendDirection | None = None momentum_direction: TrendDirection | None = None
momentum_change_percent: float | None = None momentum_change_percent: float | None = None
momentum_strength: float | None = None momentum_strength: float | None = None
breakout_level: float | None = None breakout_level: float | None = None
breakout_distance_percent: float | None = None breakout_distance_percent: float | None = None
breakout_reason: str | None = None breakout_reason: str | None = None
htf_interval: str | None = None
htf_atr_percent: float | None = None
htf_atr_percent_baseline: float | None = None
htf_volatility_ratio: float | None = None
htf_volatility: VolatilityState | None = None
trend_quality_score: float | None = None
ema_distance_state: EmaDistanceState | None = None
entry_timing_state: EntryTimingState | None = None
entry_timing_reason: str | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import time import time
from typing import Any
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.trading.market_analysis.models import ( from src.trading.market_analysis.models import (
MarketPhase, MarketPhase,
@@ -133,8 +135,16 @@ class TrendStrategy:
"market_phase_change_percent": market.phase_change_percent, "market_phase_change_percent": market.phase_change_percent,
"market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"), "market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"),
"market_phase_reason": market.phase_reason, "market_phase_reason": market.phase_reason,
"momentum_state": market.momentum_state.value, "momentum_state": (
"momentum_direction": market.momentum_direction.value, market.momentum_state.value
if market.momentum_state is not None
else "UNKNOWN"
),
"momentum_direction": (
market.momentum_direction.value
if market.momentum_direction is not None
else "UNKNOWN"
),
"momentum_change_percent": market.momentum_change_percent, "momentum_change_percent": market.momentum_change_percent,
"momentum_strength": market.momentum_strength, "momentum_strength": market.momentum_strength,
"breakout_level": market.breakout_level, "breakout_level": market.breakout_level,
@@ -381,8 +391,12 @@ class TrendStrategy:
confidence = 0.55 + (strength_score * 0.35) confidence = 0.55 + (strength_score * 0.35)
return round(min(0.95, confidence), 2) return round(min(0.95, confidence), 2)
def _analysis_price(self, snapshot: dict[str, object]) -> float:
def _analysis_price(
self,
snapshot: dict[str, Any],
) -> float:
bid = self._safe_float(snapshot.get("bid_price")) bid = self._safe_float(snapshot.get("bid_price"))
ask = self._safe_float(snapshot.get("ask_price")) ask = self._safe_float(snapshot.get("ask_price"))
@@ -395,7 +409,10 @@ class TrendStrategy:
return 0.0 return 0.0
def _safe_float(self, value: object) -> float | None: def _safe_float(
self,
value: float | int | str | None,
) -> float | None:
if value is None: if value is None:
return None return None

View File

@@ -1257,6 +1257,135 @@
- diagnostics layer подготовлен к semantic trade analytics - diagnostics layer подготовлен к semantic trade analytics
- diagnostics layer подготовлен к auto-refresh runtime dashboard - diagnostics layer подготовлен к auto-refresh runtime dashboard
#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer
- реализована унификация Telegram handler architecture
- реализована единая callback message validation layer
- реализован _require_message() runtime safety layer
- устранены unsafe callback.message обращения
- реализована защита Telegram handlers от InaccessibleMessage
- реализована стандартизация Telegram screen lifecycle
- реализована стандартизация ActiveScreenManager integration
- реализована стандартизация LiveScreenRunner integration
- реализована стандартизация StaticScreen registration
- реализована стандартизация render/edit Telegram flow
- реализована стандартизация edit_mode architecture
- реализована стандартизация Telegram runtime navigation
- реализована стандартизация retry flow architecture
- реализована стандартизация monitoring navigation flow
- реализована стандартизация portfolio/market/journal runtime screens
- реализована стандартизация auto runtime screen architecture
- реализована стандартизация auto diagnostics rendering
- реализована стандартизация risk controls runtime flow
- реализована стандартизация journal runtime rendering
- реализована стандартизация monitoring runtime rendering
- реализована стандартизация Telegram callback lifecycle
- реализована стандартизация runtime logging payloads
- реализована стандартизация JournalService logging payload structure
- реализована стандартизация Telegram runtime formatting
- реализована стандартизация keyboard rendering layer
- реализована стандартизация runtime message rendering
- реализована стандартизация safe runtime editing
- реализована стандартизация Telegram exception handling
- реализована стандартизация runtime error protection
- реализована стандартизация runtime fallback rendering
- реализована стандартизация runtime state cleanup
- реализована стандартизация runtime unregister flow
- реализована стандартизация runtime screen switching
- реализована стандартизация FSM cleanup flow
- реализована стандартизация risk runtime update flow
- реализована стандартизация diagnostics runtime refresh
- реализована стандартизация portfolio runtime refresh
- реализована стандартизация market runtime refresh
- реализована стандартизация journal pagination flow
- реализована стандартизация journal export flow
- реализована стандартизация journal cleanup flow
- реализована стандартизация runtime callback alerts
- реализована стандартизация runtime user notifications
- реализована стандартизация runtime retry notifications
- реализована стандартизация Telegram screen auto-refresh architecture
- реализована стандартизация runtime render callbacks
- реализована стандартизация auto runtime protection layer
- реализована стандартизация runtime semantic rendering
- реализована стандартизация runtime formatting utilities
- реализована стандартизация numeric formatting layer
- реализована стандартизация safe numeric parsing
- реализована стандартизация safe float conversion layer
- реализован global NumericLike typing layer
- реализован global JsonDict typing layer
- реализован global JsonList typing layer
- реализован centralized safe_float() conversion layer
- устранены raw float() conversions в Telegram runtime
- устранены unsafe numeric casts
- устранены implicit runtime numeric conversions
- устранены raw dict payload usages
- устранены runtime typing inconsistencies
- устранены Telegram runtime nullable access risks
- устранены duplicated callback validation patterns
- устранены duplicated Telegram screen preparation patterns
- устранены fragmented runtime formatting implementations
- устранены inconsistent runtime payload structures
- устранены inconsistent Telegram render flows
- устранены fragmented diagnostics rendering patterns
- устранены fragmented journal rendering patterns
- устранены fragmented risk controls rendering patterns
- реализована подготовка centralized runtime typing layer
- реализована подготовка advanced runtime telemetry
- реализована подготовка persistent Telegram runtime analytics
- реализована подготовка advanced runtime observability
- реализована подготовка unified runtime safety architecture
- реализована подготовка strict typing migration
- реализована подготовка runtime-safe analytics layer
- реализована подготовка advanced Telegram runtime dashboard
- реализована подготовка unified runtime infrastructure
### 07.4.4.1.11 ✅ Advanced Trend Quality & EMA Distance Layer
- реализован advanced trend efficiency layer
- реализован trend consistency analysis
- реализован trend quality score engine
- реализован EMA distance semantic layer
- реализован ATR-normalized EMA distance analysis
- реализован EMA compression detection
- реализован EMA overextension detection
- реализован healthy EMA structure detection
- реализован advanced trend quality classification
- реализован noisy trend semantic analysis
- реализован weak trend semantic analysis
- реализован clean trend semantic analysis
- реализован semantic market structure analysis
- реализован semantic breakout analysis
- реализован breakout vs trend reasoning
- реализован counter-trend breakout protection
- реализован late entry detection layer
- реализован overextended entry detection
- реализован chasing movement protection
- реализован pullback entry zone detection
- реализован semantic entry timing classification
- реализован runtime trend structure rendering
- реализован runtime EMA diagnostics rendering
- реализован semantic market explanation engine
- реализован semantic momentum explanation layer
- реализован semantic diagnostics rendering
- реализован semantic runtime reasoning
- реализован semantic blockers compression
- реализован advanced signal explanation layer
- реализован runtime semantic payload propagation
- реализован runtime trend quality propagation
- реализован runtime EMA state propagation
- реализован runtime timing state propagation
- реализован advanced breakout runtime handling
- реализован semantic HOLD reasoning
- реализован advanced _human() semantic mapping layer
- реализована стандартизация semantic diagnostics rendering
- реализована стандартизация trend structure runtime formatting
- реализована стандартизация EMA diagnostics runtime formatting
- реализована стандартизация breakout semantic rendering
- реализована стандартизация runtime trend explanations
- реализована подготовка position-aware diagnostics
- реализована подготовка runtime position pressure analysis
- реализована подготовка semantic position health layer
- реализована подготовка runtime risk reasoning layer
--- ---
### 07.4.5 ### 07.4.5

View File

@@ -1183,6 +1183,87 @@
- diagnostics layer подготовлен к semantic trade analytics - diagnostics layer подготовлен к semantic trade analytics
- diagnostics layer подготовлен к auto-refresh runtime dashboard - diagnostics layer подготовлен к auto-refresh runtime dashboard
#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer
- реализована унификация Telegram handler architecture
- реализована единая callback message validation layer
- реализован _require_message() runtime safety layer
- устранены unsafe callback.message обращения
- реализована защита Telegram handlers от InaccessibleMessage
- реализована стандартизация Telegram screen lifecycle
- реализована стандартизация ActiveScreenManager integration
- реализована стандартизация LiveScreenRunner integration
- реализована стандартизация StaticScreen registration
- реализована стандартизация render/edit Telegram flow
- реализована стандартизация edit_mode architecture
- реализована стандартизация Telegram runtime navigation
- реализована стандартизация retry flow architecture
- реализована стандартизация monitoring navigation flow
- реализована стандартизация portfolio/market/journal runtime screens
- реализована стандартизация auto runtime screen architecture
- реализована стандартизация auto diagnostics rendering
- реализована стандартизация risk controls runtime flow
- реализована стандартизация journal runtime rendering
- реализована стандартизация monitoring runtime rendering
- реализована стандартизация Telegram callback lifecycle
- реализована стандартизация runtime logging payloads
- реализована стандартизация JournalService logging payload structure
- реализована стандартизация Telegram runtime formatting
- реализована стандартизация keyboard rendering layer
- реализована стандартизация runtime message rendering
- реализована стандартизация safe runtime editing
- реализована стандартизация Telegram exception handling
- реализована стандартизация runtime error protection
- реализована стандартизация runtime fallback rendering
- реализована стандартизация runtime state cleanup
- реализована стандартизация runtime unregister flow
- реализована стандартизация runtime screen switching
- реализована стандартизация FSM cleanup flow
- реализована стандартизация risk runtime update flow
- реализована стандартизация diagnostics runtime refresh
- реализована стандартизация portfolio runtime refresh
- реализована стандартизация market runtime refresh
- реализована стандартизация journal pagination flow
- реализована стандартизация journal export flow
- реализована стандартизация journal cleanup flow
- реализована стандартизация runtime callback alerts
- реализована стандартизация runtime user notifications
- реализована стандартизация runtime retry notifications
- реализована стандартизация Telegram screen auto-refresh architecture
- реализована стандартизация runtime render callbacks
- реализована стандартизация auto runtime protection layer
- реализована стандартизация runtime semantic rendering
- реализована стандартизация runtime formatting utilities
- реализована стандартизация numeric formatting layer
- реализована стандартизация safe numeric parsing
- реализована стандартизация safe float conversion layer
- реализован global NumericLike typing layer
- реализован global JsonDict typing layer
- реализован global JsonList typing layer
- реализован centralized safe_float() conversion layer
- устранены raw float() conversions в Telegram runtime
- устранены unsafe numeric casts
- устранены implicit runtime numeric conversions
- устранены raw dict payload usages
- устранены runtime typing inconsistencies
- устранены Telegram runtime nullable access risks
- устранены duplicated callback validation patterns
- устранены duplicated Telegram screen preparation patterns
- устранены fragmented runtime formatting implementations
- устранены inconsistent runtime payload structures
- устранены inconsistent Telegram render flows
- устранены fragmented diagnostics rendering patterns
- устранены fragmented journal rendering patterns
- устранены fragmented risk controls rendering patterns
- реализована подготовка centralized runtime typing layer
- реализована подготовка advanced runtime telemetry
- реализована подготовка persistent Telegram runtime analytics
- реализована подготовка advanced runtime observability
- реализована подготовка unified runtime safety architecture
- реализована подготовка strict typing migration
- реализована подготовка runtime-safe analytics layer
- реализована подготовка advanced Telegram runtime dashboard
- реализована подготовка unified runtime infrastructure
--- ---
### 07.4.4.1.10 Semantic Runtime Diagnostics & Observability ### 07.4.4.1.10 Semantic Runtime Diagnostics & Observability
@@ -1233,6 +1314,187 @@
- diagnostic layer подготовлен к Diagnostic Journal Layer - diagnostic layer подготовлен к Diagnostic Journal Layer
- diagnostic layer подготовлен к Auto-refresh Diagnostic UI - diagnostic layer подготовлен к Auto-refresh Diagnostic UI
#### 07.4.4.1.10.3 ✅ Telegram Diagnostic Screen
- реализирован полноценный Telegram Diagnostic Screen
- реализован отдельный diagnostics Telegram UI layer
- реализован auto-refresh diagnostics screen
- реализована интеграция diagnostics screen с AutoTradeRunner
- реализована интеграция diagnostics screen с ActiveScreenManager
- реализован отдельный diagnostic navigation flow
- реализована отдельная diagnostics keyboard
- реализовано безопасное обновление diagnostic messages
- реализована защита Telegram diagnostics UI от TelegramBadRequest
- реализован explainable runtime diagnostic screen
- реализован explainable semantic diagnostic UI
- реализован explainable market diagnostics UI
- реализован explainable momentum diagnostics UI
- реализован explainable breakout diagnostics UI
- реализован explainable execution diagnostics UI
- реализован explainable adaptive sizing diagnostics UI
- реализован explainable runtime health diagnostics UI
- реализован explainable position diagnostics UI
- реализован explainable severity system
- реализована semantic severity hierarchy
- реализовано разделение WAITING / YELLOW / RED runtime states
- реализована логика semantic waiting state
- реализована логика runtime freshness interpretation
- реализована логика execution readiness interpretation
- реализована логика signal confirmation interpretation
- реализована логика market noise interpretation
- реализована логика market phase interpretation
- реализована логика breakout explanation
- реализована логика execution quality explanation
- реализована логика adaptive sizing explanation
- реализована логика runtime degradation explanation
- исключены ложные warning состояния при HOLD signal
- исключены ложные yellow состояния без momentum
- реализован semantic OFF diagnostics mode
- реализован lightweight diagnostics режим для OFF состояния
- реализована корректная diagnostics логика без RUNNING state
- реализована подготовка cycle pnl diagnostics
- реализована подготовка flip diagnostics
- реализована подготовка cumulative realized pnl diagnostics
- реализована подготовка old/new side flip diagnostics
- реализована подготовка flip pnl diagnostics
- реализована подготовка position cycle analytics
- semantic analytics layer стал explainable
- semantic analytics layer стал user-readable
- semantic analytics layer стал Telegram-ready
- diagnostics layer подготовлен к Diagnostic Journal Layer
- diagnostics layer подготовлен к persistent runtime analytics
- diagnostics layer подготовлен к advanced cycle analytics
- diagnostics layer подготовлен к semantic trade analytics
- diagnostics layer подготовлен к auto-refresh runtime dashboard
#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer
- реализована унификация Telegram handler architecture
- реализована единая callback message validation layer
- реализован _require_message() runtime safety layer
- устранены unsafe callback.message обращения
- реализована защита Telegram handlers от InaccessibleMessage
- реализована стандартизация Telegram screen lifecycle
- реализована стандартизация ActiveScreenManager integration
- реализована стандартизация LiveScreenRunner integration
- реализована стандартизация StaticScreen registration
- реализована стандартизация render/edit Telegram flow
- реализована стандартизация edit_mode architecture
- реализована стандартизация Telegram runtime navigation
- реализована стандартизация retry flow architecture
- реализована стандартизация monitoring navigation flow
- реализована стандартизация portfolio/market/journal runtime screens
- реализована стандартизация auto runtime screen architecture
- реализована стандартизация auto diagnostics rendering
- реализована стандартизация risk controls runtime flow
- реализована стандартизация journal runtime rendering
- реализована стандартизация monitoring runtime rendering
- реализована стандартизация Telegram callback lifecycle
- реализована стандартизация runtime logging payloads
- реализована стандартизация JournalService logging payload structure
- реализована стандартизация Telegram runtime formatting
- реализована стандартизация keyboard rendering layer
- реализована стандартизация runtime message rendering
- реализована стандартизация safe runtime editing
- реализована стандартизация Telegram exception handling
- реализована стандартизация runtime error protection
- реализована стандартизация runtime fallback rendering
- реализована стандартизация runtime state cleanup
- реализована стандартизация runtime unregister flow
- реализована стандартизация runtime screen switching
- реализована стандартизация FSM cleanup flow
- реализована стандартизация risk runtime update flow
- реализована стандартизация diagnostics runtime refresh
- реализована стандартизация portfolio runtime refresh
- реализована стандартизация market runtime refresh
- реализована стандартизация journal pagination flow
- реализована стандартизация journal export flow
- реализована стандартизация journal cleanup flow
- реализована стандартизация runtime callback alerts
- реализована стандартизация runtime user notifications
- реализована стандартизация runtime retry notifications
- реализована стандартизация Telegram screen auto-refresh architecture
- реализована стандартизация runtime render callbacks
- реализована стандартизация auto runtime protection layer
- реализована стандартизация runtime semantic rendering
- реализована стандартизация runtime formatting utilities
- реализована стандартизация numeric formatting layer
- реализована стандартизация safe numeric parsing
- реализована стандартизация safe float conversion layer
- реализован global NumericLike typing layer
- реализован global JsonDict typing layer
- реализован global JsonList typing layer
- реализован centralized safe_float() conversion layer
- устранены raw float() conversions в Telegram runtime
- устранены unsafe numeric casts
- устранены implicit runtime numeric conversions
- устранены raw dict payload usages
- устранены runtime typing inconsistencies
- устранены Telegram runtime nullable access risks
- устранены duplicated callback validation patterns
- устранены duplicated Telegram screen preparation patterns
- устранены fragmented runtime formatting implementations
- устранены inconsistent runtime payload structures
- устранены inconsistent Telegram render flows
- устранены fragmented diagnostics rendering patterns
- устранены fragmented journal rendering patterns
- устранены fragmented risk controls rendering patterns
- реализована подготовка centralized runtime typing layer
- реализована подготовка advanced runtime telemetry
- реализована подготовка persistent Telegram runtime analytics
- реализована подготовка advanced runtime observability
- реализована подготовка unified runtime safety architecture
- реализована подготовка strict typing migration
- реализована подготовка runtime-safe analytics layer
- реализована подготовка advanced Telegram runtime dashboard
- реализована подготовка unified runtime infrastructure
### 07.4.4.1.11 ✅ Advanced Trend Quality & EMA Distance Layer
- реализован advanced trend efficiency layer
- реализован trend consistency analysis
- реализован trend quality score engine
- реализован EMA distance semantic layer
- реализован ATR-normalized EMA distance analysis
- реализован EMA compression detection
- реализован EMA overextension detection
- реализован healthy EMA structure detection
- реализован advanced trend quality classification
- реализован noisy trend semantic analysis
- реализован weak trend semantic analysis
- реализован clean trend semantic analysis
- реализован semantic market structure analysis
- реализован semantic breakout analysis
- реализован breakout vs trend reasoning
- реализован counter-trend breakout protection
- реализован late entry detection layer
- реализован overextended entry detection
- реализован chasing movement protection
- реализован pullback entry zone detection
- реализован semantic entry timing classification
- реализован runtime trend structure rendering
- реализован runtime EMA diagnostics rendering
- реализован semantic market explanation engine
- реализован semantic momentum explanation layer
- реализован semantic diagnostics rendering
- реализован semantic runtime reasoning
- реализован semantic blockers compression
- реализован advanced signal explanation layer
- реализован runtime semantic payload propagation
- реализован runtime trend quality propagation
- реализован runtime EMA state propagation
- реализован runtime timing state propagation
- реализован advanced breakout runtime handling
- реализован semantic HOLD reasoning
- реализован advanced _human() semantic mapping layer
- реализована стандартизация semantic diagnostics rendering
- реализована стандартизация trend structure runtime formatting
- реализована стандартизация EMA diagnostics runtime formatting
- реализована стандартизация breakout semantic rendering
- реализована стандартизация runtime trend explanations
- реализована подготовка position-aware diagnostics
- реализована подготовка runtime position pressure analysis
- реализована подготовка semantic position health layer
- реализована подготовка runtime risk reasoning layer
--- ---
### 07.4.5 ### 07.4.5

View File

@@ -0,0 +1,457 @@
# 07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer
## Статус
✅ Реализовано
Рекомендуемый commit message:
```bash
git commit -m "07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer"
```
---
# Краткое описание этапа
Этап `07.4.4.1.10.4` посвящён масштабной стабилизации Telegram runtime слоя после внедрения Semantic Diagnostics и Telegram Diagnostic Screen.
Главная цель этапа:
- привести Telegram handlers к единому архитектурному стилю;
- внедрить строгий runtime-safe typing;
- унифицировать lifecycle экранов;
- устранить unsafe callback/message usage;
- внедрить централизованный numeric parsing;
- подготовить проект к advanced runtime analytics и observability.
Этап затронул:
- Telegram handlers;
- Auto runtime;
- Diagnostics runtime;
- Portfolio / Market / Journal screens;
- Risk Controls;
- System settings;
- Debug runtime;
- Execution notifications;
- Market analysis layer;
- Journal export layer;
- Exchange runtime helpers.
---
# Основные реализованные изменения
## 1. Runtime Type Safety Layer
Добавлены новые core-слои:
```text
src/core/numbers.py
src/core/types.py
src/core/telegram_errors.py
```
Реализованы новые runtime-типы:
- `NumericLike`
- `JsonDict`
- `JsonList`
Также внедрён централизованный helper:
```python
safe_float(value)
```
---
## 2. Telegram Callback Safety Layer
Во всех Telegram handlers реализован единый runtime-safe pattern:
```python
def _require_message(
callback: CallbackQuery,
) -> Message | None:
```
Теперь все callback handlers защищены от:
- `InaccessibleMessage`
- `None message`
- unsafe callback.message access
---
## 3. Unified Telegram Screen Lifecycle
Унифицирован lifecycle Telegram screens:
- ActiveScreenManager
- LiveScreenRunner
- ScreenRegistry
- StaticScreen
- LiveScreen
Реализован единый flow:
- prepare screen
- unregister old screen
- register live/static screen
- register active screen
---
## 4. Auto Runtime Stabilization
Стабилизирован:
```text
src/trading/auto/runner.py
```
Реализовано:
- ClassVar runtime fields
- staticmethod render callbacks
- safe payload handling
- safe EventBus integration
- NumericLike migration
- safe_float migration
- runtime-safe notifications
---
## 5. Telegram Live Runner Stabilization
Стабилизирован:
```text
src/telegram/live/runner.py
```
Реализовано:
- safer live refresh
- safer callback storage
- unified runtime architecture
- observability preparation
---
## 6. Auto UI Stabilization
Стабилизирован:
```text
src/telegram/handlers/auto/ui.py
```
Реализовано:
- новый runtime header
- отдельный cycle block
- cycle pnl fields
- cumulative pnl fields
- flip analytics
- cleaner semantic rendering
---
## 7. Auto Main Handler Stabilization
Стабилизирован:
```text
src/telegram/handlers/auto/main.py
```
Реализовано:
- unified render flow
- safe callback handling
- diagnostics runtime integration
- TelegramBadRequest protection
- unified screen preparation
---
## 8. Auto Risk Controls Stabilization
Стабилизирован:
```text
src/telegram/handlers/auto/risk.py
```
Реализовано:
- NumericLike migration
- safe_float migration
- JsonDict FSM data
- safe callback handling
- safe risk screen restore
- safe reset flow
- unified payload builder
---
## 9. System Handler Stabilization
Стабилизирован:
```text
src/telegram/handlers/system.py
```
Реализовано:
- safe callback parsing
- NumericLike migration
- safe_float migration
- unified callback validation
- safer settings parsing
---
## 10. Market Screen Stabilization
Стабилизирован:
```text
src/telegram/handlers/market.py
```
Реализовано:
- safe ticker parsing
- NumericLike migration
- safe callback handling
- live screen stabilization
- safer logging payloads
---
## 11. Portfolio Screen Stabilization
Стабилизирован:
```text
src/telegram/handlers/portfolio.py
```
Реализовано:
- safer live refresh
- safer retry flow
- unified monitoring navigation
- unified screen lifecycle
---
## 12. Journal Runtime Stabilization
Стабилизирован:
```text
src/telegram/handlers/journal.py
```
Реализовано:
- safer pagination
- safer export flow
- safer cleanup flow
- unified runtime style
---
## 13. Journal UI Stabilization
Стабилизирован:
```text
src/telegram/handlers/journal_ui.py
```
Реализовано:
- JsonDict
- JsonList
- NumericLike
- safe_float()
- safer event parsing
- safer datetime parsing
- malformed event protection
---
## 14. Monitoring / Home / Start Stabilization
Стабилизированы:
```text
src/telegram/handlers/monitoring.py
src/telegram/handlers/home.py
src/telegram/handlers/start.py
```
Реализовано:
- unified lifecycle
- unified menu rendering
- safer screen registration
- unified runtime architecture
---
## 15. Debug Runtime Stabilization
Стабилизирован:
```text
src/telegram/handlers/debug.py
```
Реализовано:
- NumericLike migration
- safe_float migration
- safer runtime formatting
- safer pnl rendering
- safer leverage formatting
- cleaner signal duration runtime
---
## 16. Execution Notification Stabilization
Стабилизирован:
```text
src/notifications/templates/execution.py
```
Исправлены:
- Long/Short formatting
- flip notifications
- pnl formatting
- execution message readability
---
## 17. Auto Service Typing Migration
Частично переведён на новый typing layer:
```text
src/trading/auto/service.py
```
Реализовано:
- JsonDict payloads
- NumericLike migration
- safe_float migration
- safer diagnostics payloads
---
## 18. Market Analysis Preparation
Подготовлены diagnostics fields:
```text
src/trading/market_analysis/models.py
src/trading/market_analysis/service.py
```
Подготовка к:
- Trend Efficiency
- EMA Distance Diagnostics
- Breakout Quality
- HTF Volatility Context
- Advanced Semantic Diagnostics
---
## 19. Diagnostics Runtime Continuation
Продолжено развитие:
```text
src/trading/diagnostics/formatter.py
src/trading/diagnostics/snapshot.py
```
Подготовлены:
- cycle analytics
- flip analytics
- runtime freshness
- semantic health
- adaptive diagnostics
---
## 20. Exchange Runtime Stabilization
Стабилизированы:
```text
src/integrations/exchange/market_data_runner.py
src/integrations/exchange/service.py
```
Реализовано:
- safer market snapshots
- runtime-safe payloads
- diagnostics preparation
- observability preparation
---
# Удаление legacy handler
Удалён:
```text
src/telegram/handlers/_auto.py
```
Проект окончательно переведён на новую структуру:
```text
src/telegram/handlers/auto/main.py
src/telegram/handlers/auto/risk.py
src/telegram/handlers/auto/ui.py
```
---
# Итог этапа
После этапа:
- Telegram runtime стал стабильнее;
- live refresh стал безопаснее;
- diagnostics runtime стал устойчивее;
- unified typing layer внедрён;
- runtime-safe numeric parsing внедрён;
- callback/message safety стандартизирован;
- проект подготовлен к следующему diagnostics tier.
---
# Рекомендуемый commit
```bash
git add .
git commit -m "07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer"
git push origin main
```

View File

@@ -0,0 +1,197 @@
# 07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer
## Статус
✅ Реализовано
Рекомендуемый commit message:
```bash
git commit -m "07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer"
```
---
# Краткое описание этапа
Этап `07.4.4.1.11` посвящён развитию semantic market analysis layer и внедрению advanced trend diagnostics поверх базового TREND engine.
Главная цель этапа:
- научить систему оценивать качество тренда, а не только его направление;
- внедрить EMA distance semantic analysis;
- реализовать advanced trend efficiency logic;
- реализовать overextension / chasing protection;
- реализовать entry timing semantic layer;
- подготовить систему к position-aware diagnostics и runtime risk analysis.
---
# Основные реализованные изменения
## 1. Advanced Trend Efficiency Layer
Реализован новый semantic слой оценки эффективности тренда.
Добавлены diagnostics fields:
- trend_efficiency
- trend_consistency
- trend_quality_score
Теперь рынок анализируется по:
- стабильности импульса;
- качеству структуры;
- последовательности движения;
- эффективности продвижения цены;
- уровню рыночного шума.
---
## 2. EMA Distance Semantic Layer
Реализована полноценная EMA-distance diagnostics layer.
Добавлены:
- ema_distance_atr_ratio
- ema_distance_state
Система теперь умеет различать:
- healthy trend distance;
- compressed EMA structure;
- overextended structure;
- stretched trend;
- dangerous chasing zones.
---
## 3. Entry Timing Classification
Реализован новый semantic timing layer.
Добавлены состояния:
- EARLY
- HEALTHY
- LATE
- OVEREXTENDED
- PULLBACK_ENTRY_ZONE
- ENTRY_TIMING_UNKNOWN
Теперь semantic engine способен определять:
- ранний вход;
- здоровую зону входа;
- поздний вход;
- перерастянутый вход;
- pullback entry zone.
---
## 4. Late Entry / Overextension Filter
Реализован отдельный слой защиты от поздних входов.
Добавлены semantic states:
- EMA_OVEREXTENDED
- BREAKOUT_ALREADY_EXTENDED
- CHASING
- EXTENDED
Теперь стратегия умеет:
- блокировать поздние breakout entries;
- избегать chasing movement;
- избегать входов после сильного расширения EMA.
---
## 5. Trend Quality Classification Upgrade
Существенно расширен trend quality classification engine.
Теперь TrendQuality учитывает:
- EMA structure;
- ATR-normalized distance;
- trend consistency;
- momentum alignment;
- directional stability;
- volatility context.
---
## 6. Semantic Diagnostics Integration
Расширен formatter.py.
Реализовано:
- semantic trend rendering;
- EMA diagnostics rendering;
- trend structure explanation;
- semantic momentum descriptions;
- breakout semantic rendering.
---
## 7. Runtime Trend Structure Rendering
Telegram diagnostics теперь отображает:
- trend quality score;
- trend consistency;
- trend efficiency;
- EMA/ATR distance;
- semantic EMA state;
- semantic entry timing.
---
## 8. Strategy Semantic Upgrade
Существенно улучшен trend.py.
Реализовано:
- semantic breakout handling;
- counter-trend breakout protection;
- semantic pullback blocking;
- semantic noisy trend handling;
- advanced market payload propagation.
---
## 9. Human-readable Semantic Rendering
Существенно расширен _human() mapping layer.
Добавлены semantic mappings для:
- EMA states;
- entry timing;
- breakout states;
- trend structure;
- semantic momentum.
---
# Итог этапа
После этапа:
- market analysis стал semantic-aware;
- система научилась оценивать качество тренда;
- реализован EMA distance reasoning;
- реализован entry timing analysis;
- реализован late-entry protection;
- diagnostics стали более "человеческими";
- Telegram runtime стал информативнее;
- подготовлена база для position-aware diagnostics.
---
# Рекомендуемый commit
```bash
git add .
git commit -m "07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer"
git push origin main
```