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