07.4.3.14 — Auto Trading UI. Realistic Pricing & Debug Live Tools

This commit is contained in:
2026-05-09 01:34:46 +03:00
parent ee78f9774a
commit df76490783
15 changed files with 2161 additions and 464 deletions

View File

@@ -60,12 +60,10 @@ async def open_auto(message: Message, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto") AutoTradeRunner.set_current_screen("auto")
current_state = AutoTradeService().get_state() await AutoTradeRunner.delete_registered_screen(
if current_state.status in {"RUNNING", "OBSERVING"}: bot=message.bot,
await AutoTradeRunner.delete_registered_screen( chat_id=message.chat.id,
bot=message.bot, )
chat_id=message.chat.id,
)
await render_auto_screen(message, edit_mode=False) await render_auto_screen(message, edit_mode=False)

View File

@@ -24,30 +24,45 @@ class AutoRiskStates(StatesGroup):
waiting_max_loss = State() 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: def _format_percent(value: float | None) -> str:
if value is None: if value is None:
return "off" return "off"
return f"🟢 {value:g}%" return f"{_format_number(value)}%"
def _format_usd(value: float | None) -> str: def _format_usd(value: float | None) -> str:
if value is None: if value is None:
return "off" return "off"
return f"🟢 {value:g} USD" return f"{_format_number(value)} USD"
def _rule_icon(value: float | None) -> str:
return "" if value is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup: def _risk_keyboard() -> InlineKeyboardMarkup:
state = AutoTradeService().get_state()
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=f"🛑 Stop Loss", callback_data="auto:risk:set_sl") builder.button(text="🛑 SL", callback_data="auto:risk:set_sl")
builder.button(text=f"🎯 Take Profit", callback_data="auto:risk:set_tp") builder.button(text="🎯 TP", callback_data="auto:risk:set_tp")
builder.button(text=f"💸 Max Loss", callback_data="auto:risk:set_ml") builder.button(text="💸 ML", 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="🤖 Автоторговля", callback_data="auto:home") 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() return builder.as_markup()
@@ -66,16 +81,13 @@ def _risk_text(status_message: str | None = None) -> str:
status = "🟢 Активна" if active_count else "⚪ Выключена" status = "🟢 Активна" if active_count else "⚪ Выключена"
text = ( text = (
"<b>⚠️ Risk Settings</b>\n\n" "<b>🧯 Защита позиции</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
f"Статус защиты: {status}\n" f"Статус защиты: {status}\n"
f"Активных правил: {active_count}/3\n\n" f"Активных правил: {active_count}/3\n\n"
f"🛑 Stop Loss: {_format_percent(state.stop_loss_percent)}\n" f"{_rule_icon(state.stop_loss_percent)} Stop Loss · {_format_percent(state.stop_loss_percent)}\n"
f"🎯 Take Profit: {_format_percent(state.take_profit_percent)}\n" f"{_rule_icon(state.take_profit_percent)} Take Profit · {_format_percent(state.take_profit_percent)}\n"
f"💸 Max Loss: {_format_usd(state.max_loss_usd)}\n\n" f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n"
"<b>Подсказка:</b>\n"
"Пример: <code>0.5</code>, <code>1</code>\n"
"Введите <code>0</code>, чтобы отключить параметр."
) )
if status_message: 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: def _parse_positive_or_none(raw_text: str | None) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".") value_text = (raw_text or "").strip().replace(",", ".")
if value_text in {"0", "0.0", "off", "OFF", "-"}: if value_text.lower() in {"0", "0.0", "off", "-"}:
return None return None
value = float(value_text) value = float(value_text)
@@ -222,11 +234,11 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None: if callback.message is not None:
await callback.message.edit_text( await callback.message.edit_text(
"<b>🛑 Stop Loss</b>\n\n" "<b>Stop Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Stop Loss в процентах.\n" "Введите Stop Loss в процентах.\n"
"Например: <code>2</code>\n\n" "Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n"
"Введите <code>0</code>, чтобы отключить." "отключить параметр - <code>0</code>"
) )
await callback.answer() await callback.answer()
@@ -240,11 +252,11 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None: if callback.message is not None:
await callback.message.edit_text( await callback.message.edit_text(
"<b>🎯 Take Profit</b>\n\n" "<b>Take Profit</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Take Profit в процентах.\n" "Введите Take Profit в процентах.\n"
"Например: <code>3</code>\n\n" "Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n"
"Введите <code>0</code>, чтобы отключить." "отключить параметр - <code>0</code>"
) )
await callback.answer() await callback.answer()
@@ -258,11 +270,11 @@ async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is not None: if callback.message is not None:
await callback.message.edit_text( await callback.message.edit_text(
"<b>💸 Max Loss</b>\n\n" "<b>Maximum Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите максимальный paper-убыток в USD.\n" "Введите максимальный paper-убыток в USD.\n"
"Например: <code>10</code>\n\n" "Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n"
"Введите <code>0</code>, чтобы отключить." "отключить параметр - <code>0</code>"
) )
await callback.answer() await callback.answer()
@@ -309,7 +321,7 @@ async def set_stop_loss(message: Message, state: FSMContext) -> None:
try: try:
value = _parse_positive_or_none(message.text) value = _parse_positive_or_none(message.text)
except ValueError: except ValueError:
await message.answer("Введите число. Например: 2 или 0 для отключения.") await message.answer("Введите число. Например: 1, 0.5 или 0 для отключения.")
return return
if not _validate_percent(value): if not _validate_percent(value):
@@ -333,7 +345,7 @@ async def set_take_profit(message: Message, state: FSMContext) -> None:
try: try:
value = _parse_positive_or_none(message.text) value = _parse_positive_or_none(message.text)
except ValueError: except ValueError:
await message.answer("Введите число. Например: 3 или 0 для отключения.") await message.answer("Введите число. Например: 2, 1.5 или 0 для отключения.")
return return
if not _validate_percent(value): if not _validate_percent(value):
@@ -357,7 +369,7 @@ async def set_max_loss(message: Message, state: FSMContext) -> None:
try: try:
value = _parse_positive_or_none(message.text) value = _parse_positive_or_none(message.text)
except ValueError: except ValueError:
await message.answer("Введите число. Например: 10 или 0 для отключения.") await message.answer("Введите число. Например: 100, 50.5 или 0 для отключения.")
return return
if not _validate_max_loss(value): if not _validate_max_loss(value):

