diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py
index fdc60cd..54cf8d7 100644
--- a/app/src/telegram/handlers/auto/main.py
+++ b/app/src/telegram/handlers/auto/main.py
@@ -60,12 +60,10 @@ async def open_auto(message: Message, state: FSMContext) -> None:
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 AutoTradeRunner.delete_registered_screen(
+ bot=message.bot,
+ chat_id=message.chat.id,
+ )
await render_auto_screen(message, edit_mode=False)
diff --git a/app/src/telegram/handlers/auto/risk.py b/app/src/telegram/handlers/auto/risk.py
index b9aad36..671ba62 100644
--- a/app/src/telegram/handlers/auto/risk.py
+++ b/app/src/telegram/handlers/auto/risk.py
@@ -24,30 +24,45 @@ class AutoRiskStates(StatesGroup):
waiting_max_loss = State()
+def _format_number(value: float | int | None) -> str:
+ if value is None:
+ return "—"
+
+ number = float(value)
+
+ if abs(number - round(number)) < 1e-9:
+ return f"{int(round(number))}"
+
+ return f"{number:.2f}".rstrip("0").rstrip(".")
+
+
def _format_percent(value: float | None) -> str:
if value is None:
- return "⚪ off"
- return f"🟢 {value:g}%"
+ return "off"
+ return f"{_format_number(value)}%"
def _format_usd(value: float | None) -> str:
if value is None:
- return "⚪ off"
- return f"🟢 {value:g} USD"
+ return "off"
+ return f"{_format_number(value)} USD"
+
+
+def _rule_icon(value: float | None) -> str:
+ return "✅" if value is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup:
- state = AutoTradeService().get_state()
builder = InlineKeyboardBuilder()
- builder.button(text=f"🛑 Stop Loss", callback_data="auto:risk:set_sl")
- builder.button(text=f"🎯 Take Profit", callback_data="auto:risk:set_tp")
- builder.button(text=f"💸 Max Loss", callback_data="auto:risk:set_ml")
- builder.button(text="♻️ Reset", callback_data="auto:risk:reset")
- builder.button(text="⬅️ Назад", callback_data="settings:auto")
+ builder.button(text="🛑 SL", callback_data="auto:risk:set_sl")
+ builder.button(text="🎯 TP", callback_data="auto:risk:set_tp")
+ builder.button(text="💸 ML", callback_data="auto:risk:set_ml")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
+ builder.button(text="⬅️ Назад", callback_data="settings:auto")
+ builder.button(text="♻️ Сбросить", callback_data="auto:risk:reset")
- builder.adjust(2, 2, 2)
+ builder.adjust(3, 1, 2)
return builder.as_markup()
@@ -66,16 +81,13 @@ def _risk_text(status_message: str | None = None) -> str:
status = "🟢 Активна" if active_count else "⚪ Выключена"
text = (
- "⚠️ Risk Settings\n\n"
+ "🧯 Защита позиции\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
f"Статус защиты: {status}\n"
f"Активных правил: {active_count}/3\n\n"
- f"🛑 Stop Loss: {_format_percent(state.stop_loss_percent)}\n"
- f"🎯 Take Profit: {_format_percent(state.take_profit_percent)}\n"
- f"💸 Max Loss: {_format_usd(state.max_loss_usd)}\n\n"
- "Подсказка:\n"
- "Пример: 0.5, 1\n"
- "Введите 0, чтобы отключить параметр."
+ f"{_rule_icon(state.stop_loss_percent)} Stop Loss · {_format_percent(state.stop_loss_percent)}\n"
+ f"{_rule_icon(state.take_profit_percent)} Take Profit · {_format_percent(state.take_profit_percent)}\n"
+ f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n"
)
if status_message:
@@ -155,7 +167,7 @@ async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> N
def _parse_positive_or_none(raw_text: str | None) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".")
- if value_text in {"0", "0.0", "off", "OFF", "-"}:
+ if value_text.lower() in {"0", "0.0", "off", "-"}:
return None
value = float(value_text)
@@ -222,11 +234,11 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None:
await callback.message.edit_text(
- "🛑 Stop Loss\n\n"
+ "Stop Loss\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
"Введите Stop Loss в процентах.\n"
- "Например: 2\n\n"
- "Введите 0, чтобы отключить."
+ "Например: 1, 0.5, 0,5\n\n"
+ "отключить параметр - 0"
)
await callback.answer()
@@ -240,11 +252,11 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None:
await callback.message.edit_text(
- "🎯 Take Profit\n\n"
+ "Take Profit\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
"Введите Take Profit в процентах.\n"
- "Например: 3\n\n"
- "Введите 0, чтобы отключить."
+ "Например: 2, 1.5, 1,5\n\n"
+ "отключить параметр - 0"
)
await callback.answer()
@@ -258,11 +270,11 @@ async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None:
await callback.message.edit_text(
- "💸 Max Loss\n\n"
+ "Maximum Loss\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
"Введите максимальный paper-убыток в USD.\n"
- "Например: 10\n\n"
- "Введите 0, чтобы отключить."
+ "Например: 100, 50.5, 50,5\n\n"
+ "отключить параметр - 0"
)
await callback.answer()
@@ -309,7 +321,7 @@ async def set_stop_loss(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
- await message.answer("Введите число. Например: 2 или 0 для отключения.")
+ await message.answer("Введите число. Например: 1, 0.5 или 0 для отключения.")
return
if not _validate_percent(value):
@@ -333,7 +345,7 @@ async def set_take_profit(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
- await message.answer("Введите число. Например: 3 или 0 для отключения.")
+ await message.answer("Введите число. Например: 2, 1.5 или 0 для отключения.")
return
if not _validate_percent(value):
@@ -357,7 +369,7 @@ async def set_max_loss(message: Message, state: FSMContext) -> None:
try:
value = _parse_positive_or_none(message.text)
except ValueError:
- await message.answer("Введите число. Например: 10 или 0 для отключения.")
+ await message.answer("Введите число. Например: 100, 50.5 или 0 для отключения.")
return
if not _validate_max_loss(value):
diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py
index 4817a1f..382491f 100644
--- a/app/src/telegram/handlers/auto/ui.py
+++ b/app/src/telegram/handlers/auto/ui.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+import math
+import time
+
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -11,15 +14,6 @@ from src.telegram.ui.currency_ui import format_usd_amount
from src.trading.auto.service import AutoTradeService
-PAPER_BALANCE_USD = 1000.0
-
-
-def _format_percent2(value: float | None) -> str:
- if value is None:
- return "off"
- return f"{float(value):.2f}%"
-
-
def auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
@@ -27,18 +21,31 @@ def auto_keyboard() -> InlineKeyboardMarkup:
builder.button(text="👀 Watch", callback_data="auto:observe")
builder.button(text="🛑 Stop", callback_data="auto:stop")
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
- builder.button(text="⚠️ Risk", callback_data="auto:risk")
+ builder.button(text="🧯 Защита", callback_data="auto:risk")
builder.adjust(3, 2)
return builder.as_markup()
def is_auto_configured(state) -> bool:
- return bool(
- state.symbol
- and state.strategy
- and state.risk_percent is not None
- )
+ if not state.symbol:
+ return False
+
+ if not state.strategy:
+ return False
+
+ if state.risk_percent is None:
+ return False
+
+ strategy = state.strategy.upper()
+
+ if strategy == "TREND":
+ return (
+ state.stop_loss_percent is not None
+ and state.stop_loss_percent > 0
+ )
+
+ return True
def build_auto_text() -> str:
@@ -50,141 +57,258 @@ def build_auto_text() -> str:
if state.position_side != "NONE" and state.entry_price is not None:
return _build_active_position_text(state)
+ if state.status == "OFF":
+ return _build_stopped_without_position_text(state)
+
return _build_waiting_text(state)
def _build_not_configured_text(state) -> str:
- return (
- "🤖 Автоторговля · ⚪ Не настроена\n"
- f"{_account_mode_line()}\n\n"
- "Configuration required\n\n"
- f"Pair: {_required_value(_asset_symbol(state.symbol))}\n"
- f"Strategy: {_required_value(_strategy_short(state.strategy))}\n"
- f"Position Risk: {_required_value(_risk_percent_text(state))}\n\n"
- "🛠️ Открой настройки для запуска"
+ symbol_ready = state.symbol is not None
+ strategy_ready = state.strategy is not None
+ risk_ready = state.risk_percent is not None
+
+ symbol_icon = "" if symbol_ready else "⚠️"
+ strategy_icon = "" if strategy_ready else "⚠️"
+ risk_icon = "" if risk_ready else "⚠️"
+
+ parts = [
+ "🤖 Автоторговля ⚪ Не настроена",
+ _account_mode_line(),
+ "",
+ f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}\n"
+ f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}\n"
+ f"{risk_icon} Риск на сделку · {_required_value(_risk_percent_text(state))}\n"
+ ]
+
+ strategy = (state.strategy or "").upper()
+
+ if strategy == "TREND":
+ sl_value = (
+ _format_percent(state.stop_loss_percent)
+ if state.stop_loss_percent is not None
+ else "⏤"
+ )
+
+ sl_icon = "" if sl_value != "⏤" else "⚠️"
+
+ parts.append(f"{sl_icon} SL · {sl_value}")
+
+ parts.extend([
+ "",
+ "⚠️ Требуется настройка параметров",
+ ])
+
+ return "\n".join(parts)
+
+
+def _build_stopped_without_position_text(state) -> str:
+ price = _current_price(state.symbol)
+
+ available = _allocated_balance(state) + _realized_pnl(state)
+ estimated_size = _estimated_size(state, price)
+
+ rr_line = _risk_reward_line(state)
+
+ risk_line = _risk_summary_line(
+ state,
+ estimated_size,
+ entry_price_override=price,
)
+ parts = [
+ f"🤖 Автоторговля {_status_text(state.status)}",
+ _account_mode_line(),
+ "",
+ f"Доступно · $ {_format_money_compact(available)}\n",
+ "🧾 Подготовка ордера",
+ "",
+ _order_header_line(state),
+ f"Цена · {_format_usd_or_dash(price)}",
+ _estimated_size_text(state, price),
+ _max_reserved_line(state, price),
+ f"Риск · {_risk_percent_text(state)} ($ {_format_money_compact(_target_risk_usd(state))})",
+ ]
+
+ if rr_line or risk_line:
+ parts.append("")
+
+ if rr_line:
+ parts.append(rr_line)
+
+ if risk_line:
+ parts.append(risk_line)
+
+ return "\n".join(parts)
+
+
+def _build_waiting_text(state) -> str:
+ price = _signal_entry_price(state)
+
+ available = _allocated_balance(state) + _realized_pnl(state)
+ estimated_size = _estimated_size(state, price)
+
+ rr_line = _risk_reward_line(state)
+ risk_line = _risk_summary_line(
+ state,
+ estimated_size,
+ entry_price_override=price,
+ )
+
+ parts = [
+ f"🤖 Автоторговля {_status_text(state.status)}",
+ _account_mode_line(),
+ "",
+ f"Доступно · $ {_format_money_compact(available)}",
+ "",
+ _signal_line(state),
+ *_signal_confidence_lines(state),
+ *_execution_block_lines(state),
+ "",
+ "🧾 Подготовка ордера",
+ "",
+ _order_header_line(state),
+ f"{_price_label_for_signal(state)} · {_format_usd_or_dash(price)}",
+ _estimated_size_text(state, price),
+ _max_reserved_line(state, price),
+ f"Риск · {_risk_percent_text(state)} ($ {_format_money_compact(_target_risk_usd(state))})",
+ ]
+
+ if rr_line or risk_line:
+ parts.append("")
+
+ if rr_line:
+ parts.append(rr_line)
+
+ if risk_line:
+ parts.append(risk_line)
+
+ return "\n".join(parts)
+
+
+def _build_active_position_text(state) -> str:
+ current_price = _current_price(state.symbol)
+ price_for_calc = current_price or state.entry_price or 0.0
+
+ size = state.position_size or 0.0
+ notional = size * price_for_calc
+ reserved = _position_reserved_usd(state, current_price)
+ available = _allocated_balance(state) + _realized_pnl(state) - reserved
+ pnl = state.unrealized_pnl_usd or 0.0
+
+ rr_line = _risk_reward_line(state)
+ risk_line = _risk_summary_line(state, size)
+
+ side_icon = "🟢" if state.position_side == "LONG" else "🔴"
+
+ parts = [
+ f"🤖 Автоторговля {_status_text(state.status)}",
+ _account_mode_line(),
+ "",
+ f"Доступно · $ {_format_money_compact(available)}",
+ f"Зарезервировано · $ {_format_money_compact(reserved)}",
+ f"P&L {_format_signed_usd_with_direction(pnl)}",
+ "",
+ (
+ f"{side_icon} {_asset_symbol(state.symbol)} · "
+ f"{_strategy_short(state.strategy)} · "
+ f"{state.position_side} {_leverage_text(state.leverage)}"
+ ),
+ "",
+ f"Количество · {_format_crypto_size(size)} ⇢ $ {_format_money_compact(notional)}",
+ f"Цена входа · $ {_format_money(state.entry_price)}",
+ f"Текущая цена · {_format_usd_or_dash(current_price)}",
+ "",
+ "⚠️ Комиссии не учтены",
+ ]
+
+ if rr_line or risk_line:
+ parts.append("")
+
+ if rr_line:
+ parts.append(rr_line)
+
+ if risk_line:
+ parts.append(risk_line)
+
+ return "\n".join(parts)
+
def _execution_block_lines(state) -> list[str]:
lines: list[str] = []
reason = getattr(state, "execution_block_reason", None)
if reason:
- lines.append(f"🔴 Blocked: {reason}")
+ lines.append(f"Blocked · {reason}")
adjustment = getattr(state, "execution_size_adjustment_reason", None)
if adjustment == "MARGIN_LIMIT":
- lines.append("🟠 Size adjusted by Max Reserved")
+ lines.append("Size adjusted by Max Reserved")
return lines
-def _estimated_margin_text(state, price: float | None) -> str:
+def _allocated_balance(state) -> float:
+ return float(getattr(state, "allocated_balance_usd", 1000.0) or 1000.0)
+
+
+def _realized_pnl(state) -> float:
+ return float(getattr(state, "realized_pnl_usd", 0.0) or 0.0)
+
+
+def _position_reserved_usd(state, current_price: float | None) -> float:
+ if (
+ state.position_side == "NONE"
+ or state.position_size is None
+ or state.position_size <= 0
+ ):
+ return 0.0
+
+ price = current_price or state.entry_price or 0.0
+ leverage = state.leverage or 1.0
+
+ if price <= 0 or leverage <= 0:
+ return 0.0
+
+ return (state.position_size * price) / leverage
+
+
+def _max_reserved_line(state, price: float | None = None) -> str:
size = _estimated_size(state, price)
- if size is None or price is None:
- return "Est. Margin: —"
+ if size is None or price is None or price <= 0:
+ return "Собственные средства · —"
leverage = state.leverage or 1.0
if leverage <= 0:
- return "Est. Margin: —"
+ return "Собственные средства · —"
- notional = size * price
- reserved = notional / leverage
+ position_size_usd = size * price
+ own_funds_usd = position_size_usd / leverage
- return f"Est. Margin: $ {_format_money0(reserved)}"
+ return (
+ f"Собственные средства · $ {_format_money_compact(own_funds_usd)}"
+ )
-def _max_reserved_text(state) -> str:
- max_percent = getattr(state, "max_reserved_balance_percent", None)
+def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
+ if not symbol:
+ return None
- if max_percent is None or max_percent <= 0:
- return "Max Reserved: off"
-
- max_reserved = PAPER_BALANCE_USD * (max_percent / 100)
-
- return f"Max Reserved: {max_percent:g}% · $ {_format_money0(max_reserved)}"
-
-
-def _build_waiting_text(state) -> str:
- price = _current_price(state.symbol)
- signal = state.last_signal or "—"
- repeats = state.last_signal_repeat_count or 0
- confidence = state.last_signal_confidence or 0.0
- rr_line = _risk_reward_line(state)
-
- parts = [
- f"🤖 Автоторговля · {_status_text(state.status)}",
- _account_mode_line(),
- f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}",
- "",
- f"{_asset_symbol(state.symbol)} · {_strategy_short(state.strategy)} · {_leverage_text(state.leverage)}",
- f"💲 Price: {_format_usd_or_dash(price)}",
- "",
- _decision_human_text(state.decision_status),
- f"{_signal_icon(signal)} {signal} ×{repeats}",
- f"Confidence: {confidence:.2f}",
- *(_execution_block_lines(state)),
- "",
- f"Position Risk: {_risk_percent_text(state)} · $ {_format_money0(_target_risk_usd(state))}",
- _estimated_size_text(state, price),
- _estimated_margin_text(state, price),
- _max_reserved_text(state),
- "",
- f"🛑 SL: {_format_percent2(state.stop_loss_percent)}",
- f"🎯 TP: {_format_percent2(state.take_profit_percent)}",
- f"💣 ML: {_max_loss_or_off(state.max_loss_usd)}",
- ]
-
- if rr_line:
- parts.extend(["", rr_line])
-
- return "\n".join(parts)
-
-
-def _build_active_position_text(state) -> str:
- current_price = _current_price(state.symbol)
- current_price_for_calc = current_price or state.entry_price or 0.0
-
- size = state.position_size or 0.0
- leverage = state.leverage or 1.0
-
- notional = size * current_price_for_calc
- reserved = notional / leverage if leverage > 0 else 0.0
- pnl = state.unrealized_pnl_usd or 0.0
-
- rr_line = _risk_reward_line(state)
- side_icon = "🟢" if state.position_side == "LONG" else "🔴"
-
- parts = [
- f"🤖 Автоторговля · {_status_text(state.status)}",
- _account_mode_line(),
- f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}",
- f"💵 Reserved: $ {_format_money(reserved)}",
- "",
- (
- f"{side_icon} {_asset_symbol(state.symbol)} · "
- f"{_strategy_short(state.strategy)} · "
- f"{state.position_side} {_leverage_text(state.leverage)}"
- ),
- f"📦 Size: {_format_crypto_size(size)} ($ {_format_money0(notional)})",
- f"💲 Entry: $ {_format_money(state.entry_price)}",
- f"💲 Current: {_format_usd_or_dash(current_price)}",
- f"⚖️ P&L: {_format_signed_usd(pnl)}",
- "Fees: not included",
- "",
- _active_sl_line(state),
- _active_tp_line(state),
- _active_ml_line(state),
- ]
-
- if rr_line:
- parts.append(rr_line)
-
- return "\n".join(parts)
+ try:
+ return ExchangeService().get_market_snapshot(symbol)
+ except Exception:
+ return None
def _current_price(symbol: str | None) -> float | None:
+ snapshot = _market_snapshot(symbol)
+
+ if snapshot is not None:
+ price = snapshot.get("last_price")
+ if price is not None:
+ return float(price)
+
if not symbol:
return None
@@ -194,10 +318,32 @@ def _current_price(symbol: str | None) -> float | None:
return None
+def _signal_entry_price(state) -> float | None:
+ snapshot = _market_snapshot(state.symbol)
+
+ if snapshot is None:
+ return _current_price(state.symbol)
+
+ signal = (state.last_signal or "HOLD").upper()
+
+ if signal == "BUY":
+ price = snapshot.get("ask_price")
+ elif signal == "SELL":
+ price = snapshot.get("bid_price")
+ else:
+ price = snapshot.get("last_price")
+
+ if price is None:
+ return None
+
+ return float(price)
+
+
def _target_risk_usd(state) -> float:
if state.risk_percent is None:
return 0.0
- return PAPER_BALANCE_USD * (state.risk_percent / 100)
+
+ return _allocated_balance(state) * (state.risk_percent / 100)
def _estimated_size(state, price: float | None) -> float | None:
@@ -219,93 +365,113 @@ def _estimated_size(state, price: float | None) -> float | None:
max_percent = getattr(state, "max_reserved_balance_percent", None)
if max_percent is None or max_percent <= 0:
- return risk_size
+ return _round_size(risk_size)
leverage = state.leverage or 1.0
if leverage <= 0:
- return risk_size
+ return _round_size(risk_size)
- max_reserved_usd = PAPER_BALANCE_USD * (max_percent / 100)
+ max_reserved_usd = _allocated_balance(state) * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / price
- return min(risk_size, max_size)
+ return _round_size(min(risk_size, max_size))
def _estimated_size_text(state, price: float | None) -> str:
size = _estimated_size(state, price)
if size is None or price is None:
- return "Est. Size: —"
+ return "Количество · —"
notional = size * price
+
return (
- f"Est. Size: {_format_crypto_size(size)} "
- f"{_asset_symbol(state.symbol)} ($ {_format_money0(notional)})"
+ f"Количество · {_format_crypto_size(size)}\n"
+ f"Размер позиции · $ {_format_money_compact(notional)}"
)
-def _active_sl_line(state) -> str:
- if (
- state.stop_loss_percent is None
- or state.entry_price is None
- or state.position_size is None
- ):
- return "🛑 SL: off"
+def _risk_summary_line(
+ state,
+ size: float | None,
+ *,
+ entry_price_override: float | None = None,
+) -> str:
+ entry_price = entry_price_override or state.entry_price
- move = state.entry_price * (state.stop_loss_percent / 100)
- loss = move * state.position_size
-
- if state.position_side == "SHORT":
- price = state.entry_price + move
- else:
- price = state.entry_price - move
-
- return (
- f"🛑 SL: {_format_percent2(state.stop_loss_percent)} · "
- f"-$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}"
+ sl = _risk_loss_text(
+ percent=state.stop_loss_percent,
+ fixed_loss=None,
+ size=size,
+ entry_price=entry_price,
+ )
+ tp = _risk_profit_text(
+ percent=state.take_profit_percent,
+ size=size,
+ entry_price=entry_price,
+ )
+ ml = _risk_loss_text(
+ percent=None,
+ fixed_loss=state.max_loss_usd,
+ size=size,
+ entry_price=entry_price,
)
+ items = [
+ f"SL {sl}" if sl else "SL off",
+ f"TP {tp}" if tp else "TP off",
+ f"ML {ml}" if ml else "ML off",
+ ]
-def _active_tp_line(state) -> str:
- if (
- state.take_profit_percent is None
- or state.entry_price is None
- or state.position_size is None
- ):
- return "🎯 TP: off"
-
- move = state.entry_price * (state.take_profit_percent / 100)
- profit = move * state.position_size
-
- if state.position_side == "SHORT":
- price = state.entry_price - move
- else:
- price = state.entry_price + move
-
- return (
- f"🎯 TP: {_format_percent2(state.take_profit_percent)} · "
- f"+$ {_format_money0(profit)} ⇢ $ {_format_money0(price)}"
- )
+ return " | ".join(items)
-def _active_ml_line(state) -> str:
- if (
- state.max_loss_usd is None
- or state.entry_price is None
- or state.position_size is None
- or state.position_size <= 0
- ):
- return "💣 ML: off"
+def _risk_loss_text(
+ *,
+ percent: float | None,
+ fixed_loss: float | None,
+ size: float | None,
+ entry_price: float | None,
+) -> str:
+ if fixed_loss is not None:
+ return f"-$ {_format_money_compact(abs(fixed_loss))}"
- loss = abs(state.max_loss_usd)
- price_move = loss / state.position_size
+ if percent is None:
+ return ""
- if state.position_side == "SHORT":
- price = state.entry_price + price_move
- else:
- price = state.entry_price - price_move
+ if size is None or size <= 0 or entry_price is None or entry_price <= 0:
+ loss = _target_loss_by_percent_stub(percent)
+ return f"-$ {_format_money_compact(loss)}" if loss is not None else ""
- return f"💣 ML: -$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}"
+ move = entry_price * (percent / 100)
+ loss = move * size
+
+ return f"-$ {_format_money_compact(loss)}"
+
+
+def _risk_profit_text(
+ *,
+ percent: float | None,
+ size: float | None,
+ entry_price: float | None,
+) -> str:
+ if percent is None:
+ return ""
+
+ if size is None or size <= 0 or entry_price is None or entry_price <= 0:
+ return ""
+
+ move = entry_price * (percent / 100)
+ profit = move * size
+
+ return f"+$ {_format_money_compact(profit)}"
+
+
+def _target_loss_by_percent_stub(percent: float | None) -> float | None:
+ if percent is None:
+ return None
+
+ return None
def _risk_reward_line(state) -> str:
@@ -318,12 +484,82 @@ def _risk_reward_line(state) -> str:
return ""
ratio = state.take_profit_percent / state.stop_loss_percent
- return f"⚖️ R:R = 1 : {_format_ratio_value(ratio)}"
+ return f"R:R = 1 : {_format_ratio_value(ratio)}"
+
+
+def _order_header_line(state) -> str:
+ signal = (state.last_signal or "").upper()
+
+ if signal == "BUY":
+ return (
+ f"🟢 {_asset_symbol(state.symbol)} · "
+ f"{_strategy_short(state.strategy)} · "
+ f"LONG {_leverage_text(state.leverage)}"
+ )
+
+ if signal == "SELL":
+ return (
+ f"🔴 {_asset_symbol(state.symbol)} · "
+ f"{_strategy_short(state.strategy)} · "
+ f"SHORT {_leverage_text(state.leverage)}"
+ )
+
+ return (
+ f"{_asset_symbol(state.symbol)} · "
+ f"{_strategy_short(state.strategy)} · "
+ f"{_leverage_text(state.leverage)}"
+ )
+
+
+def _price_label_for_signal(state) -> str:
+ signal = (state.last_signal or "").upper()
+
+ if signal in {"BUY", "SELL"}:
+ return "Цена входа"
+
+ return "Цена"
+
+
+def _signal_line(state) -> str:
+ signal = (state.last_signal or "HOLD").upper()
+
+ if signal in {"BUY", "SELL"} and (
+ state.decision_status == "READY"
+ or getattr(state, "is_signal_ready", False)
+ ):
+ return f"Сигнал {_signal_icon(signal)} {signal} · READY"
+
+ duration = _signal_duration_text(state)
+
+ return f"Сигнал {_signal_icon(signal)} {signal} · {duration}"
+
+
+def _signal_duration_text(state) -> str:
+ started_at = getattr(state, "signal_started_at", None)
+
+ if started_at is not None:
+ total_seconds = max(0, int(time.monotonic() - float(started_at)))
+ else:
+ repeat_count = state.last_signal_repeat_count or 0
+ total_seconds = max(0, repeat_count * 5)
+
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ seconds = total_seconds % 60
+
+ if hours > 0:
+ return f"{hours}ч {minutes:02d}м"
+
+ if minutes > 0:
+ return f"{minutes}м {seconds:02d}с"
+
+ return f"{seconds}с"
def _format_ratio_value(value: float) -> str:
if abs(value - round(value)) < 1e-9:
return str(int(round(value)))
+
return f"{value:.2f}".rstrip("0").rstrip(".")
@@ -338,16 +574,16 @@ def _status_text(status: str) -> str:
def _decision_human_text(status: str) -> str:
mapping = {
- "WAITING": "🟡 Ожидание сигнала",
- "CONFIRMING": "🟠 Подтверждение сигнала",
- "READY": "🟢 Сигнал готов",
- "BLOCKED": "🔴 Сигнал заблокирован",
+ "WAITING": "Ожидание сигнала",
+ "CONFIRMING": "Подтверждение сигнала",
+ "READY": "Сигнал готов",
+ "BLOCKED": "Сигнал заблокирован",
}
return mapping.get(status, status)
def _account_mode_line() -> str:
- return "🔸 DEMO аккаунт" if "DEMO" in mode_line().upper() else "🔸 LIVE аккаунт"
+ return "DEMO аккаунт" if "DEMO" in mode_line().upper() else "LIVE аккаунт"
def _asset_symbol(symbol: str | None) -> str:
@@ -387,51 +623,90 @@ def _leverage_text(value: float | None) -> str:
def _risk_percent_text(state) -> str:
if state.risk_percent is None:
return "—"
- return f"{state.risk_percent:g}%"
-
-def _percent_or_off(value: float | None) -> str:
- if value is None:
- return "off"
- return f"{value:g}%"
-
-
-def _max_loss_or_off(value: float | None) -> str:
- if value is None:
- return "off"
- return f"$ {_format_money0(value)}"
+ return _format_percent(state.risk_percent)
def _required_value(value: str) -> str:
if not value or value == "—":
- return "required"
+ return "⏤"
+
return value
+def _signal_confidence_lines(state) -> list[str]:
+ signal = (state.last_signal or "HOLD").upper()
+
+ if signal == "HOLD":
+ return []
+
+ return [
+ f"Уверенность · {(state.last_signal_confidence or 0.0):.2f}"
+ ]
+
+
def _signal_icon(signal: str | None) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
- return mapping.get(signal or "", "⚪")
+ return mapping.get(signal or "", "")
+
+
+def _round_size(value: float | int | None) -> float | None:
+ if value is None:
+ return None
+
+ precision = 5
+ factor = 10 ** precision
+
+ return math.floor(float(value) * factor) / factor
+
+
+def _format_crypto_size(value: float | int | None) -> str:
+ rounded = _round_size(value)
+ if rounded is None:
+ return "—"
+
+ return f"{rounded:.5f}".rstrip("0").rstrip(".")
+
+
+def _format_percent(value: float | int | None) -> str:
+ if value is None:
+ return "off"
+
+ number = float(value)
+
+ if abs(number - round(number)) < 1e-9:
+ return f"{int(round(number))}%"
+
+ return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_money(value: float | int | None) -> str:
if value is None:
return "—"
+
return format_usd_amount(float(value))
-def _format_money0(value: float | int | None) -> str:
+def _format_money_compact(value: float | int | None) -> str:
if value is None:
return "—"
- return f"{float(value):,.0f}".replace(",", " ")
+
+ number = float(value)
+
+ if abs(number - round(number)) < 1e-9:
+ return f"{number:,.0f}".replace(",", " ")
+
+ return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
def _format_usd_or_dash(value: float | None) -> str:
if value is None:
return "—"
+
return f"$ {_format_money(value)}"
@@ -442,16 +717,24 @@ def _format_signed_usd(value: float | int | None) -> str:
amount = float(value)
if amount > 0:
- return f"+$ {_format_money(amount)}"
+ return f"+$ {_format_money_compact(amount)}"
if amount < 0:
- return f"−$ {_format_money(abs(amount))}"
+ return f"−$ {_format_money_compact(abs(amount))}"
- return "$ 0.00"
+ return "$ 0"
-def _format_crypto_size(value: float | int | None) -> str:
+def _format_signed_usd_with_direction(value: float | int | None) -> str:
if value is None:
return "—"
- return f"{float(value):.4f}".rstrip("0").rstrip(".")
\ No newline at end of file
+ amount = float(value)
+
+ if amount > 0:
+ return f"🟢 +$ {_format_money_compact(amount)}"
+
+ if amount < 0:
+ return f"🔴 −$ {_format_money_compact(abs(amount))}"
+
+ return "$ 0"
\ No newline at end of file
diff --git a/app/src/telegram/handlers/debug.py b/app/src/telegram/handlers/debug.py
index 70b0235..8142cf5 100644
--- a/app/src/telegram/handlers/debug.py
+++ b/app/src/telegram/handlers/debug.py
@@ -2,14 +2,20 @@
from __future__ import annotations
+import math
+import time
+from datetime import datetime
+
from aiogram import F, Router
from aiogram.types import Message
from src.core.config import load_settings
+from src.integrations.exchange.service import ExchangeService
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService
+from src.trading.position.state import PositionState
router = Router(name="debug")
@@ -19,6 +25,566 @@ def _debug_enabled() -> bool:
return load_settings().debug_enabled
+def _debug_help_text() -> str:
+ return (
+ "🧪 Debug commands\n\n"
+ "Auto UI states:\n"
+ "/debug_auto off\n"
+ "/debug_auto hold 335\n"
+ "/debug_auto buy 12 0.74\n"
+ "/debug_auto buy_ready 0.88\n"
+ "/debug_auto sell 9 0.71\n"
+ "/debug_auto sell_ready 0.91\n"
+ "/debug_auto long\n"
+ "/debug_auto short\n"
+ "/debug_auto reset\n"
+ "/debug_auto state\n\n"
+ "Paper execution:\n"
+ "/debug_exec buy — открыть LONG\n"
+ "/debug_exec sell — открыть SHORT\n"
+ "/debug_exec flip — перевернуть текущую позицию\n"
+ "/debug_exec flip_buy — перевернуть в LONG\n"
+ "/debug_exec flip_sell — перевернуть в SHORT\n"
+ "/debug_exec close — закрыть позицию\n"
+ "/debug_exec state — состояние позиции\n\n"
+ "Live paper test:\n"
+ "/debug_live buy — открыть LONG и запустить мониторинг\n"
+ "/debug_live sell — открыть SHORT и запустить мониторинг\n"
+ "/debug_live flip — перевернуть текущую позицию и продолжить мониторинг\n"
+ "/debug_live close — закрыть позицию\n"
+ "/debug_live stop — остановить мониторинг, позицию не закрывать\n"
+ "/debug_live state — состояние live paper test\n\n"
+ "Legacy:\n"
+ "/debug_signal BUY 0.95 3\n"
+ "/debug_signal SELL 0.70 2\n"
+ "/debug_signal HOLD 0.00 1\n"
+ "/debug_ready\n"
+ "/debug_state"
+ )
+
+
+@router.message(F.text == "/debug_help")
+async def debug_help(message: Message) -> None:
+ if not _debug_enabled():
+ await message.answer("Debug mode выключен.")
+ return
+
+ await message.answer(_debug_help_text())
+
+
+@router.message(F.text.startswith("/debug_auto"))
+async def debug_auto(message: Message) -> None:
+ if not _debug_enabled():
+ await message.answer("Debug mode выключен.")
+ return
+
+ parts = (message.text or "").split()
+ command = parts[1].lower() if len(parts) > 1 else "help"
+
+ service = AutoTradeService()
+ state = service.get_state()
+
+ if command in {"help", "-h", "--help"}:
+ await message.answer(_debug_help_text())
+ return
+
+ if command == "off":
+ _clear_debug_position(state)
+ state.status = "OFF"
+ state.decision_status = "WAITING"
+ state.last_signal = "HOLD"
+ state.last_signal_confidence = 0.0
+ state.last_signal_repeat_count = 1
+ state.is_signal_confirmed = False
+ state.is_signal_ready = False
+ _set_signal_started_at(state)
+ await _refresh_auto_screen()
+ await message.answer("✅ Debug Auto: OFF")
+ return
+
+ if command == "reset":
+ _clear_debug_position(state)
+ state.status = "RUNNING"
+ state.decision_status = "WAITING"
+ state.decision_reason = None
+ state.last_signal = "HOLD"
+ state.last_signal_reason = "DEBUG RESET HOLD"
+ state.last_signal_confidence = 0.0
+ state.last_signal_repeat_count = 1
+ state.is_signal_confirmed = False
+ state.is_signal_ready = False
+ state.execution_block_reason = None
+ state.execution_size_adjustment_reason = None
+ _set_signal_started_at(state)
+ await _refresh_auto_screen()
+ await message.answer("✅ Debug Auto: reset to RUNNING HOLD")
+ return
+
+ if command == "state":
+ _sync_state_from_position(state)
+ await message.answer(_debug_state_text(state))
+ return
+
+ if command == "hold":
+ seconds = _parse_int(parts, index=2, default=335)
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
+ signal="HOLD",
+ seconds=seconds,
+ confidence=0.0,
+ decision_status="WAITING",
+ ready=False,
+ )
+ await _refresh_auto_screen()
+ await message.answer(f"✅ Debug Auto: HOLD {seconds}s")
+ return
+
+ if command == "buy":
+ seconds = _parse_int(parts, index=2, default=12)
+ confidence = _parse_float(parts, index=3, default=0.74)
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
+ signal="BUY",
+ seconds=seconds,
+ confidence=confidence,
+ decision_status="CONFIRMING",
+ ready=False,
+ )
+ await _refresh_auto_screen()
+ await message.answer(f"✅ Debug Auto: BUY {seconds}s confidence={confidence:.2f}")
+ return
+
+ if command == "buy_ready":
+ confidence = _parse_float(parts, index=2, default=0.88)
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
+ signal="BUY",
+ seconds=15,
+ confidence=confidence,
+ decision_status="READY",
+ ready=True,
+ )
+ await _refresh_auto_screen()
+ await message.answer(f"✅ Debug Auto: BUY READY confidence={confidence:.2f}")
+ return
+
+ if command == "sell":
+ seconds = _parse_int(parts, index=2, default=9)
+ confidence = _parse_float(parts, index=3, default=0.71)
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
+ signal="SELL",
+ seconds=seconds,
+ confidence=confidence,
+ decision_status="CONFIRMING",
+ ready=False,
+ )
+ await _refresh_auto_screen()
+ await message.answer(f"✅ Debug Auto: SELL {seconds}s confidence={confidence:.2f}")
+ return
+
+ if command == "sell_ready":
+ confidence = _parse_float(parts, index=2, default=0.91)
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
+ signal="SELL",
+ seconds=15,
+ confidence=confidence,
+ decision_status="READY",
+ ready=True,
+ )
+ await _refresh_auto_screen()
+ await message.answer(f"✅ Debug Auto: SELL READY confidence={confidence:.2f}")
+ return
+
+ if command == "long":
+ _set_debug_position(state=state, side="LONG")
+ await _refresh_auto_screen()
+ await message.answer("✅ Debug Auto: active LONG position")
+ return
+
+ if command == "short":
+ _set_debug_position(state=state, side="SHORT")
+ await _refresh_auto_screen()
+ await message.answer("✅ Debug Auto: active SHORT position")
+ return
+
+ await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
+
+
+@router.message(F.text.startswith("/debug_exec"))
+async def debug_exec(message: Message) -> None:
+ if not _debug_enabled():
+ await message.answer("Debug mode выключен.")
+ return
+
+ parts = (message.text or "").split()
+ command = parts[1].lower() if len(parts) > 1 else "help"
+
+ service = AutoTradeService()
+ state = service.get_state()
+ engine = ExecutionEngine()
+
+ if command in {"help", "-h", "--help"}:
+ await message.answer(_debug_help_text())
+ return
+
+ if command == "state":
+ _sync_state_from_position(state)
+ await message.answer(_debug_state_text(state))
+ return
+
+ if command == "buy":
+ _prepare_ready_signal(state=state, signal="BUY", confidence=0.95)
+ result = engine.process(state)
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("BUY execution", result, state))
+ return
+
+ if command == "sell":
+ _prepare_ready_signal(state=state, signal="SELL", confidence=0.95)
+ result = engine.process(state)
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("SELL execution", result, state))
+ return
+
+ if command == "flip":
+ position = engine.get_position()
+ current_side = position.side or state.position_side or "NONE"
+
+ if current_side == "LONG":
+ target_signal = "SELL"
+ elif current_side == "SHORT":
+ target_signal = "BUY"
+ else:
+ await message.answer(
+ "⛔️ Flip невозможен: нет открытой позиции.\n\n"
+ "Сначала выполните /debug_exec buy или /debug_exec sell."
+ )
+ return
+
+ _prepare_ready_signal(state=state, signal=target_signal, confidence=0.95)
+ result = engine.process(state)
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("AUTO FLIP execution", result, state))
+ return
+
+ if command == "flip_buy":
+ _prepare_ready_signal(state=state, signal="BUY", confidence=0.95)
+ result = engine.process(state)
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("FLIP to LONG execution", result, state))
+ return
+
+ if command == "flip_sell":
+ _prepare_ready_signal(state=state, signal="SELL", confidence=0.95)
+ result = engine.process(state)
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("FLIP to SHORT execution", result, state))
+ return
+
+ if command == "close":
+ result = engine._close_position(state, forced_reason="DEBUG_CLOSE")
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("CLOSE execution", result, state))
+ return
+
+ await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
+
+
+@router.message(F.text.startswith("/debug_live"))
+async def debug_live(message: Message) -> None:
+ if not _debug_enabled():
+ await message.answer("Debug mode выключен.")
+ return
+
+ parts = (message.text or "").split()
+ command = parts[1].lower() if len(parts) > 1 else "help"
+
+ service = AutoTradeService()
+ state = service.get_state()
+ engine = ExecutionEngine()
+
+ if command in {"help", "-h", "--help"}:
+ await message.answer(_debug_help_text())
+ return
+
+ if command == "buy":
+ _prepare_ready_signal(state=state, signal="BUY", confidence=0.95)
+ result = engine.process(state)
+ await _start_live_monitoring()
+ await message.answer(_execution_result_text("LIVE BUY execution", result, state))
+ return
+
+ if command == "sell":
+ _prepare_ready_signal(state=state, signal="SELL", confidence=0.95)
+ result = engine.process(state)
+ await _start_live_monitoring()
+ await message.answer(_execution_result_text("LIVE SELL execution", result, state))
+ return
+
+ if command == "flip":
+ position = engine.get_position()
+ current_side = position.side or state.position_side or "NONE"
+
+ if current_side == "LONG":
+ target_signal = "SELL"
+ elif current_side == "SHORT":
+ target_signal = "BUY"
+ else:
+ await message.answer(
+ "⛔️ Live flip невозможен: нет открытой позиции.\n\n"
+ "Сначала выполните /debug_live buy или /debug_live sell."
+ )
+ return
+
+ _prepare_ready_signal(state=state, signal=target_signal, confidence=0.95)
+ result = engine.process(state)
+ await _start_live_monitoring()
+ await message.answer(_execution_result_text("LIVE AUTO FLIP execution", result, state))
+ return
+
+ if command == "close":
+ result = engine._close_position(state, forced_reason="DEBUG_LIVE_CLOSE")
+ await _after_debug_execution()
+ await message.answer(_execution_result_text("LIVE CLOSE execution", result, state))
+ return
+
+ if command == "stop":
+ AutoTradeRunner.stop()
+ await _refresh_auto_screen()
+ await message.answer("✅ Debug live stopped. Позиция не закрыта.")
+ return
+
+ if command == "state":
+ _sync_state_from_position(state)
+ await message.answer(_debug_state_text(state))
+ return
+
+ await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
+
+
+def _prepare_ready_signal(*, state, signal: str, confidence: float) -> None:
+ state.status = "RUNNING"
+ state.last_signal = signal
+ state.last_signal_confidence = max(0.0, min(1.0, confidence))
+ state.last_signal_repeat_count = 3
+ state.last_signal_reason = f"DEBUG EXEC {signal}"
+ state.decision_status = "READY"
+ state.decision_reason = "DEBUG EXEC READY"
+ state.is_signal_confirmed = True
+ state.is_signal_ready = True
+ state.execution_block_reason = None
+ state.execution_size_adjustment_reason = None
+ _set_signal_started_at(state)
+
+
+def _set_signal_state(
+ *,
+ state,
+ signal: str,
+ seconds: int,
+ confidence: float,
+ decision_status: str,
+ ready: bool,
+) -> None:
+ state.status = "RUNNING"
+ state.last_signal = signal
+ state.last_signal_confidence = max(0.0, min(1.0, confidence))
+ state.last_signal_repeat_count = _seconds_to_repeats(seconds)
+ state.last_signal_reason = f"DEBUG {signal} {seconds}s"
+ state.decision_status = decision_status
+ state.decision_reason = f"DEBUG {decision_status}"
+ state.is_signal_confirmed = ready
+ state.is_signal_ready = ready
+ state.execution_block_reason = None
+ state.execution_size_adjustment_reason = None
+ _set_signal_started_at(state, seconds_ago=seconds)
+
+
+def _set_signal_started_at(state, *, seconds_ago: int = 0) -> None:
+ if hasattr(state, "signal_started_at"):
+ state.signal_started_at = time.monotonic() - max(0, seconds_ago)
+
+
+def _set_debug_position(*, state, side: str) -> None:
+ state.status = "RUNNING"
+ state.last_signal = "BUY" if side == "LONG" else "SELL"
+ state.last_signal_confidence = 0.90
+ state.last_signal_repeat_count = 3
+ state.decision_status = "READY"
+ state.is_signal_confirmed = True
+ state.is_signal_ready = True
+ _set_signal_started_at(state, seconds_ago=15)
+
+ entry_price = _debug_entry_price(state.symbol, side)
+ size = _debug_size_for_notional(entry_price, notional=1000.0)
+ now = datetime.now().strftime("%H:%M:%S")
+
+ position = PositionState(
+ side=side,
+ symbol=state.symbol,
+ entry_price=entry_price,
+ size=size,
+ leverage=state.leverage,
+ unrealized_pnl_usd=0.0,
+ opened_at=now,
+ updated_at=now,
+ )
+
+ ExecutionEngine._position = position
+ _sync_state_from_position(state)
+
+
+def _clear_debug_position(state) -> None:
+ ExecutionEngine._position = PositionState()
+
+ state.position_side = "NONE"
+ state.entry_price = None
+ state.position_size = None
+ state.unrealized_pnl_usd = None
+
+
+def _sync_state_from_position(state) -> None:
+ position = ExecutionEngine().get_position()
+
+ state.position_side = position.side
+ state.entry_price = position.entry_price
+ state.position_size = position.size
+ state.unrealized_pnl_usd = position.unrealized_pnl_usd
+
+
+def _debug_entry_price(symbol: str, side: str) -> float:
+ try:
+ snapshot = ExchangeService().get_market_snapshot(symbol)
+
+ if side == "LONG":
+ return float(snapshot.get("ask_price") or snapshot.get("last_price"))
+
+ if side == "SHORT":
+ return float(snapshot.get("bid_price") or snapshot.get("last_price"))
+
+ return float(snapshot.get("last_price"))
+ except Exception:
+ return 100000.0
+
+
+def _debug_size_for_notional(entry_price: float, *, notional: float) -> float:
+ if entry_price <= 0:
+ return 0.0
+
+ value = notional / entry_price
+ factor = 10**5
+ return math.floor(value * factor) / factor
+
+
+def _seconds_to_repeats(seconds: int) -> int:
+ return max(1, math.ceil(max(0, seconds) / 5))
+
+
+def _parse_int(parts: list[str], *, index: int, default: int) -> int:
+ try:
+ return int(parts[index])
+ except (IndexError, TypeError, ValueError):
+ return default
+
+
+def _parse_float(parts: list[str], *, index: int, default: float) -> float:
+ try:
+ return float(parts[index])
+ except (IndexError, TypeError, ValueError):
+ return default
+
+
+async def _refresh_auto_screen() -> None:
+ AutoTradeRunner.set_current_screen("auto")
+ AutoTradeRunner._last_text = None
+ await AutoTradeRunner._refresh_screen(force=True)
+
+
+async def _start_live_monitoring() -> None:
+ state = AutoTradeService().get_state()
+
+ state.status = "RUNNING"
+ _sync_state_from_position(state)
+
+ AutoTradeRunner.set_current_screen("auto")
+ AutoTradeRunner._last_text = None
+
+ await AutoTradeRunner.process_last_event_now()
+ await _refresh_auto_screen()
+
+ AutoTradeRunner.start()
+
+
+async def _after_debug_execution() -> None:
+ state = AutoTradeService().get_state()
+
+ _sync_state_from_position(state)
+
+ AutoTradeRunner.set_current_screen("auto")
+ AutoTradeRunner._last_text = None
+
+ await AutoTradeRunner.process_last_event_now()
+ await _refresh_auto_screen()
+
+
+def _execution_result_text(title: str, result, state) -> str:
+ _sync_state_from_position(state)
+
+ return (
+ f"✅ Debug {title}\n\n"
+ f"Action: {result.action}\n"
+ f"Can execute: {result.can_execute}\n"
+ f"Reason: {result.reason}\n\n"
+ f"Signal: {state.last_signal}\n"
+ f"Decision: {state.decision_status}\n\n"
+ f"Position: {state.position_side}\n"
+ f"Entry: {state.entry_price}\n"
+ f"Size: {state.position_size}\n"
+ f"PnL: {state.unrealized_pnl_usd}"
+ )
+
+
+def _debug_state_text(state) -> str:
+ runner_task_running = (
+ AutoTradeRunner._task is not None
+ and not AutoTradeRunner._task.done()
+ )
+
+ return (
+ "Debug Auto State\n\n"
+ f"Status: {state.status}\n"
+ f"Symbol: {state.symbol}\n"
+ f"Strategy: {state.strategy}\n"
+ f"Risk: {state.risk_percent}\n"
+ f"Leverage: {state.leverage}\n\n"
+ f"Signal: {state.last_signal}\n"
+ f"Repeats: {state.last_signal_repeat_count}\n"
+ f"Confidence: {state.last_signal_confidence:.2f}\n"
+ f"Decision: {state.decision_status}\n"
+ f"Ready: {state.is_signal_ready}\n"
+ f"Signal started at: {getattr(state, 'signal_started_at', None)}\n\n"
+ f"Runner\n"
+ f"Screen: {AutoTradeRunner._current_screen}\n"
+ f"Chat ID: {AutoTradeRunner._chat_id}\n"
+ f"Message ID: {AutoTradeRunner._message_id}\n"
+ f"Has bot: {AutoTradeRunner._bot is not None}\n"
+ f"Has render_text: {AutoTradeRunner._render_text is not None}\n"
+ f"Task running: {runner_task_running}\n\n"
+ f"Position\n"
+ f"Side: {state.position_side}\n"
+ f"Entry: {state.entry_price}\n"
+ f"Size: {state.position_size}\n"
+ f"PnL: {state.unrealized_pnl_usd}"
+ )
+
+
def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split()
@@ -45,34 +611,6 @@ def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str
return signal, confidence, repeat_count, None
-def _debug_help_text() -> str:
- return (
- "🧪 Debug commands\n\n"
- "Основная команда:\n"
- "/debug_signal BUY 0.95 3\n"
- "/debug_signal SELL 0.70 2\n"
- "/debug_signal HOLD 0.00 1\n\n"
- "Быстрые команды:\n"
- "/debug_signal — BUY 0.90 2\n"
- "/debug_ready — READY BUY\n"
- "/debug_state — текущее состояние\n"
- "/debug_help — список команд\n\n"
- "Priority тест:\n"
- "HIGH: confidence >= 0.80 и repeats >= 3\n"
- "MEDIUM: confidence >= 0.60 или repeats >= 2\n"
- "LOW: всё остальное"
- )
-
-
-@router.message(F.text == "/debug_help")
-async def debug_help(message: Message) -> None:
- if not _debug_enabled():
- await message.answer("Debug mode выключен.")
- return
-
- await message.answer(_debug_help_text())
-
-
@router.message(F.text.startswith("/debug_signal"))
async def debug_signal(message: Message) -> None:
if not _debug_enabled():
@@ -82,10 +620,7 @@ async def debug_signal(message: Message) -> None:
signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text)
if error is not None:
- await message.answer(
- f"⛔️ {error}\n\n"
- f"{_debug_help_text()}"
- )
+ await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}")
return
service = AutoTradeService()
@@ -99,13 +634,8 @@ async def debug_signal(message: Message) -> None:
if state.status == "OFF":
state.status = "RUNNING"
- await AutoTradeRunner._handle_important_event(state)
-
- execution_result = ExecutionEngine().process(state)
-
- await AutoTradeRunner.process_last_event_now()
-
- AutoTradeRunner.start()
+ _set_signal_started_at(state)
+ await _refresh_auto_screen()
JournalService().log_ui_info(
event_type="debug_signal_forced",
@@ -119,9 +649,6 @@ async def debug_signal(message: Message) -> None:
"decision_status": state.decision_status,
"confidence": state.last_signal_confidence,
"repeat_count": state.last_signal_repeat_count,
- "execution_action": execution_result.action,
- "execution_can_execute": execution_result.can_execute,
- "execution_reason": execution_result.reason,
},
)
@@ -130,10 +657,7 @@ async def debug_signal(message: Message) -> None:
f"Signal: {state.last_signal}\n"
f"Decision: {state.decision_status}\n"
f"Confidence: {state.last_signal_confidence:.2f}\n"
- f"Repeats: {state.last_signal_repeat_count}\n\n"
- f"Execution: {execution_result.action}\n"
- f"Can execute: {execution_result.can_execute}\n"
- f"Reason: {execution_result.reason}"
+ f"Repeats: {state.last_signal_repeat_count}"
)
@@ -144,30 +668,25 @@ async def debug_ready(message: Message) -> None:
return
service = AutoTradeService()
- state = service.debug_force_signal(
+ state = service.get_state()
+
+ _clear_debug_position(state)
+ _set_signal_state(
+ state=state,
signal="BUY",
+ seconds=15,
confidence=0.95,
- repeat_count=3,
- reason="DEBUG READY BUY 0.95 ×3",
+ decision_status="READY",
+ ready=True,
)
- if state.status == "OFF":
- state.status = "RUNNING"
-
- await AutoTradeRunner._handle_important_event(state)
-
- execution_result = ExecutionEngine().process(state)
-
- await AutoTradeRunner.process_last_event_now()
-
- AutoTradeRunner.start()
+ await _refresh_auto_screen()
await message.answer(
"✅ Debug READY создан\n\n"
f"Signal: {state.last_signal}\n"
f"Decision: {state.decision_status}\n"
- f"Execution: {execution_result.action}\n"
- f"Can execute: {execution_result.can_execute}"
+ f"Confidence: {state.last_signal_confidence:.2f}"
)
@@ -178,19 +697,5 @@ async def debug_state(message: Message) -> None:
return
state = AutoTradeService().get_state()
-
- await message.answer(
- "Debug Auto State\n\n"
- f"Status: {state.status}\n"
- f"Symbol: {state.symbol}\n"
- f"Strategy: {state.strategy}\n"
- f"Risk: {state.risk_percent}\n"
- f"Leverage: {state.leverage}\n\n"
- f"Signal: {state.last_signal}\n"
- f"Repeats: {state.last_signal_repeat_count}\n"
- f"Confidence: {state.last_signal_confidence:.2f}\n"
- f"Decision: {state.decision_status}\n\n"
- f"Position: {state.position_side}\n"
- f"Entry: {state.entry_price}\n"
- f"PnL: {state.unrealized_pnl_usd}"
- )
\ No newline at end of file
+ _sync_state_from_position(state)
+ await message.answer(_debug_state_text(state))
\ No newline at end of file
diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py
index 6749e8f..8c757f4 100644
--- a/app/src/telegram/handlers/system.py
+++ b/app/src/telegram/handlers/system.py
@@ -181,9 +181,9 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
state = AutoTradeService().get_state()
strategy_map = {
- "TREND": "📈 Trend Following",
- "GRID": "🧩 Grid Trading",
- "SCALP": "⚡ Scalping",
+ "TREND": "TREND FOLLOWING",
+ "GRID": "GRID TRADING",
+ "SCALP": "SCALPING",
}
strategy_ready = state.strategy is not None
@@ -191,10 +191,37 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
risk_ready = state.risk_percent is not None
leverage_ready = state.leverage is not None
- is_configured = strategy_ready and symbol_ready and risk_ready and leverage_ready
+ is_trend_strategy = (state.strategy or "").upper() == "TREND"
+ sl_ready = (
+ state.stop_loss_percent is not None
+ and state.stop_loss_percent > 0
+ )
+
+ is_configured = (
+ strategy_ready
+ and symbol_ready
+ and risk_ready
+ and leverage_ready
+ and (not is_trend_strategy or sl_ready)
+ )
strategy = strategy_map.get(state.strategy or "", "—")
- symbol = state.symbol or "—"
+
+ symbol = "—"
+
+ if state.symbol:
+ base = state.symbol.split("_", 1)[0].upper()
+
+ if "/" in base:
+ symbol = base.split("/", 1)[0]
+ else:
+ for suffix in ("USDT", "USD", "EUR", "BTC"):
+ if base.endswith(suffix) and len(base) > len(suffix):
+ base = base[: -len(suffix)]
+ break
+
+ symbol = base
+
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 = (
@@ -202,44 +229,70 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
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_controls = f"SL {sl} · TP {tp} · ML {ml}"
+ sl = (
+ f"{state.stop_loss_percent:g}%"
+ if state.stop_loss_percent 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 "👉"
+ 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"
+ )
+
+ 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 = (
+ "Защита позиции:\n"
+ f"{sl_icon} Stop Loss · {'required' if not sl_ready else sl}\n"
+ f"✅ Take Profit · {tp}\n"
+ f"✅ Max Loss · {ml}"
+ )
+ else:
+ risk_controls_block = (
+ "Защита позиции:\n"
+ f"✅ Stop Loss · {sl}\n"
+ f"✅ Take Profit · {tp}\n"
+ f"✅ Max Loss · {ml}"
+ )
config_status = (
"✅ Все параметры настроены"
if is_configured
- else "⛔️ Настрой все параметры"
+ else "⚠️ Настрой все параметры"
)
text = (
"🤖 Автоторговля\n\n"
"СИСТЕМА · Настройки\n\n"
- f"{strategy_icon} Стратегия: {strategy}\n"
- f"{symbol_icon} Инструмент: {symbol}\n"
- f"{risk_icon} Риск на сделку: {risk}\n"
- f"{leverage_icon} Плечо: {leverage}\n\n"
- f"✅ Max Reserved: {max_reserved}\n"
- f"✅ Risk Controls: {risk_controls}\n\n"
+ f"{strategy_icon} Стратегия: {strategy}\n"
+ f"{symbol_icon} Актив: {symbol}\n"
+ f"{risk_icon} Риск на сделку: {risk}\n"
+ f"{leverage_icon} Плечо: {leverage}\n\n"
+ f"✅ Лимит на сделку: {max_reserved}\n\n"
+ f"{risk_controls_block}\n\n"
f"{config_status}"
)
- if not is_configured:
- text += "\n\nВыберите настройку:"
-
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_risk")
+ builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
- builder.button(text="⚠️ Risk Controls", callback_data="auto:risk")
- builder.button(text="🏦 Max Reserved", callback_data="settings:auto_max_reserved")
+ 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)
@@ -294,17 +347,36 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
settings = load_settings()
text = (
- "📈 Инструмент\n\n"
+ "💱 Актив\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
- "Выберите инструмент:"
+ "Выберите актив:"
)
builder = InlineKeyboardBuilder()
- builder.button(text=settings.default_symbol, callback_data=f"settings:auto_symbol:{settings.default_symbol}")
- builder.button(text="BTCUSDT", callback_data="settings:auto_symbol:BTCUSDT")
- builder.button(text="ETHUSDT", callback_data="settings:auto_symbol:ETHUSDT")
+
+ builder.button(
+ text="BTC",
+ callback_data="settings:auto_symbol:BTC/USD_LEVERAGE",
+ )
+
+ builder.button(
+ text="ETH",
+ callback_data="settings:auto_symbol:ETH/USD_LEVERAGE",
+ )
+
+ builder.button(
+ text="LTC",
+ callback_data="settings:auto_symbol:LTC/USD_LEVERAGE",
+ )
+
+ builder.button(
+ text="XRP",
+ callback_data="settings:auto_symbol:XRP/USD_LEVERAGE",
+ )
+
builder.button(text="⬅️ Назад", callback_data="settings:auto")
- builder.adjust(1, 2, 1)
+
+ builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
await callback.answer()
@@ -319,7 +391,7 @@ async def set_auto_symbol(callback: CallbackQuery) -> None:
await open_auto_settings(callback)
AutoTradeRunner.set_current_screen("settings_auto")
- await callback.answer("Инструмент обновлён")
+ await callback.answer("Актив обновлён")
@router.callback_query(F.data == "settings:auto_risk")
@@ -330,7 +402,7 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
return
text = (
- "🛡️ Риск\n\n"
+ "🛡️ Риск на сделку\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
"Выберите риск на сделку:"
)
@@ -407,7 +479,7 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
text = (
"💹 Торговля\n\n"
"СИСТЕМА · Настройки\n\n"
- "Инструмент: —\n"
+ "Актив: —\n"
"Тип ордера по умолчанию: —\n"
"Пресеты количества: —\n\n"
"В разработке."
@@ -618,7 +690,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
reply_markup=builder.as_markup(),
)
await callback.answer()
-
+
@router.callback_query(F.data == "settings:auto_max_reserved")
async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
@@ -629,7 +701,7 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
return
text = (
- "🏦 Max Reserved\n\n"
+ "🏦 Лимит на сделку\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
"Максимальная доля баланса, которую можно зарезервировать под позицию:"
)
diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py
index 31684ce..0d98e16 100644
--- a/app/src/trading/auto/runner.py
+++ b/app/src/trading/auto/runner.py
@@ -26,7 +26,7 @@ class AutoTradeRunner:
_current_screen: str | None = None
_analysis_interval_seconds = 5
- _ui_interval_seconds = 60
+ _ui_interval_seconds = 5
_last_text: str | None = None
_last_ui_refresh_at: float = 0.0
@@ -550,17 +550,66 @@ class AutoTradeRunner:
except (TypeError, ValueError):
return "—"
+ @classmethod
+ def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
+ try:
+ JournalService().log_ui_info(
+ event_type="auto_screen_refresh_skipped",
+ message=f"Auto screen refresh skipped: {reason}",
+ screen="auto",
+ action="refresh_screen",
+ payload=payload or {},
+ )
+ except Exception:
+ pass
+
+ @classmethod
+ def _log_refresh_success(cls, payload: dict | None = None) -> None:
+ try:
+ JournalService().log_ui_info(
+ event_type="auto_screen_refreshed",
+ message="Auto screen refreshed.",
+ screen="auto",
+ action="refresh_screen",
+ payload=payload or {},
+ )
+ except Exception:
+ pass
+
+ @classmethod
+ def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None:
+ try:
+ JournalService().log_error(
+ "auto_screen_refresh_error",
+ f"Auto screen refresh error: {reason}",
+ payload or {},
+ )
+ except Exception:
+ pass
+
@classmethod
async def _refresh_screen(cls, *, force: bool = False) -> None:
if cls._current_screen != "auto":
+ cls._log_refresh_skip("current_screen_not_auto")
return
now = time.monotonic()
if now < cls._retry_after_until:
+ cls._log_refresh_skip(
+ "retry_after_active",
+ {"retry_after_until": cls._retry_after_until, "now": now},
+ )
return
if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds:
+ cls._log_refresh_skip(
+ "ui_interval_not_reached",
+ {
+ "elapsed": round(now - cls._last_ui_refresh_at, 2),
+ "interval": cls._ui_interval_seconds,
+ },
+ )
return
if not all(
@@ -572,11 +621,22 @@ class AutoTradeRunner:
cls._render_markup,
]
):
+ cls._log_refresh_skip(
+ "screen_not_registered",
+ {
+ "has_bot": cls._bot is not None,
+ "chat_id": cls._chat_id,
+ "message_id": cls._message_id,
+ "has_render_text": cls._render_text is not None,
+ "has_render_markup": cls._render_markup is not None,
+ },
+ )
return
text = cls._render_text()
if text == cls._last_text:
+ cls._log_refresh_skip("text_not_changed")
return
try:
@@ -589,8 +649,23 @@ class AutoTradeRunner:
cls._last_text = text
cls._last_ui_refresh_at = now
+ cls._log_refresh_success(
+ {
+ "chat_id": cls._chat_id,
+ "message_id": cls._message_id,
+ "text_length": len(text),
+ }
+ )
+
except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
+ cls._log_refresh_error(
+ "telegram_retry_after",
+ {
+ "retry_after": exc.retry_after,
+ "retry_after_until": cls._retry_after_until,
+ },
+ )
except TelegramBadRequest as exc:
error_text = str(exc).lower()
@@ -598,6 +673,7 @@ class AutoTradeRunner:
if "message is not modified" in error_text:
cls._last_text = text
cls._last_ui_refresh_at = now
+ cls._log_refresh_skip("telegram_message_not_modified")
return
if "message to edit not found" in error_text:
@@ -605,7 +681,19 @@ class AutoTradeRunner:
cls._render_text = None
cls._render_markup = None
cls._last_text = None
+ cls._log_refresh_error(
+ "telegram_message_to_edit_not_found",
+ {"error": str(exc)},
+ )
return
- except Exception:
- pass
\ No newline at end of file
+ cls._log_refresh_error(
+ "telegram_bad_request",
+ {"error": str(exc)},
+ )
+
+ except Exception as exc:
+ cls._log_refresh_error(
+ "unexpected_refresh_error",
+ {"error": str(exc)},
+ )
\ No newline at end of file
diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py
index dbb1a17..1e3f9b8 100644
--- a/app/src/trading/auto/service.py
+++ b/app/src/trading/auto/service.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import time
from datetime import datetime
from src.core.config import load_settings
@@ -49,6 +50,9 @@ class AutoTradeService:
previous_signal = state.last_signal
previous_decision_status = state.decision_status
+
+ if previous_signal != normalized_signal or state.signal_started_at is None:
+ state.signal_started_at = time.monotonic()
state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count
@@ -85,6 +89,18 @@ class AutoTradeService:
return state
+ # установить капитал, выделенный под автоторговлю
+ def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
+ state = self.get_state()
+
+ if value <= 0:
+ value = 1000.0
+
+ state.allocated_balance_usd = value
+ state.execution_block_reason = None
+ state.execution_size_adjustment_reason = None
+ return state
+
# получить текущее состояние автоторговли
def get_state(self) -> AutoTradeState:
if not self._state.symbol:
@@ -264,6 +280,7 @@ class AutoTradeService:
state.is_signal_confirmed = False
state.is_signal_ready = False
state.execution_block_reason = None
+ state.signal_started_at = None
# собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext:
@@ -397,6 +414,9 @@ class AutoTradeService:
previous_signal = state.last_signal
previous_decision_status = state.decision_status
+ if previous_signal != signal or state.signal_started_at is None:
+ state.signal_started_at = time.monotonic()
+
state.last_signal = signal
state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = confidence
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index 324e44c..bb04d2c 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -11,13 +11,13 @@ class AutoTradeState:
status: str = "OFF"
# выбранная стратегия: TREND / GRID / SCALP
- strategy: str | None = None
+ strategy: str | None = "TREND"
# торговый инструмент
- symbol: str = ""
+ symbol: str = "BTC/USD_LEVERAGE"
# риск на одну сделку в %
- risk_percent: float | None = None
+ risk_percent: float | None = 1.0
# текущий PnL
pnl_usd: float = 0.0
@@ -37,6 +37,9 @@ class AutoTradeState:
# причина последнего сигнала
last_signal_reason: str | None = None
+ # время начала текущего сигнала, monotonic timestamp
+ signal_started_at: float | None = None
+
# статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED
decision_status: str = "WAITING"
@@ -68,7 +71,7 @@ class AutoTradeState:
leverage: float | None = 2.0
# stop loss по движению цены в %
- stop_loss_percent: float | None = None
+ stop_loss_percent: float | None = 1.0
# take profit по движению цены в %
take_profit_percent: float | None = None
@@ -83,4 +86,10 @@ class AutoTradeState:
execution_block_reason: str | None = None
# причина авто-уменьшения размера позиции
- execution_size_adjustment_reason: str | None = None
\ No newline at end of file
+ execution_size_adjustment_reason: str | None = None
+
+ # капитал, выделенный только под AutoTrade
+ allocated_balance_usd: float = 1000.0
+
+ # зафиксированный результат закрытых paper-сделок
+ realized_pnl_usd: float = 0.0
\ No newline at end of file
diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py
index b6f86f6..fd45141 100644
--- a/app/src/trading/execution/engine.py
+++ b/app/src/trading/execution/engine.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import math
from datetime import datetime
from src.core.event_bus import EventBus
@@ -14,6 +15,7 @@ from src.trading.position.state import PositionState
class ExecutionEngine:
_position = PositionState()
+ _size_precision = 5
def get_position(self) -> PositionState:
return type(self)._position
@@ -58,8 +60,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Позиция уже открыта.")
try:
- ticker = ExchangeService().get_price(state.symbol)
- entry_price = ticker.price
+ entry_price = self._entry_price_for_side(state.symbol, side)
except Exception as exc:
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
@@ -72,13 +73,22 @@ class ExecutionEngine:
False,
"Позиция не открыта: невозможно рассчитать size без Stop Loss.",
)
-
+
size = self._adjust_size_by_margin_limit(
state=state,
entry_price=entry_price,
size=size,
)
+ size = self._round_order_size(size)
+
+ if size <= 0:
+ return ExecutionDecision(
+ "NONE",
+ False,
+ "Позиция не открыта: итоговый size равен 0.",
+ )
+
type(self)._position = PositionState(
side=side,
symbol=state.symbol,
@@ -105,6 +115,7 @@ class ExecutionEngine:
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"opened_at": now,
+ "pricing": "ask_for_long_bid_for_short",
}
JournalService().log_ui_info(
@@ -131,14 +142,14 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет направления для flip.")
try:
- ticker = ExchangeService().get_price(state.symbol)
- flip_price = ticker.price
+ exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
+ new_entry_price = self._entry_price_for_side(state.symbol, new_side)
except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}")
now = self._now_time()
- pnl = self._calculate_pnl(flip_price)
- new_size = self._calculate_position_size(state, entry_price=flip_price)
+ pnl = self._calculate_pnl(exit_price)
+ new_size = self._calculate_position_size(state, entry_price=new_entry_price)
if new_size <= 0:
return ExecutionDecision(
@@ -146,13 +157,24 @@ class ExecutionEngine:
False,
"Flip отменён: невозможно рассчитать size без Stop Loss.",
)
-
+
new_size = self._adjust_size_by_margin_limit(
state=state,
- entry_price=flip_price,
+ entry_price=new_entry_price,
size=new_size,
)
+ new_size = self._round_order_size(new_size)
+
+ if new_size <= 0:
+ return ExecutionDecision(
+ "NONE",
+ False,
+ "Flip отменён: итоговый size равен 0.",
+ )
+
+ state.realized_pnl_usd += pnl
+
old_side = position.side
old_entry_price = position.entry_price
old_size = position.size
@@ -162,7 +184,7 @@ class ExecutionEngine:
type(self)._position = PositionState(
side=new_side,
symbol=state.symbol,
- entry_price=flip_price,
+ entry_price=new_entry_price,
size=new_size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
@@ -180,8 +202,8 @@ class ExecutionEngine:
"new_side": new_side,
"side": new_side,
"entry_price": old_entry_price,
- "exit_price": flip_price,
- "new_entry_price": flip_price,
+ "exit_price": exit_price,
+ "new_entry_price": new_entry_price,
"old_size": old_size,
"new_size": new_size,
"size": new_size,
@@ -195,6 +217,7 @@ class ExecutionEngine:
"opened_at": old_opened_at,
"closed_at": now,
"new_opened_at": now,
+ "pricing": "exit_by_side_then_entry_by_side",
}
JournalService().log_ui_info(
@@ -231,13 +254,14 @@ class ExecutionEngine:
exit_price = forced_exit_price
else:
try:
- ticker = ExchangeService().get_price(state.symbol)
- exit_price = ticker.price
+ exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
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)
+ state.realized_pnl_usd += pnl
+
now = self._now_time()
payload = {
@@ -258,6 +282,7 @@ class ExecutionEngine:
"is_forced": forced_reason is not None,
"opened_at": position.opened_at,
"closed_at": now,
+ "pricing": "bid_for_long_exit_ask_for_short_exit",
}
JournalService().log_ui_info(
@@ -293,8 +318,7 @@ class ExecutionEngine:
return None
try:
- ticker = ExchangeService().get_price(position.symbol or state.symbol)
- current_price = ticker.price
+ current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
except Exception:
return None
@@ -327,34 +351,19 @@ class ExecutionEngine:
return None
- def _is_stop_loss_hit(
- self,
- state: AutoTradeState,
- price_move_percent: float,
- ) -> bool:
+ def _is_stop_loss_hit(self, state: AutoTradeState, price_move_percent: float) -> bool:
if state.stop_loss_percent is None:
return False
-
return price_move_percent <= -abs(state.stop_loss_percent)
- def _is_take_profit_hit(
- self,
- state: AutoTradeState,
- price_move_percent: float,
- ) -> bool:
+ def _is_take_profit_hit(self, state: AutoTradeState, price_move_percent: float) -> bool:
if state.take_profit_percent is None:
return False
-
return price_move_percent >= abs(state.take_profit_percent)
- def _is_max_loss_hit(
- self,
- state: AutoTradeState,
- unrealized_pnl: float,
- ) -> bool:
+ def _is_max_loss_hit(self, state: AutoTradeState, unrealized_pnl: float) -> bool:
if state.max_loss_usd is None:
return False
-
return unrealized_pnl <= -abs(state.max_loss_usd)
def _calculate_price_move_percent(self, current_price: float) -> float:
@@ -371,7 +380,7 @@ class ExecutionEngine:
return round(((entry - current_price) / entry) * 100, 4)
return 0.0
-
+
def _should_flip_position(self, state: AutoTradeState) -> bool:
position = type(self)._position
@@ -403,8 +412,7 @@ class ExecutionEngine:
return
try:
- ticker = ExchangeService().get_price(position.symbol or state.symbol)
- current_price = ticker.price
+ current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
except Exception:
self._sync_state_from_position(state)
return
@@ -430,15 +438,14 @@ class ExecutionEngine:
if price is None:
try:
- ticker = ExchangeService().get_price(state.symbol)
- price = ticker.price
+ price = self._signal_entry_price(state)
except Exception:
return 0.0
if price <= 0:
return 0.0
- balance_usd = 1000.0
+ balance_usd = state.allocated_balance_usd
target_risk_usd = balance_usd * (state.risk_percent / 100)
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
@@ -446,8 +453,7 @@ class ExecutionEngine:
return 0.0
size = target_risk_usd / stop_loss_distance_usd
-
- return round(size, 8)
+ return self._round_size(size)
def _adjust_size_by_margin_limit(
self,
@@ -462,26 +468,85 @@ class ExecutionEngine:
state.execution_size_adjustment_reason = None
if max_percent is None or max_percent <= 0:
- return round(size, 8)
+ return self._round_size(size)
leverage = state.leverage or 1.0
if leverage <= 0 or entry_price <= 0:
state.execution_block_reason = "Invalid leverage or entry price."
return 0.0
- balance_usd = 1000.0
+ balance_usd = state.allocated_balance_usd
max_reserved_usd = balance_usd * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / entry_price
if size <= max_size:
- return round(size, 8)
+ return self._round_size(size)
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
+ return self._round_size(max_size)
+
+ def _signal_entry_price(self, state: AutoTradeState) -> float:
+ if state.last_signal == "BUY":
+ return self._entry_price_for_side(state.symbol, "LONG")
+
+ if state.last_signal == "SELL":
+ return self._entry_price_for_side(state.symbol, "SHORT")
+
+ return self._market_last_price(state.symbol)
+
+ def _entry_price_for_side(self, symbol: str, side: str) -> float:
+ snapshot = ExchangeService().get_market_snapshot(symbol)
+
+ if side == "LONG":
+ return self._snapshot_price(snapshot, "ask_price", "last_price")
+
+ if side == "SHORT":
+ return self._snapshot_price(snapshot, "bid_price", "last_price")
+
+ return self._snapshot_price(snapshot, "last_price")
+
+ def _exit_price_for_side(self, symbol: str, side: str) -> float:
+ snapshot = ExchangeService().get_market_snapshot(symbol)
+
+ if side == "LONG":
+ return self._snapshot_price(snapshot, "bid_price", "last_price")
+
+ if side == "SHORT":
+ return self._snapshot_price(snapshot, "ask_price", "last_price")
+
+ return self._snapshot_price(snapshot, "last_price")
+
+ def _market_last_price(self, symbol: str) -> float:
+ snapshot = ExchangeService().get_market_snapshot(symbol)
+ return self._snapshot_price(snapshot, "last_price")
+
+ def _snapshot_price(
+ self,
+ snapshot: dict[str, object],
+ primary_key: str,
+ fallback_key: str | None = None,
+ ) -> float:
+ raw_price = snapshot.get(primary_key)
+
+ if raw_price is None and fallback_key is not None:
+ raw_price = snapshot.get(fallback_key)
+
+ if raw_price is None:
+ raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
+
+ price = float(raw_price)
+
+ if price <= 0:
+ raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
+
+ return price
+
+ def _round_size(self, size: float) -> float:
+ factor = 10 ** self._size_precision
+ return math.floor(float(size) * factor) / factor
- return round(max_size, 8)
-
def _calculate_pnl(self, current_price: float) -> float:
position = type(self)._position
@@ -504,5 +569,9 @@ 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")
\ No newline at end of file
diff --git a/app/src/trading/strategies/registry.py b/app/src/trading/strategies/registry.py
index c861724..13dda73 100644
--- a/app/src/trading/strategies/registry.py
+++ b/app/src/trading/strategies/registry.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from src.trading.strategies.base import BaseStrategy
from src.trading.strategies.hold import HoldStrategy
+from src.trading.strategies.scalp import ScalpStrategy
from src.trading.strategies.trend import TrendStrategy
@@ -13,7 +14,7 @@ class StrategyRegistry:
"HOLD": HoldStrategy(),
"TREND": TrendStrategy(),
"GRID": HoldStrategy(),
- "SCALP": HoldStrategy(),
+ "SCALP": ScalpStrategy(),
}
# получить стратегию по имени
diff --git a/app/src/trading/strategies/scalp.py b/app/src/trading/strategies/scalp.py
new file mode 100644
index 0000000..d1af93f
--- /dev/null
+++ b/app/src/trading/strategies/scalp.py
@@ -0,0 +1,156 @@
+# app/src/trading/strategies/scalp.py
+
+from __future__ import annotations
+
+from src.integrations.exchange.service import ExchangeService
+from src.trading.strategies.base import StrategyContext
+from src.trading.strategies.signals import SignalResult, SignalType
+
+
+class ScalpStrategy:
+ name = "SCALP"
+
+ _price_window: dict[str, list[float]] = {}
+
+ # короткое окно = быстрая реакция
+ _window_size = 4
+
+ # ниже порог = чувствительнее TREND
+ _threshold_percent = 0.02
+
+ # для scalp допускаем чуть больше шума
+ _min_direction_ratio = 0.55
+
+ def analyze(self, context: StrategyContext) -> SignalResult:
+ try:
+ ticker = ExchangeService().get_price(context.symbol)
+ except Exception as exc:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Не удалось получить рыночную цену. Безопасный HOLD.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": context.symbol,
+ "error": str(exc),
+ },
+ )
+
+ symbol = ticker.symbol
+ current_price = float(ticker.price)
+
+ prices = self._price_window.setdefault(symbol, [])
+ prices.append(current_price)
+
+ if len(prices) > self._window_size:
+ prices.pop(0)
+
+ if len(prices) < self._window_size:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Недостаточно данных для SCALP.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "price": current_price,
+ "window_size": len(prices),
+ "required_window_size": self._window_size,
+ },
+ )
+
+ first_price = prices[0]
+ last_price = prices[-1]
+
+ if first_price <= 0:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Некорректная стартовая цена в окне SCALP.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "prices": prices,
+ },
+ )
+
+ change_percent = ((last_price - first_price) / first_price) * 100
+ direction_ratio = self._direction_ratio(prices, change_percent)
+
+ payload = {
+ "strategy": self.name,
+ "symbol": symbol,
+ "first_price": first_price,
+ "current_price": last_price,
+ "change_percent": round(change_percent, 5),
+ "direction_ratio": round(direction_ratio, 3),
+ "window_size": len(prices),
+ "threshold_percent": self._threshold_percent,
+ "min_direction_ratio": self._min_direction_ratio,
+ }
+
+ if (
+ change_percent >= self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
+ return SignalResult(
+ signal=SignalType.BUY,
+ reason="Быстрый краткосрочный импульс вверх.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
+ )
+
+ if (
+ change_percent <= -self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
+ return SignalResult(
+ signal=SignalType.SELL,
+ reason="Быстрый краткосрочный импульс вниз.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
+ )
+
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="SCALP-импульс недостаточно сильный.",
+ confidence=0.0,
+ payload=payload,
+ )
+
+ def _direction_ratio(self, prices: list[float], change_percent: float) -> float:
+ if len(prices) < 2:
+ return 0.0
+
+ up_moves = 0
+ down_moves = 0
+
+ for previous_price, current_price in zip(prices, prices[1:]):
+ if current_price > previous_price:
+ up_moves += 1
+ elif current_price < previous_price:
+ down_moves += 1
+
+ total_moves = max(1, len(prices) - 1)
+
+ if change_percent >= 0:
+ return up_moves / total_moves
+
+ return down_moves / total_moves
+
+ def _calculate_confidence(
+ self,
+ change_percent: float,
+ direction_ratio: float,
+ ) -> float:
+ strength = abs(change_percent) / self._threshold_percent
+
+ if strength < 1:
+ return 0.0
+
+ strength_score = min(1.0, strength / 2)
+ direction_score = min(1.0, direction_ratio)
+
+ confidence = 0.35 + (strength_score * 0.4) + (direction_score * 0.25)
+
+ return round(min(1.0, confidence), 2)
\ No newline at end of file
diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py
index ed28ef0..93eb432 100644
--- a/app/src/trading/strategies/trend.py
+++ b/app/src/trading/strategies/trend.py
@@ -10,28 +10,24 @@ from src.trading.strategies.signals import SignalResult, SignalType
class TrendStrategy:
name = "TREND"
- _last_prices: dict[str, float] = {}
- _threshold_percent = 0.02
+ _price_window: dict[str, list[float]] = {}
- # рассчитать уверенность сигнала по силе движения цены
- def _calculate_confidence(self, change_percent: float) -> float:
- strength = abs(change_percent) / self._threshold_percent
+ # длиннее окно = меньше шума
+ _window_size = 8
- if strength < 1:
- return 0.0
+ # общий порог изменения за окно
+ _threshold_percent = 0.05
- confidence = 0.35 + ((strength - 1) / 2) * 0.65
+ # сколько движений внутри окна должно быть в сторону сигнала
+ _min_direction_ratio = 0.6
- return round(min(1.0, confidence), 2)
-
- # анализ простого тренда по изменению цены
def analyze(self, context: StrategyContext) -> SignalResult:
try:
- ticker = ExchangeService().get_price(context.symbol)
+ snapshot = ExchangeService().get_market_snapshot(context.symbol)
except Exception as exc:
return SignalResult(
signal=SignalType.HOLD,
- reason="Не удалось получить рыночную цену. Безопасный HOLD.",
+ reason="Не удалось получить рыночный snapshot. Безопасный HOLD.",
confidence=0.0,
payload={
"strategy": self.name,
@@ -40,63 +36,159 @@ class TrendStrategy:
},
)
- symbol = ticker.symbol
- current_price = ticker.price
- previous_price = self._last_prices.get(symbol)
+ symbol = str(snapshot.get("symbol") or context.symbol)
+ current_price = self._analysis_price(snapshot)
- self._last_prices[symbol] = current_price
-
- if previous_price is None or previous_price <= 0:
+ if current_price <= 0:
return SignalResult(
signal=SignalType.HOLD,
- reason="Недостаточно данных для определения тренда.",
+ reason="Некорректная рыночная цена. Безопасный HOLD.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "snapshot": snapshot,
+ },
+ )
+
+ prices = self._price_window.setdefault(symbol, [])
+ prices.append(current_price)
+
+ if len(prices) > self._window_size:
+ prices.pop(0)
+
+ if len(prices) < self._window_size:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Недостаточно данных для анализа тренда.",
confidence=0.0,
payload={
"strategy": self.name,
"symbol": symbol,
"price": current_price,
+ "window_size": len(prices),
+ "required_window_size": self._window_size,
},
)
- change_percent = ((current_price - previous_price) / previous_price) * 100
+ first_price = prices[0]
+ last_price = prices[-1]
- if change_percent >= self._threshold_percent:
+ if first_price <= 0:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Некорректная стартовая цена в окне.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "prices": prices,
+ },
+ )
+
+ change_percent = ((last_price - first_price) / first_price) * 100
+ direction_ratio = self._direction_ratio(prices, change_percent)
+
+ payload = {
+ "strategy": self.name,
+ "symbol": symbol,
+ "analysis_price": last_price,
+ "first_price": first_price,
+ "current_price": last_price,
+ "last_price": snapshot.get("last_price"),
+ "bid_price": snapshot.get("bid_price"),
+ "ask_price": snapshot.get("ask_price"),
+ "change_percent": round(change_percent, 5),
+ "direction_ratio": round(direction_ratio, 3),
+ "window_size": len(prices),
+ "threshold_percent": self._threshold_percent,
+ "min_direction_ratio": self._min_direction_ratio,
+ }
+
+ if (
+ change_percent >= self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
return SignalResult(
signal=SignalType.BUY,
- reason="Цена растёт выше порога тренда.",
- confidence=self._calculate_confidence(change_percent),
- payload={
- "strategy": self.name,
- "symbol": symbol,
- "previous_price": previous_price,
- "current_price": current_price,
- "change_percent": round(change_percent, 5),
- },
+ reason="Устойчивый рост цены в окне TREND.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
)
- if change_percent <= -self._threshold_percent:
+ if (
+ change_percent <= -self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
return SignalResult(
signal=SignalType.SELL,
- reason="Цена падает ниже порога тренда.",
- confidence=self._calculate_confidence(change_percent),
- payload={
- "strategy": self.name,
- "symbol": symbol,
- "previous_price": previous_price,
- "current_price": current_price,
- "change_percent": round(change_percent, 5),
- },
+ reason="Устойчивое снижение цены в окне TREND.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
)
return SignalResult(
signal=SignalType.HOLD,
- reason="Изменение цены ниже порога тренда.",
+ reason="Тренд недостаточно устойчивый.",
confidence=0.0,
- payload={
- "strategy": self.name,
- "symbol": symbol,
- "previous_price": previous_price,
- "current_price": current_price,
- "change_percent": round(change_percent, 5),
- },
- )
\ No newline at end of file
+ payload=payload,
+ )
+
+ def _analysis_price(self, snapshot: dict[str, object]) -> float:
+ bid = self._safe_float(snapshot.get("bid_price"))
+ ask = self._safe_float(snapshot.get("ask_price"))
+
+ if bid is not None and ask is not None and bid > 0 and ask > 0:
+ return (bid + ask) / 2
+
+ last = self._safe_float(snapshot.get("last_price"))
+ if last is not None:
+ return last
+
+ return 0.0
+
+ def _safe_float(self, value: object) -> float | None:
+ if value is None:
+ return None
+
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+ def _direction_ratio(self, prices: list[float], change_percent: float) -> float:
+ if len(prices) < 2:
+ return 0.0
+
+ up_moves = 0
+ down_moves = 0
+
+ for previous_price, current_price in zip(prices, prices[1:]):
+ if current_price > previous_price:
+ up_moves += 1
+ elif current_price < previous_price:
+ down_moves += 1
+
+ total_moves = max(1, len(prices) - 1)
+
+ if change_percent >= 0:
+ return up_moves / total_moves
+
+ return down_moves / total_moves
+
+ def _calculate_confidence(
+ self,
+ change_percent: float,
+ direction_ratio: float,
+ ) -> float:
+ strength = abs(change_percent) / self._threshold_percent
+
+ if strength < 1:
+ return 0.0
+
+ strength_score = min(1.0, strength / 3)
+ direction_score = min(1.0, direction_ratio)
+
+ confidence = 0.3 + (strength_score * 0.4) + (direction_score * 0.3)
+
+ return round(min(1.0, confidence), 2)
\ No newline at end of file
diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md
index c24a6f8..614a349 100644
--- a/docs/roadmap/master-roadmap.md
+++ b/docs/roadmap/master-roadmap.md
@@ -242,6 +242,30 @@
- risk_percent теперь реально влияет на размер позиции
- flip теперь проходит через margin protection
+#### 07.4.3.14 — Auto UI, Realistic Pricing & Debug Live Tools ✅
+- redesigned RUNNING auto-trading UI
+- HOLD / BUY / SELL / READY state separation
+- compact signal rendering with real duration
+- confidence hidden for HOLD state
+- direction-aware LONG / SHORT UI blocks
+- compact active position rendering
+- removed zero-value UI noise without position
+- realistic bid / ask pricing in auto UI
+- realistic bid / ask execution pricing
+- TREND strategy switched to mid-price analysis
+- corrected own funds / margin calculations
+- safer size rounding for margin protection
+- signal_started_at support for real-time duration tracking
+- improved auto screen refresh handling
+- live UI refresh diagnostics in AutoTradeRunner
+- new debug UI-state commands
+- new paper execution debug commands
+- automatic flip direction detection
+- live paper execution monitoring commands
+- integration testing flow for SL / TP / ML
+- integration testing flow for execution alerts
+- preparation for isolated debug runtime architecture
+
### 07.4.4
⏳ Grid Strategy
diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md
index f9ec6e3..124ae3e 100644
--- a/docs/roadmap/stage-07-auto-trading-roadmap.md
+++ b/docs/roadmap/stage-07-auto-trading-roadmap.md
@@ -228,6 +228,31 @@
- risk_percent теперь реально влияет на размер позиции
- flip теперь проходит через margin protection
+#### 07.4.3.14 — Auto UI, Realistic Pricing & Debug Live Tools ✅
+
+- redesigned RUNNING auto-trading UI
+- HOLD / BUY / SELL / READY state separation
+- compact signal rendering with real duration
+- confidence hidden for HOLD state
+- direction-aware LONG / SHORT UI blocks
+- compact active position rendering
+- removed zero-value UI noise without position
+- realistic bid / ask pricing in auto UI
+- realistic bid / ask execution pricing
+- TREND strategy switched to mid-price analysis
+- corrected own funds / margin calculations
+- safer size rounding for margin protection
+- signal_started_at support for real-time duration tracking
+- improved auto screen refresh handling
+- live UI refresh diagnostics in AutoTradeRunner
+- new debug UI-state commands
+- new paper execution debug commands
+- automatic flip direction detection
+- live paper execution monitoring commands
+- integration testing flow for SL / TP / ML
+- integration testing flow for execution alerts
+- preparation for isolated debug runtime architecture
+
---
### 07.4.4
diff --git a/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md b/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md
new file mode 100644
index 0000000..6b15e40
--- /dev/null
+++ b/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md
@@ -0,0 +1,343 @@
+# 07.4.3.14 — Auto Trading UI, Realistic Pricing & Debug Live Tools
+
+## Цель
+
+Привести экран автоторговли к более понятному trading-terminal формату и сделать расчёты paper execution ближе к реальности:
+
+- обновить UI блока автоторговли
+- разделить состояния HOLD / BUY / SELL / READY
+- использовать bid / ask цены для входа и выхода
+- улучшить отображение позиции
+- добавить live debug-команды для проверки paper execution
+- подготовить основу для последующей изоляции debug-режима
+
+---
+
+## Что было раньше
+
+Ранее экран `Автоторговля — Работает` показывал технические данные:
+
+- `HOLD ×N`
+- `Зарезервировано · $ 0`
+- `P&L · $ 0`
+- confidence даже для HOLD
+- расчёты от одной цены `lastPrice`
+
+Это создавало лишний шум и не отражало реальную механику исполнения.
+
+Также debug-команды смешивали UI-проверку и execution-проверку.
+
+---
+
+## Что реализовано
+
+## 1. Новый формат экрана RUNNING
+
+Для состояния без позиции экран стал компактнее.
+
+Было:
+
+```text
+📡 Ожидание сигнала
+🟡 HOLD ×18
+Уверенность · 0.00
+```
+
+Стало:
+
+```text
+Сигнал 🟡 HOLD · 5м 35с
+```
+
+Для HOLD больше не отображается confidence, так как он не несёт торгового смысла.
+
+---
+
+## 2. BUY / SELL signal UI
+
+Для BUY:
+
+```text
+Сигнал 🟢 BUY · 12с
+Уверенность · 0.74
+```
+
+После подтверждения:
+
+```text
+Сигнал 🟢 BUY · READY
+Уверенность · 0.88
+```
+
+Для SELL:
+
+```text
+Сигнал 🔴 SELL · 9с
+Уверенность · 0.71
+```
+
+После подтверждения:
+
+```text
+Сигнал 🔴 SELL · READY
+Уверенность · 0.91
+```
+
+---
+
+## 3. Direction-aware order block
+
+Для HOLD:
+
+```text
+BTC · Trend · x2
+Цена · $ ...
+```
+
+Для BUY:
+
+```text
+🟢 BTC · Trend · LONG x2
+Цена входа · $ ...
+```
+
+Для SELL:
+
+```text
+🔴 BTC · Trend · SHORT x2
+Цена входа · $ ...
+```
+
+---
+
+## 4. Убран UI-шум без открытой позиции
+
+Если позиция не открыта, экран больше не показывает:
+
+```text
+Зарезервировано · $ 0
+P&L · $ 0
+```
+
+Эти строки остаются только для активной позиции.
+
+---
+
+## 5. Realistic bid / ask pricing в UI
+
+UI теперь использует market snapshot:
+
+```python
+ExchangeService().get_market_snapshot(symbol)
+```
+
+Правила:
+
+```text
+HOLD → last_price
+BUY / LONG → ask_price
+SELL / SHORT → bid_price
+```
+
+Если bid / ask недоступны, используется fallback на `last_price`.
+
+---
+
+## 6. TREND strategy переведена на mid price
+
+Стратегия TREND теперь анализирует рынок по:
+
+```text
+mid_price = (bid_price + ask_price) / 2
+```
+
+Fallback:
+
+```text
+last_price
+```
+
+Это делает сигнал более стабильным и не искажает анализ spread-ом.
+
+---
+
+## 7. Realistic paper execution pricing
+
+ExecutionEngine теперь использует более реалистичные цены:
+
+```text
+LONG entry → ask_price
+SHORT entry → bid_price
+LONG exit → bid_price
+SHORT exit → ask_price
+```
+
+Flip теперь состоит из:
+
+```text
+exit текущей позиции по стороне
++
+entry новой позиции по стороне
+```
+
+---
+
+## 8. Own funds / margin calculation fix
+
+Исправлен расчёт собственных средств:
+
+```text
+own_funds = position_notional / leverage
+```
+
+А не через статичный процент от allocated balance.
+
+Также size округляется вниз, чтобы reserved margin не превышал лимит.
+
+---
+
+## 9. Signal duration
+
+Добавлена поддержка реального времени удержания сигнала через:
+
+```python
+signal_started_at
+```
+
+Если timestamp недоступен, используется fallback:
+
+```text
+last_signal_repeat_count × 5 sec
+```
+
+---
+
+## 10. Debug UI states
+
+Добавлены команды для проверки UI-состояний:
+
+```text
+/debug_auto hold 335
+/debug_auto buy 12 0.74
+/debug_auto buy_ready 0.88
+/debug_auto sell 9 0.71
+/debug_auto sell_ready 0.91
+/debug_auto long
+/debug_auto short
+/debug_auto reset
+/debug_auto state
+```
+
+---
+
+## 11. Debug paper execution
+
+Добавлены команды разового paper execution:
+
+```text
+/debug_exec buy
+/debug_exec sell
+/debug_exec flip
+/debug_exec flip_buy
+/debug_exec flip_sell
+/debug_exec close
+/debug_exec state
+```
+
+`/debug_exec flip` автоматически определяет текущую позицию:
+
+```text
+LONG → SELL / SHORT
+SHORT → BUY / LONG
+```
+
+---
+
+## 12. Debug live paper test
+
+Добавлен live debug режим:
+
+```text
+/debug_live buy
+/debug_live sell
+/debug_live flip
+/debug_live close
+/debug_live stop
+/debug_live state
+```
+
+Этот режим открывает paper-позицию и запускает обычный AutoTradeRunner, чтобы проверить:
+
+- мониторинг рынка
+- обновление UI
+- P&L
+- SL / TP / ML
+- flip по стратегии
+- execution alerts
+
+---
+
+## 13. Auto screen runner diagnostics
+
+В debug state добавлена диагностика AutoTradeRunner:
+
+```text
+Screen
+Chat ID
+Message ID
+Has bot
+Has render_text
+Task running
+```
+
+Это помогает быстро понять, зарегистрирован ли экран автоторговли для live refresh.
+
+---
+
+## Архитектурный результат
+
+Теперь:
+
+- экран RUNNING стал компактнее и понятнее
+- HOLD не перегружает UI
+- BUY / SELL отображают направление LONG / SHORT
+- расчёты preview используют bid / ask
+- strategy анализирует mid price
+- paper execution использует bid / ask для входа и выхода
+- active position screen обновляется live
+- debug-команды позволяют тестировать UI и execution быстрее
+
+---
+
+## Ограничения текущей реализации
+
+Текущий debug live режим пока не изолирован.
+
+Он вмешивается в реальные runtime-компоненты:
+
+```text
+AutoTradeState
+ExecutionEngine._position
+AutoTradeRunner
+EventBus
+Auto screen
+```
+
+Это удобно для integration testing, но не является полноценным sandbox.
+
+---
+
+## Следующий этап
+
+07.4.3.15 — Isolated Debug Runtime
+
+Планируется:
+
+- отделить debug runtime от обычной автоторговли
+- создать отдельный DebugAutoState
+- создать отдельный DebugRunner
+- создать отдельный DebugExecutionEngine
+- создать отдельный debug screen
+- исключить влияние debug-команд на AutoTradeService
+- маркировать debug-логи и уведомления как `[DEBUG]`
+- оставить `/debug_live` только как временный legacy integration test