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

View File

@@ -36,6 +36,73 @@ class ExchangeService:
_execution_cache_max_age_seconds = 2.0
_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:
self.settings = load_settings()
self.journal = JournalService()

View File

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

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
@@ -11,34 +13,49 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY:
return None
payload = event.payload
payload: JsonDict = event.payload
signal = str(payload.get("signal") or "").upper()
symbol = _format_symbol(str(payload.get("symbol") or ""))
confidence = float(payload.get("confidence") or 0.0)
position_context = str(payload.get("position_context") or "NONE").upper()
semantic_lines = payload.get("semantic_lines") or []
confidence = safe_float(payload.get("confidence")) or 0.0
repeat_count = int(safe_float(payload.get("repeat_count")) or 0)
priority = str(event.priority or _alert_priority(
confidence=confidence,
repeat_count=int(payload.get("repeat_count") or 0),
)).upper()
position_context = str(payload.get("position_context") or "NONE").upper()
semantic_lines = _as_json_list(payload.get("semantic_lines"))
bid_price = payload.get("bid_price")
ask_price = payload.get("ask_price")
priority = str(
event.priority
or _alert_priority(
confidence=confidence,
repeat_count=repeat_count,
)
).upper()
direction = _signal_direction(signal)
direction_key = direction.upper()
icon = _direction_icon(direction)
strength = _strength_label(priority)
strength_bar = _strength_bar(priority)
market_price_line = _market_price_line(
direction=direction_key,
bid_price=bid_price,
ask_price=ask_price,
)
lines = [
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
"",
]
if position_context not in {"NONE", "", ""} and position_context != direction:
lines.extend([
"⚠️ ПРОТИВ ПОЗИЦИИ",
"",
])
if market_price_line:
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}")
@@ -87,19 +104,21 @@ def _strength_bar(priority: str) -> str:
def _signal_direction(signal: str) -> str:
if signal == "BUY":
return "LONG"
return "Long"
if signal == "SELL":
return "SHORT"
return "Short"
return ""
def _direction_icon(direction: str) -> str:
if direction == "LONG":
normalized = direction.upper()
if normalized == "LONG":
return "🟢"
if direction == "SHORT":
if normalized == "SHORT":
return "🔴"
return ""
@@ -112,14 +131,39 @@ def _format_symbol(symbol: str) -> str:
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_leverage(leverage: object) -> str:
if isinstance(leverage, (int, float)):
return f"x{leverage:g}"
def _market_price_line(
*,
direction: str,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> str:
bid = _format_price(bid_price)
ask = _format_price(ask_price)
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 (
f"auto_signal_ready:"
f"{payload.get('position_context')}:"
@@ -127,7 +171,14 @@ def _dedupe_key(payload: dict) -> str:
f"{payload.get('strategy')}:"
f"{payload.get('signal')}:"
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('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
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, Message
from aiogram.types import CallbackQuery, InaccessibleMessage, Message
from src.telegram.handlers.auto.ui import (
auto_diagnostics_keyboard,
@@ -24,6 +24,20 @@ from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
async def render_auto_screen(
target_message: Message,
*,
@@ -38,8 +52,13 @@ async def render_auto_screen(
if "message is not modified" not in str(exc).lower():
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=target_message.bot,
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_text,
@@ -53,9 +72,13 @@ async def render_auto_screen(
return
sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=sent_message.bot,
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_text,
@@ -68,29 +91,43 @@ async def render_auto_screen(
)
async def _prepare_auto_from_message(message: Message) -> None:
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=message.bot,
chat_id=message.chat.id,
)
async def _prepare_auto_from_message(message: Message) -> bool:
bot = message.bot
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
def build_auto_diagnostics_text() -> str:
service = AutoTradeService()
@@ -118,10 +155,28 @@ async def render_auto_diagnostics_screen(
error_text = str(exc).lower()
if "message to edit not found" in error_text:
await target_message.answer(
sent_message = await target_message.answer(
text,
reply_markup=auto_diagnostics_keyboard(),
)
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_diagnostics_text,
render_markup=auto_diagnostics_keyboard,
)
ActiveScreenManager.register(
screen="auto_diagnostics",
message=sent_message,
)
return
if "message is not modified" in error_text:
@@ -129,8 +184,13 @@ async def render_auto_diagnostics_screen(
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=target_message.bot,
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
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:
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)
@@ -159,7 +220,13 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
if not await _prepare_auto_from_callback(callback):
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()
@@ -174,20 +241,22 @@ async def auto_start(callback: CallbackQuery) -> None:
show_alert=True,
)
if callback.message is not None:
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
_, message = service.start()
_, message_text = service.start()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:observe")
@@ -201,48 +270,60 @@ async def auto_observe(callback: CallbackQuery) -> None:
show_alert=True,
)
if callback.message is not None:
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
_, message = service.observe()
_, message_text = service.observe()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService()
_, message = service.stop()
_, message_text = service.stop()
AutoTradeRunner.stop()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
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")
async def open_auto_diagnostics(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return
await ActiveScreenManager.prepare_new_screen(
screen="auto_diagnostics",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
await render_auto_diagnostics_screen(callback.message)
await render_auto_diagnostics_screen(message)
await callback.answer()

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,11 +1,18 @@
# app/src/telegram/handlers/journal.py
from __future__ import annotations
from aiogram import F, Router
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 (
PAGE_SIZE,
build_actions_keyboard,
@@ -23,12 +30,26 @@ from src.trading.journal.service import JournalService
router = Router(name="journal")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def _chat_id_from_message(message: Message) -> int | None:
return message.chat.id if message.chat else None
def _chat_id_from_message(message: Message) -> int:
return message.chat.id
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:
if callback.message and callback.message.chat:
return callback.message.chat.id
message = _require_message(callback)
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:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -55,7 +97,7 @@ def _register_journal_screen(message: Message) -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen="journal",
bot=message.bot,
bot=bot,
chat_id=message.chat.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(
target_message: Message,
*,
@@ -85,33 +175,37 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages)
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)
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)
@router.callback_query(F.data == "journal:actions")
async def journal_actions(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
await callback.message.edit_text(
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer()
@@ -120,11 +214,8 @@ async def journal_actions(callback: CallbackQuery) -> None:
async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear()
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=message.bot,
chat_id=message.chat.id,
)
if not await _prepare_journal_from_message(message):
return
await _show_journal_page(
message,
@@ -134,22 +225,23 @@ async def open_journal(message: Message, state: FSMContext) -> None:
@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()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await _show_journal_page(
callback.message,
message,
page=1,
edit_mode=True,
)
@@ -165,6 +257,7 @@ async def journal_noop(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "journal:export_csv")
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
try:
data = service.export_csv()
@@ -173,8 +266,8 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
filename=service.build_export_filename("csv"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
if message is not None:
await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -183,10 +276,11 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"},
payload=_journal_payload(format="csv"),
)
await callback.answer("CSV экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -195,15 +289,20 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"},
payload=_journal_payload(format="csv"),
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")
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
try:
data = service.export_xlsx()
@@ -212,8 +311,8 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
filename=service.build_export_filename("xlsx"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
if message is not None:
await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -222,10 +321,11 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"},
payload=_journal_payload(format="xlsx"),
)
await callback.answer("Excel экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -234,56 +334,56 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"},
payload=_journal_payload(format="xlsx"),
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")
async def clear_journal_confirm(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_all()
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count,
@@ -291,29 +391,27 @@ async def clear_journal(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(
total_count=total_count,
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(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
page_raw = callback.data.split(":", 1)[1]
message = _require_message(callback)
try:
page = int(page_raw)
except ValueError:
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
data = callback.data or ""
parts = data.split(":", 1)
if len(parts) < 2:
await callback.answer("Неизвестное действие", show_alert=True)
return
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
page = _parse_page(parts[1])
if page is None:
await callback.answer("Неизвестное действие", show_alert=True)
return
await _show_journal_page(
callback.message,
message,
page=page,
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.event_titles import event_title
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
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()
if page > 1:
if current_page > 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:
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
if current_page < pages_count:
kb.button(text="➡️", callback_data=f"journal:{current_page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
nav_count = 1
if page > 1:
if current_page > 1:
nav_count += 2
if page < total_pages:
if current_page < pages_count:
nav_count += 1
kb.adjust(nav_count, 2, 1)
@@ -83,33 +93,51 @@ def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
return kb.as_markup()
def _format_int(
value: NumericLike | None,
*,
default: int = 0,
) -> int:
number = safe_float(value)
if number is None:
return default
return int(number)
def render_clear_confirm(
*,
total_count: int,
deleted_count: int | None = None,
no_old_records_days: int | None = None,
total_count: NumericLike,
deleted_count: NumericLike | None = None,
no_old_records_days: NumericLike | None = None,
) -> str:
total = _format_int(total_count)
lines = [
"<b>⚠️ Очистить журнал</b>",
"",
"<b>СИСТЕМА</b> · Настройки · Журнал",
"",
f"📄 Записей: {total_count}",
f"📄 Записей: {total}",
]
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:
lines.append(f"📭 Нет записей старше {no_old_records_days} дней")
lines.append(
f"📭 Нет записей старше {_format_int(no_old_records_days)} дней"
)
return "\n".join(lines)
def _parse_local_datetime(value: str) -> datetime | None:
def _parse_local_datetime(value: object) -> datetime | None:
try:
settings = load_settings()
dt = datetime.fromisoformat(value)
raw_value = str(value or "")
dt = datetime.fromisoformat(raw_value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
@@ -135,39 +163,52 @@ def _date_group_label(dt: datetime | None) -> str:
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:
return raw_value
return str(raw_value or "")
return dt.strftime("%H:%M:%S")
def _event_title(event_type: str) -> str:
return event_title(event_type)
def _event_title(event_type: object) -> str:
return event_title(str(event_type or ""))
def _humanize_message(message: str) -> str:
lower = message.lower()
for k, v in TECH_TO_HUMAN_MESSAGES.items():
if k in lower:
return v
return message
def _humanize_message(message: object) -> str:
text = str(message or "")
lower = text.lower()
for technical_text, human_text in TECH_TO_HUMAN_MESSAGES.items():
if technical_text in lower:
return human_text
return text
def _payload(event: dict) -> dict:
def _payload(event: JsonDict) -> JsonDict:
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]:
level = str(event.get("level", "INFO")).upper()
def _render_auto_signal(
event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", "")))
message = _humanize_message(str(event.get("message", "")))
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
lines = [
f"{icon} <b>{level}</b> · {title}",
f"{created_time}",
created_time,
]
if message:
@@ -176,15 +217,18 @@ def _render_auto_signal(event: dict, created_time: str) -> list[str]:
return lines
def _render_default_event(event: dict, created_time: str) -> list[str]:
level = str(event.get("level", "INFO")).upper()
def _render_default_event(
event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", "")))
message = _humanize_message(str(event.get("message", "")))
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
lines = [
f"{icon} <b>{level}</b> · {title}",
f"{created_time}",
created_time,
]
if message:
@@ -193,7 +237,11 @@ def _render_default_event(event: dict, created_time: str) -> list[str]:
return lines
def render(events, page, total_pages):
def render(
events: JsonList,
page: NumericLike,
total_pages: NumericLike,
) -> str:
lines = [
"<b>📒 Журнал</b>",
"",
@@ -205,10 +253,15 @@ def render(events, page, total_pages):
lines.append("Событий пока нет.")
return "\n".join(lines)
current_group = None
current_group: str | None = None
for event in events:
raw_created_at = str(event.get("created_at", ""))
for raw_event in events:
if not isinstance(raw_event, dict):
continue
event: JsonDict = raw_event
raw_created_at = event.get("created_at") or ""
dt = _parse_local_datetime(raw_created_at)
group_label = _date_group_label(dt)
created_time = _time_label(dt, raw_created_at)
@@ -218,7 +271,7 @@ def render(events, page, total_pages):
lines.append(f"<b>{group_label}</b>")
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"}:
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.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
CallbackQuery,
InlineKeyboardMarkup,
Message,
InaccessibleMessage,
)
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.telegram.live.active_screen import ActiveScreenManager
@@ -26,6 +33,20 @@ _last_market_prices: dict[str, float] = {}
_last_market_directions: dict[str, str] = {}
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
@@ -35,22 +56,27 @@ def _market_keyboard() -> InlineKeyboardMarkup:
def _build_market_text(
*,
ticker_price: float,
ticker_price: NumericLike,
name: str,
market_type: str,
base_asset: str,
quote_asset: str,
) -> str:
price = safe_float(ticker_price)
if price is None:
price = 0.0
previous_price = _last_market_prices.get(name)
price_direction = _last_market_directions.get(name, "")
if previous_price is not None:
if ticker_price > previous_price:
if price > previous_price:
price_direction = "🔺"
elif ticker_price < previous_price:
elif price < previous_price:
price_direction = "🔻"
_last_market_prices[name] = ticker_price
_last_market_prices[name] = price
_last_market_directions[name] = price_direction
type_map = {
@@ -64,7 +90,7 @@ def _build_market_text(
f"{mode_line()}"
"\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()}"
)
@@ -87,9 +113,21 @@ def _build_market_live_text() -> str:
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
base_asset = (
symbol_info.base_asset
if symbol_info and symbol_info.base_asset
else "n/a"
)
quote_asset = (
symbol_info.quote_asset
if symbol_info and symbol_info.quote_asset
else "n/a"
)
name = (
symbol_info.name
if symbol_info and symbol_info.name
else ticker.symbol
)
return _build_market_text(
ticker_price=ticker.price,
@@ -101,10 +139,16 @@ def _build_market_live_text() -> str:
def _register_market_live_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -113,7 +157,7 @@ def _register_market_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="market",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_build_market_live_text,
@@ -121,9 +165,58 @@ def _register_market_live_screen(message: Message) -> None:
interval_seconds=5,
)
)
LiveScreenRunner.start("market")
async def _prepare_market_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_market_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return False
bot = message.bot
if bot is None:
await callback.answer(
"Bot недоступен",
show_alert=True,
)
return False
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _render_market_screen(
target_message: Message,
*,
@@ -184,9 +277,21 @@ async def _render_market_screen(
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
base_asset = (
symbol_info.base_asset
if symbol_info and symbol_info.base_asset
else "n/a"
)
quote_asset = (
symbol_info.quote_asset
if symbol_info and symbol_info.quote_asset
else "n/a"
)
name = (
symbol_info.name
if symbol_info and symbol_info.name
else ticker.symbol
)
text = _build_market_text(
ticker_price=ticker.price,
@@ -205,7 +310,7 @@ async def _render_market_screen(
chat_id=chat_id,
payload={
"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:
await state.clear()
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=message.bot,
chat_id=message.chat.id,
)
if not await _prepare_market_from_message(message):
return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
@@ -263,32 +365,38 @@ async def open_market(message: Message, state: FSMContext) -> None:
@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()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_market_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_market_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="open_from_monitoring",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_open_error",
@@ -312,32 +420,38 @@ async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext
@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()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_market_from_callback(callback):
return
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_market_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_retry_error",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,16 @@ from __future__ import annotations
import asyncio
import time
from typing import Callable
from collections.abc import Callable
from typing import ClassVar
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.core.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry
from src.runtime_events.event_types import RuntimeEventType
@@ -17,30 +21,27 @@ from src.runtime_events.models import RuntimeEvent
from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.telegram.handlers.auto.ui import build_auto_semantic_text
from src.telegram.handlers.auto.ui import build_auto_notification_text
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
_task: asyncio.Task | None = None
_bot: Bot | None = None
_chat_id: int | None = None
_message_id: int | None = None
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_current_screen: str | None = None
_task: ClassVar[asyncio.Task | None] = None
_bot: ClassVar[Bot | None] = None
_chat_id: ClassVar[int | None] = None
_message_id: ClassVar[int | None] = None
_render_text: ClassVar[staticmethod | None] = None
_render_markup: ClassVar[staticmethod | None] = None
_current_screen: ClassVar[str | None] = None
_analysis_interval_seconds = 5
_ui_interval_seconds = 30
_last_text: str | None = None
_last_semantic_text: str | None = None
_last_ui_refresh_at: float = 0.0
_last_event_version: int = 0
_retry_after_until: float = 0.0
_last_screen_state_key: str | None = None
_last_text: ClassVar[str | None] = None
_last_semantic_text: ClassVar[str | None] = None
_last_ui_refresh_at: ClassVar[float] = 0.0
_last_event_version: ClassVar[int] = 0
_retry_after_until: ClassVar[float] = 0.0
_last_screen_state_key: ClassVar[str | None] = None
_position_aligned_signal_log_interval_seconds = 900
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
@@ -57,8 +58,8 @@ class AutoTradeRunner:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._render_text = render_text
cls._render_markup = render_markup
cls._render_text = staticmethod(render_text)
cls._render_markup = staticmethod(render_markup)
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@@ -260,8 +261,13 @@ class AutoTradeRunner:
await cls._handle_important_event(state)
@classmethod
async def _handle_important_event(cls, state) -> None:
async def _handle_important_event(
cls,
state,
) -> None:
event_type, payload = EventBus.last_event()
if not isinstance(payload, dict):
payload = {}
if event_type == "auto_decision_changed":
if payload.get("decision_status") != "READY":
@@ -298,7 +304,10 @@ class AutoTradeRunner:
return
@classmethod
def _notification_reason_lines(cls, state) -> list[str]:
def _notification_reason_lines(
cls,
state,
) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=True,
@@ -326,14 +335,29 @@ class AutoTradeRunner:
cls,
*,
state,
payload: dict,
payload: JsonDict,
signal: str,
) -> None:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count 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
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
log_key = (
f"{position_side}:"
@@ -377,12 +401,31 @@ class AutoTradeRunner:
pass
@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()
symbol = str(payload.get("symbol") or state.symbol 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)
confidence = float(payload.get("confidence") or state.last_signal_confidence or 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)
confidence = safe_float(
payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
reason = str(payload.get("reason") or state.last_signal_reason or "")
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
@@ -409,6 +452,9 @@ class AutoTradeRunner:
"decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context,
"bid_price": payload.get("bid_price"),
"ask_price": payload.get("ask_price"),
"last_price": payload.get("last_price"),
},
priority=priority.lower(),
dedupe_key=(
@@ -431,7 +477,7 @@ class AutoTradeRunner:
*,
state,
event_type: str,
payload: dict,
payload: JsonDict,
) -> None:
runtime_event_type = cls._runtime_execution_event_type(event_type)
if runtime_event_type is None:
@@ -450,13 +496,17 @@ class AutoTradeRunner:
source="auto_trade_runner",
title=cls._execution_event_title(runtime_event_type),
payload={
**payload,
"source_event_type": event_type,
"symbol": symbol,
"side": side,
"old_side": old_side,
"new_side": new_side,
"leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
**payload,
"leverage": (
payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
),
"strategy": state.strategy,
"semantic_lines": semantic_lines,
},
@@ -493,7 +543,7 @@ class AutoTradeRunner:
cls,
*,
runtime_event_type: RuntimeEventType,
payload: dict,
payload: JsonDict,
) -> str:
return (
f"{runtime_event_type.value}:"
@@ -516,27 +566,40 @@ class AutoTradeRunner:
def _alert_priority(
cls,
*,
confidence: float,
confidence: NumericLike,
repeat_count: int,
) -> 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"
if confidence >= 0.6 or repeat_count >= 2:
if confidence_value >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
@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
@classmethod
def _log_refresh_success(cls, payload: dict | None = None) -> None:
def _log_refresh_success(
cls,
payload: JsonDict | None = None,
) -> None:
return
@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:
JournalService().log_error(
"auto_screen_refresh_error",
@@ -547,7 +610,10 @@ class AutoTradeRunner:
pass
@classmethod
def _screen_state_key(cls, state) -> str:
def _screen_state_key(
cls,
state,
) -> str:
return "|".join(
str(value)
for value in [
@@ -581,6 +647,9 @@ class AutoTradeRunner:
getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None),
getattr(state, "cycle_closed_trades", None),
getattr(state, "cycle_realized_pnl_usd", None),
getattr(state, "cycle_winning_trades", None),
getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None),
]
@@ -628,19 +697,30 @@ class AutoTradeRunner:
)
return
text = cls._render_text()
semantic_text = build_auto_semantic_text()
render_text = cls._render_text
render_markup = cls._render_markup
bot = cls._bot
if (
render_text is None
or render_markup is None
or bot is None
):
return
text = render_text()
semantic_text = build_auto_notification_text()
if semantic_text == cls._last_semantic_text:
cls._log_refresh_skip("text_not_changed")
return
try:
await cls._bot.edit_message_text(
await bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
text=text,
reply_markup=cls._render_markup(),
reply_markup=render_markup(),
)
cls._last_text = 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.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.state import AutoTradeState
from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService
@@ -40,7 +42,7 @@ class AutoTradeService:
_last_signal_value: str | None = None
_last_signal_reason: str = ""
_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_logged_market_state: str | None = None
_last_logged_market_trend: str | None = None
@@ -50,46 +52,145 @@ class AutoTradeService:
_max_snapshot_age_seconds = 5.0
_warning_snapshot_age_seconds = 2.0
_spread_warning_enter_percent = 0.08
_spread_warning_exit_percent = 0.06
_spread_block_enter_percent = 0.15
_spread_block_exit_percent = 0.12
_spread_thresholds_by_asset: dict[str, dict[str, float]] = {
"BTC": {
"warning_enter": 0.08,
"warning_exit": 0.06,
"block_enter": 0.15,
"block_exit": 0.12,
},
"ETH": {
"warning_enter": 0.10,
"warning_exit": 0.08,
"block_enter": 0.18,
"block_exit": 0.15,
},
"LTC": {
"warning_enter": 0.18,
"warning_exit": 0.14,
"block_enter": 0.35,
"block_exit": 0.28,
},
"XRP": {
"warning_enter": 0.20,
"warning_exit": 0.16,
"block_enter": 0.40,
"block_exit": 0.32,
},
}
_default_spread_thresholds: 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
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(
self,
*,
state: AutoTradeState,
spread_percent: float | None,
spread_percent: NumericLike | None,
) -> 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
thresholds = self._spread_thresholds(state.symbol)
warning_enter = thresholds["warning_enter"]
warning_exit = thresholds["warning_exit"]
block_enter = thresholds["block_enter"]
block_exit = thresholds["block_exit"]
previous_quality = state.execution_quality
previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
if spread_percent > self._spread_block_exit_percent:
if spread > block_exit:
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 "GOOD", "MARKET_OK", "рынок готов", False
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
if spread_percent > self._spread_warning_exit_percent:
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if spread_percent >= self._spread_block_enter_percent:
if spread >= block_enter:
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 "GOOD", "MARKET_OK", "рынок готов", False
@@ -99,11 +200,12 @@ class AutoTradeService:
self,
*,
signal: str,
confidence: float = 0.9,
confidence: NumericLike = 0.9,
repeat_count: int = 2,
reason: str = "DEBUG SIGNAL",
) -> AutoTradeState:
state = self.get_state()
confidence_value = safe_float(confidence) or 0.0
normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
@@ -117,7 +219,7 @@ class AutoTradeService:
state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count
state.last_signal_confidence = confidence
state.last_signal_confidence = confidence_value
state.last_signal_reason = reason
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
@@ -162,13 +264,15 @@ class AutoTradeService:
return state
# установить капитал, выделенный под автоторговлю
def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
state = self.get_state()
if value <= 0:
value = 1000.0
numeric_value = safe_float(value)
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_size_adjustment_reason = None
return state
@@ -231,6 +335,10 @@ class AutoTradeService:
state.status = "RUNNING"
self._reset_signal_tracking()
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = time.monotonic()
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -268,6 +376,9 @@ class AutoTradeService:
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = time.monotonic()
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -288,6 +399,10 @@ class AutoTradeService:
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = None
state.adaptive_size_changed_at = None
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -333,39 +448,39 @@ class AutoTradeService:
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.risk_percent = risk_percent
state.risk_percent = safe_float(risk_percent)
return state
# установить плечо
def set_leverage(self, leverage: float) -> AutoTradeState:
def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
state = self.get_state()
state.leverage = leverage
state.leverage = safe_float(leverage)
return state
# установить 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.stop_loss_percent = value
state.stop_loss_percent = safe_float(value)
return state
# установить 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.take_profit_percent = value
state.take_profit_percent = safe_float(value)
return state
# установить 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.max_loss_usd = value
state.max_loss_usd = safe_float(value)
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.max_reserved_balance_percent = value
state.max_reserved_balance_percent = safe_float(value)
state.execution_block_reason = None
return state
@@ -380,6 +495,7 @@ class AutoTradeService:
self._same_signal_count = 0
state = self.get_state()
state.adaptive_size_base = None
state.adaptive_size_final = None
state.adaptive_size_multiplier = None
@@ -387,6 +503,7 @@ class AutoTradeService:
state.adaptive_size_factors = None
state.effective_risk_percent = None
state.effective_target_risk_usd = None
state.last_signal_repeat_count = 0
state.last_signal_confidence = 0.0
state.last_signal_reason = None
@@ -399,6 +516,9 @@ class AutoTradeService:
state.signal_confirmation_missing_repeats = self._confirm_repeats
state.signal_confirmation_progress = 0.0
state.signal_confirmation_reason = None
state.signal_started_at = None
state.signal_updated_at = None
state.execution_block_reason = None
state.execution_semantic_status = None
state.execution_semantic_message = None
@@ -411,8 +531,7 @@ class AutoTradeService:
state.execution_confidence_required_score = self._execution_confidence_required_score
state.execution_confidence_reason = None
state.execution_confidence_factors = None
state.signal_started_at = None
state.signal_updated_at = None
state.market_state = None
state.market_trend = None
state.market_volatility = None
@@ -424,8 +543,29 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
state.market_trend_gap_percent = None
state.market_trend_consistency = None
state.market_trend_efficiency = None
state.trend_quality_score = None
state.ema_distance_atr_ratio = None
state.ema_distance_state = None
state.entry_timing_state = None
state.entry_timing_reason = None
state.ema_fast_slope_percent = None
state.ema_slow_slope_percent = None
state.candle_noise_score = None
state.price_position_score = None
state.htf_interval = None
state.htf_atr_percent = None
state.htf_atr_percent_baseline = None
state.htf_volatility_ratio = None
state.htf_volatility = None
state.entry_block_reason = None
state.entry_block_message = None
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -433,6 +573,7 @@ class AutoTradeService:
state.breakout_level = None
state.breakout_distance_percent = None
state.breakout_reason = None
state.runtime_expired_reason = None
state.runtime_expired_message = None
state.snapshot_age_seconds = None
@@ -508,7 +649,12 @@ class AutoTradeService:
if state.signal_started_at is None:
signal_age_seconds = 0
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_seconds = max(
@@ -589,7 +735,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
) -> None:
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value
@@ -757,7 +903,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
) -> None:
return
@@ -772,7 +918,7 @@ class AutoTradeService:
next_signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
duration_seconds: int,
) -> None:
if previous_signal != "HOLD":
@@ -822,6 +968,11 @@ class AutoTradeService:
if normalized_signal not in {"BUY", "SELL"}:
return
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
try:
JournalService().log_ui_info(
event_type="signal_ready",
@@ -846,6 +997,9 @@ class AutoTradeService:
"confirmation_seconds": state.signal_confirmation_seconds,
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
"confirmation_progress": state.signal_confirmation_progress,
"bid_price": snapshot.get("bid_price"),
"ask_price": snapshot.get("ask_price"),
"last_price": snapshot.get("last_price"),
},
)
except Exception:
@@ -855,7 +1009,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict | None,
payload: JsonDict | None,
) -> None:
if not isinstance(payload, dict):
return
@@ -864,25 +1018,42 @@ class AutoTradeService:
previous_market_trend = state.market_trend
previous_market_volatility = state.market_volatility
state.market_state = payload.get("market_state")
state.market_trend = payload.get("market_trend")
state.market_volatility = payload.get("market_volatility")
state.market_trend_strength = payload.get("market_trend_strength")
state.market_trend_quality = payload.get("market_trend_quality")
state.market_phase = payload.get("market_phase")
state.market_phase_direction = payload.get("market_phase_direction")
state.market_analysis_interval = payload.get("market_analysis_interval")
state.market_analysis_reason = payload.get("market_analysis_reason")
state.momentum_state = payload.get("momentum_state")
state.momentum_direction = payload.get("momentum_direction")
state.momentum_change_percent = payload.get("momentum_change_percent")
state.momentum_strength = payload.get("momentum_strength")
state.breakout_level = payload.get("breakout_level")
state.breakout_distance_percent = payload.get("breakout_distance_percent")
state.breakout_reason = payload.get("breakout_reason")
state.market_state = str(payload.get("market_state") or "")
state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
state.market_trend_strength = str(payload.get("market_trend_strength") or "")
state.market_trend_quality = str(payload.get("market_trend_quality") or "")
state.market_phase = str(payload.get("market_phase") or "")
state.market_phase_direction = str(payload.get("market_phase_direction") or "")
state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
state.ema_distance_state = str(payload.get("ema_distance_state") or "")
state.entry_timing_state = str(payload.get("entry_timing_state") or "")
state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
state.ema_fast_slope_percent = safe_float(payload.get("ema_fast_slope_percent"))
state.ema_slow_slope_percent = safe_float(payload.get("ema_slow_slope_percent"))
state.candle_noise_score = safe_float(payload.get("candle_noise_score"))
state.price_position_score = safe_float(payload.get("price_position_score"))
state.htf_interval = str(payload.get("htf_interval") or "")
state.htf_atr_percent = safe_float(payload.get("htf_atr_percent"))
state.htf_atr_percent_baseline = safe_float(payload.get("htf_atr_percent_baseline"))
state.htf_volatility_ratio = safe_float(payload.get("htf_volatility_ratio"))
state.htf_volatility = str(payload.get("htf_volatility") or "")
state.market_analysis_interval = str(payload.get("interval") or payload.get("market_analysis_interval") or "")
state.market_analysis_reason = str(payload.get("reason") or payload.get("market_analysis_reason") or "")
state.momentum_state = str(payload.get("momentum_state") or "")
state.momentum_direction = str(payload.get("momentum_direction") or "")
state.momentum_change_percent = safe_float(payload.get("momentum_change_percent"))
state.momentum_strength = safe_float(payload.get("momentum_strength"))
state.breakout_level = safe_float(payload.get("breakout_level"))
state.breakout_distance_percent = safe_float(payload.get("breakout_distance_percent"))
state.breakout_reason = str(payload.get("breakout_reason") or "")
state.market_analysis_updated_at = time.monotonic()
state.entry_block_reason = payload.get("entry_block_reason")
state.entry_block_message = payload.get("entry_block_message")
state.entry_block_reason = str(payload.get("entry_block_reason") or "")
state.entry_block_message = str(payload.get("entry_block_message") or "")
self._log_market_state_if_changed(
state=state,
@@ -901,7 +1072,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict,
payload: JsonDict,
) -> None:
reason = state.entry_block_reason
message = state.entry_block_message
@@ -938,7 +1109,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict,
payload: JsonDict,
previous_market_state: str | None,
previous_market_trend: str | None,
previous_market_volatility: str | None,
@@ -1003,7 +1174,7 @@ class AutoTradeService:
event_type: str,
market_state: str,
message: str,
payload: dict,
payload: JsonDict,
) -> None:
level = self._market_journal_level(market_state)
@@ -1034,7 +1205,7 @@ class AutoTradeService:
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":
return "WARNING"
@@ -1056,8 +1227,11 @@ class AutoTradeService:
signal_updated_at = getattr(state, "signal_updated_at", 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:
previous_signal = state.last_signal
@@ -1081,7 +1255,12 @@ class AutoTradeService:
market_updated_at = getattr(state, "market_analysis_updated_at", 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:
state.market_state = None
@@ -1096,7 +1275,23 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
state.market_trend_gap_percent = None
state.market_trend_consistency = None
state.market_trend_efficiency = None
state.trend_quality_score = None
state.ema_distance_atr_ratio = None
state.ema_distance_state = None
state.entry_timing_state = None
state.entry_timing_reason = None
state.ema_fast_slope_percent = None
state.ema_slow_slope_percent = None
state.candle_noise_score = None
state.price_position_score = None
state.htf_interval = None
state.htf_atr_percent = None
state.htf_atr_percent_baseline = None
state.htf_volatility_ratio = None
state.htf_volatility = None
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -1123,7 +1318,7 @@ class AutoTradeService:
state: AutoTradeState,
reason: str,
message: str,
payload: dict,
payload: JsonDict,
) -> None:
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
@@ -1159,7 +1354,7 @@ class AutoTradeService:
fallback_price = None
try:
fallback_price = float(
fallback_price = safe_float(
ExchangeService().get_price(
state.symbol,
runtime_key="auto",
@@ -1192,10 +1387,10 @@ class AutoTradeService:
)
return
bid_price = self._safe_float(snapshot.get("bid_price"))
ask_price = self._safe_float(snapshot.get("ask_price"))
last_price = self._safe_float(snapshot.get("last_price"))
age_seconds = self._safe_float(snapshot.get("age_seconds"))
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
last_price = safe_float(snapshot.get("last_price"))
age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "")
@@ -1240,6 +1435,8 @@ class AutoTradeService:
elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None
spread_thresholds = self._spread_thresholds(state.symbol)
self._log_execution_quality_if_changed(
state=state,
payload={
@@ -1258,49 +1455,46 @@ class AutoTradeService:
"market_runtime_degraded": state.market_runtime_degraded,
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
"spread_warning_enter_percent": self._spread_warning_enter_percent,
"spread_warning_exit_percent": self._spread_warning_exit_percent,
"spread_block_enter_percent": self._spread_block_enter_percent,
"spread_block_exit_percent": self._spread_block_exit_percent,
"spread_asset": self._asset_symbol(state.symbol),
"spread_warning_enter_percent": spread_thresholds["warning_enter"],
"spread_warning_exit_percent": spread_thresholds["warning_exit"],
"spread_block_enter_percent": spread_thresholds["block_enter"],
"spread_block_exit_percent": spread_thresholds["block_exit"],
},
)
def _spread_percent(
self,
*,
bid_price: float | None,
ask_price: float | None,
bid_price: NumericLike | None,
ask_price: NumericLike | 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
if bid_price <= 0 or ask_price <= 0:
if bid <= 0 or ask <= 0:
return None
mid_price = (bid_price + ask_price) / 2
mid_price = (bid + ask) / 2
if mid_price <= 0:
return None
spread = ask_price - bid_price
spread = ask - bid
if spread < 0:
return None
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(
self,
*,
state: AutoTradeState,
payload: dict,
payload: JsonDict,
) -> None:
quality = state.execution_quality
reason = state.execution_quality_reason
@@ -1408,8 +1602,18 @@ class AutoTradeService:
strength = state.market_trend_strength
quality = state.market_trend_quality
phase = state.market_phase
ema_distance_state = state.ema_distance_state
entry_timing_state = state.entry_timing_state
trend_quality_score = safe_float(state.trend_quality_score)
if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE", "UNKNOWN", None}:
if market_state in {
"HIGH_VOLATILITY",
"LOW_VOLATILITY",
"RANGE",
"UNKNOWN",
None,
"",
}:
return 0.25
score = 0.65
@@ -1422,7 +1626,9 @@ class AutoTradeService:
score -= 0.25
if quality == "CLEAN":
score += 0.1
score += 0.12
elif quality == "NORMAL":
score += 0.04
elif quality == "NOISY":
score -= 0.25
@@ -1433,6 +1639,30 @@ class AutoTradeService:
elif phase in {"RANGE", "SQUEEZE"}:
score -= 0.3
if ema_distance_state == "HEALTHY":
score += 0.08
elif ema_distance_state == "EXTENDED":
score -= 0.08
elif ema_distance_state == "COMPRESSED":
score -= 0.18
elif ema_distance_state == "OVEREXTENDED":
score -= 0.35
if entry_timing_state == "NORMAL":
score += 0.08
elif entry_timing_state == "EARLY":
score -= 0.05
elif entry_timing_state == "LATE":
score -= 0.2
elif entry_timing_state == "CHASING":
score -= 0.35
if trend_quality_score is not None:
if trend_quality_score >= 0.7:
score += 0.08
elif trend_quality_score < 0.45:
score -= 0.15
return self._clamp_score(score)
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
@@ -1482,11 +1712,16 @@ class AutoTradeService:
return "достаточная совокупная уверенность входа"
def _clamp_score(self, value: float | int | None) -> float:
def _clamp_score(self, value: NumericLike | None) -> float:
if value is None:
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:
if state.execution_quality == "BLOCKED":
@@ -1541,6 +1776,9 @@ class AutoTradeService:
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
reason = state.execution_quality_reason
if reason == "MARKET_CLOSED":
return "⏸️ Исполнение · рынок закрыт"
if reason == "STALE_SNAPSHOT":
return "⛔ Исполнение · рынок неактуален"
@@ -1561,6 +1799,11 @@ class AutoTradeService:
if state.status == "OFF":
return state
if not self._sync_market_availability_state(state):
state.last_check_at = datetime.now().strftime("%H:%M:%S")
self._sync_execution_semantic_state(state)
return state
self._expire_runtime_if_needed(state)
strategy = self._get_strategy()

View File

@@ -97,6 +97,18 @@ class AutoTradeState:
# cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0
# количество закрытых сделок в текущем цикле
cycle_closed_trades: int = 0
# количество прибыльных закрытых сделок
cycle_winning_trades: int = 0
# время запуска текущего цикла
cycle_started_at: float | None = None
# время последней adaptive size корректировки
adaptive_size_changed_at: float | None = None
# данные последнего flip
last_flip_old_side: str | None = None
last_flip_new_side: str | None = None
@@ -130,7 +142,7 @@ class AutoTradeState:
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
market_trend_strength: str | None = None
# качество тренда: CLEAN / NOISY / UNKNOWN
# качество тренда: CLEAN / NORMAL / NOISY / UNKNOWN
market_trend_quality: str | None = None
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
@@ -139,6 +151,29 @@ class AutoTradeState:
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
market_phase_direction: str | None = None
# advanced trend quality metrics
market_trend_gap_percent: float | None = None
market_trend_consistency: float | None = None
market_trend_efficiency: float | None = None
ema_distance_atr_ratio: float | None = None
ema_fast_slope_percent: float | None = None
ema_slow_slope_percent: float | None = None
candle_noise_score: float | None = None
price_position_score: float | None = None
# advanced trend quality semantic states
trend_quality_score: float | None = None
ema_distance_state: str | None = None
entry_timing_state: str | None = None
entry_timing_reason: str | None = None
# higher timeframe volatility context
htf_interval: str | None = None
htf_atr_percent: float | None = None
htf_atr_percent_baseline: float | None = None
htf_volatility_ratio: float | None = None
htf_volatility: str | None = None
# состояние momentum/breakout semantic engine
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
momentum_state: str | None = None
@@ -262,4 +297,13 @@ class AutoTradeState:
adaptive_size_reason: str | None = None
# факторы 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 time
from typing import Callable
from collections.abc import Callable
from typing import ClassVar, Protocol
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from src.core.telegram_errors import (
is_message_not_modified,
is_message_to_edit_not_found,
)
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.trading.debug.service import DebugTradeService
from src.notifications.targets import NotificationTargetRegistry
from src.trading.debug.service import DebugTradeService
class RenderText(Protocol):
def __call__(self) -> str: ...
class RenderMarkup(Protocol):
def __call__(self) -> InlineKeyboardMarkup | None: ...
class DebugTradeRunner:
_task: asyncio.Task | None = None
_task: ClassVar[asyncio.Task[None] | None] = None
_bot: Bot | None = None
_chat_id: int | None = None
_message_id: int | None = None
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_bot: ClassVar[Bot | None] = None
_chat_id: ClassVar[int | None] = None
_message_id: ClassVar[int | None] = None
_current_screen: str | None = None
_text_renderer: ClassVar[RenderText | None] = None
_markup_renderer: ClassVar[RenderMarkup | None] = None
_interval_seconds = 5
_market_interval_seconds = 1
_current_screen: ClassVar[str | None] = None
_last_text: str | None = None
_last_refresh_at: float = 0.0
_retry_after_until: float = 0.0
_interval_seconds: ClassVar[int] = 5
_market_interval_seconds: ClassVar[int] = 1
_last_text: ClassVar[str | None] = None
_last_refresh_at: ClassVar[float] = 0.0
_retry_after_until: ClassVar[float] = 0.0
@classmethod
def register_screen(
@@ -39,14 +55,14 @@ class DebugTradeRunner:
bot: Bot,
chat_id: int,
message_id: int,
render_text: Callable[[], str],
render_markup: Callable[[], object],
render_text: RenderText,
render_markup: RenderMarkup,
) -> None:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._render_text = render_text
cls._render_markup = render_markup
cls._text_renderer = render_text
cls._markup_renderer = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
@@ -54,6 +70,30 @@ class DebugTradeRunner:
chat_id=chat_id,
)
@classmethod
def _reset_screen(cls) -> None:
cls._message_id = None
cls._text_renderer = None
cls._markup_renderer = None
cls._last_text = None
@classmethod
def _reset_runtime(cls) -> None:
cls._bot = None
cls._chat_id = None
cls._current_screen = None
cls._reset_screen()
@classmethod
def _is_screen_ready(cls) -> bool:
return (
cls._bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._text_renderer is not None
and cls._markup_renderer is not None
)
@classmethod
async def delete_registered_screen(
cls,
@@ -75,10 +115,7 @@ class DebugTradeRunner:
except Exception:
pass
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._reset_screen()
@classmethod
async def detach_screen(
@@ -105,13 +142,7 @@ class DebugTradeRunner:
except Exception:
pass
cls._bot = None
cls._chat_id = None
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._current_screen = None
cls._last_text = None
cls._reset_runtime()
@classmethod
def set_current_screen(cls, screen: str) -> None:
@@ -121,6 +152,7 @@ class DebugTradeRunner:
def start(cls) -> None:
service = DebugTradeService()
state = service.get_state()
state.status = "RUNNING"
MarketDataRunner.start(
@@ -167,7 +199,11 @@ class DebugTradeRunner:
await asyncio.sleep(cls._interval_seconds)
@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":
return
@@ -176,32 +212,43 @@ class DebugTradeRunner:
if now < cls._retry_after_until:
return
if not force and now - cls._last_refresh_at < cls._interval_seconds:
return
if not all(
[
cls._bot,
cls._chat_id,
cls._message_id,
cls._render_text,
cls._render_markup,
]
if (
not force
and now - cls._last_refresh_at < cls._interval_seconds
):
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:
return
try:
await cls._bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=cls._render_markup(),
reply_markup=markup_renderer(),
)
cls._last_text = text
cls._last_refresh_at = now
@@ -209,18 +256,13 @@ class DebugTradeRunner:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message is not modified" in error_text:
if is_message_not_modified(exc):
cls._last_text = text
cls._last_refresh_at = now
return
if "message to edit not found" in error_text:
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
if is_message_to_edit_not_found(exc):
cls._reset_screen()
return
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 src.trading.auto.state import AutoTradeState
from src.core.numbers import safe_float
class SemanticDiagnosticSnapshotBuilder:
@@ -30,6 +31,8 @@ class SemanticDiagnosticSnapshotBuilder:
blockers=blockers,
)
position_current_price = self._position_current_price(state)
return {
"status": {
"status": state.status,
@@ -59,6 +62,27 @@ class SemanticDiagnosticSnapshotBuilder:
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds,
"market_is_open": state.market_is_open,
"market_status": state.market_status,
"market_status_message": state.market_status_message,
"market_status_updated_at": state.market_status_updated_at,
"trend_gap_percent": state.market_trend_gap_percent,
"trend_consistency": state.market_trend_consistency,
"trend_efficiency": state.market_trend_efficiency,
"trend_quality_score": state.trend_quality_score,
"ema_distance_atr_ratio": state.ema_distance_atr_ratio,
"ema_distance_state": state.ema_distance_state,
"entry_timing_state": state.entry_timing_state,
"entry_timing_reason": state.entry_timing_reason,
"ema_fast_slope_percent": state.ema_fast_slope_percent,
"ema_slow_slope_percent": state.ema_slow_slope_percent,
"candle_noise_score": state.candle_noise_score,
"price_position_score": state.price_position_score,
"htf_interval": state.htf_interval,
"htf_atr_percent": state.htf_atr_percent,
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
"htf_volatility_ratio": state.htf_volatility_ratio,
"htf_volatility": state.htf_volatility,
},
"momentum": {
"state": getattr(state, "momentum_state", None),
@@ -113,6 +137,11 @@ class SemanticDiagnosticSnapshotBuilder:
"last_flip_pnl_usd": state.last_flip_pnl_usd,
"last_flip_reason": state.last_flip_reason,
"last_flip_monotonic_at": state.last_flip_monotonic_at,
"current_price": position_current_price,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"stop_loss_usd": state.effective_target_risk_usd,
"take_profit_usd": self._take_profit_usd(state),
"max_loss_usd": state.max_loss_usd,
},
"runtime_health": {
"health_score": health_score,
@@ -151,19 +180,52 @@ class SemanticDiagnosticSnapshotBuilder:
"is_ready": state.is_signal_ready,
"is_blocked": bool(blockers),
"blockers": blockers,
"symbol": state.symbol,
},
}
def _position_current_price(
self,
state: AutoTradeState,
) -> float | None:
if state.position_side == "NONE":
return None
try:
from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
side = str(state.position_side or "").upper()
price = snapshot.get("last_price")
if side == "LONG":
price = snapshot.get("bid_price") or price
elif side == "SHORT":
price = snapshot.get("ask_price") or price
return safe_float(price)
except Exception:
return None
def _age_seconds(
self,
*,
now: float,
started_at: float | None,
) -> int | None:
if started_at is None:
started = safe_float(started_at)
if started is 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:
return bool(
@@ -203,6 +265,28 @@ class SemanticDiagnosticSnapshotBuilder:
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10
if state.ema_distance_state == "COMPRESSED":
score -= 10
if state.ema_distance_state == "EXTENDED":
score -= 8
if state.ema_distance_state == "OVEREXTENDED":
score -= 25
if state.entry_timing_state == "LATE":
score -= 18
if state.entry_timing_state == "CHASING":
score -= 30
trend_quality_score = safe_float(state.trend_quality_score)
if trend_quality_score is not None:
if trend_quality_score < 0.45:
score -= 12
elif trend_quality_score >= 0.7:
score += 5
if state.market_runtime_degraded:
score -= 15
@@ -225,6 +309,9 @@ class SemanticDiagnosticSnapshotBuilder:
has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE"
if state.market_is_open is False:
return "RED"
has_waiting_data_blocker = any(
str(item).strip().lower()
in {
@@ -258,6 +345,12 @@ class SemanticDiagnosticSnapshotBuilder:
return "WAITING"
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
return "RED"
if state.market_trend_quality == "NOISY":
return "RED"
return "YELLOW"
if health_score < 45:
@@ -301,6 +394,9 @@ class SemanticDiagnosticSnapshotBuilder:
state: AutoTradeState,
blockers: list[str],
) -> str:
if state.market_is_open is False:
return state.market_status_message or "Биржа временно недоступна для торговли."
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
return "Ожидание: рынок без направления."
@@ -341,6 +437,13 @@ class SemanticDiagnosticSnapshotBuilder:
def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = []
if state.market_is_open is False:
blockers.append(
state.market_status_message
or "рынок закрыт"
)
return blockers
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления")
@@ -349,7 +452,17 @@ class SemanticDiagnosticSnapshotBuilder:
else:
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:
blockers.append(str(state.entry_block_message))
@@ -363,4 +476,26 @@ class SemanticDiagnosticSnapshotBuilder:
if 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.journal.service import JournalService
from src.trading.position.state import PositionState
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
@dataclass(slots=True)
@@ -108,7 +110,7 @@ class ExecutionEngine:
final_size=size,
)
size = self._round_order_size(size)
size = self._round_size(size)
if size <= 0:
return ExecutionDecision(
@@ -134,7 +136,7 @@ class ExecutionEngine:
state.last_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта."
payload = {
payload: JsonDict = {
"execution_type": "ENTRY",
"action": action,
"symbol": state.symbol,
@@ -218,7 +220,7 @@ class ExecutionEngine:
final_size=new_size,
)
new_size = self._round_order_size(new_size)
new_size = self._round_size(new_size)
if new_size <= 0:
return ExecutionDecision(
@@ -230,11 +232,10 @@ class ExecutionEngine:
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
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()
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
old_side = position.side
old_entry_price = position.entry_price
@@ -242,6 +243,12 @@ class ExecutionEngine:
old_leverage = position.leverage
old_opened_at = position.opened_at
state.last_flip_old_side = old_side
state.last_flip_new_side = new_side
state.last_flip_pnl_usd = pnl
state.last_flip_reason = state.last_signal_reason
state.last_flip_monotonic_at = time.monotonic()
type(self)._position = PositionState(
side=new_side,
symbol=state.symbol,
@@ -261,7 +268,7 @@ class ExecutionEngine:
state.last_flip_at = now
type(self)._last_flip_block_key = None
payload = {
payload: JsonDict = {
"execution_type": "FLIP",
"action": f"FLIP_{old_side}_TO_{new_side}",
"symbol": state.symbol,
@@ -326,8 +333,8 @@ class ExecutionEngine:
state: AutoTradeState,
*,
forced_reason: str | None = None,
forced_exit_price: float | None = None,
forced_pnl: float | None = None,
forced_exit_price: NumericLike | None = None,
forced_pnl: NumericLike | None = None,
forced_price_meta: _ExecutionPrice | None = None,
) -> ExecutionDecision:
position = type(self)._position
@@ -337,7 +344,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.")
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
else:
try:
@@ -346,14 +353,26 @@ class ExecutionEngine:
except Exception as 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.cycle_realized_pnl_usd += pnl
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
now = self._now_time()
payload = {
payload: JsonDict = {
"execution_type": "EXIT",
"action": "CLOSE",
"symbol": state.symbol,
@@ -413,7 +432,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
@@ -475,18 +494,24 @@ class ExecutionEngine:
return False
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
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:
return 0.0
if position.side == "LONG":
return round(((current_price - entry) / entry) * 100, 4)
return round(((price - entry) / entry) * 100, 4)
if position.side == "SHORT":
return round(((entry - current_price) / entry) * 100, 4)
return round(((entry - price) / entry) * 100, 4)
return 0.0
@@ -507,9 +532,9 @@ class ExecutionEngine:
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
position = type(self)._position
confidence = float(state.last_signal_confidence or 0.0)
repeat_count = int(state.last_signal_repeat_count or 0)
unrealized_pnl = float(state.unrealized_pnl_usd or 0.0)
confidence = safe_float(state.last_signal_confidence) or 0.0
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
hold_seconds = self._position_hold_seconds(position)
momentum_direction = getattr(state, "momentum_direction", None)
momentum_state = getattr(state, "momentum_state", None)
@@ -560,7 +585,7 @@ class ExecutionEngine:
reason: str,
) -> ExecutionDecision:
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.last_flip_block_reason = reason
@@ -578,7 +603,7 @@ class ExecutionEngine:
if block_key != type(self)._last_flip_block_key:
type(self)._last_flip_block_key = block_key
payload = {
payload: JsonDict = {
"execution_type": "FLIP_BLOCKED",
"symbol": state.symbol,
"position_side": position.side,
@@ -700,8 +725,10 @@ class ExecutionEngine:
multiplier = 1.0
execution_confidence_score = getattr(state, "execution_confidence_score", None)
if execution_confidence_score is not None:
score = max(0.0, min(1.0, float(execution_confidence_score)))
score_raw = safe_float(execution_confidence_score)
if score_raw is not None:
score = max(0.0, min(1.0, score_raw))
if score < 0.55:
multiplier *= 0.0
@@ -750,17 +777,14 @@ class ExecutionEngine:
multiplier *= 1.05
if momentum_strength is not None:
try:
strength = float(momentum_strength)
strength = safe_float(momentum_strength)
if strength is not None:
if strength >= 1.5:
multiplier *= 1.1
elif strength <= 0.7:
multiplier *= 0.8
except Exception:
pass
if signal == "BUY":
if momentum_direction == "DOWN":
multiplier *= 0.75
@@ -800,7 +824,10 @@ class ExecutionEngine:
state.adaptive_size_final = self._round_size(final_size)
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(
base_risk_percent * multiplier,
@@ -847,7 +874,7 @@ class ExecutionEngine:
base_size: float,
final_size: float,
) -> 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:
state.effective_risk_percent = 0.0
@@ -859,7 +886,7 @@ class ExecutionEngine:
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(
current_effective_risk * margin_ratio,
@@ -917,7 +944,7 @@ class ExecutionEngine:
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:
effective_multiplier = limited_size / adaptive_final
@@ -1011,32 +1038,55 @@ class ExecutionEngine:
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:
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:
raise ValueError(f"Execution snapshot price '{name}' is invalid: {price}")
raise ValueError(
f"Execution snapshot price '{name}' is invalid: {price}"
)
return price
def _round_size(self, size: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(size) * factor) / factor
def _round_size(self, size: NumericLike | None) -> float:
value = safe_float(size)
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
entry = position.entry_price or 0.0
size = position.size or 0.0
price = safe_float(current_price) or 0.0
entry = safe_float(position.entry_price) or 0.0
size = safe_float(position.size) or 0.0
if position.side == "LONG":
return round((current_price - entry) * size, 4)
return round((price - entry) * size, 4)
if position.side == "SHORT":
return round((entry - current_price) * size, 4)
return round((entry - price) * size, 4)
return 0.0
@@ -1048,9 +1098,5 @@ class ExecutionEngine:
state.position_size = position.size
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:
return datetime.now().strftime("%H:%M:%S")

View File

@@ -1,17 +1,16 @@
# app/src/trading/journal/exporter.py
from __future__ import annotations
import csv
import json
import re
import zipfile
from datetime import datetime
from io import BytesIO, StringIO
from xml.sax.saxutils import escape
from zoneinfo import ZoneInfo
from openpyxl import Workbook
from openpyxl.styles import Font
from src.core.config import load_settings
from src.core.event_titles import event_title
@@ -61,12 +60,12 @@ def _event_title(event_type: object) -> str:
return event_title(event_type)
def _payload(row: dict) -> dict:
def _payload(row: dict[str, object]) -> dict[str, object]:
payload = row.get("payload")
return payload if isinstance(payload, dict) else {}
def _payload_json(payload: dict) -> str:
def _payload_json(payload: dict[str, object]) -> str:
if not payload:
return ""
@@ -74,7 +73,7 @@ def _payload_json(payload: dict) -> str:
return _strip_emoji(text)
def _export_row(row: dict) -> list[str]:
def _export_row(row: dict[str, object]) -> list[str]:
payload = _payload(row)
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(
{str(row.get("level") or "").upper() for row in rows if row.get("level")}
)
return ", ".join(levels) if levels else ""
def _period_summary(rows: list[dict]) -> str:
dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")]
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 = [value for value in dates if value]
if not dates:
@@ -127,7 +130,7 @@ def _period_summary(rows: list[dict]) -> str:
def _metadata_rows(
*,
rows: list[dict],
rows: list[dict[str, object]],
total_count: int,
export_limit: int,
account_mode: str,
@@ -152,7 +155,7 @@ def _metadata_rows(
def build_csv(
rows: list[dict],
rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
@@ -185,42 +188,199 @@ def build_csv(
def build_xlsx(
rows: list[dict],
rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "Journal"
sheet_rows: list[list[str]] = []
for metadata_row in _metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
):
ws.append(metadata_row)
sheet_rows.extend(
_metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
)
)
header_row_index = ws.max_row + 1
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)
sheet_rows.append(_headers())
for row in rows:
ws.append(_export_row(row))
sheet_rows.append(_export_row(row))
for column_cells in ws.columns:
max_length = max(len(str(cell.value or "")) for cell in column_cells)
ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60)
return _build_xlsx_bytes(
sheet_name="Journal",
rows=sheet_rows,
)
def _build_xlsx_bytes(
*,
sheet_name: str,
rows: list[list[str]],
) -> bytes:
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 enum import StrEnum
from src.core.types import JsonDict
class MarketState(StrEnum):
TREND_UP = "TREND_UP"
@@ -46,12 +48,6 @@ class TrendStrength(StrEnum):
UNKNOWN = "UNKNOWN"
class TrendQuality(StrEnum):
CLEAN = "CLEAN"
NOISY = "NOISY"
UNKNOWN = "UNKNOWN"
class MarketPhase(StrEnum):
IMPULSE = "IMPULSE"
PULLBACK = "PULLBACK"
@@ -60,6 +56,29 @@ class MarketPhase(StrEnum):
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)
class MarketAnalysisResult:
symbol: str
@@ -80,23 +99,42 @@ class MarketAnalysisResult:
reason: str
is_trade_allowed: bool
payload: dict
payload: JsonDict
trend_strength: TrendStrength
trend_quality: TrendQuality
market_phase: MarketPhase
trend_gap_percent: float | None
trend_consistency: float | None
trend_efficiency: float | None
ema_distance_atr_ratio: float | None
phase_direction: TrendDirection
phase_change_percent: float | 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
momentum_state: MomentumState | None = None
momentum_direction: TrendDirection | None = None
momentum_change_percent: float | None = None
momentum_strength: float | None = None
breakout_level: 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
from typing import Any
from src.integrations.exchange.service import ExchangeService
from src.trading.market_analysis.models import (
MarketPhase,
@@ -133,8 +135,16 @@ class TrendStrategy:
"market_phase_change_percent": market.phase_change_percent,
"market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"),
"market_phase_reason": market.phase_reason,
"momentum_state": market.momentum_state.value,
"momentum_direction": market.momentum_direction.value,
"momentum_state": (
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_strength": market.momentum_strength,
"breakout_level": market.breakout_level,
@@ -381,8 +391,12 @@ class TrendStrategy:
confidence = 0.55 + (strength_score * 0.35)
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"))
ask = self._safe_float(snapshot.get("ask_price"))
@@ -395,7 +409,10 @@ class TrendStrategy:
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:
return None

View File

@@ -1257,6 +1257,135 @@
- 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

View File

@@ -1183,6 +1183,87 @@
- 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.10 Semantic Runtime Diagnostics & Observability
@@ -1233,6 +1314,187 @@
- diagnostic layer подготовлен к Diagnostic Journal Layer
- 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

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