View File

@@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import math
import time
from aiogram.types import InlineKeyboardMarkup from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder 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 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: def auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -27,18 +21,31 @@ def auto_keyboard() -> InlineKeyboardMarkup:
builder.button(text="👀 Watch", callback_data="auto:observe") builder.button(text="👀 Watch", callback_data="auto:observe")
builder.button(text="🛑 Stop", callback_data="auto:stop") builder.button(text="🛑 Stop", callback_data="auto:stop")
builder.button(text="🛠️ Настройки", callback_data="settings:auto") 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) builder.adjust(3, 2)
return builder.as_markup() return builder.as_markup()
def is_auto_configured(state) -> bool: def is_auto_configured(state) -> bool:
return bool( if not state.symbol:
state.symbol return False
and state.strategy
and state.risk_percent is not None 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: 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: if state.position_side != "NONE" and state.entry_price is not None:
return _build_active_position_text(state) return _build_active_position_text(state)
if state.status == "OFF":
return _build_stopped_without_position_text(state)
return _build_waiting_text(state) return _build_waiting_text(state)
def _build_not_configured_text(state) -> str: def _build_not_configured_text(state) -> str:
return ( symbol_ready = state.symbol is not None
"🤖 Автоторговля · ⚪ Не настроена\n" strategy_ready = state.strategy is not None
f"{_account_mode_line()}\n\n" risk_ready = state.risk_percent is not None
"Configuration required\n\n"
f"Pair: {_required_value(_asset_symbol(state.symbol))}\n" symbol_icon = "" if symbol_ready else "⚠️"
f"Strategy: {_required_value(_strategy_short(state.strategy))}\n" strategy_icon = "" if strategy_ready else "⚠️"
f"Position Risk: {_required_value(_risk_percent_text(state))}\n\n" risk_icon = "" if risk_ready else "⚠️"
"🛠️ Открой настройки для запуска"
parts = [
"🤖 Автоторговля ⚪ Не настроена",
_account_mode_line(),
"",
f"{symbol_icon} <b>Актив</b> · {_asset_symbol(state.symbol)}\n"
f"{strategy_icon} <b>Стратегия</b> · {_required_value(_strategy_short(state.strategy))}\n"
f"{risk_icon} <b>Риск на сделку</b> · {_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} <b>SL</b> · {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"<b>Доступно</b> · $ {_format_money_compact(available)}\n",
"🧾 <b>Подготовка ордера</b>",
"",
_order_header_line(state),
f"<b>Цена</b> · {_format_usd_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
f"<b>Риск</b> · {_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"<b>Доступно</b> · $ {_format_money_compact(available)}",
"",
_signal_line(state),
*_signal_confidence_lines(state),
*_execution_block_lines(state),
"",
"🧾 <b>Подготовка ордера</b>",
"",
_order_header_line(state),
f"<b>{_price_label_for_signal(state)}</b> · {_format_usd_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
f"<b>Риск</b> · {_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"<b>Доступно</b> · $ {_format_money_compact(available)}",
f"<b>Зарезервировано</b> · $ {_format_money_compact(reserved)}",
f"<b>P&L</b> {_format_signed_usd_with_direction(pnl)}",
"",
(
f"{side_icon} <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>{state.position_side}</b> {_leverage_text(state.leverage)}"
),
"",
f"<b>Количество</b> · {_format_crypto_size(size)} ⇢ $ {_format_money_compact(notional)}",
f"<b>Цена входа</b> · $ {_format_money(state.entry_price)}",
f"<b>Текущая цена</b> · {_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]: def _execution_block_lines(state) -> list[str]:
lines: list[str] = [] lines: list[str] = []
reason = getattr(state, "execution_block_reason", None) reason = getattr(state, "execution_block_reason", None)
if reason: if reason:
lines.append(f"🔴 Blocked: {reason}") lines.append(f"Blocked · {reason}")
adjustment = getattr(state, "execution_size_adjustment_reason", None) adjustment = getattr(state, "execution_size_adjustment_reason", None)
if adjustment == "MARGIN_LIMIT": if adjustment == "MARGIN_LIMIT":
lines.append("🟠 Size adjusted by Max Reserved") lines.append("Size adjusted by Max Reserved")
return lines 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) size = _estimated_size(state, price)
if size is None or price is None: if size is None or price is None or price <= 0:
return "Est. Margin:" return "<b>Собственные средства</b> ·"
leverage = state.leverage or 1.0 leverage = state.leverage or 1.0
if leverage <= 0: if leverage <= 0:
return "Est. Margin:" return "<b>Собственные средства</b> ·"
notional = size * price position_size_usd = size * price
reserved = notional / leverage own_funds_usd = position_size_usd / leverage
return f"Est. Margin: $ {_format_money0(reserved)}" return (
f"<b>Собственные средства</b> · $ {_format_money_compact(own_funds_usd)}"
)
def _max_reserved_text(state) -> str: def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
max_percent = getattr(state, "max_reserved_balance_percent", None) if not symbol:
return None
if max_percent is None or max_percent <= 0: try:
return "Max Reserved: off" return ExchangeService().get_market_snapshot(symbol)
except Exception:
max_reserved = PAPER_BALANCE_USD * (max_percent / 100) return None
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"<b>{_asset_symbol(state.symbol)}</b> · {_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"<b>🛑 SL:</b> {_format_percent2(state.stop_loss_percent)}",
f"<b>🎯 TP:</b> {_format_percent2(state.take_profit_percent)}",
f"<b>💣 ML:</b> {_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} <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>{state.position_side}</b> {_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)
def _current_price(symbol: str | None) -> float | 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: if not symbol:
return None return None
@@ -194,10 +318,32 @@ def _current_price(symbol: str | None) -> float | None:
return 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: def _target_risk_usd(state) -> float:
if state.risk_percent is None: if state.risk_percent is None:
return 0.0 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: 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) max_percent = getattr(state, "max_reserved_balance_percent", None)
if max_percent is None or max_percent <= 0: if max_percent is None or max_percent <= 0:
return risk_size return _round_size(risk_size)
leverage = state.leverage or 1.0 leverage = state.leverage or 1.0
if leverage <= 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_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / price 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: def _estimated_size_text(state, price: float | None) -> str:
size = _estimated_size(state, price) size = _estimated_size(state, price)
if size is None or price is None: if size is None or price is None:
return "Est. Size:" return "<b>Количество</b> ·"
notional = size * price notional = size * price
return ( return (
f"Est. Size: {_format_crypto_size(size)} " f"<b>Количество</b> · {_format_crypto_size(size)}\n"
f"{_asset_symbol(state.symbol)} ($ {_format_money0(notional)})" f"<b>Размер позиции</b> · $ {_format_money_compact(notional)}"
) )
def _active_sl_line(state) -> str: def _risk_summary_line(
if ( state,
state.stop_loss_percent is None size: float | None,
or state.entry_price is None *,
or state.position_size is None entry_price_override: float | None = None,
): ) -> str:
return "<b>🛑 SL:</b> off" entry_price = entry_price_override or state.entry_price
move = state.entry_price * (state.stop_loss_percent / 100) sl = _risk_loss_text(
loss = move * state.position_size percent=state.stop_loss_percent,
fixed_loss=None,
if state.position_side == "SHORT": size=size,
price = state.entry_price + move entry_price=entry_price,
else: )
price = state.entry_price - move tp = _risk_profit_text(
percent=state.take_profit_percent,
return ( size=size,
f"<b>🛑 SL:</b> {_format_percent2(state.stop_loss_percent)} · " entry_price=entry_price,
f"-$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}" )
ml = _risk_loss_text(
percent=None,
fixed_loss=state.max_loss_usd,
size=size,
entry_price=entry_price,
) )
items = [
f"<b>SL</b> {sl}" if sl else "<b>SL</b> off",
f"<b>TP</b> {tp}" if tp else "<b>TP</b> off",
f"<b>ML</b> {ml}" if ml else "<b>ML</b> off",
]
def _active_tp_line(state) -> str: return " | ".join(items)
if (
state.take_profit_percent is None
or state.entry_price is None
or state.position_size is None
):
return "<b>🎯 TP:</b> 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"<b>🎯 TP:</b> {_format_percent2(state.take_profit_percent)} · "
f"+$ {_format_money0(profit)} ⇢ $ {_format_money0(price)}"
)
def _active_ml_line(state) -> str: def _risk_loss_text(
if ( *,
state.max_loss_usd is None percent: float | None,
or state.entry_price is None fixed_loss: float | None,
or state.position_size is None size: float | None,
or state.position_size <= 0 entry_price: float | None,
): ) -> str:
return "<b>💣 ML:</b> off" if fixed_loss is not None:
return f"-$ {_format_money_compact(abs(fixed_loss))}"
loss = abs(state.max_loss_usd) if percent is None:
price_move = loss / state.position_size return ""
if state.position_side == "SHORT": if size is None or size <= 0 or entry_price is None or entry_price <= 0:
price = state.entry_price + price_move loss = _target_loss_by_percent_stub(percent)
else: return f"-$ {_format_money_compact(loss)}" if loss is not None else ""
price = state.entry_price - price_move
return f"<b>💣 ML:</b> -$ {_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: def _risk_reward_line(state) -> str:
@@ -318,12 +484,82 @@ def _risk_reward_line(state) -> str:
return "" return ""
ratio = state.take_profit_percent / state.stop_loss_percent ratio = state.take_profit_percent / state.stop_loss_percent
return f"<b>⚖️ R:R</b> = 1 : {_format_ratio_value(ratio)}" return f"<b>R:R</b> = 1 : {_format_ratio_value(ratio)}"
def _order_header_line(state) -> str:
signal = (state.last_signal or "").upper()
if signal == "BUY":
return (
f"🟢 <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>LONG</b> {_leverage_text(state.leverage)}"
)
if signal == "SELL":
return (
f"🔴 <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>SHORT</b> {_leverage_text(state.leverage)}"
)
return (
f"<b>{_asset_symbol(state.symbol)}</b> · "
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: def _format_ratio_value(value: float) -> str:
if abs(value - round(value)) < 1e-9: if abs(value - round(value)) < 1e-9:
return str(int(round(value))) return str(int(round(value)))
return f"{value:.2f}".rstrip("0").rstrip(".") return f"{value:.2f}".rstrip("0").rstrip(".")
@@ -338,16 +574,16 @@ def _status_text(status: str) -> str:
def _decision_human_text(status: str) -> str: def _decision_human_text(status: str) -> str:
mapping = { mapping = {
"WAITING": "🟡 Ожидание сигнала", "WAITING": "Ожидание сигнала",
"CONFIRMING": "🟠 Подтверждение сигнала", "CONFIRMING": "Подтверждение сигнала",
"READY": "🟢 Сигнал готов", "READY": "Сигнал готов",
"BLOCKED": "🔴 Сигнал заблокирован", "BLOCKED": "Сигнал заблокирован",
} }
return mapping.get(status, status) return mapping.get(status, status)
def _account_mode_line() -> str: 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: def _asset_symbol(symbol: str | None) -> str:
@@ -387,51 +623,90 @@ def _leverage_text(value: float | None) -> str:
def _risk_percent_text(state) -> str: def _risk_percent_text(state) -> str:
if state.risk_percent is None: if state.risk_percent is None:
return "" return ""
return f"{state.risk_percent:g}%"
return _format_percent(state.risk_percent)
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)}"
def _required_value(value: str) -> str: def _required_value(value: str) -> str:
if not value or value == "": if not value or value == "":
return "required" return ""
return value 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: def _signal_icon(signal: str | None) -> str:
mapping = { mapping = {
"BUY": "🟢", "BUY": "🟢",
"SELL": "🔴", "SELL": "🔴",
"HOLD": "🟡", "HOLD": "🟡",
} }
return mapping.get(signal or "", "") return mapping.get(signal or "", "")
def _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: def _format_money(value: float | int | None) -> str:
if value is None: if value is None:
return "" return ""
return format_usd_amount(float(value)) 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: if value is None:
return "" 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: def _format_usd_or_dash(value: float | None) -> str:
if value is None: if value is None:
return "" return ""
return f"$ {_format_money(value)}" return f"$ {_format_money(value)}"
@@ -442,16 +717,24 @@ def _format_signed_usd(value: float | int | None) -> str:
amount = float(value) amount = float(value)
if amount > 0: if amount > 0:
return f"+$ {_format_money(amount)}" return f"+$ {_format_money_compact(amount)}"
if amount < 0: 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: if value is None:
return "" return ""
return f"{float(value):.4f}".rstrip("0").rstrip(".") amount = float(value)
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"
if amount < 0:
return f"🔴 $ {_format_money_compact(abs(amount))}"
return "$ 0"

