fix(ingestor): align WebSocket subscribe + routing with live API (#105)

* fix(ingestor): align WebSocket subscribe + routing with live API

The Polymarket ws-live-data WebSocket requires `action: "subscribe"` in
the subscribe envelope. Without it the server accepts the connection but
never delivers trade events, causing the tracker to silently produce
zero alerts.

Additionally, incoming frames are shaped `{connection_id, payload:{...}}`
-- they do NOT echo the `topic`/`type` keys we sent. The previous routing
check matched nothing and every real trade was silently dropped.

Changes:
- Add `action: "subscribe"` to subscription message
- Route incoming messages by payload shape (transactionHash + proxyWallet)
- Add ratchet tests for payload routing edge cases
- Rewrite README as agent-first with <2min quickstart
- Add skill draft (docs/skill-tracking-prediction-market-flow.md)

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(lint): remove unused imports in test_pipeline_persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply ruff formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Selamy
2026-06-14 14:24:54 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ff678468e3
commit b962bdaee2
6 changed files with 309 additions and 283 deletions
+41 -12
View File
@@ -84,7 +84,10 @@ class TestTradeStreamHandler:
"""Test building subscription message without filters."""
msg = handler._build_subscription_message()
assert msg == {"subscriptions": [{"topic": "activity", "type": "trades"}]}
assert msg == {
"action": "subscribe",
"subscriptions": [{"topic": "activity", "type": "trades"}],
}
def test_build_subscription_message_with_event_filter(self, on_trade_mock: AsyncMock) -> None:
"""Test building subscription message with event filter."""
@@ -110,11 +113,10 @@ class TestTradeStreamHandler:
async def test_handle_message_trade(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Test handling a valid trade message."""
"""Test handling a valid trade message (payload-based routing)."""
message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "abc123",
"payload": {
"conditionId": "0xmarket",
"transactionHash": "0xtx",
@@ -142,11 +144,10 @@ class TestTradeStreamHandler:
async def test_handle_message_non_trade(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Test handling a non-trade message."""
"""Test handling a non-trade message (no transactionHash/proxyWallet)."""
message = json.dumps(
{
"topic": "comments",
"type": "comment_created",
"connection_id": "abc123",
"payload": {"body": "Hello"},
}
)
@@ -156,6 +157,35 @@ class TestTradeStreamHandler:
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_payload_missing_proxy_wallet(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Ratchet: payload with transactionHash but no proxyWallet is not a trade."""
message = json.dumps(
{
"connection_id": "abc",
"payload": {"transactionHash": "0xtx", "other": "field"},
}
)
await handler._handle_message(message)
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_no_payload_key(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Ratchet: message without payload key is ignored."""
message = json.dumps({"connection_id": "abc", "status": "ok"})
await handler._handle_message(message)
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_invalid_json(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
@@ -175,8 +205,7 @@ class TestTradeStreamHandler:
message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "abc",
"payload": {
"conditionId": "0x",
"transactionHash": "0x",
@@ -240,8 +269,9 @@ class TestTradeStreamHandler:
assert ws is mock_ws
mock_ws.send.assert_called_once()
# Verify subscription message
# Verify subscription message includes action: subscribe
sent_msg = json.loads(mock_ws.send.call_args[0][0])
assert sent_msg["action"] == "subscribe"
assert "subscriptions" in sent_msg
assert sent_msg["subscriptions"][0]["topic"] == "activity"
assert sent_msg["subscriptions"][0]["type"] == "trades"
@@ -292,8 +322,7 @@ class TestTradeStreamHandlerIntegration:
trade_message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "test-conn",
"payload": {
"conditionId": "0xtest",
"transactionHash": "0xtx",
+4 -9
View File
@@ -6,14 +6,13 @@ wallet_profiles and funding_transfers tables when fresh wallets are detected.
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from polymarket_insider_tracker.config import Settings
from polymarket_insider_tracker.detector.models import FreshWalletSignal
@@ -83,9 +82,7 @@ async def db_manager(async_engine):
manager._sync_engine = None
manager._async_engine = async_engine
manager._sync_session_factory = None
manager._async_session_factory = async_sessionmaker(
bind=async_engine, expire_on_commit=False
)
manager._async_session_factory = async_sessionmaker(bind=async_engine, expire_on_commit=False)
return manager
@@ -266,9 +263,7 @@ class TestPipelinePersistence:
# Use a broken db_manager that raises on get_async_session
broken_db = MagicMock()
broken_db.get_async_session = MagicMock(
side_effect=Exception("DB connection failed")
)
broken_db.get_async_session = MagicMock(side_effect=Exception("DB connection failed"))
pipeline._db_manager = broken_db
fresh_signal = FreshWalletSignal(