build 039: complete Quotes Feed migration foundation
This commit is contained in:
0
app/tests/unit/telegram/__init__.py
Normal file
0
app/tests/unit/telegram/__init__.py
Normal file
0
app/tests/unit/telegram/ui/__init__.py
Normal file
0
app/tests/unit/telegram/ui/__init__.py
Normal file
607
app/tests/unit/telegram/ui/test_currency_ui.py
Normal file
607
app/tests/unit/telegram/ui/test_currency_ui.py
Normal file
@@ -0,0 +1,607 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user