View File

@@ -2,14 +2,20 @@
from __future__ import annotations from __future__ import annotations
import math
import time
from datetime import datetime
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message
from src.core.config import load_settings from src.core.config import load_settings
from src.integrations.exchange.service import ExchangeService
from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService from src.trading.auto.service import AutoTradeService
from src.trading.execution.engine import ExecutionEngine from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState
router = Router(name="debug") router = Router(name="debug")
@@ -19,6 +25,566 @@ def _debug_enabled() -> bool:
return load_settings().debug_enabled return load_settings().debug_enabled
def _debug_help_text() -> str:
return (
"<b>🧪 Debug commands</b>\n\n"
"<b>Auto UI states:</b>\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"
"<b>Paper execution:</b>\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"
"<b>Live paper test:</b>\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"
"<b>Legacy:</b>\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 (
"<b>Debug Auto State</b>\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"<b>Runner</b>\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"<b>Position</b>\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]: def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split() parts = (raw_text or "").split()
@@ -45,34 +611,6 @@ def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str
return signal, confidence, repeat_count, None return signal, confidence, repeat_count, None
def _debug_help_text() -> str:
return (
"<b>🧪 Debug commands</b>\n\n"
"<b>Основная команда:</b>\n"
"/debug_signal BUY 0.95 3\n"
"/debug_signal SELL 0.70 2\n"
"/debug_signal HOLD 0.00 1\n\n"
"<b>Быстрые команды:</b>\n"
"/debug_signal — BUY 0.90 2\n"
"/debug_ready — READY BUY\n"
"/debug_state — текущее состояние\n"
"/debug_help — список команд\n\n"
"<b>Priority тест:</b>\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")) @router.message(F.text.startswith("/debug_signal"))
async def debug_signal(message: Message) -> None: async def debug_signal(message: Message) -> None:
if not _debug_enabled(): 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) signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text)
if error is not None: if error is not None:
await message.answer( await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}")
f"⛔️ {error}\n\n"
f"{_debug_help_text()}"
)
return return
service = AutoTradeService() service = AutoTradeService()
@@ -99,13 +634,8 @@ async def debug_signal(message: Message) -> None:
if state.status == "OFF": if state.status == "OFF":
state.status = "RUNNING" state.status = "RUNNING"
await AutoTradeRunner._handle_important_event(state) _set_signal_started_at(state)
await _refresh_auto_screen()
execution_result = ExecutionEngine().process(state)
await AutoTradeRunner.process_last_event_now()
AutoTradeRunner.start()
JournalService().log_ui_info( JournalService().log_ui_info(
event_type="debug_signal_forced", event_type="debug_signal_forced",
@@ -119,9 +649,6 @@ async def debug_signal(message: Message) -> None:
"decision_status": state.decision_status, "decision_status": state.decision_status,
"confidence": state.last_signal_confidence, "confidence": state.last_signal_confidence,
"repeat_count": state.last_signal_repeat_count, "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"Signal: {state.last_signal}\n"
f"Decision: {state.decision_status}\n" f"Decision: {state.decision_status}\n"
f"Confidence: {state.last_signal_confidence:.2f}\n" f"Confidence: {state.last_signal_confidence:.2f}\n"
f"Repeats: {state.last_signal_repeat_count}\n\n" f"Repeats: {state.last_signal_repeat_count}"
f"Execution: {execution_result.action}\n"
f"Can execute: {execution_result.can_execute}\n"
f"Reason: {execution_result.reason}"
) )
@@ -144,30 +668,25 @@ async def debug_ready(message: Message) -> None:
return return
service = AutoTradeService() service = AutoTradeService()
state = service.debug_force_signal( state = service.get_state()
_clear_debug_position(state)
_set_signal_state(
state=state,
signal="BUY", signal="BUY",
seconds=15,
confidence=0.95, confidence=0.95,
repeat_count=3, decision_status="READY",
reason="DEBUG READY BUY 0.95 ×3", ready=True,
) )
if state.status == "OFF": await _refresh_auto_screen()
state.status = "RUNNING"
await AutoTradeRunner._handle_important_event(state)
execution_result = ExecutionEngine().process(state)
await AutoTradeRunner.process_last_event_now()
AutoTradeRunner.start()
await message.answer( await message.answer(
"✅ Debug READY создан\n\n" "✅ Debug READY создан\n\n"
f"Signal: {state.last_signal}\n" f"Signal: {state.last_signal}\n"
f"Decision: {state.decision_status}\n" f"Decision: {state.decision_status}\n"
f"Execution: {execution_result.action}\n" f"Confidence: {state.last_signal_confidence:.2f}"
f"Can execute: {execution_result.can_execute}"
) )
@@ -178,19 +697,5 @@ async def debug_state(message: Message) -> None:
return return
state = AutoTradeService().get_state() state = AutoTradeService().get_state()
_sync_state_from_position(state)
await message.answer( await message.answer(_debug_state_text(state))
"<b>Debug Auto State</b>\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}"
)

