07.4.4.1.9.2 Signal Confirmation Runtime

This commit is contained in:
2026-05-12 10:57:31 +03:00
parent 5325ea3855
commit fc50cadabf
8 changed files with 1122 additions and 410 deletions

View File

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

View File

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

View File

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

View File

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