The CLOB API does not expose 24h volume or order-book liquidity, so the size_anomaly detector currently has no real ratio to compare a trade against and falls back to the niche-base 0.2 confidence floor. That makes the volume_impact / book_impact thresholds essentially dead code. This change adds a small client for the public gamma-api markets endpoint and merges its volume24hr / liquidityNum snapshot into MarketMetadata during the existing periodic sync. The detector can now compute real volume and book impact ratios. Notes on the gamma client: - gamma-api enforces a server-side max of 100 markets per page and caps `offset` around 10000. The client paginates with bounded concurrency, sorted by `volume24hr desc`, so the most-traded markets (which is where size anomalies actually matter) are always covered. Markets beyond that window have negligible recent volume and the niche path handles them fine without a ratio. - Failures are swallowed: a degraded gamma endpoint must not stop CLOB metadata from being cached, since size_anomaly + the niche path remain functional with `daily_volume=None`. MarketMetadata gains three optional Decimal fields (daily_volume, weekly_volume, liquidity); to_dict / from_dict round-trip is preserved and older cache entries without these keys deserialize cleanly. Tests: 12 new tests for GammaClient (parsing, single-page, short-page stop, offset-cap clean stop, retry, malformed responses); existing metadata_sync tests updated to inject a mocked GammaClient so they don't hit the real network. Co-authored-by: schrodinger01 <schrodinger01@users.noreply.github.com>
250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
"""Tests for the gamma-api client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from polymarket_insider_tracker.ingestor import gamma_client as gamma_module
|
|
from polymarket_insider_tracker.ingestor.gamma_client import (
|
|
GammaClient,
|
|
GammaClientError,
|
|
GammaMarketStats,
|
|
_parse_market,
|
|
)
|
|
|
|
|
|
class TestParseMarket:
|
|
def test_parses_full_payload(self) -> None:
|
|
raw = {
|
|
"conditionId": "0xabc",
|
|
"volume24hr": "12345.67",
|
|
"volume1wk": "100000",
|
|
"volume1mo": "500000",
|
|
"volumeNum": "999999.5",
|
|
"liquidityNum": "42000",
|
|
}
|
|
stats = _parse_market(raw)
|
|
assert stats is not None
|
|
assert stats.condition_id == "0xabc"
|
|
assert stats.daily_volume == Decimal("12345.67")
|
|
assert stats.weekly_volume == Decimal("100000")
|
|
assert stats.monthly_volume == Decimal("500000")
|
|
assert stats.total_volume == Decimal("999999.5")
|
|
assert stats.liquidity == Decimal("42000")
|
|
|
|
def test_falls_back_to_alternative_keys(self) -> None:
|
|
raw = {
|
|
"conditionId": "0x1",
|
|
"volume24hr": "1",
|
|
"volume": "777",
|
|
"liquidity": "55",
|
|
}
|
|
stats = _parse_market(raw)
|
|
assert stats is not None
|
|
assert stats.total_volume == Decimal("777")
|
|
assert stats.liquidity == Decimal("55")
|
|
|
|
def test_handles_missing_numeric_fields(self) -> None:
|
|
stats = _parse_market({"conditionId": "0x2"})
|
|
assert stats is not None
|
|
assert stats.daily_volume is None
|
|
assert stats.liquidity is None
|
|
|
|
def test_drops_garbage_decimals(self) -> None:
|
|
stats = _parse_market(
|
|
{"conditionId": "0x3", "volume24hr": "not-a-number", "liquidityNum": ""}
|
|
)
|
|
assert stats is not None
|
|
assert stats.daily_volume is None
|
|
assert stats.liquidity is None
|
|
|
|
def test_rejects_missing_condition_id(self) -> None:
|
|
assert _parse_market({"volume24hr": "1"}) is None
|
|
assert _parse_market({"conditionId": ""}) is None
|
|
assert _parse_market({"conditionId": 123}) is None # type: ignore[arg-type]
|
|
|
|
|
|
def _make_client(
|
|
_handler: httpx.MockTransport,
|
|
*,
|
|
page_limit: int = 100,
|
|
max_pages: int = 5,
|
|
page_concurrency: int = 5,
|
|
max_retries: int = 1,
|
|
) -> GammaClient:
|
|
"""Build a GammaClient that constructs httpx.AsyncClient with the given transport.
|
|
|
|
GammaClient creates its own AsyncClient inside `get_active_market_stats`,
|
|
so we monkeypatch the AsyncClient factory in the module to inject the mock
|
|
transport.
|
|
"""
|
|
return GammaClient(
|
|
page_limit=page_limit,
|
|
max_pages=max_pages,
|
|
page_concurrency=page_concurrency,
|
|
max_retries=max_retries,
|
|
retry_base_delay_seconds=0.0,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_async_client(monkeypatch: pytest.MonkeyPatch):
|
|
"""Replace the AsyncClient used by gamma_client with one bound to a MockTransport."""
|
|
|
|
def _apply(handler: httpx.MockTransport) -> None:
|
|
original = gamma_module.httpx.AsyncClient
|
|
|
|
def factory(*args: object, **kwargs: object) -> httpx.AsyncClient:
|
|
kwargs["transport"] = handler # type: ignore[index]
|
|
return original(*args, **kwargs) # type: ignore[arg-type]
|
|
|
|
monkeypatch.setattr(gamma_module.httpx, "AsyncClient", factory)
|
|
|
|
return _apply
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_market_stats_single_page(patch_async_client) -> None:
|
|
page_one = [
|
|
{"conditionId": "0xa", "volume24hr": "100", "liquidityNum": "10"},
|
|
{"conditionId": "0xb", "volume24hr": "200", "liquidityNum": "20"},
|
|
]
|
|
calls: list[dict[str, str]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(dict(request.url.params))
|
|
offset = int(request.url.params.get("offset", "0"))
|
|
if offset == 0:
|
|
return httpx.Response(200, json=page_one)
|
|
return httpx.Response(200, json=[])
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=2, max_pages=3)
|
|
|
|
result = await client.get_active_market_stats()
|
|
|
|
assert set(result.keys()) == {"0xa", "0xb"}
|
|
assert isinstance(result["0xa"], GammaMarketStats)
|
|
assert result["0xa"].daily_volume == Decimal("100")
|
|
assert calls[0]["limit"] == "2"
|
|
assert calls[0]["order"] == "volume24hr"
|
|
assert calls[0]["ascending"] == "false"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_market_stats_short_page_stops(patch_async_client) -> None:
|
|
"""A page shorter than page_limit signals end-of-data after a small empty streak."""
|
|
page_zero = [{"conditionId": f"0x{i}", "volume24hr": str(i)} for i in range(5)]
|
|
page_one_short = [{"conditionId": "0xshort", "volume24hr": "1"}]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
offset = int(request.url.params.get("offset", "0"))
|
|
if offset == 0:
|
|
return httpx.Response(200, json=page_zero)
|
|
if offset == 5:
|
|
return httpx.Response(200, json=page_one_short)
|
|
return httpx.Response(200, json=[])
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=5, max_pages=10)
|
|
|
|
result = await client.get_active_market_stats()
|
|
|
|
assert "0xshort" in result
|
|
assert len(result) == 6
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_market_stats_offset_cap_clean_stop(
|
|
patch_async_client,
|
|
) -> None:
|
|
"""Gamma rejects offsets past its hard cap; that error is swallowed cleanly."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
offset = int(request.url.params.get("offset", "0"))
|
|
if offset == 0:
|
|
return httpx.Response(200, json=[{"conditionId": "0xa", "volume24hr": "1"}])
|
|
return httpx.Response(400, json={"error": "offset too large"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=1, max_pages=4, max_retries=1)
|
|
|
|
result = await client.get_active_market_stats()
|
|
assert "0xa" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_retry_recovers_after_transient_error(
|
|
patch_async_client,
|
|
) -> None:
|
|
"""Transient HTTP errors retry up to max_retries before giving up."""
|
|
state = {"attempts": 0}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
offset = int(request.url.params.get("offset", "0"))
|
|
if offset == 0:
|
|
state["attempts"] += 1
|
|
if state["attempts"] < 2:
|
|
return httpx.Response(503, json={"error": "transient"})
|
|
return httpx.Response(200, json=[{"conditionId": "0xrecover", "volume24hr": "1"}])
|
|
return httpx.Response(200, json=[])
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=1, max_pages=2, max_retries=3)
|
|
|
|
result = await client.get_active_market_stats()
|
|
assert "0xrecover" in result
|
|
assert state["attempts"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unexpected_response_shape_is_handled(patch_async_client) -> None:
|
|
"""A non-list payload becomes a clean stop, not a crash."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"unexpected": "shape"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=1, max_pages=2, max_retries=1)
|
|
|
|
result = await client.get_active_market_stats()
|
|
assert result == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_non_dict_entries(patch_async_client) -> None:
|
|
"""Defensive: server returning mixed-type list items shouldn't crash."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
offset = int(request.url.params.get("offset", "0"))
|
|
if offset == 0:
|
|
return httpx.Response(
|
|
200,
|
|
json=[
|
|
{"conditionId": "0xa", "volume24hr": "1"},
|
|
"garbage",
|
|
None,
|
|
42,
|
|
],
|
|
)
|
|
return httpx.Response(200, json=[])
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
patch_async_client(transport)
|
|
client = _make_client(transport, page_limit=4, max_pages=2)
|
|
|
|
result = await client.get_active_market_stats()
|
|
assert list(result.keys()) == ["0xa"]
|
|
|
|
|
|
def test_gamma_client_error_inherits_exception() -> None:
|
|
assert issubclass(GammaClientError, Exception)
|