View File

@@ -181,9 +181,9 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
state = AutoTradeService().get_state() state = AutoTradeService().get_state()
strategy_map = { strategy_map = {
"TREND": "📈 Trend Following", "TREND": "TREND FOLLOWING",
"GRID": "🧩 Grid Trading", "GRID": "GRID TRADING",
"SCALP": "⚡ Scalping", "SCALP": "SCALPING",
} }
strategy_ready = state.strategy is not None 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 risk_ready = state.risk_percent is not None
leverage_ready = state.leverage 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 "", "") 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 "" 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 "" leverage = f"x{state.leverage:g}" if state.leverage is not None else ""
max_reserved = ( max_reserved = (
@@ -202,44 +229,70 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
if state.max_reserved_balance_percent is not None if state.max_reserved_balance_percent is not None
else "off" else "off"
) )
sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" sl = (
tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off" f"{state.stop_loss_percent:g}%"
ml = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" if state.stop_loss_percent is not None
risk_controls = f"SL {sl} · TP {tp} · ML {ml}" else "off"
)
strategy_icon = "" if strategy_ready else "👉" tp = (
symbol_icon = "" if symbol_ready else "👉" f"{state.take_profit_percent:g}%"
risk_icon = "" if risk_ready else "👉" if state.take_profit_percent is not None
leverage_icon = "" if leverage_ready else "👉" 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 = (
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · <b>{'required' if not sl_ready else sl}</b>\n"
f"✅ Take Profit · {tp}\n"
f"✅ Max Loss · {ml}"
)
else:
risk_controls_block = (
"<b>Защита позиции:</b>\n"
f"✅ Stop Loss · {sl}\n"
f"✅ Take Profit · {tp}\n"
f"✅ Max Loss · {ml}"
)
config_status = ( config_status = (
"Все параметры настроены" "Все параметры настроены"
if is_configured if is_configured
else " Настрой все параметры" else " Настрой все параметры"
) )
text = ( text = (
"<b>🤖 Автоторговля</b>\n\n" "<b>🤖 Автоторговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n" "<b>СИСТЕМА</b> · Настройки\n\n"
f"{strategy_icon} Стратегия: {strategy}\n" f"{strategy_icon} Стратегия: <b>{strategy}</b>\n"
f"{symbol_icon} Инструмент: {symbol}\n" f"{symbol_icon} Актив: <b>{symbol}</b>\n"
f"{risk_icon} Риск на сделку: {risk}\n" f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
f"{leverage_icon} Плечо: {leverage}\n\n" f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
f"Max Reserved: {max_reserved}\n" f"Лимит на сделку: <b>{max_reserved}</b>\n\n"
f"✅ Risk Controls: {risk_controls}\n\n" f"{risk_controls_block}\n\n"
f"{config_status}" f"{config_status}"
) )
if not is_configured:
text += "\n\nВыберите настройку:"
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy") builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="📈 Инструмент", callback_data="settings:auto_symbol") builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
builder.button(text="🛡️ Риск на сделку", callback_data="settings:auto_risk")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage") builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
builder.button(text="⚠️ Risk Controls", callback_data="auto:risk") builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved")
builder.button(text="🏦 Max Reserved", 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="auto:home")
builder.button(text="⬅️ Назад", callback_data="system:management") builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(2, 2, 2, 2) builder.adjust(2, 2, 2, 2)
@@ -294,17 +347,36 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
settings = load_settings() settings = load_settings()
text = ( text = (
"<b>📈 Инструмент</b>\n\n" "<b>💱 Актив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите инструмент:" "Выберите актив:"
) )
builder = InlineKeyboardBuilder() 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(
builder.button(text="ETHUSDT", callback_data="settings:auto_symbol:ETHUSDT") 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.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.message.edit_text(text, reply_markup=builder.as_markup())
await callback.answer() await callback.answer()
@@ -319,7 +391,7 @@ async def set_auto_symbol(callback: CallbackQuery) -> None:
await open_auto_settings(callback) await open_auto_settings(callback)
AutoTradeRunner.set_current_screen("settings_auto") AutoTradeRunner.set_current_screen("settings_auto")
await callback.answer("Инструмент обновлён") await callback.answer("Актив обновлён")
@router.callback_query(F.data == "settings:auto_risk") @router.callback_query(F.data == "settings:auto_risk")
@@ -330,7 +402,7 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
return return
text = ( text = (
"<b>🛡️ Риск</b>\n\n" "<b>🛡️ Риск на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите риск на сделку:" "Выберите риск на сделку:"
) )
@@ -407,7 +479,7 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
text = ( text = (
"<b>💹 Торговля</b>\n\n" "<b>💹 Торговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n" "<b>СИСТЕМА</b> · Настройки\n\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(), reply_markup=builder.as_markup(),
) )
await callback.answer() await callback.answer()
@router.callback_query(F.data == "settings:auto_max_reserved") @router.callback_query(F.data == "settings:auto_max_reserved")
async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: 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 return
text = ( text = (
"<b>🏦 Max Reserved</b>\n\n" "<b>🏦 Лимит на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n" "<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Максимальная доля баланса, которую можно зарезервировать под позицию:" "Максимальная доля баланса, которую можно зарезервировать под позицию:"
) )

View File

@@ -26,7 +26,7 @@ class AutoTradeRunner:
_current_screen: str | None = None _current_screen: str | None = None
_analysis_interval_seconds = 5 _analysis_interval_seconds = 5
_ui_interval_seconds = 60 _ui_interval_seconds = 5
_last_text: str | None = None _last_text: str | None = None
_last_ui_refresh_at: float = 0.0 _last_ui_refresh_at: float = 0.0
@@ -550,17 +550,66 @@ class AutoTradeRunner:
except (TypeError, ValueError): except (TypeError, ValueError):
return "" 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 @classmethod
async def _refresh_screen(cls, *, force: bool = False) -> None: async def _refresh_screen(cls, *, force: bool = False) -> None:
if cls._current_screen != "auto": if cls._current_screen != "auto":
cls._log_refresh_skip("current_screen_not_auto")
return return
now = time.monotonic() now = time.monotonic()
if now < cls._retry_after_until: if now < cls._retry_after_until:
cls._log_refresh_skip(
"retry_after_active",
{"retry_after_until": cls._retry_after_until, "now": now},
)
return return
if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds: 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 return
if not all( if not all(
@@ -572,11 +621,22 @@ class AutoTradeRunner:
cls._render_markup, 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 return
text = cls._render_text() text = cls._render_text()
if text == cls._last_text: if text == cls._last_text:
cls._log_refresh_skip("text_not_changed")
return return
try: try:
@@ -589,8 +649,23 @@ class AutoTradeRunner:
cls._last_text = text cls._last_text = text
cls._last_ui_refresh_at = now 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: except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5 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: except TelegramBadRequest as exc:
error_text = str(exc).lower() error_text = str(exc).lower()
@@ -598,6 +673,7 @@ class AutoTradeRunner:
if "message is not modified" in error_text: if "message is not modified" in error_text:
cls._last_text = text cls._last_text = text
cls._last_ui_refresh_at = now cls._last_ui_refresh_at = now
cls._log_refresh_skip("telegram_message_not_modified")
return return
if "message to edit not found" in error_text: if "message to edit not found" in error_text:
@@ -605,7 +681,19 @@ class AutoTradeRunner:
cls._render_text = None cls._render_text = None
cls._render_markup = None cls._render_markup = None
cls._last_text = None cls._last_text = None
cls._log_refresh_error(
"telegram_message_to_edit_not_found",
{"error": str(exc)},
)
return return
except Exception: cls._log_refresh_error(
pass "telegram_bad_request",
{"error": str(exc)},
)
except Exception as exc:
cls._log_refresh_error(
"unexpected_refresh_error",
{"error": str(exc)},
)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from datetime import datetime from datetime import datetime
from src.core.config import load_settings from src.core.config import load_settings
@@ -49,6 +50,9 @@ class AutoTradeService:
previous_signal = state.last_signal previous_signal = state.last_signal
previous_decision_status = state.decision_status 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 = normalized_signal
state.last_signal_repeat_count = repeat_count state.last_signal_repeat_count = repeat_count
@@ -85,6 +89,18 @@ class AutoTradeService:
return state 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: def get_state(self) -> AutoTradeState:
if not self._state.symbol: if not self._state.symbol:
@@ -264,6 +280,7 @@ class AutoTradeService:
state.is_signal_confirmed = False state.is_signal_confirmed = False
state.is_signal_ready = False state.is_signal_ready = False
state.execution_block_reason = None state.execution_block_reason = None
state.signal_started_at = None
# собрать контекст для стратегии # собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext: def _build_strategy_context(self) -> StrategyContext:
@@ -397,6 +414,9 @@ class AutoTradeService:
previous_signal = state.last_signal previous_signal = state.last_signal
previous_decision_status = state.decision_status 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 = signal
state.last_signal_repeat_count = self._same_signal_count state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = confidence state.last_signal_confidence = confidence

View File

@@ -11,13 +11,13 @@ class AutoTradeState:
status: str = "OFF" status: str = "OFF"
# выбранная стратегия: TREND / GRID / SCALP # выбранная стратегия: 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
pnl_usd: float = 0.0 pnl_usd: float = 0.0
@@ -37,6 +37,9 @@ class AutoTradeState:
# причина последнего сигнала # причина последнего сигнала
last_signal_reason: str | None = None last_signal_reason: str | None = None
# время начала текущего сигнала, monotonic timestamp
signal_started_at: float | None = None
# статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED # статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED
decision_status: str = "WAITING" decision_status: str = "WAITING"
@@ -68,7 +71,7 @@ class AutoTradeState:
leverage: float | None = 2.0 leverage: float | None = 2.0
# stop loss по движению цены в % # stop loss по движению цены в %
stop_loss_percent: float | None = None stop_loss_percent: float | None = 1.0
# take profit по движению цены в % # take profit по движению цены в %
take_profit_percent: float | None = None take_profit_percent: float | None = None
@@ -83,4 +86,10 @@ class AutoTradeState:
execution_block_reason: str | None = None execution_block_reason: str | None = None
# причина авто-уменьшения размера позиции # причина авто-уменьшения размера позиции
execution_size_adjustment_reason: str | None = None execution_size_adjustment_reason: str | None = None
# капитал, выделенный только под AutoTrade
allocated_balance_usd: float = 1000.0
# зафиксированный результат закрытых paper-сделок
realized_pnl_usd: float = 0.0

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import math
from datetime import datetime from datetime import datetime
from src.core.event_bus import EventBus from src.core.event_bus import EventBus
@@ -14,6 +15,7 @@ from src.trading.position.state import PositionState
class ExecutionEngine: class ExecutionEngine:
_position = PositionState() _position = PositionState()
_size_precision = 5
def get_position(self) -> PositionState: def get_position(self) -> PositionState:
return type(self)._position return type(self)._position
@@ -58,8 +60,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Позиция уже открыта.") return ExecutionDecision("NONE", False, "Позиция уже открыта.")
try: try:
ticker = ExchangeService().get_price(state.symbol) entry_price = self._entry_price_for_side(state.symbol, side)
entry_price = ticker.price
except Exception as exc: except Exception as exc:
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
@@ -72,13 +73,22 @@ class ExecutionEngine:
False, False,
"Позиция не открыта: невозможно рассчитать size без Stop Loss.", "Позиция не открыта: невозможно рассчитать size без Stop Loss.",
) )
size = self._adjust_size_by_margin_limit( size = self._adjust_size_by_margin_limit(
state=state, state=state,
entry_price=entry_price, entry_price=entry_price,
size=size, size=size,
) )
size = self._round_order_size(size)
if size <= 0:
return ExecutionDecision(
"NONE",
False,
"Позиция не открыта: итоговый size равен 0.",
)
type(self)._position = PositionState( type(self)._position = PositionState(
side=side, side=side,
symbol=state.symbol, symbol=state.symbol,
@@ -105,6 +115,7 @@ class ExecutionEngine:
"repeat_count": state.last_signal_repeat_count, "repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason, "reason": state.last_signal_reason,
"opened_at": now, "opened_at": now,
"pricing": "ask_for_long_bid_for_short",
} }
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -131,14 +142,14 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет направления для flip.") return ExecutionDecision("NONE", False, "Нет направления для flip.")
try: try:
ticker = ExchangeService().get_price(state.symbol) exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
flip_price = ticker.price new_entry_price = self._entry_price_for_side(state.symbol, new_side)
except Exception as exc: except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}") return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}")
now = self._now_time() now = self._now_time()
pnl = self._calculate_pnl(flip_price) pnl = self._calculate_pnl(exit_price)
new_size = self._calculate_position_size(state, entry_price=flip_price) new_size = self._calculate_position_size(state, entry_price=new_entry_price)
if new_size <= 0: if new_size <= 0:
return ExecutionDecision( return ExecutionDecision(
@@ -146,13 +157,24 @@ class ExecutionEngine:
False, False,
"Flip отменён: невозможно рассчитать size без Stop Loss.", "Flip отменён: невозможно рассчитать size без Stop Loss.",
) )
new_size = self._adjust_size_by_margin_limit( new_size = self._adjust_size_by_margin_limit(
state=state, state=state,
entry_price=flip_price, entry_price=new_entry_price,
size=new_size, 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_side = position.side
old_entry_price = position.entry_price old_entry_price = position.entry_price
old_size = position.size old_size = position.size
@@ -162,7 +184,7 @@ class ExecutionEngine:
type(self)._position = PositionState( type(self)._position = PositionState(
side=new_side, side=new_side,
symbol=state.symbol, symbol=state.symbol,
entry_price=flip_price, entry_price=new_entry_price,
size=new_size, size=new_size,
leverage=state.leverage, leverage=state.leverage,
unrealized_pnl_usd=0.0, unrealized_pnl_usd=0.0,
@@ -180,8 +202,8 @@ class ExecutionEngine:
"new_side": new_side, "new_side": new_side,
"side": new_side, "side": new_side,
"entry_price": old_entry_price, "entry_price": old_entry_price,
"exit_price": flip_price, "exit_price": exit_price,
"new_entry_price": flip_price, "new_entry_price": new_entry_price,
"old_size": old_size, "old_size": old_size,
"new_size": new_size, "new_size": new_size,
"size": new_size, "size": new_size,
@@ -195,6 +217,7 @@ class ExecutionEngine:
"opened_at": old_opened_at, "opened_at": old_opened_at,
"closed_at": now, "closed_at": now,
"new_opened_at": now, "new_opened_at": now,
"pricing": "exit_by_side_then_entry_by_side",
} }
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -231,13 +254,14 @@ class ExecutionEngine:
exit_price = forced_exit_price exit_price = forced_exit_price
else: else:
try: try:
ticker = ExchangeService().get_price(state.symbol) exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
exit_price = ticker.price
except Exception as exc: except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}")
pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price) pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price)
state.realized_pnl_usd += pnl
now = self._now_time() now = self._now_time()
payload = { payload = {
@@ -258,6 +282,7 @@ class ExecutionEngine:
"is_forced": forced_reason is not None, "is_forced": forced_reason is not None,
"opened_at": position.opened_at, "opened_at": position.opened_at,
"closed_at": now, "closed_at": now,
"pricing": "bid_for_long_exit_ask_for_short_exit",
} }
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -293,8 +318,7 @@ class ExecutionEngine:
return None return None
try: try:
ticker = ExchangeService().get_price(position.symbol or state.symbol) current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
current_price = ticker.price
except Exception: except Exception:
return None return None
@@ -327,34 +351,19 @@ class ExecutionEngine:
return None return None
def _is_stop_loss_hit( def _is_stop_loss_hit(self, state: AutoTradeState, price_move_percent: float) -> bool:
self,
state: AutoTradeState,
price_move_percent: float,
) -> bool:
if state.stop_loss_percent is None: if state.stop_loss_percent is None:
return False return False
return price_move_percent <= -abs(state.stop_loss_percent) return price_move_percent <= -abs(state.stop_loss_percent)
def _is_take_profit_hit( def _is_take_profit_hit(self, state: AutoTradeState, price_move_percent: float) -> bool:
self,
state: AutoTradeState,
price_move_percent: float,
) -> bool:
if state.take_profit_percent is None: if state.take_profit_percent is None:
return False return False
return price_move_percent >= abs(state.take_profit_percent) return price_move_percent >= abs(state.take_profit_percent)
def _is_max_loss_hit( def _is_max_loss_hit(self, state: AutoTradeState, unrealized_pnl: float) -> bool:
self,
state: AutoTradeState,
unrealized_pnl: float,
) -> bool:
if state.max_loss_usd is None: if state.max_loss_usd is None:
return False return False
return unrealized_pnl <= -abs(state.max_loss_usd) return unrealized_pnl <= -abs(state.max_loss_usd)
def _calculate_price_move_percent(self, current_price: float) -> float: def _calculate_price_move_percent(self, current_price: float) -> float:
@@ -371,7 +380,7 @@ class ExecutionEngine:
return round(((entry - current_price) / entry) * 100, 4) return round(((entry - current_price) / entry) * 100, 4)
return 0.0 return 0.0
def _should_flip_position(self, state: AutoTradeState) -> bool: def _should_flip_position(self, state: AutoTradeState) -> bool:
position = type(self)._position position = type(self)._position
@@ -403,8 +412,7 @@ class ExecutionEngine:
return return
try: try:
ticker = ExchangeService().get_price(position.symbol or state.symbol) current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
current_price = ticker.price
except Exception: except Exception:
self._sync_state_from_position(state) self._sync_state_from_position(state)
return return
@@ -430,15 +438,14 @@ class ExecutionEngine:
if price is None: if price is None:
try: try:
ticker = ExchangeService().get_price(state.symbol) price = self._signal_entry_price(state)
price = ticker.price
except Exception: except Exception:
return 0.0 return 0.0
if price <= 0: if price <= 0:
return 0.0 return 0.0
balance_usd = 1000.0 balance_usd = state.allocated_balance_usd
target_risk_usd = balance_usd * (state.risk_percent / 100) target_risk_usd = balance_usd * (state.risk_percent / 100)
stop_loss_distance_usd = price * (state.stop_loss_percent / 100) stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
@@ -446,8 +453,7 @@ class ExecutionEngine:
return 0.0 return 0.0
size = target_risk_usd / stop_loss_distance_usd size = target_risk_usd / stop_loss_distance_usd
return self._round_size(size)
return round(size, 8)
def _adjust_size_by_margin_limit( def _adjust_size_by_margin_limit(
self, self,
@@ -462,26 +468,85 @@ class ExecutionEngine:
state.execution_size_adjustment_reason = None state.execution_size_adjustment_reason = None
if max_percent is None or max_percent <= 0: if max_percent is None or max_percent <= 0:
return round(size, 8) return self._round_size(size)
leverage = state.leverage or 1.0 leverage = state.leverage or 1.0
if leverage <= 0 or entry_price <= 0: if leverage <= 0 or entry_price <= 0:
state.execution_block_reason = "Invalid leverage or entry price." state.execution_block_reason = "Invalid leverage or entry price."
return 0.0 return 0.0
balance_usd = 1000.0 balance_usd = state.allocated_balance_usd
max_reserved_usd = balance_usd * (max_percent / 100) max_reserved_usd = balance_usd * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / entry_price max_size = max_notional_usd / entry_price
if size <= max_size: if size <= max_size:
return round(size, 8) return self._round_size(size)
state.execution_size_adjustment_reason = "MARGIN_LIMIT" 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: def _calculate_pnl(self, current_price: float) -> float:
position = type(self)._position position = type(self)._position
@@ -504,5 +569,9 @@ class ExecutionEngine:
state.position_size = position.size state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd state.unrealized_pnl_usd = position.unrealized_pnl_usd
def _round_order_size(self, value: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(value) * factor) / factor
def _now_time(self) -> str: def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S") return datetime.now().strftime("%H:%M:%S")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from src.trading.strategies.base import BaseStrategy from src.trading.strategies.base import BaseStrategy
from src.trading.strategies.hold import HoldStrategy from src.trading.strategies.hold import HoldStrategy
from src.trading.strategies.scalp import ScalpStrategy
from src.trading.strategies.trend import TrendStrategy from src.trading.strategies.trend import TrendStrategy
@@ -13,7 +14,7 @@ class StrategyRegistry:
"HOLD": HoldStrategy(), "HOLD": HoldStrategy(),
"TREND": TrendStrategy(), "TREND": TrendStrategy(),
"GRID": HoldStrategy(), "GRID": HoldStrategy(),
"SCALP": HoldStrategy(), "SCALP": ScalpStrategy(),
} }
# получить стратегию по имени # получить стратегию по имени

