759 lines
20 KiB
Python
759 lines
20 KiB
Python
# app/src/telegram/handlers/auto/ui.py
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import time
|
||
|
||
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
|
||
|
||
|
||
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="🧯 Защита", callback_data="auto:risk")
|
||
|
||
builder.adjust(3, 2)
|
||
return builder.as_markup()
|
||
|
||
|
||
def is_auto_configured(state) -> bool:
|
||
if not state.symbol:
|
||
return False
|
||
|
||
if not state.strategy:
|
||
return False
|
||
|
||
if state.risk_percent is None:
|
||
return False
|
||
|
||
strategy = state.strategy.upper()
|
||
|
||
if strategy == "TREND":
|
||
return (
|
||
state.stop_loss_percent is not None
|
||
and state.stop_loss_percent > 0
|
||
)
|
||
|
||
return True
|
||
|
||
|
||
def build_auto_text() -> str:
|
||
state = AutoTradeService().get_state()
|
||
|
||
if not is_auto_configured(state):
|
||
return _build_not_configured_text(state)
|
||
|
||
if state.position_side != "NONE" and state.entry_price is not None:
|
||
return _build_active_position_text(state)
|
||
|
||
if state.status == "OFF":
|
||
return _build_stopped_without_position_text(state)
|
||
|
||
return _build_waiting_text(state)
|
||
|
||
|
||
def _build_not_configured_text(state) -> str:
|
||
symbol_ready = state.symbol is not None
|
||
strategy_ready = state.strategy is not None
|
||
risk_ready = state.risk_percent is not None
|
||
|
||
symbol_icon = "" if symbol_ready else "⚠️"
|
||
strategy_icon = "" if strategy_ready else "⚠️"
|
||
risk_icon = "" if risk_ready else "⚠️"
|
||
|
||
parts = [
|
||
"🤖 Автоторговля ⚪ Не настроена",
|
||
_account_mode_line(),
|
||
"",
|
||
f"{symbol_icon} <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),
|
||
_market_state_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)}",
|
||
_market_state_line(state),
|
||
*_execution_block_lines(state),
|
||
"",
|
||
(
|
||
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 _market_state_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 _execution_block_lines(state) -> list[str]:
|
||
lines: list[str] = []
|
||
|
||
reason = getattr(state, "execution_block_reason", None)
|
||
if reason:
|
||
lines.append(f"Blocked · {reason}")
|
||
|
||
adjustment = getattr(state, "execution_size_adjustment_reason", None)
|
||
if adjustment == "MARGIN_LIMIT":
|
||
lines.append("Size adjusted by Max Reserved")
|
||
|
||
return lines
|
||
|
||
|
||
def _allocated_balance(state) -> float:
|
||
return float(getattr(state, "allocated_balance_usd", 1000.0) or 1000.0)
|
||
|
||
|
||
def _realized_pnl(state) -> float:
|
||
return float(getattr(state, "realized_pnl_usd", 0.0) or 0.0)
|
||
|
||
|
||
def _position_reserved_usd(state, current_price: float | None) -> float:
|
||
if (
|
||
state.position_side == "NONE"
|
||
or state.position_size is None
|
||
or state.position_size <= 0
|
||
):
|
||
return 0.0
|
||
|
||
price = current_price or state.entry_price or 0.0
|
||
leverage = state.leverage or 1.0
|
||
|
||
if price <= 0 or leverage <= 0:
|
||
return 0.0
|
||
|
||
return (state.position_size * price) / leverage
|
||
|
||
|
||
def _max_reserved_line(state, price: float | None = None) -> str:
|
||
size = _estimated_size(state, price)
|
||
|
||
if size is None or price is None or price <= 0:
|
||
return "<b>Собственные средства</b> · —"
|
||
|
||
leverage = state.leverage or 1.0
|
||
if leverage <= 0:
|
||
return "<b>Собственные средства</b> · —"
|
||
|
||
position_size_usd = size * price
|
||
own_funds_usd = position_size_usd / leverage
|
||
|
||
return (
|
||
f"<b>Собственные средства</b> · $ {_format_money_compact(own_funds_usd)}"
|
||
)
|
||
|
||
|
||
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
||
if not symbol:
|
||
return None
|
||
|
||
try:
|
||
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _current_price(symbol: str | None) -> float | None:
|
||
snapshot = _market_snapshot(symbol)
|
||
|
||
if snapshot is not None:
|
||
price = snapshot.get("last_price")
|
||
if price is not None:
|
||
return float(price)
|
||
|
||
if not symbol:
|
||
return None
|
||
|
||
try:
|
||
return float(ExchangeService().get_price(symbol).price)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _signal_entry_price(state) -> float | None:
|
||
snapshot = _market_snapshot(state.symbol)
|
||
|
||
if snapshot is None:
|
||
return _current_price(state.symbol)
|
||
|
||
signal = (state.last_signal or "HOLD").upper()
|
||
|
||
if signal == "BUY":
|
||
price = snapshot.get("ask_price")
|
||
elif signal == "SELL":
|
||
price = snapshot.get("bid_price")
|
||
else:
|
||
price = snapshot.get("last_price")
|
||
|
||
if price is None:
|
||
return None
|
||
|
||
return float(price)
|
||
|
||
|
||
def _target_risk_usd(state) -> float:
|
||
if state.risk_percent is None:
|
||
return 0.0
|
||
|
||
return _allocated_balance(state) * (state.risk_percent / 100)
|
||
|
||
|
||
def _estimated_size(state, price: float | None) -> float | None:
|
||
if (
|
||
price is None
|
||
or price <= 0
|
||
or state.risk_percent is None
|
||
or state.risk_percent <= 0
|
||
or state.stop_loss_percent is None
|
||
or state.stop_loss_percent <= 0
|
||
):
|
||
return None
|
||
|
||
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
|
||
if stop_loss_distance_usd <= 0:
|
||
return None
|
||
|
||
risk_size = _target_risk_usd(state) / stop_loss_distance_usd
|
||
|
||
max_percent = getattr(state, "max_reserved_balance_percent", None)
|
||
if max_percent is None or max_percent <= 0:
|
||
return _round_size(risk_size)
|
||
|
||
leverage = state.leverage or 1.0
|
||
if leverage <= 0:
|
||
return _round_size(risk_size)
|
||
|
||
max_reserved_usd = _allocated_balance(state) * (max_percent / 100)
|
||
max_notional_usd = max_reserved_usd * leverage
|
||
max_size = max_notional_usd / price
|
||
|
||
return _round_size(min(risk_size, max_size))
|
||
|
||
|
||
def _estimated_size_text(state, price: float | None) -> str:
|
||
size = _estimated_size(state, price)
|
||
if size is None or price is None:
|
||
return "<b>Количество</b> · —"
|
||
|
||
notional = size * price
|
||
|
||
return (
|
||
f"<b>Количество</b> · {_format_crypto_size(size)}\n"
|
||
f"<b>Размер позиции</b> · $ {_format_money_compact(notional)}"
|
||
)
|
||
|
||
|
||
def _risk_summary_line(
|
||
state,
|
||
size: float | None,
|
||
*,
|
||
entry_price_override: float | None = None,
|
||
) -> str:
|
||
entry_price = entry_price_override or state.entry_price
|
||
|
||
sl = _risk_loss_text(
|
||
percent=state.stop_loss_percent,
|
||
fixed_loss=None,
|
||
size=size,
|
||
entry_price=entry_price,
|
||
)
|
||
tp = _risk_profit_text(
|
||
percent=state.take_profit_percent,
|
||
size=size,
|
||
entry_price=entry_price,
|
||
)
|
||
ml = _risk_loss_text(
|
||
percent=None,
|
||
fixed_loss=state.max_loss_usd,
|
||
size=size,
|
||
entry_price=entry_price,
|
||
)
|
||
|
||
items = [
|
||
f"<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",
|
||
]
|
||
|
||
return " | ".join(items)
|
||
|
||
|
||
def _risk_loss_text(
|
||
*,
|
||
percent: float | None,
|
||
fixed_loss: float | None,
|
||
size: float | None,
|
||
entry_price: float | None,
|
||
) -> str:
|
||
if fixed_loss is not None:
|
||
return f"-$ {_format_money_compact(abs(fixed_loss))}"
|
||
|
||
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 ""
|
||
|
||
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:
|
||
if (
|
||
state.stop_loss_percent is None
|
||
or state.stop_loss_percent <= 0
|
||
or state.take_profit_percent is None
|
||
or state.take_profit_percent <= 0
|
||
):
|
||
return ""
|
||
|
||
ratio = state.take_profit_percent / state.stop_loss_percent
|
||
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:
|
||
if abs(value - round(value)) < 1e-9:
|
||
return str(int(round(value)))
|
||
|
||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||
|
||
|
||
def _status_text(status: str) -> str:
|
||
mapping = {
|
||
"OFF": "⚪ Остановлена",
|
||
"OBSERVING": "👀 Наблюдение",
|
||
"RUNNING": "🟢 Работает",
|
||
}
|
||
return mapping.get(status, status)
|
||
|
||
|
||
def _decision_human_text(status: str) -> str:
|
||
mapping = {
|
||
"WAITING": "Ожидание сигнала",
|
||
"CONFIRMING": "Подтверждение сигнала",
|
||
"READY": "Сигнал готов",
|
||
"BLOCKED": "Сигнал заблокирован",
|
||
}
|
||
return mapping.get(status, status)
|
||
|
||
|
||
def _account_mode_line() -> str:
|
||
return "DEMO аккаунт" if "DEMO" in mode_line().upper() else "LIVE аккаунт"
|
||
|
||
|
||
def _asset_symbol(symbol: str | None) -> str:
|
||
if not symbol:
|
||
return "—"
|
||
|
||
base = symbol.split("_", 1)[0].upper()
|
||
|
||
if "/" in base:
|
||
return base.split("/", 1)[0]
|
||
|
||
for suffix in ("USDT", "USD", "EUR", "BTC"):
|
||
if base.endswith(suffix) and len(base) > len(suffix):
|
||
return base[: -len(suffix)]
|
||
|
||
return base
|
||
|
||
|
||
def _strategy_short(strategy: str | None) -> str:
|
||
if not strategy:
|
||
return "—"
|
||
|
||
mapping = {
|
||
"TREND": "Trend",
|
||
"GRID": "Grid",
|
||
"SCALP": "Scalp",
|
||
}
|
||
return mapping.get(strategy.upper(), strategy.title())
|
||
|
||
|
||
def _leverage_text(value: float | None) -> str:
|
||
if value is None:
|
||
return "x—"
|
||
return f"x{value:g}"
|
||
|
||
|
||
def _risk_percent_text(state) -> str:
|
||
if state.risk_percent is None:
|
||
return "—"
|
||
|
||
return _format_percent(state.risk_percent)
|
||
|
||
|
||
def _required_value(value: str) -> str:
|
||
if not value or value == "—":
|
||
return "⏤"
|
||
|
||
return value
|
||
|
||
|
||
def _signal_confidence_lines(state) -> list[str]:
|
||
signal = (state.last_signal or "HOLD").upper()
|
||
|
||
if signal == "HOLD":
|
||
return []
|
||
|
||
return [
|
||
f"Уверенность · {(state.last_signal_confidence or 0.0):.2f}"
|
||
]
|
||
|
||
|
||
def _signal_icon(signal: str | None) -> str:
|
||
mapping = {
|
||
"BUY": "🟢",
|
||
"SELL": "🔴",
|
||
"HOLD": "🟡",
|
||
}
|
||
return mapping.get(signal or "", "")
|
||
|
||
|
||
def _round_size(value: float | int | None) -> float | None:
|
||
if value is None:
|
||
return None
|
||
|
||
precision = 5
|
||
factor = 10 ** precision
|
||
|
||
return math.floor(float(value) * factor) / factor
|
||
|
||
|
||
def _format_crypto_size(value: float | int | None) -> str:
|
||
rounded = _round_size(value)
|
||
if rounded is None:
|
||
return "—"
|
||
|
||
return f"{rounded:.5f}".rstrip("0").rstrip(".")
|
||
|
||
|
||
def _format_percent(value: float | int | None) -> str:
|
||
if value is None:
|
||
return "off"
|
||
|
||
number = float(value)
|
||
|
||
if abs(number - round(number)) < 1e-9:
|
||
return f"{int(round(number))}%"
|
||
|
||
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
|
||
|
||
|
||
def _format_money(value: float | int | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
|
||
return format_usd_amount(float(value))
|
||
|
||
|
||
def _format_money_compact(value: float | int | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
|
||
number = float(value)
|
||
|
||
if abs(number - round(number)) < 1e-9:
|
||
return f"{number:,.0f}".replace(",", " ")
|
||
|
||
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||
|
||
|
||
def _format_usd_or_dash(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
|
||
return f"$ {_format_money(value)}"
|
||
|
||
|
||
def _format_signed_usd(value: float | int | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
|
||
amount = float(value)
|
||
|
||
if amount > 0:
|
||
return f"+$ {_format_money_compact(amount)}"
|
||
|
||
if amount < 0:
|
||
return f"−$ {_format_money_compact(abs(amount))}"
|
||
|
||
return "$ 0"
|
||
|
||
|
||
def _format_signed_usd_with_direction(value: float | int | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
|
||
amount = float(value)
|
||
|
||
if amount > 0:
|
||
return f"🟢 +$ {_format_money_compact(amount)}"
|
||
|
||
if amount < 0:
|
||
return f"🔴 −$ {_format_money_compact(abs(amount))}"
|
||
|
||
return "$ 0" |