Spaces:
Running
Running
| """Tests for the polymarket subpackage. | |
| Covers: | |
| * Mock client deterministic stream | |
| * Real-mode happy path (httpx mocked) | |
| * Real-mode network failure falls back to mock + labels result | |
| * Mode toggle via env var | |
| * Builder code registration round-trip (demo + real) | |
| * FillListener calls recordFill with correct args + dedupes fills | |
| * FillListener emits SSE + DB events with correct shape | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| from pathlib import Path | |
| from typing import Any | |
| import httpx | |
| import pytest | |
| from polyglot_alpha.polymarket import ( | |
| BuilderFeeEvent, | |
| Fill, | |
| MockPolymarketClient, | |
| PolymarketMode, | |
| PolymarketV2Client, | |
| Question, | |
| register_builder_code, | |
| resolve_translator_for_code, | |
| ) | |
| from polyglot_alpha.polymarket.fill_listener import FillListener | |
| # --------------------------------------------------------------------------- | |
| # fixtures | |
| # --------------------------------------------------------------------------- | |
| def question() -> Question: | |
| return Question( | |
| question_id="q-1", | |
| text="Will GPT-5 ship before 2027?", | |
| category="ai", | |
| resolution_source="https://openai.com", | |
| end_date_iso="2026-12-31T23:59:59Z", | |
| initial_liquidity_usdc=500.0, | |
| ) | |
| def translator_address() -> str: | |
| return "0xAbCdEf0000000000000000000000000000000001" | |
| def isolated_builder_registry(tmp_path, monkeypatch): | |
| """Each test gets its own builder-code registry file.""" | |
| target = tmp_path / "builder_codes.json" | |
| monkeypatch.setenv("POLYGLOT_BUILDER_REGISTRY_PATH", str(target)) | |
| yield target | |
| # --------------------------------------------------------------------------- | |
| # MockPolymarketClient | |
| # --------------------------------------------------------------------------- | |
| async def test_mock_client_submit_and_fills_are_deterministic(question): | |
| # Same seed -> identical streams. Demonstrates reproducibility for demos. | |
| fake_time = [1_700_000_000] | |
| def now() -> int: | |
| return fake_time[0] | |
| a = MockPolymarketClient("CODE1", seed=42, time_fn=now) | |
| b = MockPolymarketClient("CODE1", seed=42, time_fn=now) | |
| submit_a = await a.submit_question(question) | |
| submit_b = await b.submit_question(question) | |
| assert submit_a.is_simulated is True | |
| assert submit_b.is_simulated is True | |
| assert submit_a.status == "submitted" | |
| assert submit_a.polymarket_url.startswith("https://polymarket.com/market/") | |
| assert submit_a.fees_estimate_usdc == pytest.approx(500.0 * 0.004) | |
| # Advance synthetic time by 2 minutes so a fills batch can land. | |
| fake_time[0] += 120 | |
| fills_a = await a.list_fills(submit_a.market_id, since_ts=now() - 120) | |
| fills_b = await b.list_fills(submit_b.market_id, since_ts=now() - 120) | |
| assert [f.fill_amount_usdc for f in fills_a] == [ | |
| f.fill_amount_usdc for f in fills_b | |
| ] | |
| for f in fills_a: | |
| assert 50.0 <= f.fill_amount_usdc <= 500.0 | |
| assert f.builder_fee_usdc == pytest.approx(f.fill_amount_usdc * 0.004) | |
| assert f.is_simulated is True | |
| await a.close() | |
| await b.close() | |
| # --------------------------------------------------------------------------- | |
| # PolymarketV2Client — real mode happy path & fallback | |
| # --------------------------------------------------------------------------- | |
| async def test_real_mode_submit_question_happy_path(question, monkeypatch): | |
| """Real mode hits Gamma; we mock the transport with httpx.MockTransport.""" | |
| captured: dict[str, Any] = {} | |
| def handler(request: httpx.Request) -> httpx.Response: | |
| captured["url"] = str(request.url) | |
| captured["body"] = request.content.decode() | |
| return httpx.Response( | |
| 200, | |
| json={ | |
| "id": "0xMARKET", | |
| "url": "https://polymarket.com/event/test", | |
| "status": "submitted", | |
| "fees_estimate_usdc": 2.5, | |
| }, | |
| ) | |
| transport = httpx.MockTransport(handler) | |
| http = httpx.AsyncClient(transport=transport, timeout=5.0) | |
| client = PolymarketV2Client( | |
| builder_code="BUILDER1", | |
| api_key="key", | |
| mode=PolymarketMode.REAL, | |
| http_client=http, | |
| ) | |
| # Real-mode now requires builder secrets + explicit | |
| # ``confirm_real_submission=True`` for safety. Inject the secrets | |
| # so the call is allowed to hit the mocked HTTP transport. | |
| import os as _os | |
| _os.environ["POLYMARKET_BUILDER_API_KEY"] = "test-key" | |
| _os.environ["POLYMARKET_BUILDER_API_SECRET"] = "test-secret" | |
| _os.environ["POLYMARKET_BUILDER_API_PASSPHRASE"] = "test-pass" | |
| try: | |
| result = await client.submit_question( | |
| question, confirm_real_submission=True | |
| ) | |
| finally: | |
| for k in ( | |
| "POLYMARKET_BUILDER_API_KEY", | |
| "POLYMARKET_BUILDER_API_SECRET", | |
| "POLYMARKET_BUILDER_API_PASSPHRASE", | |
| ): | |
| _os.environ.pop(k, None) | |
| assert result.market_id == "0xMARKET" | |
| assert result.is_simulated is False | |
| assert result.polymarket_url == "https://polymarket.com/event/test" | |
| assert result.fees_estimate_usdc == pytest.approx(2.5) | |
| assert "/markets" in captured["url"] | |
| assert "BUILDER1" in captured["body"] | |
| await client.close() | |
| async def test_real_mode_falls_back_to_mock_on_network_error(question): | |
| """If the real API errors out we must degrade to dry_run and label it.""" | |
| def handler(request: httpx.Request) -> httpx.Response: | |
| raise httpx.ConnectError("simulated outage") | |
| transport = httpx.MockTransport(handler) | |
| http = httpx.AsyncClient(transport=transport, timeout=1.0) | |
| client = PolymarketV2Client( | |
| builder_code="BUILDER1", | |
| mode=PolymarketMode.REAL, | |
| http_client=http, | |
| ) | |
| import os as _os | |
| _os.environ["POLYMARKET_BUILDER_API_KEY"] = "test-key" | |
| _os.environ["POLYMARKET_BUILDER_API_SECRET"] = "test-secret" | |
| _os.environ["POLYMARKET_BUILDER_API_PASSPHRASE"] = "test-pass" | |
| try: | |
| result = await client.submit_question( | |
| question, confirm_real_submission=True | |
| ) | |
| finally: | |
| for k in ( | |
| "POLYMARKET_BUILDER_API_KEY", | |
| "POLYMARKET_BUILDER_API_SECRET", | |
| "POLYMARKET_BUILDER_API_PASSPHRASE", | |
| ): | |
| _os.environ.pop(k, None) | |
| assert result.is_simulated is True | |
| assert result.error is not None | |
| assert "real_api_unavailable" in result.error | |
| await client.close() | |
| # --------------------------------------------------------------------------- | |
| # Mode toggle | |
| # --------------------------------------------------------------------------- | |
| def test_mode_defaults_to_dry_run_when_env_unset(monkeypatch): | |
| monkeypatch.delenv("POLYMARKET_MODE", raising=False) | |
| client = PolymarketV2Client(builder_code="X") | |
| assert client.mode == PolymarketMode.DRY_RUN | |
| def test_mode_honors_env_real(monkeypatch): | |
| monkeypatch.setenv("POLYMARKET_MODE", "real") | |
| client = PolymarketV2Client(builder_code="X") | |
| assert client.mode == PolymarketMode.REAL | |
| def test_mode_invalid_env_falls_back_to_dry_run(monkeypatch): | |
| monkeypatch.setenv("POLYMARKET_MODE", "production") | |
| client = PolymarketV2Client(builder_code="X") | |
| assert client.mode == PolymarketMode.DRY_RUN | |
| # --------------------------------------------------------------------------- | |
| # Builder code registry | |
| # --------------------------------------------------------------------------- | |
| def test_register_builder_code_demo_mode_is_deterministic(translator_address): | |
| code1 = register_builder_code(translator_address) | |
| code2 = register_builder_code(translator_address) | |
| assert code1 == code2 | |
| assert len(code1) == 10 | |
| assert resolve_translator_for_code(code1) == translator_address.lower() | |
| def test_register_builder_code_real_mode_preserves_code(translator_address): | |
| code = register_builder_code( | |
| translator_address, real_code="POLYGLOT_ALPHA_BUILDER_V1" | |
| ) | |
| assert code == "POLYGLOT_ALPHA_BUILDER_V1" | |
| assert resolve_translator_for_code(code) == translator_address.lower() | |
| def test_register_builder_code_rejects_bad_address(): | |
| with pytest.raises(ValueError): | |
| register_builder_code("not-an-address") | |
| # --------------------------------------------------------------------------- | |
| # FillListener | |
| # --------------------------------------------------------------------------- | |
| class _RecordingChain: | |
| """Minimal stand-in for ChainRecorder — records calls + returns tx hashes.""" | |
| def __init__(self, fail_for: set[str] | None = None) -> None: | |
| self.calls: list[dict[str, Any]] = [] | |
| self._fail_for = fail_for or set() | |
| self._counter = 0 | |
| async def record_fill( | |
| self, | |
| *, | |
| market_id: str, | |
| fill_amount_usdc: float, | |
| translator_address: str, | |
| ) -> str | None: | |
| self.calls.append( | |
| { | |
| "market_id": market_id, | |
| "fill_amount_usdc": fill_amount_usdc, | |
| "translator_address": translator_address, | |
| } | |
| ) | |
| if market_id in self._fail_for: | |
| raise RuntimeError("chain boom") | |
| self._counter += 1 | |
| return f"0x{self._counter:064x}" | |
| class _ScriptedFillSource: | |
| """Returns predefined fill batches per poll call.""" | |
| def __init__(self, batches: list[list[Fill]]) -> None: | |
| self._batches = list(batches) | |
| self.requests: list[tuple[str, int]] = [] | |
| async def list_fills(self, market_id: str, since_ts: int) -> list[Fill]: | |
| self.requests.append((market_id, since_ts)) | |
| if not self._batches: | |
| return [] | |
| return self._batches.pop(0) | |
| async def test_fill_listener_records_each_fill_with_correct_args( | |
| translator_address, | |
| ): | |
| fills = [ | |
| Fill( | |
| fill_id="f1", | |
| market_id="m-1", | |
| fill_amount_usdc=100.0, | |
| builder_fee_usdc=0.4, | |
| timestamp=1_700_000_010, | |
| is_simulated=True, | |
| ), | |
| Fill( | |
| fill_id="f2", | |
| market_id="m-1", | |
| fill_amount_usdc=250.0, | |
| builder_fee_usdc=1.0, | |
| timestamp=1_700_000_020, | |
| is_simulated=True, | |
| ), | |
| ] | |
| source = _ScriptedFillSource([fills, []]) | |
| chain = _RecordingChain() | |
| sse_events: list[dict] = [] | |
| db_rows: list[BuilderFeeEvent] = [] | |
| async def sse(event: dict) -> None: | |
| sse_events.append(event) | |
| async def db(event: BuilderFeeEvent) -> None: | |
| db_rows.append(event) | |
| listener = FillListener( | |
| client=source, | |
| market_id="m-1", | |
| translator_address=translator_address, | |
| sse_sink=sse, | |
| db_sink=db, | |
| chain_recorder=chain, | |
| time_fn=lambda: 1_700_000_000, | |
| ) | |
| events = await listener.poll_once() | |
| assert [c["market_id"] for c in chain.calls] == ["m-1", "m-1"] | |
| assert [c["fill_amount_usdc"] for c in chain.calls] == [0.4, 1.0] | |
| assert all(c["translator_address"] == translator_address for c in chain.calls) | |
| assert len(events) == 2 | |
| assert all(e.on_chain_status == "confirmed" for e in events) | |
| assert all(e.tx_hash and e.tx_hash.startswith("0x") for e in events) | |
| assert [r.fill_id for r in db_rows] == ["f1", "f2"] | |
| assert [e["type"] for e in sse_events] == [ | |
| "builder_fee.accrued", | |
| "builder_fee.accrued", | |
| ] | |
| assert sse_events[0]["data"]["fill_id"] == "f1" | |
| assert sse_events[0]["data"]["is_simulated"] is True | |
| # Second poll yields nothing — cursor advanced past last fill ts. | |
| follow_up = await listener.poll_once() | |
| assert follow_up == [] | |
| async def test_fill_listener_dedupes_repeated_fill_ids(translator_address): | |
| repeated = Fill( | |
| fill_id="dup-1", | |
| market_id="m-9", | |
| fill_amount_usdc=80.0, | |
| builder_fee_usdc=0.32, | |
| timestamp=1_700_000_050, | |
| is_simulated=True, | |
| ) | |
| # Same fill id served twice — listener must only record once. | |
| source = _ScriptedFillSource([[repeated], [repeated]]) | |
| chain = _RecordingChain() | |
| listener = FillListener( | |
| client=source, | |
| market_id="m-9", | |
| translator_address=translator_address, | |
| chain_recorder=chain, | |
| time_fn=lambda: 1_700_000_000, | |
| ) | |
| first = await listener.poll_once() | |
| second = await listener.poll_once() | |
| assert len(first) == 1 | |
| assert second == [] | |
| assert len(chain.calls) == 1 | |
| async def test_fill_listener_marks_chain_failures(translator_address): | |
| fill = Fill( | |
| fill_id="ferr", | |
| market_id="m-err", | |
| fill_amount_usdc=120.0, | |
| builder_fee_usdc=0.48, | |
| timestamp=1_700_000_100, | |
| is_simulated=True, | |
| ) | |
| source = _ScriptedFillSource([[fill]]) | |
| chain = _RecordingChain(fail_for={"m-err"}) | |
| listener = FillListener( | |
| client=source, | |
| market_id="m-err", | |
| translator_address=translator_address, | |
| chain_recorder=chain, | |
| time_fn=lambda: 1_700_000_000, | |
| ) | |
| events = await listener.poll_once() | |
| assert len(events) == 1 | |
| assert events[0].on_chain_status == "failed" | |
| assert events[0].tx_hash is None | |
| async def test_fill_listener_without_chain_marks_skipped(translator_address): | |
| fill = Fill( | |
| fill_id="fnochain", | |
| market_id="m-x", | |
| fill_amount_usdc=60.0, | |
| builder_fee_usdc=0.24, | |
| timestamp=1_700_000_200, | |
| is_simulated=True, | |
| ) | |
| source = _ScriptedFillSource([[fill]]) | |
| listener = FillListener( | |
| client=source, | |
| market_id="m-x", | |
| translator_address=translator_address, | |
| time_fn=lambda: 1_700_000_000, | |
| ) | |
| events = await listener.poll_once() | |
| assert len(events) == 1 | |
| assert events[0].on_chain_status == "skipped" | |
| assert events[0].tx_hash is None | |