View File

@@ -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)

View File

@@ -10,28 +10,24 @@ from src.trading.strategies.signals import SignalResult, SignalType
class TrendStrategy: class TrendStrategy:
name = "TREND" name = "TREND"
_last_prices: dict[str, float] = {} _price_window: dict[str, list[float]] = {}
_threshold_percent = 0.02
# рассчитать уверенность сигнала по силе движения цены # длиннее окно = меньше шума
def _calculate_confidence(self, change_percent: float) -> float: _window_size = 8
strength = abs(change_percent) / self._threshold_percent
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: def analyze(self, context: StrategyContext) -> SignalResult:
try: try:
ticker = ExchangeService().get_price(context.symbol) snapshot = ExchangeService().get_market_snapshot(context.symbol)
except Exception as exc: except Exception as exc:
return SignalResult( return SignalResult(
signal=SignalType.HOLD, signal=SignalType.HOLD,
reason="Не удалось получить рыночную цену. Безопасный HOLD.", reason="Не удалось получить рыночный snapshot. Безопасный HOLD.",
confidence=0.0, confidence=0.0,
payload={ payload={
"strategy": self.name, "strategy": self.name,
@@ -40,63 +36,159 @@ class TrendStrategy:
}, },
) )
symbol = ticker.symbol symbol = str(snapshot.get("symbol") or context.symbol)
current_price = ticker.price current_price = self._analysis_price(snapshot)
previous_price = self._last_prices.get(symbol)
self._last_prices[symbol] = current_price if current_price <= 0:
if previous_price is None or previous_price <= 0:
return SignalResult( return SignalResult(
signal=SignalType.HOLD, 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, confidence=0.0,
payload={ payload={
"strategy": self.name, "strategy": self.name,
"symbol": symbol, "symbol": symbol,
"price": current_price, "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( return SignalResult(
signal=SignalType.BUY, signal=SignalType.BUY,
reason="Цена растёт выше порога тренда.", reason="Устойчивый рост цены в окне TREND.",
confidence=self._calculate_confidence(change_percent), confidence=self._calculate_confidence(change_percent, direction_ratio),
payload={ payload=payload,
"strategy": self.name,
"symbol": symbol,
"previous_price": previous_price,
"current_price": current_price,
"change_percent": round(change_percent, 5),
},
) )
if change_percent <= -self._threshold_percent: if (
change_percent <= -self._threshold_percent
and direction_ratio >= self._min_direction_ratio
):
return SignalResult( return SignalResult(
signal=SignalType.SELL, signal=SignalType.SELL,
reason="Цена падает ниже порога тренда.", reason="Устойчивое снижение цены в окне TREND.",
confidence=self._calculate_confidence(change_percent), confidence=self._calculate_confidence(change_percent, direction_ratio),
payload={ payload=payload,
"strategy": self.name,
"symbol": symbol,
"previous_price": previous_price,
"current_price": current_price,
"change_percent": round(change_percent, 5),
},
) )
return SignalResult( return SignalResult(
signal=SignalType.HOLD, signal=SignalType.HOLD,
reason="Изменение цены ниже порога тренда.", reason="Тренд недостаточно устойчивый.",
confidence=0.0, confidence=0.0,
payload={ payload=payload,
"strategy": self.name, )
"symbol": symbol,
"previous_price": previous_price, def _analysis_price(self, snapshot: dict[str, object]) -> float:
"current_price": current_price, bid = self._safe_float(snapshot.get("bid_price"))
"change_percent": round(change_percent, 5), 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)

View File

@@ -242,6 +242,30 @@
- risk_percent теперь реально влияет на размер позиции - risk_percent теперь реально влияет на размер позиции
- flip теперь проходит через margin protection - 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 ### 07.4.4
⏳ Grid Strategy ⏳ Grid Strategy

View File

@@ -228,6 +228,31 @@
- risk_percent теперь реально влияет на размер позиции - risk_percent теперь реально влияет на размер позиции
- flip теперь проходит через margin protection - 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 ### 07.4.4

View File

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