feat: add market data architecture and complete migration through build 039

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit a996f2f797
443 changed files with 80452 additions and 1335 deletions

View 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