607 lines
12 KiB
Python
607 lines
12 KiB
Python
# app/tests/unit/telegram/ui/test_currency_ui.py
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
from src.integrations.exchange.exceptions import ExchangeError
|
|
from src.integrations.exchange.models import (
|
|
BalanceSummary,
|
|
)
|
|
from src.integrations.exchange.service import ExchangeService
|
|
from src.market_data.acquisition.models.instrument import Instrument
|
|
from src.market_data.acquisition.models.quote import Quote
|
|
from src.telegram.ui.currency_ui import (
|
|
_resolve_asset_quote_instrument,
|
|
estimate_balance_usd,
|
|
get_asset_usd_rate,
|
|
)
|
|
|
|
|
|
def _instrument(
|
|
*,
|
|
symbol: str,
|
|
base_asset: str,
|
|
quote_asset: str = "USD",
|
|
status: str = "TRADING",
|
|
market_type: str = "SPOT",
|
|
) -> Instrument:
|
|
return Instrument(
|
|
symbol=symbol,
|
|
name=symbol,
|
|
status=status,
|
|
base_asset=base_asset,
|
|
quote_asset=quote_asset,
|
|
asset_type="CRYPTOCURRENCY",
|
|
market_type=market_type,
|
|
market_modes=("REGULAR",),
|
|
order_types=("LIMIT", "MARKET"),
|
|
base_asset_precision=8,
|
|
quote_asset_precision=8,
|
|
tick_size=Decimal("0.01"),
|
|
tick_value=None,
|
|
step_size=Decimal("0.0001"),
|
|
min_qty=Decimal("0.0001"),
|
|
max_qty=None,
|
|
min_notional=Decimal("1"),
|
|
country=None,
|
|
sector=None,
|
|
industry=None,
|
|
trading_hours=None,
|
|
)
|
|
|
|
|
|
def _service() -> ExchangeService:
|
|
return object.__new__(
|
|
ExchangeService
|
|
)
|
|
|
|
|
|
def test_resolver_uses_get_instruments(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
instruments = (
|
|
_instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
),
|
|
)
|
|
call_count = 0
|
|
|
|
def get_instruments() -> tuple[Instrument, ...]:
|
|
nonlocal call_count
|
|
|
|
call_count += 1
|
|
return instruments
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
get_instruments,
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is instruments[0]
|
|
assert call_count == 1
|
|
|
|
|
|
def test_resolver_uses_canonical_instrument_reference(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
instrument = _instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
)
|
|
|
|
calls = 0
|
|
|
|
def get_instruments() -> tuple[Instrument, ...]:
|
|
nonlocal calls
|
|
|
|
calls += 1
|
|
return (
|
|
instrument,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
get_instruments,
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is instrument
|
|
assert calls == 1
|
|
|
|
|
|
def test_resolver_prefers_usd_over_usdt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
usd = _instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
quote_asset="USD",
|
|
market_type="LEVERAGE",
|
|
)
|
|
usdt = _instrument(
|
|
symbol="BTC/USDT",
|
|
base_asset="BTC",
|
|
quote_asset="USDT",
|
|
market_type="SPOT",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
usdt,
|
|
usd,
|
|
),
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is usd
|
|
|
|
|
|
def test_resolver_prefers_trading_status(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
active = _instrument(
|
|
symbol="BTC/USD_ACTIVE",
|
|
base_asset="BTC",
|
|
status="TRADING",
|
|
market_type="LEVERAGE",
|
|
)
|
|
inactive = _instrument(
|
|
symbol="BTC/USD_INACTIVE",
|
|
base_asset="BTC",
|
|
status="UNKNOWN",
|
|
market_type="SPOT",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
inactive,
|
|
active,
|
|
),
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is active
|
|
|
|
|
|
def test_resolver_prefers_spot_when_other_priority_is_equal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
spot = _instrument(
|
|
symbol="BTC/USD_SPOT",
|
|
base_asset="BTC",
|
|
market_type="SPOT",
|
|
)
|
|
leverage = _instrument(
|
|
symbol="BTC/USD_LEVERAGE",
|
|
base_asset="BTC",
|
|
market_type="LEVERAGE",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
leverage,
|
|
spot,
|
|
),
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is spot
|
|
|
|
|
|
def test_resolver_ignores_other_base_assets(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
_instrument(
|
|
symbol="ETH/USD",
|
|
base_asset="ETH",
|
|
),
|
|
),
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolver_ignores_unsupported_quote_assets(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
_instrument(
|
|
symbol="BTC/EUR",
|
|
base_asset="BTC",
|
|
quote_asset="EUR",
|
|
),
|
|
),
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolver_returns_none_when_get_instruments_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
def raise_error() -> tuple[Instrument, ...]:
|
|
raise ExchangeError(
|
|
"exchangeInfo unavailable"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
raise_error,
|
|
)
|
|
|
|
result = _resolve_asset_quote_instrument(
|
|
service,
|
|
"BTC",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"currency",
|
|
[
|
|
"USD",
|
|
"usd",
|
|
"USDT",
|
|
"usdt",
|
|
],
|
|
)
|
|
def test_usd_and_usdt_rates_are_one(
|
|
currency: str,
|
|
) -> None:
|
|
service = _service()
|
|
cache: dict[str, float | None] = {}
|
|
|
|
result = get_asset_usd_rate(
|
|
service,
|
|
currency,
|
|
cache,
|
|
)
|
|
|
|
assert result == 1.0
|
|
assert cache == {}
|
|
|
|
|
|
def test_get_asset_usd_rate_uses_existing_price_cache(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
cache: dict[str, float | None] = {
|
|
"BTC": 50_000.0,
|
|
}
|
|
|
|
def fail_if_called() -> tuple[Instrument, ...]:
|
|
raise AssertionError(
|
|
"Instrument lookup must not run on price cache hit."
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
fail_if_called,
|
|
)
|
|
|
|
result = get_asset_usd_rate(
|
|
service,
|
|
"BTC",
|
|
cache,
|
|
)
|
|
|
|
assert result == 50_000.0
|
|
|
|
|
|
def test_get_asset_usd_rate_loads_price_by_instrument_symbol(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
instrument = _instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
)
|
|
requested_symbols: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
instrument,
|
|
),
|
|
)
|
|
|
|
def get_quote(
|
|
symbol: str,
|
|
*,
|
|
runtime_key: str | None = None,
|
|
) -> Quote:
|
|
del runtime_key
|
|
|
|
requested_symbols.append(symbol)
|
|
|
|
return Quote(
|
|
symbol=symbol,
|
|
last_price=Decimal("50000"),
|
|
bid_price=Decimal("49999"),
|
|
ask_price=Decimal("50001"),
|
|
exchange_timestamp=None,
|
|
received_at=datetime.now(timezone.utc),
|
|
source="test",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_quote",
|
|
get_quote,
|
|
)
|
|
|
|
cache: dict[str, float | None] = {}
|
|
|
|
result = get_asset_usd_rate(
|
|
service,
|
|
"BTC",
|
|
cache,
|
|
)
|
|
|
|
assert result == 50_000.0
|
|
assert requested_symbols == [
|
|
"BTC/USD",
|
|
]
|
|
assert cache == {
|
|
"BTC": 50_000.0,
|
|
}
|
|
|
|
|
|
def test_get_asset_usd_rate_caches_missing_instrument(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (),
|
|
)
|
|
|
|
cache: dict[str, float | None] = {}
|
|
|
|
result = get_asset_usd_rate(
|
|
service,
|
|
"BTC",
|
|
cache,
|
|
)
|
|
|
|
assert result is None
|
|
assert cache == {
|
|
"BTC": None,
|
|
}
|
|
|
|
|
|
def test_get_asset_usd_rate_caches_price_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
_instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
),
|
|
),
|
|
)
|
|
|
|
def raise_error(
|
|
symbol: str,
|
|
*,
|
|
runtime_key: str | None = None,
|
|
) -> Quote:
|
|
del symbol
|
|
del runtime_key
|
|
|
|
raise ExchangeError(
|
|
"ticker unavailable"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_quote",
|
|
raise_error,
|
|
)
|
|
|
|
cache: dict[str, float | None] = {}
|
|
|
|
result = get_asset_usd_rate(
|
|
service,
|
|
"BTC",
|
|
cache,
|
|
)
|
|
|
|
assert result is None
|
|
assert cache == {
|
|
"BTC": None,
|
|
}
|
|
|
|
|
|
def test_estimate_balance_usd_for_fiat_balance() -> None:
|
|
service = _service()
|
|
|
|
balance = BalanceSummary(
|
|
currency="USD",
|
|
available=100.0,
|
|
locked=25.0,
|
|
source="test",
|
|
)
|
|
|
|
result = estimate_balance_usd(
|
|
balance,
|
|
service,
|
|
{},
|
|
)
|
|
|
|
assert result == 125.0
|
|
|
|
|
|
def test_estimate_balance_usd_for_crypto_balance(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (
|
|
_instrument(
|
|
symbol="BTC/USD",
|
|
base_asset="BTC",
|
|
),
|
|
),
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_quote",
|
|
lambda symbol, **_: Quote(
|
|
symbol=symbol,
|
|
last_price=Decimal("50000"),
|
|
bid_price=Decimal("49999"),
|
|
ask_price=Decimal("50001"),
|
|
exchange_timestamp=None,
|
|
received_at=datetime.now(timezone.utc),
|
|
source="test",
|
|
),
|
|
)
|
|
|
|
balance = BalanceSummary(
|
|
currency="BTC",
|
|
available=0.01,
|
|
locked=0.005,
|
|
source="test",
|
|
)
|
|
|
|
result = estimate_balance_usd(
|
|
balance,
|
|
service,
|
|
{},
|
|
)
|
|
|
|
assert result == pytest.approx(
|
|
750.0
|
|
)
|
|
|
|
|
|
def test_estimate_balance_usd_returns_none_for_zero_total() -> None:
|
|
service = _service()
|
|
|
|
balance = BalanceSummary(
|
|
currency="BTC",
|
|
available=0.0,
|
|
locked=0.0,
|
|
source="test",
|
|
)
|
|
|
|
result = estimate_balance_usd(
|
|
balance,
|
|
service,
|
|
{},
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_estimate_balance_usd_returns_none_when_rate_is_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"get_instruments",
|
|
lambda: (),
|
|
)
|
|
|
|
balance = BalanceSummary(
|
|
currency="BTC",
|
|
available=1.0,
|
|
locked=0.0,
|
|
source="test",
|
|
)
|
|
|
|
result = estimate_balance_usd(
|
|
balance,
|
|
service,
|
|
{},
|
|
)
|
|
|
|
assert result is None |