07.4.4.1.9.2 Signal Confirmation Runtime
This commit is contained in:
@@ -1,280 +0,0 @@
|
||||
# app/src/telegram/handlers/auto/ui.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.telegram.ui.common import mode_line
|
||||
from src.telegram.ui.currency_ui import format_usd_amount
|
||||
from src.trading.auto.service import AutoTradeService
|
||||
|
||||
|
||||
PAPER_BALANCE_USD = 1000.0
|
||||
|
||||
def strategy_label(strategy: str | None) -> str:
|
||||
mapping = {
|
||||
"TREND": "📈 Trend Following",
|
||||
"GRID": "🧩 Grid Trading",
|
||||
"SCALP": "⚡ Scalping",
|
||||
}
|
||||
return mapping.get(strategy or "", "—")
|
||||
|
||||
|
||||
def status_label(status: str) -> str:
|
||||
mapping = {
|
||||
"OFF": "⚪ Выключена",
|
||||
"OBSERVING": "👀 Наблюдение",
|
||||
"RUNNING": "🟢 Активна",
|
||||
}
|
||||
return mapping.get(status, status)
|
||||
|
||||
|
||||
def signal_label(signal: str | None) -> str:
|
||||
mapping = {
|
||||
"BUY": "🟢 BUY",
|
||||
"SELL": "🔴 SELL",
|
||||
"HOLD": "🟡 HOLD",
|
||||
}
|
||||
return mapping.get(signal or "", "—")
|
||||
|
||||
|
||||
def decision_label(status: str) -> str:
|
||||
mapping = {
|
||||
"WAITING": "🟡 Ожидание",
|
||||
"CONFIRMING": "🟠 Подтверждение",
|
||||
"READY": "🟢 Готово к входу",
|
||||
"BLOCKED": "🔴 Заблокировано",
|
||||
}
|
||||
return mapping.get(status, status)
|
||||
|
||||
|
||||
def value_or_dash(value: object) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return str(value)
|
||||
|
||||
|
||||
def price_or_dash(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def market_price_or_dash(symbol: str | None) -> str:
|
||||
if not symbol:
|
||||
return "—"
|
||||
|
||||
try:
|
||||
ticker = ExchangeService().get_price(symbol)
|
||||
return f"$ {format_usd_amount(ticker.price)}"
|
||||
except Exception:
|
||||
return "—"
|
||||
|
||||
|
||||
def usd_or_dash(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:.2f} USD"
|
||||
|
||||
|
||||
def size_or_dash(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:.8f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def leverage_or_dash(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:.1f}x"
|
||||
|
||||
|
||||
def format_symbol(symbol: str | None) -> str:
|
||||
if not symbol:
|
||||
return "—"
|
||||
|
||||
base_symbol = symbol.split("_", 1)[0]
|
||||
parts = base_symbol.split("/", 1)
|
||||
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]} / {parts[1]}"
|
||||
|
||||
return base_symbol
|
||||
|
||||
|
||||
def compact_strategy(strategy: str | None) -> str:
|
||||
if not strategy:
|
||||
return "—"
|
||||
return strategy.upper()
|
||||
|
||||
|
||||
def compact_leverage(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"x{value:g}"
|
||||
|
||||
|
||||
def is_auto_configured(state) -> bool:
|
||||
return bool(
|
||||
state.symbol
|
||||
and state.strategy
|
||||
and state.risk_percent is not None
|
||||
)
|
||||
|
||||
|
||||
def context_line(state) -> str:
|
||||
symbol = format_symbol(state.symbol)
|
||||
strategy = compact_strategy(state.strategy)
|
||||
leverage = compact_leverage(state.leverage)
|
||||
|
||||
if leverage == "—":
|
||||
return f"{symbol} · {strategy}"
|
||||
|
||||
return f"{symbol} · {strategy} · {leverage}"
|
||||
|
||||
|
||||
def auto_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="▶️ Start", callback_data="auto:start")
|
||||
builder.button(text="👀 Watch", callback_data="auto:observe")
|
||||
builder.button(text="🛑 Stop", callback_data="auto:stop")
|
||||
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
|
||||
builder.button(text="⚠️ Risk", callback_data="auto:risk")
|
||||
|
||||
builder.adjust(3, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def risk_settings_line(state) -> str:
|
||||
sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off"
|
||||
tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off"
|
||||
max_loss = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off"
|
||||
|
||||
return f"Controls: SL {sl} · TP {tp} · ML {max_loss}"
|
||||
|
||||
|
||||
def target_risk_usd_line(state) -> str:
|
||||
if state.risk_percent is None:
|
||||
return "Target Risk: —"
|
||||
|
||||
risk_usd = PAPER_BALANCE_USD * (state.risk_percent / 100)
|
||||
return f"Target Risk: {risk_usd:.2f} USD"
|
||||
|
||||
|
||||
def estimated_size_line(state) -> str:
|
||||
if (
|
||||
state.risk_percent is None
|
||||
or state.risk_percent <= 0
|
||||
or state.stop_loss_percent is None
|
||||
or state.stop_loss_percent <= 0
|
||||
):
|
||||
return "Est. Size: —"
|
||||
|
||||
try:
|
||||
ticker = ExchangeService().get_price(state.symbol)
|
||||
price = ticker.price
|
||||
except Exception:
|
||||
return "Est. Size: —"
|
||||
|
||||
if price <= 0:
|
||||
return "Est. Size: —"
|
||||
|
||||
target_risk_usd = PAPER_BALANCE_USD * (state.risk_percent / 100)
|
||||
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
|
||||
|
||||
if stop_loss_distance_usd <= 0:
|
||||
return "Est. Size: —"
|
||||
|
||||
size = target_risk_usd / stop_loss_distance_usd
|
||||
return f"Est. Size: {size:.8f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def position_size_line(state) -> str:
|
||||
if state.position_size is None or state.position_size == 0:
|
||||
return "Position Size: —"
|
||||
|
||||
return f"Position Size: {state.position_size:.8f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def actual_risk_usd_line(state) -> str:
|
||||
if (
|
||||
state.position_side == "NONE"
|
||||
or state.entry_price is None
|
||||
or state.position_size is None
|
||||
or state.stop_loss_percent is None
|
||||
or state.stop_loss_percent <= 0
|
||||
):
|
||||
return "Actual Risk: —"
|
||||
|
||||
stop_loss_distance_usd = state.entry_price * (state.stop_loss_percent / 100)
|
||||
actual_risk_usd = stop_loss_distance_usd * state.position_size
|
||||
|
||||
return f"Actual Risk: {actual_risk_usd:.2f} USD"
|
||||
|
||||
|
||||
def build_auto_text() -> str:
|
||||
service = AutoTradeService()
|
||||
state = service.get_state()
|
||||
|
||||
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
|
||||
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
|
||||
configured = is_auto_configured(state)
|
||||
price = market_price_or_dash(state.symbol)
|
||||
|
||||
status_line = {
|
||||
"OFF": "⚪ Off",
|
||||
"OBSERVING": "👀 Watch",
|
||||
"RUNNING": "🟢 On",
|
||||
}.get(state.status, state.status)
|
||||
|
||||
header = (
|
||||
f"<b>🤖 Автоторговля · {status_line}</b>\n"
|
||||
f"🔸 {account_mode} аккаунт\n\n"
|
||||
)
|
||||
|
||||
if state.status == "OFF":
|
||||
if not configured:
|
||||
return (
|
||||
f"{header}"
|
||||
"⚠️ Не настроена\n"
|
||||
"Настрой параметры"
|
||||
)
|
||||
|
||||
return (
|
||||
f"{header}"
|
||||
f"{context_line(state)}\n"
|
||||
f"Price: {price}\n"
|
||||
f"Position Risk: {risk}\n"
|
||||
f"{target_risk_usd_line(state)}\n"
|
||||
f"{estimated_size_line(state)}\n"
|
||||
f"{risk_settings_line(state)}"
|
||||
)
|
||||
|
||||
position_line = (
|
||||
f"Pos: {value_or_dash(state.position_side)} | "
|
||||
f"PnL: {usd_or_dash(state.unrealized_pnl_usd)}"
|
||||
)
|
||||
|
||||
if state.position_side != "NONE" and state.entry_price is not None:
|
||||
position_line = (
|
||||
f"Pos: {value_or_dash(state.position_side)} | "
|
||||
f"Entry: $ {price_or_dash(state.entry_price)} | "
|
||||
f"PnL: {usd_or_dash(state.unrealized_pnl_usd)}"
|
||||
)
|
||||
|
||||
return (
|
||||
f"{header}"
|
||||
f"{context_line(state)}\n"
|
||||
f"Price: {price}\n\n"
|
||||
f"{signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
|
||||
f"· {state.decision_status}\n\n"
|
||||
f"{position_line}\n"
|
||||
f"Position Risk: {risk}\n"
|
||||
f"{target_risk_usd_line(state)}\n"
|
||||
f"{position_size_line(state)}\n"
|
||||
f"{actual_risk_usd_line(state)}\n"
|
||||
f"{risk_settings_line(state)}"
|
||||
)
|
||||
@@ -10,7 +10,6 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.telegram.ui.common import mode_line
|
||||
from src.telegram.ui.currency_ui import format_usd_amount
|
||||
from src.trading.auto.service import AutoTradeService
|
||||
|
||||
|
||||
@@ -76,9 +75,9 @@ def _build_not_configured_text(state) -> str:
|
||||
"🤖 Автоторговля ⚪ Не настроена",
|
||||
_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"
|
||||
f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}",
|
||||
f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}",
|
||||
f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}",
|
||||
]
|
||||
|
||||
strategy = (state.strategy or "").upper()
|
||||
@@ -91,8 +90,7 @@ def _build_not_configured_text(state) -> str:
|
||||
)
|
||||
|
||||
sl_icon = "" if sl_value != "⏤" else "⚠️"
|
||||
|
||||
parts.append(f"{sl_icon} <b>SL</b> · {sl_value}")
|
||||
parts.append(f"{sl_icon} SL · {sl_value}")
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
@@ -109,7 +107,6 @@ def _build_stopped_without_position_text(state) -> str:
|
||||
estimated_size = _estimated_size(state, price)
|
||||
|
||||
rr_line = _risk_reward_line(state)
|
||||
|
||||
risk_line = _risk_summary_line(
|
||||
state,
|
||||
estimated_size,
|
||||
@@ -120,14 +117,14 @@ def _build_stopped_without_position_text(state) -> str:
|
||||
f"🤖 Автоторговля {_status_text(state.status)}",
|
||||
_account_mode_line(),
|
||||
"",
|
||||
f"<b>Доступно</b> · $ {_format_money_compact(available)}\n",
|
||||
"🧾 <b>Подготовка ордера</b>",
|
||||
f"Доступно 💰 {_format_money_compact(available)}",
|
||||
"",
|
||||
"Подготовка ордера 🧾",
|
||||
_order_header_line(state),
|
||||
f"<b>Цена</b> · {_format_usd_or_dash(price)}",
|
||||
f"Цена · {_format_plain_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))})",
|
||||
f"Риск · {_format_money_compact(_target_risk_usd(state))}",
|
||||
]
|
||||
|
||||
if rr_line or risk_line:
|
||||
@@ -157,8 +154,8 @@ def _build_waiting_text(state) -> str:
|
||||
|
||||
signal_lines = [
|
||||
_signal_line(state),
|
||||
_market_state_line(state),
|
||||
_market_diagnostics_line(state),
|
||||
_signal_confirmation_line(state),
|
||||
_market_semantic_line(state),
|
||||
_entry_block_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_signal_confidence_lines(state),
|
||||
@@ -171,17 +168,21 @@ def _build_waiting_text(state) -> str:
|
||||
f"🤖 Автоторговля {_status_text(state.status)}",
|
||||
_account_mode_line(),
|
||||
"",
|
||||
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
|
||||
f"Доступно 💰 {_format_money_compact(available)}",
|
||||
]
|
||||
|
||||
if signal_lines:
|
||||
parts.extend(["", *signal_lines])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
*signal_lines,
|
||||
"",
|
||||
"🧾 Подготовка ордера",
|
||||
"Подготовка ордера 🧾",
|
||||
_order_header_line(state),
|
||||
f"<b>{_price_label_for_signal(state)}</b> · {_format_usd_or_dash(price)}",
|
||||
f"{_price_label_for_signal(state)} · {_format_plain_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))})",
|
||||
]
|
||||
f"Риск · {_format_money_compact(_target_risk_usd(state))}",
|
||||
])
|
||||
|
||||
if rr_line or risk_line:
|
||||
parts.append("")
|
||||
@@ -210,29 +211,40 @@ def _build_active_position_text(state) -> str:
|
||||
|
||||
side_icon = "🟢" if state.position_side == "LONG" else "🔴"
|
||||
|
||||
market_lines = [
|
||||
_market_semantic_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_execution_block_lines(state),
|
||||
]
|
||||
market_lines = [line for line in market_lines if line]
|
||||
|
||||
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)}",
|
||||
_market_state_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_execution_block_lines(state),
|
||||
f"Доступно 💰 {_format_money_compact(available)}",
|
||||
f"Маржа · {_format_money_compact(reserved)}",
|
||||
f"P&L {_format_signed_plain_with_direction(pnl)}",
|
||||
]
|
||||
|
||||
if market_lines:
|
||||
parts.extend(["", *market_lines])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
(
|
||||
f"{side_icon} <b>{_asset_symbol(state.symbol)}</b> · "
|
||||
f"{side_icon} {_asset_symbol(state.symbol)} · "
|
||||
f"{_strategy_short(state.strategy)} · "
|
||||
f"<b>{state.position_side}</b> {_leverage_text(state.leverage)}"
|
||||
f"{state.position_side} {_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)}",
|
||||
f"Размер · {_format_crypto_size(size)}",
|
||||
f"Позиция · {_format_money_compact(notional)}",
|
||||
f"Вход · {_format_plain_or_dash(state.entry_price)}",
|
||||
f"Цена · {_format_plain_or_dash(price_for_calc)}",
|
||||
"",
|
||||
"⚠️ Комиссии не учтены",
|
||||
]
|
||||
])
|
||||
|
||||
if rr_line or risk_line:
|
||||
parts.append("")
|
||||
@@ -246,63 +258,72 @@ def _build_active_position_text(state) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _market_state_line(state) -> str:
|
||||
def _market_semantic_line(state) -> str:
|
||||
market_state = getattr(state, "market_state", None)
|
||||
|
||||
labels = {
|
||||
"TREND_UP": "📈 Тренд · Вверх",
|
||||
"TREND_DOWN": "📉 Тренд · Вниз",
|
||||
"RANGE": "🟰 Рынок · Флэт",
|
||||
"HIGH_VOLATILITY": "⚠️ Рынок · Высокая волатильность",
|
||||
"LOW_VOLATILITY": "🟰 Рынок · Низкая активность",
|
||||
"UNKNOWN": "⏳ Рынок · Идёт анализ",
|
||||
None: "⏳ Рынок · Идёт анализ",
|
||||
}
|
||||
|
||||
return labels.get(market_state, "⏳ Рынок · Идёт анализ")
|
||||
|
||||
|
||||
def _market_diagnostics_line(state) -> str:
|
||||
trend = getattr(state, "market_trend", None)
|
||||
strength = getattr(state, "market_trend_strength", None)
|
||||
quality = getattr(state, "market_trend_quality", None)
|
||||
phase = getattr(state, "market_phase", None)
|
||||
|
||||
if not strength and not quality and not phase:
|
||||
return ""
|
||||
if market_state in {None, "UNKNOWN"}:
|
||||
return "⏳ Рынок · анализ"
|
||||
|
||||
strength_labels = {
|
||||
"WEAK": "слабый",
|
||||
"NORMAL": "нормальный",
|
||||
"STRONG": "сильный",
|
||||
}
|
||||
if market_state == "HIGH_VOLATILITY":
|
||||
return "⚠️ Рынок · перегрев"
|
||||
|
||||
quality_labels = {
|
||||
"CLEAN": "чистый",
|
||||
"NOISY": "шумный",
|
||||
}
|
||||
if market_state == "LOW_VOLATILITY" or phase == "SQUEEZE":
|
||||
return "🟦 Рынок · сжатие"
|
||||
|
||||
phase_labels = {
|
||||
"IMPULSE": "импульс",
|
||||
"PULLBACK": "откат",
|
||||
"RANGE": "флэт",
|
||||
"SQUEEZE": "сжатие",
|
||||
}
|
||||
if market_state == "RANGE" or phase == "RANGE":
|
||||
return "🟰 Рынок · флэт"
|
||||
|
||||
parts = []
|
||||
if phase == "PULLBACK":
|
||||
if trend == "UP":
|
||||
return "↘️ Рынок · коррекция"
|
||||
|
||||
if strength in strength_labels:
|
||||
parts.append(strength_labels[strength])
|
||||
if trend == "DOWN":
|
||||
return "↗️ Рынок · откат вверх"
|
||||
|
||||
if quality in quality_labels:
|
||||
parts.append(quality_labels[quality])
|
||||
return "↔️ Рынок · откат"
|
||||
|
||||
if phase in phase_labels:
|
||||
parts.append(phase_labels[phase])
|
||||
if quality == "NOISY":
|
||||
if trend == "UP":
|
||||
return "⚠️ Рынок · шумный рост"
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
if trend == "DOWN":
|
||||
return "⚠️ Рынок · шумное снижение"
|
||||
|
||||
return f"Анализ · {' · '.join(parts)}"
|
||||
return "⚠️ Рынок · шум"
|
||||
|
||||
if strength == "WEAK":
|
||||
if trend == "UP":
|
||||
return "🟡 Рынок · слабый рост"
|
||||
|
||||
if trend == "DOWN":
|
||||
return "🟡 Рынок · слабое снижение"
|
||||
|
||||
return "🟡 Рынок · слабое движение"
|
||||
|
||||
if phase == "IMPULSE":
|
||||
if trend == "UP" and strength == "STRONG":
|
||||
return "⚡ Рынок · сильный рост"
|
||||
|
||||
if trend == "DOWN" and strength == "STRONG":
|
||||
return "⚡ Рынок · сильное снижение"
|
||||
|
||||
if trend == "UP":
|
||||
return "📈 Рынок · рост"
|
||||
|
||||
if trend == "DOWN":
|
||||
return "📉 Рынок · снижение"
|
||||
|
||||
if trend == "UP":
|
||||
return "📈 Рынок · рост"
|
||||
|
||||
if trend == "DOWN":
|
||||
return "📉 Рынок · снижение"
|
||||
|
||||
return "⏳ Рынок · анализ"
|
||||
|
||||
|
||||
def _compact_entry_block_message(message: str) -> str:
|
||||
@@ -328,15 +349,8 @@ def _entry_block_line(state) -> str:
|
||||
return ""
|
||||
|
||||
compact_message = _compact_entry_block_message(str(message))
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
if signal == "HOLD":
|
||||
return f"Условие · {compact_message}"
|
||||
|
||||
if signal in {"BUY", "SELL"}:
|
||||
return f"Вход · {compact_message}"
|
||||
|
||||
return ""
|
||||
return f"🧩 Фильтр · {compact_message}"
|
||||
|
||||
|
||||
def _execution_quality_line(state) -> str:
|
||||
@@ -352,10 +366,10 @@ def _execution_quality_line(state) -> str:
|
||||
return ""
|
||||
|
||||
if reason == "WIDE_SPREAD" and spread_percent is not None:
|
||||
return f"⚠️ Рынок · spread {_format_percent(spread_percent)}"
|
||||
return f"⚠️ Вход · spread {_format_percent(spread_percent)}"
|
||||
|
||||
if reason == "AGING_SNAPSHOT" and age_seconds is not None:
|
||||
return f"⚠️ Рынок · данные стареют ({age_seconds:.1f}с)"
|
||||
return f"⚠️ Вход · данные стареют {age_seconds:.1f}с"
|
||||
|
||||
if reason == "STALE_SNAPSHOT":
|
||||
return "⛔ Вход · рынок неактуален"
|
||||
@@ -364,7 +378,7 @@ def _execution_quality_line(state) -> str:
|
||||
return f"⛔ Вход · высокий spread {_format_percent(spread_percent)}"
|
||||
|
||||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||||
return "⚠️ Рынок · нет depth snapshot"
|
||||
return "⚠️ Вход · нет стакана"
|
||||
|
||||
if reason == "SNAPSHOT_ERROR":
|
||||
return "⛔ Вход · нет данных рынка"
|
||||
@@ -374,7 +388,7 @@ def _execution_quality_line(state) -> str:
|
||||
if not message:
|
||||
return ""
|
||||
|
||||
return f"⚠️ Рынок · {message}"
|
||||
return f"⚠️ Вход · {message}"
|
||||
|
||||
|
||||
def _execution_block_lines(state) -> list[str]:
|
||||
@@ -392,9 +406,7 @@ def _execution_block_lines(state) -> list[str]:
|
||||
adjustment = getattr(state, "execution_size_adjustment_reason", None)
|
||||
|
||||
if adjustment == "MARGIN_LIMIT":
|
||||
lines.append(
|
||||
"Позиция ограничена настройкой Max Reserved."
|
||||
)
|
||||
lines.append("Позиция ограничена настройкой Max Reserved.")
|
||||
|
||||
return lines
|
||||
|
||||
@@ -428,18 +440,16 @@ def _max_reserved_line(state, price: float | None = None) -> str:
|
||||
size = _estimated_size(state, price)
|
||||
|
||||
if size is None or price is None or price <= 0:
|
||||
return "<b>Собственные средства</b> · —"
|
||||
return "Маржа · —"
|
||||
|
||||
leverage = state.leverage or 1.0
|
||||
if leverage <= 0:
|
||||
return "<b>Собственные средства</b> · —"
|
||||
return "Маржа · —"
|
||||
|
||||
position_size_usd = size * price
|
||||
own_funds_usd = position_size_usd / leverage
|
||||
|
||||
return (
|
||||
f"<b>Собственные средства</b> · $ {_format_money_compact(own_funds_usd)}"
|
||||
)
|
||||
return f"Маржа · {_format_money_compact(own_funds_usd)}"
|
||||
|
||||
|
||||
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
||||
@@ -532,13 +542,13 @@ def _estimated_size(state, price: float | None) -> float | None:
|
||||
def _estimated_size_text(state, price: float | None) -> str:
|
||||
size = _estimated_size(state, price)
|
||||
if size is None or price is None:
|
||||
return "<b>Количество</b> · —"
|
||||
return "Размер · —\nПозиция · —"
|
||||
|
||||
notional = size * price
|
||||
|
||||
return (
|
||||
f"<b>Количество</b> · {_format_crypto_size(size)}\n"
|
||||
f"<b>Размер позиции</b> · $ {_format_money_compact(notional)}"
|
||||
f"Размер · {_format_crypto_size(size)}\n"
|
||||
f"Позиция · {_format_money_compact(notional)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -569,9 +579,9 @@ def _risk_summary_line(
|
||||
)
|
||||
|
||||
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",
|
||||
f"SL {sl}" if sl else "SL off",
|
||||
f"TP {tp}" if tp else "TP off",
|
||||
f"ML {ml}" if ml else "ML off",
|
||||
]
|
||||
|
||||
return " | ".join(items)
|
||||
@@ -585,19 +595,19 @@ def _risk_loss_text(
|
||||
entry_price: float | None,
|
||||
) -> str:
|
||||
if fixed_loss is not None:
|
||||
return f"-$ {_format_money_compact(abs(fixed_loss))}"
|
||||
return f"-{_format_money_compact(abs(fixed_loss))}"
|
||||
|
||||
if percent is None:
|
||||
return ""
|
||||
|
||||
if size is None or size <= 0 or entry_price is None or entry_price <= 0:
|
||||
loss = _target_loss_by_percent_stub(percent)
|
||||
return f"-$ {_format_money_compact(loss)}" if loss is not None else ""
|
||||
return f"-{_format_money_compact(loss)}" if loss is not None else ""
|
||||
|
||||
move = entry_price * (percent / 100)
|
||||
loss = move * size
|
||||
|
||||
return f"-$ {_format_money_compact(loss)}"
|
||||
return f"-{_format_money_compact(loss)}"
|
||||
|
||||
|
||||
def _risk_profit_text(
|
||||
@@ -615,7 +625,7 @@ def _risk_profit_text(
|
||||
move = entry_price * (percent / 100)
|
||||
profit = move * size
|
||||
|
||||
return f"+$ {_format_money_compact(profit)}"
|
||||
return f"+{_format_money_compact(profit)}"
|
||||
|
||||
|
||||
def _target_loss_by_percent_stub(percent: float | None) -> float | None:
|
||||
@@ -635,7 +645,7 @@ def _risk_reward_line(state) -> str:
|
||||
return ""
|
||||
|
||||
ratio = state.take_profit_percent / state.stop_loss_percent
|
||||
return f"<b>R:R</b> = 1 : {_format_ratio_value(ratio)}"
|
||||
return f"R:R = 1 : {_format_ratio_value(ratio)}"
|
||||
|
||||
|
||||
def _order_header_line(state) -> str:
|
||||
@@ -645,14 +655,14 @@ def _order_header_line(state) -> str:
|
||||
return (
|
||||
f"🟢 <b>{_asset_symbol(state.symbol)}</b> · "
|
||||
f"{_strategy_short(state.strategy)} · "
|
||||
f"<b>LONG</b> {_leverage_text(state.leverage)}"
|
||||
f"LONG {_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)}"
|
||||
f"SHORT {_leverage_text(state.leverage)}"
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -666,7 +676,7 @@ def _price_label_for_signal(state) -> str:
|
||||
signal = (state.last_signal or "").upper()
|
||||
|
||||
if signal in {"BUY", "SELL"}:
|
||||
return "Цена входа"
|
||||
return "Вход"
|
||||
|
||||
return "Цена"
|
||||
|
||||
@@ -674,15 +684,52 @@ def _price_label_for_signal(state) -> str:
|
||||
def _signal_line(state) -> str:
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
signal_text = f"Сигнал {_signal_icon(signal)} {signal}"
|
||||
|
||||
if signal in {"BUY", "SELL"} and (
|
||||
state.decision_status == "READY"
|
||||
or getattr(state, "is_signal_ready", False)
|
||||
):
|
||||
return f"Сигнал {_signal_icon(signal)} {signal} · READY"
|
||||
return f"{signal_text} · READY"
|
||||
|
||||
duration = _signal_duration_text(state)
|
||||
|
||||
return f"Сигнал {_signal_icon(signal)} {signal} · {duration}"
|
||||
return f"{signal_text} · {duration}"
|
||||
|
||||
|
||||
def _signal_confirmation_line(state) -> str:
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
return ""
|
||||
|
||||
status = getattr(state, "decision_status", None)
|
||||
|
||||
seconds = int(getattr(state, "signal_confirmation_seconds", 0) or 0)
|
||||
required_seconds = int(
|
||||
getattr(state, "signal_confirmation_required_seconds", 10) or 10
|
||||
)
|
||||
repeats = int(getattr(state, "last_signal_repeat_count", 0) or 0)
|
||||
|
||||
missing_repeats = int(
|
||||
getattr(state, "signal_confirmation_missing_repeats", 0) or 0
|
||||
)
|
||||
required_repeats = repeats + missing_repeats
|
||||
|
||||
if status == "READY" or getattr(state, "is_signal_ready", False):
|
||||
return "✅ Подтверждение · готово"
|
||||
|
||||
if status == "BLOCKED":
|
||||
return "⛔ Подтверждение · заблокировано"
|
||||
|
||||
if status == "CONFIRMING":
|
||||
return (
|
||||
f"⏳ Подтверждение · "
|
||||
f"{repeats}/{required_repeats} · "
|
||||
f"{seconds}/{required_seconds}с"
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _signal_duration_text(state) -> str:
|
||||
@@ -768,6 +815,7 @@ def _strategy_short(strategy: str | None) -> str:
|
||||
def _leverage_text(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "x—"
|
||||
|
||||
return f"x{value:g}"
|
||||
|
||||
|
||||
@@ -802,6 +850,7 @@ def _signal_icon(signal: str | None) -> str:
|
||||
"SELL": "🔴",
|
||||
"HOLD": "🟡",
|
||||
}
|
||||
|
||||
return mapping.get(signal or "", "")
|
||||
|
||||
|
||||
@@ -817,6 +866,7 @@ def _round_size(value: float | int | None) -> float | None:
|
||||
|
||||
def _format_crypto_size(value: float | int | None) -> str:
|
||||
rounded = _round_size(value)
|
||||
|
||||
if rounded is None:
|
||||
return "—"
|
||||
|
||||
@@ -835,13 +885,6 @@ def _format_percent(value: float | int | None) -> str:
|
||||
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
|
||||
|
||||
|
||||
def _format_money(value: float | int | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
return format_usd_amount(float(value))
|
||||
|
||||
|
||||
def _format_money_compact(value: float | int | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
@@ -854,11 +897,15 @@ def _format_money_compact(value: float | int | None) -> str:
|
||||
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_usd_or_dash(value: float | None) -> str:
|
||||
def _format_plain_or_dash(value: float | int | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
return f"$ {_format_money(value)}"
|
||||
return _format_money_compact(value)
|
||||
|
||||
|
||||
def _format_usd_or_dash(value: float | None) -> str:
|
||||
return _format_plain_or_dash(value)
|
||||
|
||||
|
||||
def _format_signed_usd(value: float | int | None) -> str:
|
||||
@@ -868,24 +915,28 @@ def _format_signed_usd(value: float | int | None) -> str:
|
||||
amount = float(value)
|
||||
|
||||
if amount > 0:
|
||||
return f"+$ {_format_money_compact(amount)}"
|
||||
return f"+{_format_money_compact(amount)}"
|
||||
|
||||
if amount < 0:
|
||||
return f"−$ {_format_money_compact(abs(amount))}"
|
||||
return f"−{_format_money_compact(abs(amount))}"
|
||||
|
||||
return "$ 0"
|
||||
return "0"
|
||||
|
||||
|
||||
def _format_signed_usd_with_direction(value: float | int | None) -> str:
|
||||
return _format_signed_plain_with_direction(value)
|
||||
|
||||
|
||||
def _format_signed_plain_with_direction(value: float | int | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
amount = float(value)
|
||||
|
||||
if amount > 0:
|
||||
return f"🟢 +$ {_format_money_compact(amount)}"
|
||||
return f"🟢 +{_format_money_compact(amount)}"
|
||||
|
||||
if amount < 0:
|
||||
return f"🔴 −$ {_format_money_compact(abs(amount))}"
|
||||
return f"🔴 −{_format_money_compact(abs(amount))}"
|
||||
|
||||
return "$ 0"
|
||||
return "0"
|
||||
@@ -24,6 +24,9 @@ class AutoTradeService:
|
||||
# минимальное количество повторов BUY / SELL для подтверждения сигнала
|
||||
_confirm_repeats = 2
|
||||
|
||||
# минимальное время удержания BUY / SELL сигнала для подтверждения
|
||||
_confirm_min_duration_seconds = 10
|
||||
|
||||
# минимальная уверенность для готовности к будущему execution
|
||||
_ready_confidence = 0.3
|
||||
|
||||
@@ -114,6 +117,11 @@ class AutoTradeService:
|
||||
state.last_signal_repeat_count = repeat_count
|
||||
state.last_signal_confidence = confidence
|
||||
state.last_signal_reason = reason
|
||||
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_missing_repeats = 0
|
||||
state.signal_confirmation_progress = 1.0
|
||||
state.signal_confirmation_reason = "debug confirmation"
|
||||
|
||||
if normalized_signal == "HOLD":
|
||||
state.decision_status = "WAITING"
|
||||
@@ -359,6 +367,11 @@ class AutoTradeService:
|
||||
state.decision_reason = None
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
state.signal_confirmation_seconds = 0
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||
state.signal_confirmation_progress = 0.0
|
||||
state.signal_confirmation_reason = None
|
||||
state.execution_block_reason = None
|
||||
state.signal_started_at = None
|
||||
state.signal_updated_at = None
|
||||
@@ -436,20 +449,60 @@ class AutoTradeService:
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
|
||||
if signal == "HOLD":
|
||||
state.signal_confirmation_seconds = 0
|
||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||
state.signal_confirmation_progress = 0.0
|
||||
state.signal_confirmation_reason = None
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = "Нет торгового направления."
|
||||
return
|
||||
|
||||
if self._same_signal_count < self._confirm_repeats:
|
||||
now = time.monotonic()
|
||||
|
||||
if state.signal_started_at is None:
|
||||
signal_age_seconds = 0
|
||||
else:
|
||||
signal_age_seconds = max(0, int(now - float(state.signal_started_at)))
|
||||
|
||||
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
|
||||
missing_seconds = max(
|
||||
0,
|
||||
self._confirm_min_duration_seconds - signal_age_seconds,
|
||||
)
|
||||
|
||||
repeat_progress = min(
|
||||
1.0,
|
||||
self._same_signal_count / max(1, self._confirm_repeats),
|
||||
)
|
||||
time_progress = min(
|
||||
1.0,
|
||||
signal_age_seconds / max(1, self._confirm_min_duration_seconds),
|
||||
)
|
||||
|
||||
confirmation_progress = min(repeat_progress, time_progress)
|
||||
|
||||
state.signal_confirmation_seconds = signal_age_seconds
|
||||
state.signal_confirmation_missing_repeats = missing_repeats
|
||||
state.signal_confirmation_progress = round(confirmation_progress, 3)
|
||||
|
||||
if missing_repeats > 0 or missing_seconds > 0:
|
||||
state.decision_status = "CONFIRMING"
|
||||
state.signal_confirmation_reason = (
|
||||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с"
|
||||
)
|
||||
state.decision_reason = (
|
||||
f"Сигнал {signal} подтверждается: "
|
||||
f"{self._same_signal_count}/{self._confirm_repeats} повторов."
|
||||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с."
|
||||
)
|
||||
return
|
||||
|
||||
state.is_signal_confirmed = True
|
||||
state.signal_confirmation_reason = "сигнал подтверждён"
|
||||
|
||||
if confidence < self._ready_confidence:
|
||||
state.decision_status = "BLOCKED"
|
||||
@@ -460,9 +513,10 @@ class AutoTradeService:
|
||||
return
|
||||
|
||||
state.is_signal_ready = True
|
||||
state.signal_confirmation_progress = 1.0
|
||||
state.decision_status = "READY"
|
||||
state.decision_reason = (
|
||||
f"Сигнал {signal} подтверждён и готов к будущему execution."
|
||||
f"Сигнал {signal} подтверждён по повторам и времени удержания."
|
||||
)
|
||||
|
||||
# записать новый сигнал и итог предыдущей серии при смене сигнала
|
||||
@@ -728,6 +782,9 @@ class AutoTradeService:
|
||||
"decision_status": state.decision_status,
|
||||
"is_strong_signal": confidence > self._ready_confidence,
|
||||
"is_aggregated": False,
|
||||
"confirmation_seconds": state.signal_confirmation_seconds,
|
||||
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||||
"confirmation_progress": state.signal_confirmation_progress,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -164,4 +164,19 @@ class AutoTradeState:
|
||||
execution_quality_message: str | None = None
|
||||
|
||||
# признак деградации runtime market data
|
||||
market_runtime_degraded: bool = False
|
||||
market_runtime_degraded: bool = False
|
||||
|
||||
# сколько секунд текущий BUY / SELL сигнал удерживается
|
||||
signal_confirmation_seconds: int = 0
|
||||
|
||||
# сколько секунд нужно удерживать BUY / SELL сигнал для подтверждения
|
||||
signal_confirmation_required_seconds: int = 10
|
||||
|
||||
# сколько повторов ещё не хватает до подтверждения
|
||||
signal_confirmation_missing_repeats: int = 0
|
||||
|
||||
# прогресс подтверждения сигнала от 0.0 до 1.0
|
||||
signal_confirmation_progress: float = 0.0
|
||||
|
||||
# человекочитаемая причина текущего confirmation status
|
||||
signal_confirmation_reason: str | None = None
|
||||
Reference in New Issue
Block a user