07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer
This commit is contained in:
23
app/src/core/numbers.py
Normal file
23
app/src/core/numbers.py
Normal 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
|
||||
18
app/src/core/telegram_errors.py
Normal file
18
app/src/core/telegram_errors.py
Normal 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
12
app/src/core/types.py
Normal 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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(".")
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)}"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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, {'"': """, "'": "'"})
|
||||
|
||||
|
||||
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>"
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user